diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index f31d4b835f..3a090b3ebd 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -45,7 +45,7 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 diff --git a/.github/workflows/benchmark-harbor.yml b/.github/workflows/benchmark-harbor.yml index 31efe933c5..6024f00575 100644 --- a/.github/workflows/benchmark-harbor.yml +++ b/.github/workflows/benchmark-harbor.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d392170f6a..60507182d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: web: ${{ steps.filter.outputs.web }} mobile: ${{ steps.filter.outputs.mobile }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 @@ -96,7 +96,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -117,7 +117,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -139,7 +139,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -235,7 +235,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Get pnpm store directory id: pnpm-cache @@ -318,7 +318,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 # Reuse the relay binaries and backend test archive when none of their # inputs changed (desktop-only PRs hit this every time). The key covers @@ -391,7 +391,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Start integration services run: | @@ -580,7 +580,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Install cargo-nextest uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 @@ -744,7 +744,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -785,7 +785,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -820,7 +820,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -881,7 +881,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Dependency policy run: cargo-deny check @@ -893,7 +893,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Check for dead API token references in client code run: | # Fail if dead API token patterns reappear in desktop, mobile, docs, or config. @@ -922,7 +922,7 @@ jobs: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -959,7 +959,7 @@ jobs: env: TARGET: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows # runner — hermit, used by the Linux jobs, does not provide MSVC. The # toolchain (1.95.0 + clippy via profile = default) comes from the @@ -1040,7 +1040,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -1054,6 +1054,7 @@ jobs: mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET" touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 52f21b28bc..564cd74e9d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -101,7 +101,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false @@ -359,7 +359,7 @@ jobs: arch: arm64 steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index e3d443d9f3..7118d16708 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -59,7 +59,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # On chart-tag rescue dispatch, lint/render the tagged commit that the # publish job will package, not whatever `main` is when the dispatch @@ -119,7 +119,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -166,7 +166,7 @@ jobs: packages: write # push the chart to GHCR steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # On the rescue dispatch, build the tagged commit (github.ref is # `main` there); on a tag push, the default ref is already the tag. diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 9806443378..1664878770 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -166,7 +166,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh - name: Build Linux Tauri app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d5f3fbf40..02011ad386 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: exit 1 fi - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false @@ -64,7 +64,7 @@ jobs: env: VERSION: ${{ needs.setup.outputs.version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -91,7 +91,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. @@ -278,7 +278,7 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} TARGET: x86_64-apple-darwin steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -308,7 +308,7 @@ jobs: - name: Build sidecars run: | - cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" - name: Build unsigned Tauri app @@ -495,7 +495,7 @@ jobs: apt-get update apt-get install -y --no-install-recommends gh - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -563,7 +563,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh - name: Generate release config @@ -666,7 +666,7 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} TARGET: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -795,7 +795,7 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index fb0656028a..0a3a513eef 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -93,7 +93,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. diff --git a/.github/workflows/sprig-image.yml b/.github/workflows/sprig-image.yml new file mode 100644 index 0000000000..5d5e12ae0c --- /dev/null +++ b/.github/workflows/sprig-image.yml @@ -0,0 +1,233 @@ +name: Sprig image + +# Builds and publishes the public agent container image as +# ghcr.io/block/buzz-sprig — the digest-pinned box the Kubernetes backend +# deploys agents into (see Dockerfile.sprig and docs/remote-agents.md). +# +# Strategy mirrors docker.yml (the relay image): each architecture builds on +# its native runner, pushes to GHCR by digest, then a merge job stitches the +# per-arch digests into one multi-arch manifest and attests provenance. +# No QEMU emulation. +# +# Triggers: +# - push to main (paths-filtered) → :main + :sha-<7> +# - tag sprig-v* → semver family (shared with sprig.yml's +# binary release — one tag versions both) +# - pull_request (paths-filtered) → build only, no push +# - workflow_dispatch → manual publish at the current ref +# +# NOTE: the first push creates the GHCR package PRIVATE by default. An org +# admin must flip ghcr.io/block/buzz-sprig to public once (Package settings → +# Change visibility). Subsequent pushes keep the visibility. + +on: + push: + branches: [main] + tags: ["sprig-v[0-9]*"] + paths: + - "Dockerfile.sprig" + - "scripts/sprig-entrypoint.sh" + - ".github/workflows/sprig-image.yml" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "crates/**" + pull_request: + paths: + - "Dockerfile.sprig" + - "scripts/sprig-entrypoint.sh" + - ".github/workflows/sprig-image.yml" + workflow_dispatch: {} + +concurrency: + group: sprig-image-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }} + +permissions: {} + +env: + # Single source of truth for the image name; override with the + # GHCR_SPRIG_IMAGE repo variable (same pattern as docker.yml). + IMAGE_NAME: ${{ vars.GHCR_SPRIG_IMAGE != '' && vars.GHCR_SPRIG_IMAGE || 'ghcr.io/block/buzz-sprig' }} + +jobs: + build: + name: Build (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + permissions: + contents: read + packages: write + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + with: + # Same OOM cap as docker.yml — Rust compiles blow the 7GB runner + # at buildkit's default parallelism of 4. + buildkitd-config-inline: | + [worker.oci] + max-parallelism = 2 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.IMAGE_NAME }} + # match=^sprig-v(.*)$ strips the tag prefix for the semver parser, + # exactly as docker.yml does for relay-v. :latest comes from + # flavor.latest=auto — stable semver only, never main pushes. + tags: | + type=ref,event=branch + type=sha,prefix=sha-,format=short + type=semver,pattern={{version}},match=^sprig-v(.*)$ + type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$ + labels: | + org.opencontainers.image.title=Buzz Sprig + org.opencontainers.image.description=Agent runtime image for Buzz remote agents (buzz-acp multicall + git + curl) + org.opencontainers.image.licenses=Apache-2.0 + + - name: Build and push by digest + id: build + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile.sprig + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + 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) || '' }} + + - name: Export digest + if: github.event_name != 'pull_request' + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload digest + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sprig-digest-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge multi-arch manifest + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + needs: build + timeout-minutes: 15 + permissions: + contents: read + packages: write + id-token: write + attestations: write + + steps: + - name: Download per-arch digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: sprig-digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.IMAGE_NAME }} + # Must mirror the build job's tag matrix exactly (see docker.yml). + flavor: | + latest=auto + tags: | + type=ref,event=branch + type=sha,prefix=sha-,format=short + type=semver,pattern={{version}},match=^sprig-v(.*)$ + type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$ + + - name: Create and push manifest list + id: manifest + working-directory: /tmp/digests + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + META_TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + tags=() + while IFS= read -r tag; do + [ -n "$tag" ] && tags+=("-t" "$tag") + done <<< "$META_TAGS" + + digests=() + for digest in *; do + digests+=("${IMAGE_NAME}@sha256:${digest}") + done + + docker buildx imagetools create "${tags[@]}" "${digests[@]}" + + first_tag=$(echo "$META_TAGS" | head -n1) + merged_digest=$(docker buildx imagetools inspect "$first_tag" \ + --format '{{json .Manifest}}' | jq -r '.digest') + echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT" + + - name: Attest provenance for the merged image + # Verify with: gh attestation verify oci://ghcr.io/block/buzz-sprig: --owner block + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.manifest.outputs.digest }} + push-to-registry: true + + - name: Summary + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + DIGEST: ${{ steps.manifest.outputs.digest }} + run: | + { + echo "### Sprig image published" + echo '```' + echo "${IMAGE_NAME}@${DIGEST}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sprig.yml b/.github/workflows/sprig.yml index b2dab3583f..5e50808b3b 100644 --- a/.github/workflows/sprig.yml +++ b/.github/workflows/sprig.yml @@ -42,7 +42,7 @@ jobs: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -116,7 +116,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download all Sprig artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -154,7 +154,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download all Sprig artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/Cargo.lock b/Cargo.lock index fa02e17ce3..62bcea0cae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -915,6 +915,26 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-backend-kubernetes" +version = "0.1.0" +dependencies = [ + "chrono", + "hex", + "http", + "http-body-util", + "k8s-openapi", + "kube", + "nostr", + "rand 0.10.1", + "rustls", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "tower", +] + [[package]] name = "buzz-cli" version = "0.1.0" @@ -3643,6 +3663,7 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", "rustls-native-certs", "tokio", @@ -4303,6 +4324,31 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "k8s-openapi" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d9e5e61dd037cdc51da0d7e2b2be10f497478ea7e120d85dad632adb99882b" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde_json", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -4348,6 +4394,70 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" +[[package]] +name = "kube" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e7bb0b6a46502cc20e4575b6ff401af45cfea150b34ba272a3410b78aa014e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", +] + +[[package]] +name = "kube-client" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4987d57a184d2b5294fdad3d7fc7f278899469d21a4da39a8f6ca16426567a36" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914bbb770e7bb721a06e3538c0edd2babed46447d128f7c21caa68747060ee73" +dependencies = [ + "chrono", + "derive_more", + "form_urlencoded", + "http", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "lab" version = "0.11.0" @@ -6265,6 +6375,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -6445,6 +6564,16 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -8170,6 +8299,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "secret-service" version = "4.0.0" @@ -8269,6 +8407,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -9869,6 +10017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", + "base64 0.22.1", "bitflags 2.13.0", "bytes", "futures-core", diff --git a/Cargo.toml b/Cargo.toml index 3268cfaf8d..cc1dd0f9df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "crates/buzz-relay-mesh", "crates/buzz-dev-mcp", "crates/buzz-voice", + "crates/buzz-backend-kubernetes", "examples/countdown-bot", ] exclude = ["desktop/src-tauri"] @@ -58,6 +59,13 @@ sqlx = { version = "0.9", features = [ redis = { version = "1.0", features = ["tokio-comp", "connection-manager", "tokio-rustls-comp"] } deadpool-redis = { version = "0.23", features = ["rt_tokio_1"] } +# Kubernetes (buzz-backend-kubernetes provider). No `ring` feature here: the +# process-level CryptoProvider is installed explicitly at startup, matching +# buzz-cli/buzz-acp/buzz-admin/buzz-relay/buzz-dev-mcp — see the comment on the +# crate's own rustls dependency. +kube = { version = "2.0", default-features = false, features = ["client", "rustls-tls"] } +k8s-openapi = { version = "0.26", features = ["v1_31"] } + # Nostr nostr = { version = "0.44", features = ["nip44", "nip98"] } diff --git a/Dockerfile.sprig b/Dockerfile.sprig new file mode 100644 index 0000000000..160e0b5662 --- /dev/null +++ b/Dockerfile.sprig @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.7 +# Multi-arch is produced by building this file on native amd64 and arm64 runners. +# Keep both bases pinned to manifest-list digests so either architecture resolves +# to immutable source bytes. +FROM rust:1.95-alpine3.22@sha256:064dfc925d68d1a63f4fd2871bd7dc6e6ea56692989a487185855d62885d90aa AS builder + +RUN apk add --no-cache \ + build-base \ + cmake \ + git \ + musl-dev \ + openssl-dev \ + openssl-libs-static \ + perl \ + pkgconf \ + protoc +WORKDIR /build +COPY . . +RUN cargo build --locked --profile sprig -p sprig \ + && strip target/sprig/sprig + +FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce + +RUN apk add --no-cache bash ca-certificates curl git \ + && adduser -D -h /home/agent agent \ + && install -d -o agent -g agent /workspace /home/agent \ + && git config --system gpg.format x509 \ + && git config --system gpg.x509.program /usr/local/bin/git-sign-nostr \ + && git config --system commit.gpgSign true \ + && git config --system tag.gpgSign true + +COPY --from=builder --chmod=0755 /build/target/sprig/sprig /usr/local/bin/sprig +COPY --chmod=0755 scripts/sprig-entrypoint.sh /usr/local/bin/sprig-entrypoint +RUN for name in \ + buzz-acp buzz-agent buzz-dev-mcp rg tree buzz \ + git-credential-nostr git-sign-nostr; do \ + ln -s sprig "/usr/local/bin/$name"; \ + done + +ENV HOME=/home/agent \ + PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin +WORKDIR /home/agent +USER agent +ENTRYPOINT ["/usr/local/bin/sprig-entrypoint"] diff --git a/Justfile b/Justfile index 64a1f36daf..8dbe125a7d 100644 --- a/Justfile +++ b/Justfile @@ -155,7 +155,11 @@ _ensure-sidecar-stubs: set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) + if [[ "$TARGET" != *windows* ]]; then + SIDECARS+=(buzz-backend-kubernetes) + fi + for bin in "${SIDECARS[@]}"; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -236,6 +240,9 @@ desktop-release-build target="aarch64-apple-darwin": mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + if [[ "$TARGET" != *windows* ]]; then + touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET" + fi touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" @@ -274,6 +281,7 @@ test: # Run unit tests only (no infra needed) test-unit: #!/usr/bin/env bash + set -euo pipefail if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-voice --lib @@ -293,6 +301,12 @@ test-unit: # Gateway unit and black-box HTTP tests are infra-free. Postgres-backed # contract/race tests run in the dedicated CI job below. cargo nextest run -p buzz-push-gateway + # Kubernetes backend provider: the decision layers (state machine, GC + # planner, env precedence, naming, wire) are pure functions with a fake + # substrate, so they belong in the unit job. Enumerated explicitly + # because nothing in CI runs `cargo test --workspace` — workspace + # membership alone buys clippy/check, not a single executed test. + cargo nextest run -p buzz-backend-kubernetes else ./scripts/run-tests.sh unit fi @@ -430,7 +444,7 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations fi done fi - cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay + cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay if [[ -n "{{mesh}}" ]]; then export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi @@ -477,10 +491,10 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" - cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr TARGET=$(rustc -vV | sed -n 's|host: ||p') TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -506,7 +520,7 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" pnpm install # unconditional: staging must always start with a clean dep tree - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) @@ -533,7 +547,7 @@ production *ARGS: bootstrap _ensure-sidecar-stubs set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" pnpm install # unconditional: production must always start with a clean dep tree - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) diff --git a/VISION_REMOTE_AGENTS.md b/VISION_REMOTE_AGENTS.md index b02d1bc92d..4b187f355a 100644 --- a/VISION_REMOTE_AGENTS.md +++ b/VISION_REMOTE_AGENTS.md @@ -56,7 +56,7 @@ Remote agents solve it from the inside. Because the desktop retains no substrate **The body's state is mortal.** Files, checkouts, half-finished working trees — gone with the body unless the substrate persists them. The agent survives; its scratch space doesn't. Durable knowledge belongs on the relay, and agents are built to put it there. -**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about ninety if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. Ninety seconds of a wrong dot, never an indefinite one. +**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about three minutes if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. A bounded wrong dot, never an indefinite one. **A running agent finishes on the configuration it started with.** New keys, new models, new settings take effect on the next body. And an instance that never got far enough to run — a body that failed to start — is the substrate operator's residue to clear, with the substrate's own tools. Editing an agent mid-sentence was never on the menu. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index dab61be30a..35aaec188d 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -474,6 +474,11 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] pub relay_observer: bool, + /// Exit after this many seconds with no dispatched events and no turn in flight. + /// 0 disables inactivity self-termination. + #[arg(long, env = "BUZZ_ACP_EXIT_AFTER_INACTIVITY", default_value_t = 0)] + pub exit_after_inactivity: u64, + /// Connect and subscribe before starting the ACP/LLM subprocess pool. #[arg(long, env = "BUZZ_ACP_LAZY_POOL", default_value_t = false)] pub lazy_pool: bool, @@ -550,6 +555,8 @@ pub struct Config { pub has_generated_codex_config: bool, /// Whether to publish encrypted observer frames through the relay. pub relay_observer: bool, + /// Seconds without dispatched events before an idle harness exits. 0 = disabled. + pub exit_after_inactivity_secs: u64, /// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives. pub lazy_pool: bool, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. @@ -1098,6 +1105,7 @@ impl Config { persona_env_vars, has_generated_codex_config, relay_observer: args.relay_observer, + exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, @@ -1468,6 +1476,7 @@ mod tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, no_base_prompt: false, @@ -2167,6 +2176,22 @@ channels = "ALL" assert!(err.to_string().contains("turn liveness interval must be 0")); } + #[test] + fn inactivity_exit_defaults_disabled_and_accepts_cli_value() { + let key = "0".repeat(64); + let default = CliArgs::parse_from(["buzz-acp", "--private-key", &key]); + assert_eq!(default.exit_after_inactivity, 0); + + let configured = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &key, + "--exit-after-inactivity", + "120", + ]); + assert_eq!(configured.exit_after_inactivity, 120); + } + #[test] fn lazy_pool_defaults_off() { let key = "0".repeat(64); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322..811253e4ac 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1228,6 +1228,59 @@ impl Drop for RespawnGuard { // sync entry point — `std::env::set_var` is only safe before tokio spawns // worker threads (Rust 2024 edition safety requirement). +fn inactivity_expired( + last_activity: tokio::time::Instant, + now: tokio::time::Instant, + bound: Duration, + turn_in_flight: bool, +) -> bool { + !bound.is_zero() && !turn_in_flight && now.duration_since(last_activity) >= bound +} + +#[cfg(test)] +mod inactivity_tests { + use super::*; + + #[test] + fn zero_disables_expiry_and_in_flight_turns_defer_it() { + let started = tokio::time::Instant::now(); + let after_bound = started + Duration::from_secs(61); + + assert!(!inactivity_expired( + started, + after_bound, + Duration::ZERO, + false + )); + assert!(!inactivity_expired( + started, + after_bound, + Duration::from_secs(60), + true + )); + assert!(inactivity_expired( + started, + after_bound, + Duration::from_secs(60), + false + )); + } + + #[test] + fn dispatched_activity_restarts_the_inactivity_bound() { + let started = tokio::time::Instant::now(); + let dispatched = started + Duration::from_secs(50); + let checked = started + Duration::from_secs(61); + + assert!(!inactivity_expired( + dispatched, + checked, + Duration::from_secs(60), + false + )); + } +} + pub fn run() -> Result<()> { config::propagate_legacy_env_vars(); tokio_main() @@ -1601,6 +1654,21 @@ async fn tokio_main() -> Result<()> { let mut typing_channels: HashMap = HashMap::new(); let mut presence_task: Option> = None; + // Independent of pool readiness: a never-mentioned lazy agent must still + // self-terminate. The watch interval is capped so small configured bounds + // remain reasonably precise without waking long-lived agents frequently. + let inactivity_bound = Duration::from_secs(config.exit_after_inactivity_secs); + let mut last_activity = tokio::time::Instant::now(); + let mut inactivity_reaper = if inactivity_bound.is_zero() { + None + } else { + let interval = inactivity_bound.min(Duration::from_secs(30)); + Some(tokio::time::interval_at( + tokio::time::Instant::now() + interval, + interval, + )) + }; + // Runs at the TOP of every loop iteration via Instant check — cannot be // starved by the biased select. Slot refill spawns background tasks so // spawn_and_init never blocks the main loop. @@ -1774,7 +1842,9 @@ async fn tokio_main() -> Result<()> { // called on relay events or pool results, neither of which // arrive when the channel is silent. if queue.has_flushable_work() { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -1810,7 +1880,9 @@ async fn tokio_main() -> Result<()> { // this, batches requeued during crash recovery sit idle until the // next relay event arrives — which can be minutes on quiet channels. if respawn_collected { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2258,7 +2330,7 @@ async fn tokio_main() -> Result<()> { } if pool_ready { for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx) + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { typing_channels.insert(channel_id, thread_tags); } @@ -2275,6 +2347,27 @@ async fn tokio_main() -> Result<()> { } None } + _ = async { + match inactivity_reaper.as_mut() { + Some(timer) => timer.tick().await, + None => std::future::pending().await, + } + } => { + let _ = result_rx; + if inactivity_expired( + last_activity, + tokio::time::Instant::now(), + inactivity_bound, + queue.has_in_flight() || heartbeat_in_flight, + ) { + tracing::info!( + inactivity_seconds = config.exit_after_inactivity_secs, + "inactivity bound reached — exiting gracefully" + ); + let _ = shutdown_tx.send(()); + } + None + } _ = async { match heartbeat.as_mut() { Some(hb) => hb.tick().await, @@ -2287,7 +2380,7 @@ async fn tokio_main() -> Result<()> { } else if queue.has_flushable_work() { tracing::debug!("heartbeat_skipped_events"); for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx) + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { typing_channels.insert(channel_id, thread_tags); } @@ -2385,7 +2478,9 @@ async fn tokio_main() -> Result<()> { { break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2408,7 +2503,9 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead — exiting"); break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2550,7 +2647,9 @@ async fn tokio_main() -> Result<()> { // tear down the in-flight task; on its completion the // queue drains. We still try here in case the in-flight // task has already returned. - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2577,7 +2676,7 @@ async fn tokio_main() -> Result<()> { None, ); for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx) + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { typing_channels.insert(channel_id, thread_tags); } @@ -2911,6 +3010,7 @@ fn dispatch_pending( pool: &mut AgentPool, queue: &mut EventQueue, ctx: &Arc, + last_activity: &mut tokio::time::Instant, ) -> Vec<(Uuid, ThreadTags)> { let mut dispatched_channels = Vec::new(); loop { @@ -2990,6 +3090,7 @@ fn dispatch_pending( }, ); dispatched_channels.push((channel_id, typing_scope)); + *last_activity = tokio::time::Instant::now(); } tracing::debug!( dispatched = dispatched_channels.len(), @@ -5031,6 +5132,7 @@ mod build_mcp_servers_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, no_base_prompt: false, @@ -5252,6 +5354,7 @@ mod error_outcome_emission_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, no_base_prompt: false, diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..5c960de202 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -646,6 +646,11 @@ impl EventQueue { self.in_flight_channels.contains(&channel_id) } + /// Whether any channel currently has a turn in flight. + pub fn has_in_flight(&self) -> bool { + !self.in_flight_channels.is_empty() + } + // ── Goose-native steer withhold (side table) ────────────────────────── // // While a goose-native `_goose/unstable/session/steer` write is in flight diff --git a/crates/buzz-backend-kubernetes/Cargo.toml b/crates/buzz-backend-kubernetes/Cargo.toml new file mode 100644 index 0000000000..1cd17030db --- /dev/null +++ b/crates/buzz-backend-kubernetes/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "buzz-backend-kubernetes" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Kubernetes backend provider for Buzz remote agents (docs/remote-agents.md)" + +[[bin]] +name = "buzz-backend-kubernetes" +path = "src/main.rs" + +[dependencies] +kube = { workspace = true } +k8s-openapi = { workspace = true } +nostr = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +rand = { workspace = true } +chrono = { workspace = true } +http = "1" +http-body-util = "0.1" + +# Explicit rustls dep with the ring provider — required to install the +# process-level CryptoProvider at startup. Without it this binary panics on its +# first TLS connection to the apiserver: the release build compiles every +# sidecar in one cargo invocation (.github/workflows/release.yml), which unifies +# both ring and aws-lc-rs features and leaves rustls unable to auto-select a +# provider. Same dependency and reason as crates/buzz-cli/Cargo.toml. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } + +[dev-dependencies] +tower = { workspace = true } diff --git a/crates/buzz-backend-kubernetes/src/classify.rs b/crates/buzz-backend-kubernetes/src/classify.rs new file mode 100644 index 0000000000..e9cfaba402 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/classify.rs @@ -0,0 +1,377 @@ +//! The deploy state machine (spec §Deploy State Machine), as a pure function. +//! +//! `classify` maps a verified observation plus the desired create intent to +//! one [`Action`]. It performs no I/O, so every row of the spec's table is a +//! unit test with no cluster. `reconcile` executes actions and re-enters. +//! +//! Two invariants are structural rather than remembered: +//! +//! * [`Action::Delete`] carries the [`Fence`] from the exact observation that +//! authorized it. There is no way to build a delete without one, so a later +//! helper cannot re-read and silently substitute a fresher fence. +//! * The pull-failure classifier ([`PullFailure`]) reaches only +//! [`Action::Report`] and [`Action::Observe`]. It is absent from +//! `Action::Delete`'s type, so "reason strings are never deletion +//! authority" is enforced by the compiler. + +use crate::intent::Fingerprint; + +/// The compare-and-delete fence: UID + resourceVersion from the observation +/// that authorized the deletion. A failed precondition means the object +/// changed since the read — re-enter, never retry the delete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fence { + pub uid: String, + pub resource_version: String, +} + +/// Why a pod that never started looks permanently broken. Reporting only. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PullFailure { + /// Registry auth: a 403/401 `ImagePullBackOff` retries forever without + /// ever succeeding, so "the pull retries" is false for this case. + Unauthorized, + /// The digest or repository does not exist at that registry. + ManifestUnknown, + /// The image has no variant for the node's architecture. + ArchMismatch, +} + +/// The container's startup state, already decoded from pod status. Decoding +/// happens at the edge so this module stays free of API types. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Startup { + /// `state.running` — the harness process is up. This, not pod phase, is + /// what "live" means. + Started, + /// Started once and reached a terminal phase (Succeeded/Failed). + Terminated, + /// Never started, and self-healing is plausible: unschedulable during + /// scale-from-zero, an image pull in progress, a transient + /// `CreateContainerConfigError` whose Secret exists. + NeverStartedRecoverable, + /// Never started, and the provider *verified* the cause — not a reason + /// string. Either the referenced Secret is confirmed absent by a + /// most-recent read, or the image reference is structurally invalid. + NeverStartedProvablyBroken, + /// Never started; the pull is failing in a way that will not self-heal. + /// Still recoverable in the *never delete* sense — this only changes what + /// we report and how long we wait. + NeverStartedPullFailing(PullFailure), +} + +/// A pod that passed identity and ownership verification: label-selected, +/// full-pubkey annotation equal to the derived pubkey, management marker +/// present. Constructing this type is the verification step's output, so an +/// unverified object cannot reach `classify` at all. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedPod { + pub name: String, + pub fence: Fence, + /// Set once the apiserver accepts a delete. In Kubernetes there is no + /// `Terminating` phase — a pod being gracefully deleted stays in phase + /// `Running` for its whole grace period — so this must be checked + /// *before* startup state or the dying pod reads as the no-op row. + pub deletion_marked: bool, + pub startup: Startup, + /// The `buzz.block.xyz/create-intent` annotation as recorded at create. + /// `None` for a pod written before the annotation existed, which counts + /// as divergence. + pub recorded_intent: Option, +} + +/// What the reconciler should do next. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Create the pod, then wait for the harness container to start. + Create, + /// Compare-and-delete, poll for actual disappearance, then re-enter. + Delete { name: String, fence: Fence }, + /// Wait for a deletion already in flight, then re-enter. + AwaitDisappearance { name: String }, + /// Strict no-op: return this `agent_id`, mutate nothing. + NoOp { agent_id: String }, + /// Keep observing until started or the operation deadline expires; on + /// expiry report the latest condition. Never deletes, on this call or any + /// later one. + Observe { name: String }, + /// Surface an actionable condition immediately rather than burning the + /// deadline on a failure that will not self-heal. + Report { name: String, failure: PullFailure }, +} + +/// Apply the spec's ordered rules to one verified observation. +/// +/// `desired` is the freshly computed create intent; comparison is always +/// recorded-annotation vs freshly-computed, never a diff against the live pod +/// (admission defaulting would make every pod look divergent). +pub fn classify(observed: Option<&VerifiedPod>, desired: &Fingerprint) -> Action { + let Some(pod) = observed else { + // Row: no instance → create. First deploy, or after GC. + return Action::Create; + }; + + // Row: deletion-marked, ANY phase. Checked before startup state because + // there is no `Terminating` phase to match on — a gracefully deleting pod + // reports phase `Running` throughout its grace period, so testing startup + // first would mistake it for the live no-op row and return an id that + // evaporates. + if pod.deletion_marked { + return Action::AwaitDisappearance { + name: pod.name.clone(), + }; + } + + match &pod.startup { + // Row: live and started → strict no-op. Start must never kill a live + // agent mid-turn, whatever the fingerprint says. + Startup::Started => Action::NoOp { + agent_id: pod.name.clone(), + }, + + // Row: terminated → delete residue, then re-enter to create. This is + // the normal restart path — how a user revives a reaped agent. + Startup::Terminated => Action::Delete { + name: pod.name.clone(), + fence: pod.fence.clone(), + }, + + // Row: never started, provably non-recoverable → fenced replace. + // "Provably" means a verified absence or a structural defect, never a + // reason string. + Startup::NeverStartedProvablyBroken => Action::Delete { + name: pod.name.clone(), + fence: pod.fence.clone(), + }, + + // Inside the recoverable row: a pull that will not self-heal is + // reported immediately instead of consuming the 600s deadline. This + // changes reporting and wait behavior only — no delete authority. + Startup::NeverStartedPullFailing(failure) => Action::Report { + name: pod.name.clone(), + failure: *failure, + }, + + // Row: never started, recoverable — split on create-intent + // divergence. Divergence is evidence of a config change the user is + // waiting on, and it is the *only* thing that replaces a + // never-started pod. Pod age triggers nothing: any finite age + // threshold collides with Cluster Autoscaler's own pod-age delays, + // and delete-recreate resets exactly the age it keys on. + Startup::NeverStartedRecoverable => { + if pod.recorded_intent.as_ref() == Some(desired) { + Action::Observe { + name: pod.name.clone(), + } + } else { + Action::Delete { + name: pod.name.clone(), + fence: pod.fence.clone(), + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fp(seed: &str) -> Fingerprint { + Fingerprint::for_test(seed) + } + + fn pod(startup: Startup, intent: Option) -> VerifiedPod { + VerifiedPod { + name: "buzz-agent-abc123def456".into(), + fence: Fence { + uid: "uid-1".into(), + resource_version: "rv-1".into(), + }, + deletion_marked: false, + startup, + recorded_intent: intent, + } + } + + #[test] + fn no_instance_creates() { + assert_eq!(classify(None, &fp("a")), Action::Create); + } + + #[test] + fn started_pod_is_strict_no_op() { + let p = pod(Startup::Started, Some(fp("a"))); + assert_eq!( + classify(Some(&p), &fp("a")), + Action::NoOp { + agent_id: p.name.clone() + } + ); + } + + /// The asymmetry the spec states plainly: an edit cannot reach a started + /// pod until it exits, but it *can* reach a never-started one — the + /// never-started pod is the one the user is editing because it did not + /// start. + #[test] + fn started_pod_no_ops_even_when_intent_diverges() { + let p = pod(Startup::Started, Some(fp("old"))); + assert_eq!( + classify(Some(&p), &fp("new")), + Action::NoOp { + agent_id: p.name.clone() + } + ); + } + + /// In Kubernetes a gracefully deleting pod stays in phase `Running`. If + /// the deletion mark were checked after startup state, this pod would + /// take the no-op row and `deploy` would return an id that evaporates. + #[test] + fn deletion_mark_beats_every_startup_state() { + for startup in [ + Startup::Started, + Startup::Terminated, + Startup::NeverStartedRecoverable, + Startup::NeverStartedProvablyBroken, + Startup::NeverStartedPullFailing(PullFailure::Unauthorized), + ] { + let mut p = pod(startup.clone(), Some(fp("a"))); + p.deletion_marked = true; + assert_eq!( + classify(Some(&p), &fp("a")), + Action::AwaitDisappearance { + name: p.name.clone() + }, + "deletion mark ignored for {startup:?}" + ); + } + } + + #[test] + fn terminated_pod_is_replaced() { + let p = pod(Startup::Terminated, Some(fp("a"))); + assert_eq!( + classify(Some(&p), &fp("a")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// A never-started winner is repairable: pod exists, Secret confirmed + /// absent, container never started. A later deploy must delete-recreate + /// rather than no-op — the test that pins started-not-phase as the no-op + /// criterion. + #[test] + fn provably_broken_never_started_pod_is_replaced() { + let p = pod(Startup::NeverStartedProvablyBroken, Some(fp("a"))); + assert_eq!( + classify(Some(&p), &fp("a")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// The anti-livelock rule: identical desired intent means *never* delete, + /// however long the pod has been pending. Age is not an input to this + /// function at all, which is the strongest way to say so. + #[test] + fn recoverable_with_matching_intent_only_observes() { + let p = pod(Startup::NeverStartedRecoverable, Some(fp("same"))); + assert_eq!( + classify(Some(&p), &fp("same")), + Action::Observe { + name: p.name.clone() + } + ); + } + + /// Repeated identical Starts can never delete anything — the same + /// classification, arbitrarily many times. + #[test] + fn repeated_identical_starts_never_delete() { + let p = pod(Startup::NeverStartedRecoverable, Some(fp("same"))); + for _ in 0..100 { + assert!(!matches!( + classify(Some(&p), &fp("same")), + Action::Delete { .. } + )); + } + } + + /// The wedge escape: the user corrected a resource request or image, so + /// the never-started pod is built from configuration they have since + /// changed. Without this row the edit could never materialize. + #[test] + fn recoverable_with_divergent_intent_is_replaced() { + let p = pod(Startup::NeverStartedRecoverable, Some(fp("old"))); + assert_eq!( + classify(Some(&p), &fp("new")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// A pod predating the annotation has no recorded intent — that is + /// absence, which the spec groups with divergence. + #[test] + fn missing_recorded_intent_counts_as_divergence() { + let p = pod(Startup::NeverStartedRecoverable, None); + assert_eq!( + classify(Some(&p), &fp("any")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// Permanent-looking pull failures report immediately instead of burning + /// 600s — and, critically, never delete. + #[test] + fn pull_failures_report_and_never_delete() { + for failure in [ + PullFailure::Unauthorized, + PullFailure::ManifestUnknown, + PullFailure::ArchMismatch, + ] { + let p = pod(Startup::NeverStartedPullFailing(failure), Some(fp("a"))); + // Divergent intent too — still no delete from this arm. + for desired in [fp("a"), fp("different")] { + assert_eq!( + classify(Some(&p), &desired), + Action::Report { + name: p.name.clone(), + failure + } + ); + } + } + } + + /// Every delete carries the fence from the observation that authorized + /// it. Exhaustive over the delete-producing states, so a future arm that + /// forgets is caught here rather than in a cluster. + #[test] + fn every_delete_carries_the_authorizing_fence() { + let states = [ + (Startup::Terminated, fp("a")), + (Startup::NeverStartedProvablyBroken, fp("a")), + (Startup::NeverStartedRecoverable, fp("divergent")), + ]; + for (startup, desired) in states { + let p = pod(startup, Some(fp("a"))); + match classify(Some(&p), &desired) { + Action::Delete { fence, .. } => assert_eq!(fence, p.fence), + other => panic!("expected Delete, got {other:?}"), + } + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/client.rs b/crates/buzz-backend-kubernetes/src/client.rs new file mode 100644 index 0000000000..0c3bed65b7 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/client.rs @@ -0,0 +1,182 @@ +//! Cluster auth and client construction (spec §Cluster auth, +//! `docs/remote-agents.md:985-995`). +//! +//! Standard kubeconfig resolution (`$KUBECONFIG` → `~/.kube/config`). +//! `provider_config` carries `context` and `namespace` only — credentials +//! never transit config (I2, `:196-198`). + +use kube::config::{ExecConfig, KubeConfigOptions, Kubeconfig}; +use kube::{Client, Config}; +use std::path::{Path, PathBuf}; + +/// Directories prepended to `PATH` before the client is built. +/// +/// Kubeconfigs at Block near-universally authenticate through `exec` +/// credential plugins (`aws eks get-token`, `gke-gcloud-auth-plugin`) that +/// resolve via `PATH` — and this provider inherits a Finder-launched +/// desktop's minimal `PATH`, which contains none of the places those plugins +/// install to (`:989-994`). +const PATH_PREPEND: [&str; 2] = ["/opt/homebrew/bin", "/usr/local/bin"]; + +/// Compute the new `PATH` value: plugin directories first, inherited entries +/// after, in order. Pure so the ordering can be tested without mutating the +/// process's environment. +fn prepended_path(home: Option<&Path>, existing: &std::ffi::OsStr) -> Option { + let mut dirs: Vec = PATH_PREPEND.iter().map(PathBuf::from).collect(); + if let Some(home) = home { + dirs.push(home.join(".local/bin")); + } + // An empty inherited PATH splits into one empty entry, which POSIX + // resolves as the current directory — a place a credential plugin should + // never be looked up. Drop empties rather than propagate them. + dirs.extend(std::env::split_paths(existing).filter(|p| !p.as_os_str().is_empty())); + std::env::join_paths(dirs).ok() +} + +/// Prepend the plugin directories to this process's `PATH`. +/// +/// Modifies the provider's own environment, which is sound here: one process +/// per operation, called before any client or task exists, and the child +/// processes that read it are exactly the credential plugins this exists for. +fn prepend_plugin_path() { + let home = std::env::var_os("HOME"); + let existing = std::env::var_os("PATH").unwrap_or_default(); + if let Some(joined) = prepended_path(home.as_ref().map(Path::new), &existing) { + std::env::set_var("PATH", joined); + } +} + +/// Is `command` runnable — an executable on `PATH`, or an existing path? +fn resolves_on_path(command: &str) -> bool { + if command.contains(std::path::MAIN_SEPARATOR) { + return Path::new(command).is_file(); + } + std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).any(|dir| dir.join(command).is_file())) + .unwrap_or(false) +} + +/// The exec plugin the selected context authenticates with, if any. +/// +/// Read from the kubeconfig directly rather than from `Config`, which does not +/// expose it. A read failure yields `None`: this lookup exists only to improve +/// an error message, and must never be the thing that fails a deploy. +fn exec_plugin_for(context: Option<&str>) -> Option { + let kubeconfig = Kubeconfig::read().ok()?; + let context_name = context + .map(str::to_string) + .or_else(|| kubeconfig.current_context.clone())?; + let user_name = kubeconfig + .contexts + .iter() + .find(|c| c.name == context_name) + .and_then(|c| c.context.as_ref()) + .and_then(|c| c.user.clone())?; + kubeconfig + .auth_infos + .iter() + .find(|a| a.name == user_name) + .and_then(|a| a.auth_info.as_ref()) + .and_then(|a| a.exec.clone()) +} + +/// Turn a client-construction failure into an error a user can act on. +/// +/// When the context authenticates through an exec plugin that is not on +/// `PATH`, that is almost always the cause, and the actionable fact is the +/// plugin's name — not a kube-rs error chain (`:994-995`). +fn explain(context: Option<&str>, error: &kube::Error) -> String { + if let Some(command) = exec_plugin_for(context).and_then(|e| e.command) { + if !resolves_on_path(&command) { + return format!( + "kubeconfig context {} authenticates with the credential plugin \ + {command:?}, which is not on PATH. Install it or add its \ + directory to PATH, then try again.", + context.unwrap_or("(current)") + ); + } + } + format!( + "could not connect to the cluster using kubeconfig context {}: {error}", + context.unwrap_or("(current)") + ) +} + +/// Build a client for the selected context. +pub async fn connect(context: Option<&str>) -> Result { + prepend_plugin_path(); + + let options = KubeConfigOptions { + context: context.map(str::to_string), + ..Default::default() + }; + let config = Config::from_kubeconfig(&options).await.map_err(|e| { + // A named context that does not exist is a user typo, and the + // kube-rs message for it is already specific. + format!( + "could not load kubeconfig for context {}: {e}", + context.unwrap_or("(current)") + ) + })?; + + Client::try_from(config).map_err(|e| explain(context, &e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The three plugin directories must end up ahead of the inherited PATH, + /// or a Finder-launched desktop never finds `aws`/`gke-gcloud-auth-plugin`. + /// Tested on the pure computation: mutating the process PATH here would + /// race every other test in the binary. + #[test] + fn plugin_directories_are_prepended_in_order() { + let joined = prepended_path( + Some(Path::new("/tmp/fake-home")), + std::ffi::OsStr::new("/inherited/bin:/usr/bin"), + ) + .unwrap(); + let dirs: Vec = std::env::split_paths(&joined).collect(); + assert_eq!( + dirs, + [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/tmp/fake-home/.local/bin", + "/inherited/bin", + "/usr/bin", + ] + .map(PathBuf::from) + ); + } + + /// No `HOME` is not a failure — the two absolute directories still apply. + #[test] + fn missing_home_still_prepends_the_absolute_directories() { + let joined = prepended_path(None, std::ffi::OsStr::new("/inherited/bin")).unwrap(); + let dirs: Vec = std::env::split_paths(&joined).collect(); + assert_eq!( + dirs, + ["/opt/homebrew/bin", "/usr/local/bin", "/inherited/bin"].map(PathBuf::from) + ); + } + + /// An empty inherited PATH must not produce an empty entry, which the + /// shell and `resolves_on_path` would both read as the cwd. + #[test] + fn empty_inherited_path_yields_no_empty_entry() { + let joined = prepended_path(None, std::ffi::OsStr::new("")).unwrap(); + let dirs: Vec = std::env::split_paths(&joined).collect(); + assert_eq!( + dirs, + ["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from) + ); + } + + #[test] + fn resolves_absolute_paths_directly() { + assert!(resolves_on_path("/bin/sh")); + assert!(!resolves_on_path("/nonexistent/plugin-binary")); + } +} diff --git a/crates/buzz-backend-kubernetes/src/cluster.rs b/crates/buzz-backend-kubernetes/src/cluster.rs new file mode 100644 index 0000000000..755f41f6c8 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/cluster.rs @@ -0,0 +1,427 @@ +//! The real [`Substrate`]: kube-rs against a live apiserver. +//! +//! Everything that *decides* lives in `classify`/`gc`; this module only +//! performs I/O and maps apiserver responses onto the trait's vocabulary. +//! Three mappings here are normative rather than incidental: +//! +//! * **409 is discriminated on `Status.reason`, never on the code.** A create +//! 409 is `AlreadyExists`; a delete 409 from a failed precondition is +//! `Conflict`. Branching on `code == 409` conflates a lost create race with +//! a stale fence and is the trap the spec names (`:780-794`). +//! * **Reads leave `resourceVersion` unset**, which is the quorum read. `"0"` +//! is the cache read, and a confirmed absence from a cache is proof of +//! nothing (`:761-769`). +//! * **Deletes never set `grace_period_seconds`**, so the object's own 60s +//! budget applies. Passing `0` is a force-kill that discards the shutdown +//! window the pod declares (`:1185-1189`). + +use crate::classify::Fence; +use crate::reconcile::{CreateOutcome, DeleteOutcome, Substrate}; +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::{Namespace, Pod, Secret}; +use kube::api::{Api, DeleteParams, GetParams, ListParams, PostParams, Preconditions}; +use kube::core::ErrorResponse; +use kube::{Client, Resource}; +use std::time::{Duration, Instant}; + +/// `Status.reason` values we branch on. Spelled once so the two 409 arms are +/// visibly the same discriminator read two ways. +/// +/// These are wire strings `apimachinery` chooses, not names this crate picks: +/// `StatusReasonAlreadyExists`, `StatusReasonConflict`, `StatusReasonNotFound`, +/// and `StatusReasonForbidden` in `k8s.io/apimachinery/pkg/apis/meta/v1/types.go`. +/// kube-core types `ErrorResponse::reason` as a bare `String`, so there is no +/// upstream constant to bind to and the spelling is pinned by test instead. +const REASON_ALREADY_EXISTS: &str = "AlreadyExists"; +const REASON_CONFLICT: &str = "Conflict"; +const REASON_NOT_FOUND: &str = "NotFound"; +const REASON_FORBIDDEN: &str = "Forbidden"; + +/// The apiserver-backed substrate for one deploy operation. +pub struct Cluster { + client: Client, + namespace: String, + /// Start of *this operation*, for the deadline. Monotonic: the 600s budget + /// must not move when the wall clock does. + started: Instant, +} + +/// The typed API error underneath a `kube::Error`, if it is one. +fn api_error(error: &kube::Error) -> Option<&ErrorResponse> { + match error { + kube::Error::Api(response) => Some(response), + _ => None, + } +} + +/// Does this error carry the given `Status.reason`? +fn reason_is(error: &kube::Error, reason: &str) -> bool { + api_error(error).is_some_and(|e| e.reason == reason) +} + +impl Cluster { + pub fn new(client: Client, namespace: &str) -> Self { + Self { + client, + namespace: namespace.to_string(), + started: Instant::now(), + } + } + + fn pods(&self) -> Api { + Api::namespaced(self.client.clone(), &self.namespace) + } + + fn secrets(&self) -> Api { + Api::namespaced(self.client.clone(), &self.namespace) + } + + /// List an object kind through the raw client so the response's HTTP + /// `Date` header is reachable. + /// + /// `Api::list` returns only the decoded body, and the apiserver's clock is + /// the *only* clock the orphan-Secret age gate may use — a desktop's local + /// clock running fast computes every in-flight Secret as expired + /// (`:1321-1335`). So the list goes through `Client::send`, which hands + /// back the whole `http::Response`. + async fn list_with_date( + &self, + selector: &str, + ) -> Result<(Vec, Option>), String> + where + K: Resource + + Clone + + serde::de::DeserializeOwned + + std::fmt::Debug, + K::DynamicType: Default, + { + let dt = K::DynamicType::default(); + let url = K::url_path(&dt, Some(&self.namespace)); + // resourceVersion deliberately unset: quorum read. + let params = ListParams { + label_selector: Some(selector.to_string()), + ..Default::default() + }; + let request = kube::core::Request::new(url) + .list(¶ms) + .map_err(|e| format!("could not build a list request: {e}"))?; + let (parts, body) = request.into_parts(); + let response = self + .client + .send(http::Request::from_parts(parts, body.into())) + .await + .map_err(|e| format!("could not list {}: {e}", K::plural(&dt)))?; + + // Parsed before the body is consumed, and independently of it: a + // missing or malformed header is not a list failure, it just means the + // orphan sweep has no clock and skips. + let server_now = response + .headers() + .get(http::header::DATE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| DateTime::parse_from_rfc2822(v).ok()) + .map(|v| v.with_timezone(&Utc)); + + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .map_err(|e| format!("could not read the {} list body: {e}", K::plural(&dt)))? + .to_bytes(); + let list: kube::core::ObjectList = serde_json::from_slice(&bytes) + .map_err(|e| format!("could not decode the {} list: {e}", K::plural(&dt)))?; + + Ok((list.items, server_now)) + } +} + +impl Substrate for Cluster { + async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> { + let api: Api = Api::all(self.client.clone()); + if api + .get_opt(namespace) + .await + .map_err(|e| format!("could not check whether namespace {namespace} exists: {e}"))? + .is_some() + { + return Ok(()); + } + + let spec = Namespace { + metadata: kube::core::ObjectMeta { + name: Some(namespace.to_string()), + ..Default::default() + }, + ..Default::default() + }; + match api.create(&PostParams::default(), &spec).await { + Ok(_) => Ok(()), + // Someone else created it between our check and our create. That + // is the desired end state, not a failure. + Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(()), + // Namespace-create is frequently denied on shared clusters. Name + // the exact command an operator runs, and never silently fall back + // to `default` — deploying an agent into someone else's namespace + // is worse than refusing (`:1002-1005`). + Err(e) if reason_is(&e, REASON_FORBIDDEN) => Err(format!( + "not authorized to create namespace {namespace}. Ask a cluster \ + administrator to run `kubectl create namespace {namespace}`, \ + then try again." + )), + Err(e) => Err(format!("could not create namespace {namespace}: {e}")), + } + } + + async fn list_pods(&self, selector: &str) -> Result<(Vec, Option>), String> { + self.list_with_date::(selector).await + } + + async fn list_secrets(&self, selector: &str) -> Result, String> { + Ok(self.list_with_date::(selector).await?.0) + } + + async fn secret_exists(&self, name: &str) -> Result { + // `GetParams::default()` leaves resourceVersion unset — the quorum + // read this check requires to be proof of anything. + match self.secrets().get_with(name, &GetParams::default()).await { + Ok(_) => Ok(true), + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(false), + Err(e) => Err(format!("could not check whether secret {name} exists: {e}")), + } + } + + async fn create_secret(&self, secret: &Secret) -> Result<(), String> { + let name = secret.metadata.name.clone().unwrap_or_default(); + self.secrets() + .create(&PostParams::default(), secret) + .await + .map(|_| ()) + .map_err(|e| format!("could not create secret {name}: {e}")) + } + + async fn create_pod(&self, pod: &Pod) -> Result { + let name = pod.metadata.name.clone().unwrap_or_default(); + match self.pods().create(&PostParams::default(), pod).await { + Ok(_) => Ok(CreateOutcome::Created), + // The deterministic name is taken: a concurrent attempt won the + // election. Discriminated on the reason — a 409 whose reason is + // `Conflict` is a different condition and must not be read as a + // lost race. + Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(CreateOutcome::AlreadyExists), + Err(e) => Err(format!("could not create pod {name}: {e}")), + } + } + + async fn delete_pod(&self, name: &str, fence: &Fence) -> Result { + let params = DeleteParams { + preconditions: Some(Preconditions { + uid: Some(fence.uid.clone()), + resource_version: Some(fence.resource_version.clone()), + }), + // grace_period_seconds deliberately unset: the pod's own 60s + // budget applies. + ..Default::default() + }; + match self.pods().delete(name, ¶ms).await { + Ok(_) => Ok(DeleteOutcome::Accepted), + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(DeleteOutcome::NotFound), + // The object changed since the observation that authorized this + // delete. Same HTTP code as the create race above, different + // reason, different meaning. + Err(e) if reason_is(&e, REASON_CONFLICT) => Ok(DeleteOutcome::PreconditionFailed), + Err(e) => Err(format!("could not delete pod {name}: {e}")), + } + } + + async fn delete_secret(&self, name: &str) -> Result<(), String> { + match self.secrets().delete(name, &DeleteParams::default()).await { + Ok(_) => Ok(()), + // Already gone is the desired end state. + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(()), + Err(e) => Err(format!("could not delete secret {name}: {e}")), + } + } + + async fn get_pod(&self, name: &str) -> Result, String> { + match self.pods().get_with(name, &GetParams::default()).await { + Ok(pod) => Ok(Some(pod)), + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(None), + Err(e) => Err(format!("could not read pod {name}: {e}")), + } + } + + async fn sleep(&self, duration: Duration) { + tokio::time::sleep(duration).await; + } + + fn elapsed(&self) -> Duration { + self.started.elapsed() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::Response; + use kube::client::Body; + use std::sync::{Arc, Mutex}; + use tower::service_fn; + + fn list_response(date: Option<&str>) -> Response { + let mut response = Response::builder().status(200); + if let Some(date) = date { + response = response.header(http::header::DATE, date); + } + response + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "apiVersion": "v1", + "kind": "PodList", + "metadata": {"resourceVersion": "17"}, + "items": [{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": "sprig"}, + "spec": {"containers": [{"name": "agent", "image": "example.invalid/sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]} + }] + })) + .unwrap(), + )) + .unwrap() + } + + async fn list_through_real_request_path( + date: Option<&'static str>, + ) -> (Vec, Option>, String) { + let observed_uri = Arc::new(Mutex::new(None)); + let service_uri = Arc::clone(&observed_uri); + let service = service_fn(move |request: http::Request| { + let service_uri = Arc::clone(&service_uri); + async move { + *service_uri.lock().unwrap() = Some(request.uri().to_string()); + Ok::<_, std::convert::Infallible>(list_response(date)) + } + }); + let cluster = Cluster::new(Client::new(service, "ignored"), "owned-ns"); + let result = cluster + .list_with_date::("app.kubernetes.io/managed-by=buzz-backend-kubernetes") + .await + .unwrap(); + let uri = observed_uri.lock().unwrap().take().unwrap(); + (result.0, result.1, uri) + } + + /// Exercise the shipped `Request` + `Client::send` seam. A fake + /// reconciler would not prove that kube-rs emits a quorum list request or + /// that the apiserver's clock survives body decoding. + #[tokio::test] + async fn list_with_date_uses_a_quorum_request_and_returns_the_server_clock() { + let (pods, server_now, uri) = + list_through_real_request_path(Some("Sun, 02 Aug 2026 04:00:00 GMT")).await; + + assert_eq!(pods.len(), 1, "fixture must contain one decoded pod"); + assert_eq!(pods[0].metadata.name.as_deref(), Some("sprig")); + assert!(uri.starts_with("/api/v1/namespaces/owned-ns/pods?")); + assert!( + uri.contains("labelSelector=app.kubernetes.io%2Fmanaged-by%3Dbuzz-backend-kubernetes") + ); + assert!( + !uri.contains("resourceVersion"), + "cache read leaked into {uri}" + ); + assert_eq!( + server_now.unwrap().to_rfc3339(), + "2026-08-02T04:00:00+00:00" + ); + } + + /// Header failure is deliberately not list failure: without a trustworthy + /// apiserver clock the orphan sweep skips, but normal reconciliation still + /// receives the decoded objects. + #[tokio::test] + async fn list_with_date_keeps_items_when_the_server_clock_is_unusable() { + for date in [Some("not a date"), None] { + let (pods, server_now, _) = list_through_real_request_path(date).await; + assert_eq!(pods.len(), 1, "fixture must contain one decoded pod"); + assert!(server_now.is_none(), "unexpected clock for {date:?}"); + } + } + + /// A typed apiserver error, as kube-rs surfaces it. + fn api(reason: &str, code: u16) -> kube::Error { + kube::Error::Api(ErrorResponse { + status: "Failure".into(), + message: String::new(), + reason: reason.into(), + code, + }) + } + + /// The one discriminator the whole file rests on, and the trap the spec + /// predicts: "an implementation that branches on the code alone will + /// eventually take the adoption path on a failed delete or vice versa" + /// (`:788-790`). + /// + /// Both of these are 409. Reading the *code* makes them identical; reading + /// `Status.reason` keeps a lost create race and a stale fence apart. The + /// mutation that must fail this test is `e.reason == …` → `e.code == 409`, + /// which no other test in the crate would catch — the fakes never produce + /// a real `kube::Error`. + #[test] + fn the_two_409s_are_never_conflated() { + let already_exists = api(REASON_ALREADY_EXISTS, 409); + let conflict = api(REASON_CONFLICT, 409); + + assert!(reason_is(&already_exists, REASON_ALREADY_EXISTS)); + assert!(reason_is(&conflict, REASON_CONFLICT)); + // The cross terms are the whole point. + assert!(!reason_is(&already_exists, REASON_CONFLICT)); + assert!(!reason_is(&conflict, REASON_ALREADY_EXISTS)); + } + + /// A transport-level failure is not an apiserver verdict. It must fall + /// through to the error arm rather than being read as any reason — a + /// connection reset silently classified as `NotFound` would report a pod + /// as confirmed-absent, which the classifier treats as proof. + #[test] + fn a_non_api_error_carries_no_reason() { + let transport = kube::Error::LinesCodecMaxLineLengthExceeded; + assert!(api_error(&transport).is_none()); + for reason in [ + REASON_ALREADY_EXISTS, + REASON_CONFLICT, + REASON_NOT_FOUND, + REASON_FORBIDDEN, + ] { + assert!(!reason_is(&transport, reason), "matched {reason}"); + } + } + + /// `reason` is `#[serde(default)]` in kube-core, so an apiserver that + /// omits it yields an empty string. That must match nothing rather than + /// matching an empty pattern by accident. + #[test] + fn an_absent_reason_matches_nothing() { + let bare = api("", 409); + assert!(!reason_is(&bare, REASON_ALREADY_EXISTS)); + assert!(!reason_is(&bare, REASON_CONFLICT)); + } + + /// The consts are the apiserver's spelling, asserted against literals + /// rather than against themselves. + /// + /// Every other test here references the consts symbolically on both sides + /// — fixture *and* assertion — which is true for any pair of distinct + /// values. That tests the discriminator is self-consistent, not that it is + /// correct: swapping the two 409 values inverts `AlreadyExists` and + /// `Conflict` at a real apiserver (`:788-790`'s failure, reached by + /// editing a string instead of a branch) with every other test still + /// green. These are `apimachinery`'s wire strings and kube-core exposes no + /// constant for them, so a literal is the only external anchor available. + /// Found by Quinn's mutation matrix; M2/M3/M4 survived without it. + #[test] + fn the_reason_consts_are_the_apiservers_spelling() { + assert_eq!(REASON_ALREADY_EXISTS, "AlreadyExists"); + assert_eq!(REASON_CONFLICT, "Conflict"); + assert_eq!(REASON_NOT_FOUND, "NotFound"); + assert_eq!(REASON_FORBIDDEN, "Forbidden"); + } +} diff --git a/crates/buzz-backend-kubernetes/src/config.rs b/crates/buzz-backend-kubernetes/src/config.rs new file mode 100644 index 0000000000..39b68ce0f2 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/config.rs @@ -0,0 +1,443 @@ +//! `provider_config` parsing and the `info` config schema +//! (spec §`provider_config` v1 fields, `docs/remote-agents.md:1384-1389`). +//! +//! Nine fields, all optional except `image` (v1 ships no baked default — +//! §Image). No credential field exists, by I2: cluster auth comes from ambient +//! kubeconfig resolution and nothing else (`:196-198`). + +use crate::image::{self, ImageRef}; + +/// Resource requests and limits (§Pod shape: 1cpu/2Gi → 2cpu/4Gi, all four +/// configurable — `cargo build` in an agent workspace makes 500m/1Gi +/// unrealistic). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resources { + pub cpu_request: String, + pub memory_request: String, + pub cpu_limit: String, + pub memory_limit: String, +} + +impl Default for Resources { + fn default() -> Self { + Self { + cpu_request: "1".into(), + memory_request: "2Gi".into(), + cpu_limit: "2".into(), + memory_limit: "4Gi".into(), + } + } +} + +/// Default inactivity budget: the I5 opt-in (§Auto-Stop). The config field and +/// `BUZZ_ACP_EXIT_AFTER_INACTIVITY` are one knob, not two. +pub const DEFAULT_INACTIVITY_SECONDS: u64 = 7200; + +/// Fixed nonzero UID/GID for the agent container (§Pod shape hardening). +pub const RUN_AS_UID: i64 = 10001; +pub const RUN_AS_GID: i64 = 10001; + +/// Writable workspace root; also `HOME` and the harness's cwd +/// (§Working directory). +pub const WORKSPACE_PATH: &str = "/home/agent"; + +/// `terminationGracePeriodSeconds` — a declared budget, not a derived sum +/// (§Pod shape). Kubernetes' default 30s would SIGKILL the harness mid-drain. +pub const TERMINATION_GRACE_SECONDS: i64 = 60; + +/// The only restart policy v1 ships. `OnFailure` is double-gated on the +/// harness exit-code contract *and* a crash-loop classification row the state +/// machine does not have (`:1121-1139`); until both land the provider refuses +/// the combination rather than shipping against an undefended convention. +pub const RESTART_POLICY: &str = "Never"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderConfig { + /// kubeconfig context; `None` uses the current context. + pub context: Option, + pub namespace: String, + pub image: ImageRef, + pub resources: Resources, + /// `None` when `inactivity_seconds` was 0 — refused in v1, see [`parse`]. + pub inactivity_seconds: Option, + pub service_account: Option, +} + +/// Read an optional non-empty string field. Rejects non-string scalars rather +/// than stringifying them, so a mistyped field is named at the boundary. +fn optional_string(cfg: &serde_json::Value, field: &str) -> Result, String> { + match cfg.get(field) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None), + Some(serde_json::Value::String(s)) => Ok(Some(s.trim().to_string())), + Some(other) => Err(format!( + "provider_config.{field} must be a string, got {other}" + )), + } +} + +/// Read an optional unsigned integer. The desktop's form omits blank numeric +/// fields rather than sending `""`, but a hand-crafted payload may send a +/// numeric string — accept both, refuse anything else. +fn optional_u64(cfg: &serde_json::Value, field: &str) -> Result, String> { + match cfg.get(field) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| { + format!("provider_config.{field} must be a non-negative integer, got {n}") + }), + Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None), + Some(serde_json::Value::String(s)) => s.trim().parse::().map(Some).map_err(|_| { + format!("provider_config.{field} must be a non-negative integer, got {s:?}") + }), + Some(other) => Err(format!( + "provider_config.{field} must be a non-negative integer, got {other}" + )), + } +} + +/// A Kubernetes namespace name: RFC 1123 label, ≤63 chars. Validated here so a +/// typo fails with a named field instead of an apiserver rejection partway +/// through a deploy. +fn valid_namespace(name: &str) -> bool { + !name.is_empty() + && name.len() <= 63 + && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && name.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +pub fn parse(cfg: &serde_json::Value) -> Result { + if !cfg.is_object() && !cfg.is_null() { + return Err("provider_config must be a JSON object".to_string()); + } + + let namespace = optional_string(cfg, "namespace")?.ok_or_else(|| { + "provider_config.namespace is required: the info schema supplies a \ + generated default, so an empty value means the form was cleared" + .to_string() + })?; + if !valid_namespace(&namespace) { + return Err(format!( + "provider_config.namespace {namespace:?} is not a valid Kubernetes \ + namespace (lowercase alphanumerics and '-', ≤63 characters)" + )); + } + + let image = image::parse(optional_string(cfg, "image")?.unwrap_or_default().as_str())?; + + let defaults = Resources::default(); + let resources = Resources { + cpu_request: optional_string(cfg, "cpu_request")?.unwrap_or(defaults.cpu_request), + memory_request: optional_string(cfg, "memory_request")?.unwrap_or(defaults.memory_request), + cpu_limit: optional_string(cfg, "cpu_limit")?.unwrap_or(defaults.cpu_limit), + memory_limit: optional_string(cfg, "memory_limit")?.unwrap_or(defaults.memory_limit), + }; + + // `inactivity_seconds: 0` is a legal, blessed value in the spec (§Auto-Stop) + // meaning "no auto-stop" — but it selects `restartPolicy: OnFailure`, which + // §Pod shape forbids until the harness exit-code contract is pinned AND the + // state machine gains a crash-loop row. Refusing the *combination* is what + // the spec asks for; silently downgrading to `Never` would ship an + // indefinite agent that dies on its first crash. + let inactivity_seconds = match optional_u64(cfg, "inactivity_seconds")? { + None => Some(DEFAULT_INACTIVITY_SECONDS), + Some(0) => { + return Err( + "provider_config.inactivity_seconds: 0 (indefinite lifetime) is not \ + supported in this version: it requires restartPolicy OnFailure, \ + which is gated on the harness exit-code contract. Set a positive \ + number of seconds." + .to_string(), + ) + } + Some(n) => Some(n), + }; + + Ok(ProviderConfig { + context: optional_string(cfg, "context")?, + namespace, + image, + resources, + inactivity_seconds, + service_account: optional_string(cfg, "service_account")?, + }) +} + +/// A fresh `buzz-agents-` namespace default. +/// +/// Computed per `info` call, which is how "random default" is satisfied with +/// zero UI changes: the schema's `default` prefills the form (§K8s Namespace). +pub fn generated_namespace() -> String { + use rand::RngExt; + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::rng(); + let suffix: String = (0..6) + .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char) + .collect(); + format!("buzz-agents-{suffix}") +} + +/// The `config_schema` returned by `info`. Drives the UI form: +/// `properties[*].default` prefill, scalar coercion, `required` gating +/// (`:407-411`). +pub fn config_schema() -> serde_json::Value { + let defaults = Resources::default(); + serde_json::json!({ + "type": "object", + "properties": { + "context": { + "type": "string", + "title": "Kubeconfig context", + "description": "Context from your kubeconfig. Leave empty to use the current context." + }, + "namespace": { + "type": "string", + "title": "Namespace", + "description": "Created if it does not exist.", + "default": generated_namespace() + }, + "image": { + "type": "string", + "title": "Agent image", + "description": "Digest-pinned image containing the buzz-acp runtime ABI, e.g. ghcr.io/block/buzz-sprig@sha256:. Tags are not accepted: this pod holds the agent's private key." + }, + "cpu_request": { + "type": "string", "title": "CPU request", "default": defaults.cpu_request + }, + "memory_request": { + "type": "string", "title": "Memory request", "default": defaults.memory_request + }, + "cpu_limit": { + "type": "string", "title": "CPU limit", "default": defaults.cpu_limit + }, + "memory_limit": { + "type": "string", "title": "Memory limit", "default": defaults.memory_limit + }, + "inactivity_seconds": { + "type": "number", + "title": "Stop after inactivity (seconds)", + "description": "The agent exits after this long with no work, and can be started again at any time.", + "default": DEFAULT_INACTIVITY_SECONDS + }, + "service_account": { + "type": "string", + "title": "Service account", + "description": "Scheduling/RBAC identity only. No API token is mounted." + } + }, + "required": ["namespace", "image"] + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn digest_ref() -> String { + format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)) + } + + fn minimal() -> serde_json::Value { + serde_json::json!({"namespace": "buzz-agents-abc123", "image": digest_ref()}) + } + + #[test] + fn applies_spec_defaults() { + let c = parse(&minimal()).unwrap(); + assert_eq!(c.resources, Resources::default()); + assert_eq!(c.resources.cpu_request, "1"); + assert_eq!(c.resources.memory_request, "2Gi"); + assert_eq!(c.resources.cpu_limit, "2"); + assert_eq!(c.resources.memory_limit, "4Gi"); + assert_eq!(c.inactivity_seconds, Some(DEFAULT_INACTIVITY_SECONDS)); + assert_eq!(c.context, None); + assert_eq!(c.service_account, None); + } + + #[test] + fn all_four_resources_are_configurable() { + let mut cfg = minimal(); + cfg["cpu_request"] = "500m".into(); + cfg["memory_request"] = "1Gi".into(); + cfg["cpu_limit"] = "4".into(); + cfg["memory_limit"] = "8Gi".into(); + let c = parse(&cfg).unwrap(); + assert_eq!( + c.resources, + Resources { + cpu_request: "500m".into(), + memory_request: "1Gi".into(), + cpu_limit: "4".into(), + memory_limit: "8Gi".into(), + } + ); + } + + /// The desktop's form omits blank numeric fields; a hand-crafted payload + /// may send a numeric string. Both must mean the same thing. + #[test] + fn inactivity_accepts_number_string_and_omission() { + let mut cfg = minimal(); + cfg["inactivity_seconds"] = serde_json::json!(300); + assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300)); + + cfg["inactivity_seconds"] = serde_json::json!("300"); + assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300)); + + cfg["inactivity_seconds"] = serde_json::json!(""); + assert_eq!( + parse(&cfg).unwrap().inactivity_seconds, + Some(DEFAULT_INACTIVITY_SECONDS) + ); + } + + /// Indefinite lifetime selects `OnFailure`, which is gated. Refuse rather + /// than silently downgrade — a downgraded agent dies on its first crash + /// while the user believes they asked for indefinite. + #[test] + fn refuses_indefinite_lifetime() { + let mut cfg = minimal(); + cfg["inactivity_seconds"] = serde_json::json!(0); + let err = parse(&cfg).unwrap_err(); + assert!(err.contains("inactivity_seconds"), "got: {err}"); + assert!( + err.contains("OnFailure"), + "error should name the gate: {err}" + ); + } + + #[test] + fn rejects_negative_and_non_numeric_inactivity() { + for bad in [ + serde_json::json!(-1), + serde_json::json!(1.5), + serde_json::json!("soon"), + serde_json::json!(true), + ] { + let mut cfg = minimal(); + cfg["inactivity_seconds"] = bad.clone(); + assert!(parse(&cfg).is_err(), "accepted {bad}"); + } + } + + #[test] + fn image_is_required_and_must_be_digest_pinned() { + let mut cfg = minimal(); + cfg.as_object_mut().unwrap().remove("image"); + assert!(parse(&cfg).unwrap_err().contains("provider_config.image")); + + cfg["image"] = "ghcr.io/block/buzz-sprig:latest".into(); + assert!(parse(&cfg).unwrap_err().contains("digest-pinned")); + } + + #[test] + fn rejects_invalid_namespace_names() { + for bad in [ + "", + "Buzz-Agents", + "-leading", + "trailing-", + "has_underscore", + &"n".repeat(64), + ] { + let mut cfg = minimal(); + cfg["namespace"] = bad.into(); + assert!(parse(&cfg).is_err(), "accepted namespace {bad:?}"); + } + } + + /// I2 corollary: there is no config path for cluster credentials, so a + /// caller that tries to supply one gets no effect from it. Asserting the + /// parsed struct has no such field is the closest a test can get to + /// "the type makes it impossible". + #[test] + fn credential_fields_have_no_effect() { + let mut cfg = minimal(); + cfg["token"] = "hunter2".into(); + cfg["client_key"] = "hunter2".into(); + let c = parse(&cfg).unwrap(); + let rendered = format!("{c:?}"); + assert!( + !rendered.contains("hunter2"), + "config absorbed a credential: {rendered}" + ); + } + + #[test] + fn mistyped_string_fields_are_named() { + let mut cfg = minimal(); + cfg["namespace"] = serde_json::json!(42); + assert!(parse(&cfg) + .unwrap_err() + .contains("provider_config.namespace")); + } + + #[test] + fn generated_namespaces_are_fresh_and_valid() { + let a = generated_namespace(); + let b = generated_namespace(); + assert_ne!(a, b, "namespace default is not random"); + assert!(valid_namespace(&a), "{a} is not a valid namespace"); + assert!(a.starts_with("buzz-agents-")); + assert_eq!(a.len(), "buzz-agents-".len() + 6); + } + + /// The schema's own namespace default must be a value the parser accepts — + /// otherwise the UI prefills a form that fails on submit. + #[test] + fn schema_default_namespace_round_trips_through_parse() { + let schema = config_schema(); + let default = schema["properties"]["namespace"]["default"] + .as_str() + .unwrap(); + let cfg = serde_json::json!({"namespace": default, "image": digest_ref()}); + assert_eq!(parse(&cfg).unwrap().namespace, default); + } + + /// Nine fields exactly (§`provider_config` v1 fields). The cap is 20; the + /// count is pinned so a field added without a spec change is caught here. + #[test] + fn schema_declares_exactly_the_nine_v1_fields() { + let schema = config_schema(); + let props = schema["properties"].as_object().unwrap(); + let mut keys: Vec<&str> = props.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!( + keys, + [ + "context", + "cpu_limit", + "cpu_request", + "image", + "inactivity_seconds", + "memory_limit", + "memory_request", + "namespace", + "service_account" + ] + ); + assert_eq!( + schema["required"], + serde_json::json!(["namespace", "image"]) + ); + } + + /// I2's key lint rejects any field whose word-split contains + /// secret|password|token|key|credential. A schema field tripping it would + /// make every deploy fail validation desktop-side (`:185-198`). + #[test] + fn no_schema_field_trips_the_i2_key_lint() { + const BANNED: [&str; 5] = ["secret", "password", "token", "key", "credential"]; + let schema = config_schema(); + for field in schema["properties"].as_object().unwrap().keys() { + for word in field.split(['_', '-']) { + assert!( + !BANNED.contains(&word), + "field {field:?} contains I2-banned word {word:?}" + ); + } + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/env.rs b/crates/buzz-backend-kubernetes/src/env.rs new file mode 100644 index 0000000000..badff621e8 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/env.rs @@ -0,0 +1,732 @@ +//! Building the pod environment (spec §Launch data, §Entrypoint mapping table). +//! +//! The three tiers are resolved *here*, before serialization, because a +//! Kubernetes Secret's `data` is a flat map with no precedence of its own: if +//! two tiers supplied the same key, whichever entry landed in the map would +//! win silently. Resolving in-provider makes later-wins explicit and testable. + +use crate::wire::{AgentPayload, LaunchBlock}; +use std::collections::BTreeMap; + +/// Keys the authoritative tier owns. +/// +/// Load-bearing, not documentation: tier 3 *clears* every key on this list +/// before writing its own values, so a key the authoritative tier has no value +/// for is **removed** rather than left holding a lower-tier value. Plain +/// overwrite is not enough — most of these are written conditionally +/// (`BUZZ_ACP_AGENT_ARGS` only when `launch.args` is non-empty, +/// `BUZZ_ACP_RESPOND_TO` only when set), and without the clear, a lower tier +/// could supply the value for exactly the cases the authoritative tier stays +/// silent on. Clearing is also what the local spawn does: the desktop strips +/// reserved keys from user env before the authoritative layer is written +/// (`env_vars.rs:54-57`), so absent-means-absent in both paths. +const AUTHORITATIVE_KEYS: &[&str] = &[ + "BUZZ_RELAY_URL", + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_ACP_AGENT_OWNER", + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + START_NONCE_KEY, +]; + +/// The attempt's generation, as the harness sees it. Also the Secret's name +/// suffix — one generation, one identity — so the reconciler restamps this on +/// every create attempt rather than letting the caller's value persist across +/// a retry. +pub const START_NONCE_KEY: &str = "BUZZ_MANAGED_AGENT_START_NONCE"; + +/// Presence is the only remote liveness signal (I3), so a launch that +/// suppresses it is non-conforming (L1 item 2) — and unlike a reserved-key +/// collision, there is no "authoritative value" to overwrite it with. Refuse. +const FORBIDDEN_KEY: &str = "BUZZ_ACP_NO_PRESENCE"; + +/// Kubernetes' own cap on the summed value bytes of a Secret +/// (`MaxSecretSize`, `pkg/apis/core/types.go`). Enforced here so an oversized +/// env surfaces as a named provider error rather than an apiserver rejection +/// partway through a deploy. +const MAX_SECRET_BYTES: usize = 1024 * 1024; + +/// A POSIX-shaped env var name: `[A-Za-z_][A-Za-z0-9_]*`. +/// +/// Kubernetes validates Secret *keys* as `IsConfigMapKey` +/// (`[-._a-zA-Z0-9]+`), which is looser — `foo.bar` is a legal Secret key. +/// What the kubelet then does with such a key **changed between versions**: +/// through 1.29 it filtered invalid env names out of `envFrom` and emitted an +/// `InvalidEnvironmentVariableNames` warning event +/// (`pkg/kubelet/kubelet_pods.go:646,654` at v1.29.0); from 1.30 that filter +/// is gone (KEP-4369) and the key is injected verbatim. The same manifest +/// would silently drop a variable on one cluster and set it on another, so we +/// fail closed on the provider side and get one deterministic behavior. +fn is_posix_env_key(key: &str) -> bool { + let mut chars = key.chars(); + match chars.next() { + Some(c) if c == '_' || c.is_ascii_alphabetic() => {} + _ => return false, + } + chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + +/// An identity component (L1 item 1) is present only if it is nonempty after +/// trimming — and the **trimmed form is what gets stored**. The validator and +/// the writer must never disagree about the value: a guard that accepts +/// `" wss://relay "` and then writes it with the padding intact has only +/// moved the failure from a loud refusal to a connect error in the harness. +fn identity_component(value: &str) -> Option<&str> { + let trimmed = value.trim(); + (!trimmed.is_empty()).then_some(trimmed) +} + +/// The harness's `allowlist` gate mode, spelled as the desktop serializes +/// `RespondTo` (kebab-case) and as `buzz-acp`'s CLI parses it. +const RESPOND_TO_ALLOWLIST: &str = "allowlist"; + +/// Every gate mode `buzz-acp` accepts, spelled as its `clap::ValueEnum` parses +/// them (`config.rs:95-101`, kebab-case via `RespondTo`'s `Display`). +/// +/// Deliberately the **harness's** four and not the desktop's three: the desktop +/// rejects `nobody` on purpose (`managed_agents/types.rs:871-880`), but the +/// harness starts fine with it. This guard exists to cover non-desktop callers, +/// so inheriting a desktop-only narrowing would refuse a launch that works. +const RESPOND_TO_MODES: [&str; 4] = ["owner-only", RESPOND_TO_ALLOWLIST, "anyone", "nobody"]; + +/// Refuse a respond-to gate the harness will reject at config parse. +/// +/// The local spawn path re-validates this before spawning — "doing it here +/// means we never spawn a doomed process" (`runtime.rs:378`) — but the deploy +/// path projects the record's fields straight through. Without this, a gate +/// the harness refuses becomes a pod that exits 1 at startup; `restartPolicy: +/// Never` turns that into `Terminated` → `Delete` → recreate, and each cycle +/// leaves a Secret the in-call path never reaps (only a later deploy's orphan +/// sweep does, at `ORPHAN_SECRET_MIN_AGE_SECS`). The user-visible ending is +/// "startup not confirmed", indistinguishable from a slow cluster. +/// +/// Mirrors `buzz-acp`'s own rules exactly (`config.rs:95-101,996-1004,629-641`), +/// deliberately including their asymmetry: the allowlist is validated **only** +/// in allowlist mode, and merely warned about otherwise. Validating it in +/// every mode would refuse a deploy whose identical local spawn succeeds — +/// and a stale list is already harmless here, since +/// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` is an authoritative key that tier 3 clears. +fn validate_respond_to_gate(respond_to: &str, allowlist: Option<&[String]>) -> Result<(), String> { + // Exact, untrimmed: `clap` does not trim, so `" allowlist "` is `rc=2` at + // the harness — a parse failure even earlier than the config errors below. + if !RESPOND_TO_MODES.contains(&respond_to) { + return Err(format!( + "deploy refused: respond_to {respond_to:?} is not a mode the \ + harness accepts (expected one of {}) — the pod would fail to \ + parse its arguments, be replaced, and leave a Secret behind on \ + every attempt", + RESPOND_TO_MODES.join(", ") + )); + } + if respond_to != RESPOND_TO_ALLOWLIST { + return Ok(()); + } + let entries = allowlist.unwrap_or_default(); + if entries.is_empty() { + return Err(format!( + "deploy refused: respond_to is {RESPOND_TO_ALLOWLIST:?} but the \ + allowlist is empty — the harness refuses this at startup, so the \ + pod would fail, be replaced, and leave a Secret behind on every \ + attempt" + )); + } + for entry in entries { + let trimmed = entry.trim(); + if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "deploy refused: invalid pubkey in respond_to_allowlist: \ + {entry:?} (must be exactly 64 hex characters)" + )); + } + } + Ok(()) +} + +/// Inputs the provider itself supplies to the authoritative tier. +pub struct AuthoritativeInputs<'a> { + /// The attempt's generation token — also the Secret's name suffix, so the + /// lifecycle correlator and the Secret generation are one identity. + pub generation: &'a str, + /// Resolved from `provider_config.inactivity_seconds`; `None` when the + /// indefinite opt-in was chosen (which this version refuses elsewhere). + pub inactivity_seconds: Option, +} + +/// Resolve the full pod environment. +/// +/// Order is the spec's, and the function body is deliberately three writes in +/// that order — tier 1, tier 2, tier 3 — so "later wins" is visible rather +/// than argued. +pub fn build_env( + agent: &AgentPayload, + auth: AuthoritativeInputs<'_>, +) -> Result, String> { + let default_launch = LaunchBlock::default(); + let launch = agent.launch.as_ref().unwrap_or(&default_launch); + + let mut env: BTreeMap = BTreeMap::new(); + + // Tier 1 — overridable behavior defaults. + env.extend(launch.policy_env.clone()); + + // Tier 2 — user/layered env. The descriptor already merged + // global < persona < agent, so `agent.env_vars` is NOT re-merged on top + // (§Launch data tier 2) — doing so would resurrect a layer the desktop + // already resolved. When the desktop predates the `launch` block we fall + // back to the legacy field, which is the only case it is the truth. + if agent.launch.is_some() { + env.extend(launch.env.clone()); + } else { + env.extend(agent.env_vars.clone()); + } + + // Validate what the lower tiers contributed, before the authoritative + // tier overwrites any of it. A reserved-key collision is NOT fatal: the + // spec's precedence is later-wins, so tier 3 simply overwrites it, which + // is exactly what a local spawn does. Only a key that has no + // authoritative counterpart to overwrite it — presence suppression — is + // a refusal. + for key in env.keys() { + if !is_posix_env_key(key) { + return Err(format!( + "env key {key:?} is not a POSIX environment variable name \ + ([A-Za-z_][A-Za-z0-9_]*); Kubernetes would treat it \ + inconsistently across cluster versions" + )); + } + if key.eq_ignore_ascii_case(FORBIDDEN_KEY) { + return Err(format!( + "{FORBIDDEN_KEY} must not be set on a remote agent: presence \ + is the only signal that a remote agent is alive" + )); + } + } + + // Tier 3 — authoritative. Every key it owns is cleared first, then the + // values it has are written, so it wins at a key whether or not it has a + // value there (see [`AUTHORITATIVE_KEYS`]). + for key in AUTHORITATIVE_KEYS { + env.remove(*key); + } + // Identity comes from top-level payload fields, never from `env_vars` + // (§Reserved-key rule). All three components must be nonempty: an agent + // that cannot reach a relay is the identityless launch L1 item 1 exists to + // prevent, and a blank field would otherwise sail through into the Secret + // and produce a pod that starts, fails to connect, and looks like a + // network problem. + let Some(relay_url) = identity_component(&agent.relay_url) else { + return Err("deploy refused: relay_url is empty — the agent would have \ + no relay to connect to" + .to_string()); + }; + env.insert("BUZZ_RELAY_URL".into(), relay_url.to_string()); + env.insert("BUZZ_PRIVATE_KEY".into(), agent.private_key_nsec.clone()); + // The git credential/signing helpers read NOSTR_PRIVATE_KEY. + env.insert("NOSTR_PRIVATE_KEY".into(), agent.private_key_nsec.clone()); + + // Owner: at least one of these must resolve, or the harness cannot match + // `!shutdown` and §Stop describes a mechanism that does not work. + let auth_tag = agent.auth_tag.as_deref().and_then(identity_component); + let owner = launch.owner_pubkey.as_deref().and_then(identity_component); + match (auth_tag, owner) { + (None, None) => { + return Err("deploy refused: neither auth_tag nor launch.owner_pubkey \ + resolved — without an owner the agent cannot honor \ + !shutdown" + .to_string()) + } + (tag, own) => { + if let Some(t) = tag { + env.insert("BUZZ_AUTH_TAG".into(), t.to_string()); + } + if let Some(o) = own { + env.insert("BUZZ_ACP_AGENT_OWNER".into(), o.to_string()); + } + } + } + + // The harness and MCP binaries are resolved against the *image's* PATH. + // A host path forwarded from the desktop is guaranteed absent in the + // container (§Launch data, host-resolved values). + if let Some(command) = launch.command.as_deref().filter(|c| !c.is_empty()) { + env.insert("BUZZ_ACP_AGENT_COMMAND".into(), command.to_string()); + } + if !launch.args.is_empty() { + // Comma-joined because that is what the harness's CLI parser decodes, + // and what the desktop's local spawn does. An argument containing a + // comma is unrepresentable in both paths; inventing an escaping + // scheme here would produce args the harness cannot decode. + env.insert("BUZZ_ACP_AGENT_ARGS".into(), launch.args.join(",")); + } + env.insert("BUZZ_ACP_MCP_COMMAND".into(), "buzz-dev-mcp".into()); + + if let Some(respond_to) = agent.respond_to.as_deref().filter(|s| !s.is_empty()) { + validate_respond_to_gate(respond_to, agent.respond_to_allowlist.as_deref())?; + env.insert("BUZZ_ACP_RESPOND_TO".into(), respond_to.to_string()); + } + if let Some(list) = agent + .respond_to_allowlist + .as_ref() + .filter(|l| !l.is_empty()) + { + env.insert("BUZZ_ACP_RESPOND_TO_ALLOWLIST".into(), list.join(",")); + } + + if let Some(secs) = auth.inactivity_seconds { + env.insert("BUZZ_ACP_EXIT_AFTER_INACTIVITY".into(), secs.to_string()); + } + // The generation token doubles as the lifecycle-frame correlator, so pod + // logs and observer frames share one identity (§K8s Secrets). + env.insert(START_NONCE_KEY.into(), auth.generation.to_string()); + + let total: usize = env.values().map(String::len).sum(); + if total > MAX_SECRET_BYTES { + return Err(format!( + "agent environment is {total} bytes; Kubernetes caps Secret data \ + at {MAX_SECRET_BYTES}" + )); + } + + Ok(env) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload_json(extra_agent: serde_json::Value) -> AgentPayload { + let mut agent = serde_json::json!({ + "name": "a", + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1example", + "auth_tag": "tag-1", + }); + let (serde_json::Value::Object(base), serde_json::Value::Object(extra)) = + (&mut agent, extra_agent) + else { + panic!("expected objects") + }; + base.extend(extra); + serde_json::from_value(agent).unwrap() + } + + fn build(agent: &AgentPayload) -> Result, String> { + build_env( + agent, + AuthoritativeInputs { + generation: "gen0001", + inactivity_seconds: Some(7200), + }, + ) + } + + #[test] + fn identity_comes_from_top_level_fields() { + let env = build(&payload_json(serde_json::json!({}))).unwrap(); + assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example"); + assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1"); + } + + /// Wren's amendment, and the spec's later-wins rule: a lower tier that + /// spoofs an authoritative key is *overwritten*, not refused. Refusing + /// would diverge from the local spawn, where the same env is written + /// before the authoritative layer and simply loses. + #[test] + fn lower_tiers_cannot_spoof_authoritative_values() { + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "goose", + "policy_env": { + "BUZZ_PRIVATE_KEY": "nsec1attacker", + "BUZZ_MANAGED_AGENT_START_NONCE": "forged", + }, + "env": { + "BUZZ_RELAY_URL": "wss://attacker.example", + "NOSTR_PRIVATE_KEY": "nsec1attacker", + "BUZZ_AUTH_TAG": "forged-tag", + "BUZZ_ACP_AGENT_OWNER": "cafe", + "BUZZ_ACP_AGENT_COMMAND": "/bin/sh", + "BUZZ_ACP_MCP_COMMAND": "/bin/sh", + "BUZZ_ACP_EXIT_AFTER_INACTIVITY": "0", + }, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example"); + assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1"); + assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beef"); + assert_eq!(env["BUZZ_ACP_AGENT_COMMAND"], "goose"); + assert_eq!(env["BUZZ_ACP_MCP_COMMAND"], "buzz-dev-mcp"); + assert_eq!(env["BUZZ_ACP_EXIT_AFTER_INACTIVITY"], "7200"); + assert_eq!(env["BUZZ_MANAGED_AGENT_START_NONCE"], "gen0001"); + } + + /// Tier 1 is *overridable* — user env beats policy defaults, matching the + /// local spawn, where the user layer is written after them. Getting this + /// backwards would make remote agents ignore overrides local agents honor. + #[test] + fn user_env_overrides_policy_defaults() { + let agent = payload_json(serde_json::json!({ + "launch": { + "policy_env": {"GOOSE_MODE": "auto", "BUZZ_ACP_MODEL": "sonnet"}, + "env": {"GOOSE_MODE": "chat"}, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!(env["GOOSE_MODE"], "chat"); + assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet"); + } + + /// `launch.env` already contains the merged user env, so re-merging the + /// legacy field would undo a layering the desktop already resolved. + #[test] + fn legacy_env_vars_are_not_remerged_when_launch_present() { + let agent = payload_json(serde_json::json!({ + "env_vars": {"STALE": "yes", "SHARED": "legacy"}, + "launch": {"env": {"SHARED": "resolved"}, "owner_pubkey": "beef"} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["SHARED"], "resolved"); + assert!(!env.contains_key("STALE"), "legacy env_vars re-merged"); + } + + /// ...but a desktop predating the `launch` block has nothing else to + /// offer, so the legacy field is the truth in exactly that case. + #[test] + fn legacy_env_vars_used_when_launch_absent() { + let agent = payload_json(serde_json::json!({"env_vars": {"API": "v"}})); + let env = build(&agent).unwrap(); + assert_eq!(env["API"], "v"); + } + + #[test] + fn refuses_when_no_owner_resolves() { + let agent = payload_json(serde_json::json!({"auth_tag": null})); + let err = build(&agent).unwrap_err(); + assert!(err.contains("!shutdown"), "unhelpful error: {err}"); + } + + /// An empty string is not an owner. Without this the refusal is + /// bypassable by a blank field and the pod launches unable to be stopped. + #[test] + fn empty_owner_fields_count_as_absent() { + for blank in ["", " "] { + let agent = payload_json(serde_json::json!({ + "auth_tag": blank, + "launch": {"owner_pubkey": blank} + })); + assert!( + build(&agent).is_err(), + "whitespace resolved as an owner: {blank:?}" + ); + } + } + + /// The other half of every identity guard: what is *stored*. A validator + /// that trims and a writer that doesn't disagree about the value, and the + /// padding reaches the harness inside the Secret. Assert on the stored + /// string — asserting only that the deploy was accepted passes either way. + #[test] + fn identity_components_are_stored_trimmed() { + let agent = payload_json(serde_json::json!({ + "relay_url": " wss://relay.example ", + "auth_tag": " tag-1 ", + "launch": {"owner_pubkey": " beefcafe "} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example"); + assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1"); + assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe"); + } + + /// L1 item 1's third identity component. The nsec arm is enforced in + /// `naming.rs` and the owner arm above; without this one an agent + /// deploys with nothing to connect to — a pod that starts, fails at the + /// relay, and reads as a network fault rather than a refused launch. + #[test] + fn refuses_empty_relay_url() { + for blank in ["", " "] { + let agent = payload_json(serde_json::json!({"relay_url": blank})); + let err = build(&agent).unwrap_err(); + assert!(err.contains("relay_url"), "unhelpful error: {err}"); + } + } + + #[test] + fn owner_pubkey_alone_is_sufficient() { + let agent = payload_json(serde_json::json!({ + "auth_tag": null, + "launch": {"owner_pubkey": "beefcafe"} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe"); + assert!(!env.contains_key("BUZZ_AUTH_TAG")); + } + + #[test] + fn refuses_presence_suppression() { + let agent = payload_json(serde_json::json!({ + "launch": {"env": {"BUZZ_ACP_NO_PRESENCE": "1"}, "owner_pubkey": "beef"} + })); + let err = build(&agent).unwrap_err(); + assert!(err.contains("BUZZ_ACP_NO_PRESENCE"), "got: {err}"); + } + + /// `foo.bar` is a legal Secret key but not a legal env name: pre-1.30 + /// kubelets drop it, 1.30+ inject it. Refuse rather than behave + /// differently depending on the cluster. + #[test] + fn refuses_non_posix_env_keys() { + for bad in ["foo.bar", "foo-bar", "1LEADING", "", "has space"] { + let agent = payload_json(serde_json::json!({ + "launch": {"env": {bad: "v"}, "owner_pubkey": "beef"} + })); + assert!(build(&agent).is_err(), "accepted non-POSIX key {bad:?}"); + } + } + + #[test] + fn args_are_comma_joined_and_omitted_when_empty() { + let agent = payload_json(serde_json::json!({ + "launch": {"command": "goose", "args": ["run", "--no-session"], "owner_pubkey": "b"} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_ACP_AGENT_ARGS"], "run,--no-session"); + + let agent = payload_json(serde_json::json!({ + "launch": {"command": "goose", "args": [], "owner_pubkey": "b"} + })); + assert!(!build(&agent).unwrap().contains_key("BUZZ_ACP_AGENT_ARGS")); + } + + /// The top-level `model`/`provider` fields are display inputs; their + /// environment consequence is per-runtime and arrives already resolved + /// inside `launch`. A provider-side mapping is wrong for three of the + /// four built-in runtimes. + #[test] + fn provider_never_maps_model_or_provider_itself() { + let agent = payload_json(serde_json::json!({ + "model": "claude-opus", "provider": "anthropic", + "launch": {"owner_pubkey": "beef"} + })); + let env = build(&agent).unwrap(); + for key in [ + "BUZZ_AGENT_PROVIDER", + "BUZZ_AGENT_MODEL", + "GOOSE_PROVIDER", + "GOOSE_MODEL", + ] { + assert!(!env.contains_key(key), "provider mapped {key} itself"); + } + } + + /// `turn_timeout_seconds` is deprecated and ignored upstream; the local + /// spawn does not emit it either. + #[test] + fn turn_timeout_is_not_mapped() { + let agent = payload_json(serde_json::json!({ + "turn_timeout_seconds": 30, "launch": {"owner_pubkey": "b"} + })); + let env = build(&agent).unwrap(); + assert!(!env.keys().any(|k| k.contains("TURN_TIMEOUT"))); + } + + #[test] + fn inactivity_omitted_when_unset() { + let agent = payload_json(serde_json::json!({"launch": {"owner_pubkey": "b"}})); + let env = build_env( + &agent, + AuthoritativeInputs { + generation: "g", + inactivity_seconds: None, + }, + ) + .unwrap(); + assert!(!env.contains_key("BUZZ_ACP_EXIT_AFTER_INACTIVITY")); + } + + /// Structural guard over the whole authoritative list at once: whatever + /// the lower tiers contain, no authoritative key holds a lower-tier value + /// — including the conditionally-written ones the authoritative tier has + /// nothing to say about, which must be **absent** rather than spoofed. + /// This test caught exactly that: `BUZZ_ACP_AGENT_ARGS` is only written + /// when `launch.args` is non-empty, so plain later-wins overwrite left the + /// spoofed value in place. + #[test] + fn no_authoritative_key_retains_a_lower_tier_value() { + let spoofed: serde_json::Map = AUTHORITATIVE_KEYS + .iter() + .map(|k| ((*k).to_string(), serde_json::json!("SPOOFED"))) + .collect(); + // Split across both lower tiers: policy_env and env are separate + // insertion points, and a fix that only cleared one would pass a + // single-tier test. + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "goose", + "policy_env": spoofed.clone(), + "env": spoofed, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + for key in AUTHORITATIVE_KEYS { + assert_ne!( + env.get(*key).map(String::as_str), + Some("SPOOFED"), + "{key} kept its lower-tier value" + ); + } + // The keys the authoritative tier had no value for are gone, not + // merely different. + for absent in [ + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + ] { + assert!(!env.contains_key(absent), "{absent} survived the clear"); + } + } + + /// A 64-hex pubkey, the only allowlist entry shape the harness accepts. + fn pubkey(fill: char) -> String { + std::iter::repeat_n(fill, 64).collect() + } + + /// The gate the harness refuses first (`config.rs:996-1004`). Refusing it + /// here is the difference between one error message and an unbounded + /// fail-replace loop that leaves a Secret per attempt. + #[test] + fn allowlist_mode_with_an_empty_list_is_refused() { + for empty in [serde_json::json!([]), serde_json::Value::Null] { + let agent = payload_json(serde_json::json!({ + "respond_to": "allowlist", + "respond_to_allowlist": empty, + })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("the allowlist is empty"), + "unexpected error: {err}" + ); + } + } + + /// `config.rs:629-641` — each entry must be exactly 64 hex characters. + /// The rejects are the distinct ways to miss that: too short, right length + /// but not hex, empty, and one character short of valid. + #[test] + fn an_allowlist_entry_that_is_not_64_hex_is_refused() { + for bad in ["abc1234", &"z".repeat(64), "", &pubkey('a')[..63]] { + let agent = payload_json(serde_json::json!({ + "respond_to": "allowlist", + "respond_to_allowlist": [pubkey('a'), bad], + })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("must be exactly 64 hex characters"), + "{bad:?} was accepted; error was: {err}" + ); + } + } + + /// The positive control: the guard refuses bad gates, not every gate. + /// Without this, a validator that refused unconditionally would pass both + /// tests above. + #[test] + fn a_valid_allowlist_gate_is_accepted_and_comma_joined() { + let agent = payload_json(serde_json::json!({ + "respond_to": "allowlist", + "respond_to_allowlist": [pubkey('a'), pubkey('b')], + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_ACP_RESPOND_TO"], "allowlist"); + assert_eq!( + env["BUZZ_ACP_RESPOND_TO_ALLOWLIST"], + format!("{},{}", pubkey('a'), pubkey('b')) + ); + } + + /// The harness validates the allowlist **only** in allowlist mode and + /// merely warns otherwise (`config.rs:1005-1010`). A stricter provider + /// would refuse a deploy whose identical local spawn succeeds, so this + /// pins the asymmetry rather than leaving it to look like an oversight. + #[test] + fn a_junk_allowlist_is_tolerated_outside_allowlist_mode() { + for mode in ["owner-only", "anyone"] { + let agent = payload_json(serde_json::json!({ + "respond_to": mode, + "respond_to_allowlist": ["not-a-pubkey"], + })); + let env = build(&agent) + .unwrap_or_else(|e| panic!("{mode} with a stale list must deploy: {e}")); + assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode); + } + } + + /// `respond_to` is an opaque `String` on the wire but a `clap::ValueEnum` + /// at the harness, so an unrecognized mode dies at `rc=2` — before config + /// parsing runs at all, earlier than either refusal above. Measured + /// against the built binary: `invalid value 'npub1abc' for '--respond-to'`. + /// This is the shape our own fixture carried until it was corrected. + #[test] + fn a_mode_the_harness_cannot_parse_is_refused() { + for bad in ["npub1abc", "OWNER-ONLY", "owner_only", "allowlistt", "x"] { + let agent = payload_json(serde_json::json!({ "respond_to": bad })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("is not a mode the harness accepts"), + "{bad:?} was accepted; error was: {err}" + ); + } + } + + /// `clap` does not trim its value-enum input, so a padded mode is `rc=2` + /// even though the same string trimmed is valid. Measured: `invalid value + /// ' allowlist ' for '--respond-to'`. Trimming here would accept a deploy + /// the harness refuses — the exact direction this guard exists to prevent. + #[test] + fn a_padded_mode_is_refused_because_clap_does_not_trim() { + for padded in [" allowlist ", "allowlist ", " owner-only", "\tnobody"] { + let agent = payload_json(serde_json::json!({ + "respond_to": padded, + "respond_to_allowlist": [pubkey('a')], + })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("is not a mode the harness accepts"), + "{padded:?} was accepted; error was: {err}" + ); + } + } + + /// Positive control for the mode check, and the reason it validates the + /// harness's four rather than the desktop's three: `nobody` is rejected by + /// `parse_wire` on purpose (`managed_agents/types.rs:871-880`) but starts + /// fine at the harness. A guard mirroring the desktop enum would refuse a + /// working launch from a non-desktop caller — the callers this guard is + /// for. Without this test, refusing `nobody` would pass everything above. + #[test] + fn every_mode_the_harness_accepts_is_deployable() { + for mode in ["owner-only", "allowlist", "anyone", "nobody"] { + let agent = payload_json(serde_json::json!({ + "respond_to": mode, + "respond_to_allowlist": [pubkey('a')], + })); + let env = build(&agent) + .unwrap_or_else(|e| panic!("{mode} is valid at the harness but was refused: {e}")); + assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode); + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/gc.rs b/crates/buzz-backend-kubernetes/src/gc.rs new file mode 100644 index 0000000000..2e18825aa4 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/gc.rs @@ -0,0 +1,368 @@ +//! Preflight garbage collection (spec §K8s GC, `docs/remote-agents.md:1282-1335`). +//! +//! GC runs on every deploy, after identity derivation and before the state +//! transition. It deletes terminated pods (and their referenced Secrets) and +//! age-eligible orphan Secrets — every one of which must pass the full-pubkey +//! annotation check *and* carry the management marker. An unmarked object is +//! never GC'd regardless of its labels. +//! +//! The decision layer here is pure. The effectful caller supplies the observed +//! objects and the apiserver's clock; this module decides what may be deleted. + +use crate::naming::AgentIdentity; +use crate::observe::{referenced_secret, secret_is_ours}; +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::{Pod, Secret}; + +/// The deploy operation deadline (spec §Deploy: `timeout: 600s`). +pub const OPERATION_DEADLINE_SECS: i64 = 600; + +/// An unreferenced Secret is GC-eligible only once it is older than **twice** +/// the deploy deadline. Rationale: Secret-create → pod-create is not atomic +/// against an independent GC pass, so without the gate a concurrent attempt's +/// preflight GC can delete a Secret whose pod has not been created yet and +/// strand that deploy. The age bound makes "unreferenced" mean "provably +/// abandoned" — any attempt that could still reference it has exceeded its own +/// deadline (`:1301-1319`). +pub const ORPHAN_SECRET_MIN_AGE_SECS: i64 = 2 * OPERATION_DEADLINE_SECS; + +/// What a GC pass decided to delete. Names only: the caller re-reads each +/// object's own fence at delete time. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct GcPlan { + /// Terminated, verified, marker-bearing pods. + pub pods: Vec, + /// Age-eligible, verified, marker-bearing orphan Secrets. + pub secrets: Vec, +} + +/// Plan a GC pass. +/// +/// `now` is the apiserver's clock — the HTTP `Date` header from the very list +/// call that produced `secrets`. `None` means the header was absent or +/// unparseable, in which case **orphan-Secret GC is skipped entirely** rather +/// than falling back to local time: this provider runs on a user's desktop, +/// and a local clock fast by more than the margin does not race — it +/// deterministically computes every in-flight Secret as expired, on every +/// pass, reopening exactly the interleaving the gate exists to close +/// (`:1321-1335`). A deferred cleanup is free; a wrong deletion is not. +/// +/// Terminated-pod GC does not use the clock and is unaffected. +pub fn plan( + identity: &AgentIdentity, + pods: &[Pod], + secrets: &[Secret], + terminated: impl Fn(&Pod) -> bool, + now: Option>, +) -> GcPlan { + // Only pods that pass the full fence participate — in either direction. + // An unverified pod is neither deleted nor allowed to protect a Secret: + // it cannot be ours, so its `envFrom` cannot reference our generation. + let ours: Vec<&Pod> = pods + .iter() + .filter(|p| { + crate::observe::verify(p, identity, crate::classify::Startup::Started).is_some() + }) + .collect(); + + let doomed_pods: Vec<&&Pod> = ours.iter().filter(|p| terminated(p)).collect(); + + // A Secret referenced by ANY existing pod is protected — deliberately + // including not-yet-started pods, whose `envFrom` is exactly as + // load-bearing as a running pod's (`:1262-1264`). Pods being GC'd in this + // same pass are excluded, so their Secrets go with them. + let doomed_names: Vec<&str> = doomed_pods + .iter() + .filter_map(|p| p.metadata.name.as_deref()) + .collect(); + let protected: Vec = ours + .iter() + .filter(|p| !doomed_names.contains(&p.metadata.name.as_deref().unwrap_or_default())) + .filter_map(|p| referenced_secret(p)) + .collect(); + + let mut plan = GcPlan { + pods: doomed_names.iter().map(|n| n.to_string()).collect(), + secrets: doomed_pods + .iter() + .filter_map(|p| referenced_secret(p)) + .collect(), + }; + + // Orphan sweep: only with a server clock. + if let Some(now) = now { + for secret in secrets { + if !secret_is_ours(secret, identity) { + continue; + } + let Some(name) = secret.metadata.name.as_deref() else { + continue; + }; + if protected.contains(&name.to_string()) || plan.secrets.iter().any(|s| s == name) { + continue; + } + let Some(created) = secret.metadata.creation_timestamp.as_ref() else { + // No server-assigned timestamp means no age proof. Skip. + continue; + }; + if (now - created.0).num_seconds() >= ORPHAN_SECRET_MIN_AGE_SECS { + plan.secrets.push(name.to_string()); + } + } + } + + plan +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::naming::{ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY}; + use k8s_openapi::api::core::v1::{Container, EnvFromSource, PodSpec, SecretEnvSource}; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time}; + use std::collections::BTreeMap; + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn pod_named(id: &AgentIdentity, name: &str, secret: Option<&str>) -> Pod { + Pod { + metadata: ObjectMeta { + name: Some(name.into()), + uid: Some(format!("uid-{name}")), + resource_version: Some("1".into()), + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into_iter() + .collect::>(), + ), + ..Default::default() + }, + spec: secret.map(|s| PodSpec { + containers: vec![Container { + name: "agent".into(), + env_from: Some(vec![EnvFromSource { + secret_ref: Some(SecretEnvSource { + name: s.into(), + optional: Some(false), + }), + ..Default::default() + }]), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + } + } + + fn secret_named(id: &AgentIdentity, name: &str, age_secs: i64, now: DateTime) -> Secret { + Secret { + metadata: ObjectMeta { + name: Some(name.into()), + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into_iter() + .collect::>(), + ), + creation_timestamp: Some(Time(now - chrono::Duration::seconds(age_secs))), + ..Default::default() + }, + ..Default::default() + } + } + + fn never(_: &Pod) -> bool { + false + } + fn always(_: &Pod) -> bool { + true + } + + #[test] + fn terminated_pods_and_their_secrets_are_collected_together() { + let id = identity(); + let now = Utc::now(); + let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1")); + let plan = plan(&id, &[pod], &[], always, Some(now)); + assert_eq!(plan.pods, ["buzz-agent-dead"]); + assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]); + } + + #[test] + fn live_pods_are_never_collected() { + let id = identity(); + let pod = pod_named(&id, "buzz-agent-live", Some("buzz-agent-live-gen1")); + let plan = plan(&id, &[pod], &[], never, Some(Utc::now())); + assert_eq!(plan, GcPlan::default()); + } + + /// The auto-repair fence applies to GC identically: an object that lacks + /// the marker, or carries a different pubkey, is never touched — however + /// well its labels match. + #[test] + fn unmarked_and_mismatched_objects_are_never_collected() { + let id = identity(); + let other = identity(); + let now = Utc::now(); + + let mut unmarked = pod_named(&id, "look-alike", Some("look-alike-gen1")); + let mut labels = id.labels(); + labels.remove(LABEL_MANAGED_BY); + unmarked.metadata.labels = Some(labels); + + let mut foreign = pod_named(&id, "someone-elses", Some("someone-elses-gen1")); + foreign.metadata.annotations = Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ); + + let mut unmarked_secret = secret_named(&id, "orphan-unmarked", 100_000, now); + unmarked_secret.metadata.labels = Some(BTreeMap::new()); + let mut foreign_secret = secret_named(&id, "orphan-foreign", 100_000, now); + foreign_secret.metadata.annotations = Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ); + + let plan = plan( + &id, + &[unmarked, foreign], + &[unmarked_secret, foreign_secret], + always, + Some(now), + ); + assert_eq!( + plan, + GcPlan::default(), + "GC touched an object it does not own" + ); + } + + /// The interleaving the age gate exists to close: attempt A creates its + /// Secret; concurrent attempt B's preflight GC runs before A creates its + /// pod. Without the gate B deletes A's Secret and strands A. + #[test] + fn young_unreferenced_secrets_are_protected() { + let id = identity(); + let now = Utc::now(); + let fresh = secret_named(&id, "buzz-agent-x-gen-inflight", 5, now); + assert_eq!( + plan(&id, &[], &[fresh], never, Some(now)), + GcPlan::default() + ); + } + + /// Past twice the deadline, any attempt that could still reference the + /// Secret has exceeded its own deadline — so it is provably abandoned. + #[test] + fn secrets_older_than_twice_the_deadline_are_collected() { + let id = identity(); + let now = Utc::now(); + let old = secret_named( + &id, + "buzz-agent-x-gen-abandoned", + ORPHAN_SECRET_MIN_AGE_SECS + 1, + now, + ); + let plan = plan(&id, &[], &[old], never, Some(now)); + assert_eq!(plan.secrets, ["buzz-agent-x-gen-abandoned"]); + } + + /// The boundary itself, both sides. `>= 1200s` is eligible. + #[test] + fn age_gate_boundary_is_exact() { + let id = identity(); + let now = Utc::now(); + let just_under = secret_named(&id, "under", ORPHAN_SECRET_MIN_AGE_SECS - 1, now); + let exactly = secret_named(&id, "exact", ORPHAN_SECRET_MIN_AGE_SECS, now); + assert!(plan(&id, &[], &[just_under], never, Some(now)) + .secrets + .is_empty()); + assert_eq!( + plan(&id, &[], &[exactly], never, Some(now)).secrets, + ["exact"] + ); + } + + /// The same-clock rule. No apiserver `Date` header → skip the orphan + /// sweep entirely. A local clock fast by more than the margin would + /// silently delete every in-flight Secret on every pass. + #[test] + fn without_a_server_clock_the_orphan_sweep_is_skipped() { + let id = identity(); + let now = Utc::now(); + let ancient = secret_named(&id, "buzz-agent-x-gen-ancient", 10_000_000, now); + let plan = plan(&id, &[], &[ancient], never, None); + assert!( + plan.secrets.is_empty(), + "orphan swept without a server clock — a fast local clock would delete live Secrets" + ); + } + + /// ...but terminated-pod GC does not consult the clock, so it still runs. + #[test] + fn terminated_pod_gc_runs_without_a_server_clock() { + let id = identity(); + let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1")); + let plan = plan(&id, &[pod], &[], always, None); + assert_eq!(plan.pods, ["buzz-agent-dead"]); + assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]); + } + + /// "Existing" includes not-yet-started pods: a Secret referenced by a pod + /// still pulling its image must not be swept, however old it is. + #[test] + fn secrets_referenced_by_a_pending_pod_are_protected() { + let id = identity(); + let now = Utc::now(); + let pending = pod_named(&id, "buzz-agent-pending", Some("buzz-agent-pending-gen1")); + let old = secret_named(&id, "buzz-agent-pending-gen1", 10_000_000, now); + let plan = plan(&id, &[pending], &[old], never, Some(now)); + assert!(plan.secrets.is_empty(), "swept a referenced Secret"); + } + + /// A Secret with no server-assigned creationTimestamp has no age proof, + /// so it is skipped rather than assumed old. + #[test] + fn secrets_without_a_creation_timestamp_are_skipped() { + let id = identity(); + let now = Utc::now(); + let mut no_timestamp = secret_named(&id, "buzz-agent-x-gen-unknown", 10_000_000, now); + no_timestamp.metadata.creation_timestamp = None; + assert!(plan(&id, &[], &[no_timestamp], never, Some(now)) + .secrets + .is_empty()); + } + + /// A Secret belonging to a pod being collected in this same pass goes with + /// it, and must not be listed twice. + #[test] + fn a_collected_pods_secret_is_listed_once() { + let id = identity(); + let now = Utc::now(); + let dead = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1")); + let its_secret = secret_named(&id, "buzz-agent-dead-gen1", 10_000_000, now); + let plan = plan(&id, &[dead], &[its_secret], always, Some(now)); + assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]); + } +} diff --git a/crates/buzz-backend-kubernetes/src/image.rs b/crates/buzz-backend-kubernetes/src/image.rs new file mode 100644 index 0000000000..b35e6bab12 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/image.rs @@ -0,0 +1,181 @@ +//! Image reference validation (spec §Image). +//! +//! The object holding this reference runs with an nsec, so the reference must +//! be **immutable**. Registry tags are mutable pointers — Kubernetes itself +//! distinguishes them from digests for exactly this reason — so a tag-only +//! reference is rejected, not just `:latest`. +//! +//! v1 ships no baked default (there is no published `ghcr.io/block/buzz-sprig` +//! image yet, so a compile-time digest would be a placeholder). `image` is +//! therefore required, and its absence fails closed with a named field. + +/// A validated, digest-qualified image reference. +/// +/// The inner string is always in canonical tagless form `name@sha256:`: +/// `name:tag@sha256:…` normalizes by dropping the tag, because the tag is +/// decorative once a digest pins the content, and leaving it in would make +/// two references to identical bytes produce different create-intent +/// fingerprints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageRef(String); + +impl ImageRef { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ImageRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Parse and normalize a user-supplied image reference. +pub fn parse(raw: &str) -> Result { + let reference = raw.trim(); + if reference.is_empty() { + return Err("provider_config.image is required: v1 ships no default \ + image, so the digest-pinned image to run must be given \ + explicitly" + .to_string()); + } + + let mut parts = reference.split('@'); + let name_and_tag = parts.next().unwrap_or_default(); + let digest = match (parts.next(), parts.next()) { + (Some(d), None) => d, + (None, _) => { + return Err(format!( + "provider_config.image {reference:?} is not digest-pinned: a \ + tag is a mutable pointer, and this object runs with the \ + agent's private key. Use name@sha256:<64 hex chars>" + )) + } + (Some(_), Some(_)) => { + return Err(format!( + "provider_config.image {reference:?} contains more than one '@'" + )) + } + }; + + let hex = digest.strip_prefix("sha256:").ok_or_else(|| { + format!("provider_config.image digest {digest:?} must start with 'sha256:'") + })?; + // Lowercase only: OCI canonicalizes digest hex, and accepting uppercase + // would let two spellings of one digest produce two fingerprints. + if hex.len() != 64 + || !hex + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) + { + return Err(format!( + "provider_config.image digest {digest:?} must be exactly 64 \ + lowercase hex characters" + )); + } + + // Drop any tag: `name:tag@sha256:…` and `name@sha256:…` name the same + // bytes and must fingerprint identically. Only a *final* colon segment + // that isn't a port counts as a tag — `host:5000/name` has no tag. + let name = match name_and_tag.rfind(':') { + Some(colon) if !name_and_tag[colon + 1..].contains('/') => &name_and_tag[..colon], + _ => name_and_tag, + }; + if name.is_empty() { + return Err(format!( + "provider_config.image {reference:?} has no repository name" + )); + } + + Ok(ImageRef(format!("{name}@sha256:{hex}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_digest_pinned_reference() { + let d = "a".repeat(64); + let r = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap(); + assert_eq!(r.as_str(), format!("ghcr.io/block/buzz-sprig@sha256:{d}")); + } + + /// The normalization that keeps the fingerprint stable: two spellings of + /// the same bytes must produce one reference. + #[test] + fn strips_tag_from_tag_plus_digest_form() { + let d = "b".repeat(64); + let tagged = parse(&format!("ghcr.io/block/buzz-sprig:v1.2@sha256:{d}")).unwrap(); + let plain = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap(); + assert_eq!(tagged, plain); + } + + /// A registry port is not a tag. `host:5000/name` must keep its port. + #[test] + fn registry_port_is_not_mistaken_for_a_tag() { + let d = "c".repeat(64); + let r = parse(&format!("localhost:5000/buzz-sprig@sha256:{d}")).unwrap(); + assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}")); + } + + #[test] + fn port_and_tag_together_drops_only_the_tag() { + let d = "d".repeat(64); + let r = parse(&format!("localhost:5000/buzz-sprig:dev@sha256:{d}")).unwrap(); + assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}")); + } + + /// Wren's amendment: *every* tag-only reference is rejected, not just + /// `:latest`. A `sha-` tag is traceable but still movable. + #[test] + fn rejects_every_tag_only_reference() { + for bad in [ + "ghcr.io/block/buzz-sprig:latest", + "ghcr.io/block/buzz-sprig:v1.2.3", + "ghcr.io/block/buzz-sprig:sha-abc1234", + "ghcr.io/block/buzz-sprig", + "localhost:5000/buzz-sprig", + ] { + let err = parse(bad).unwrap_err(); + assert!(err.contains("digest-pinned"), "for {bad:?} got: {err}"); + } + } + + /// Uppercase hex is a second spelling of one digest; accepting it would + /// let the same image fingerprint two ways. + #[test] + fn rejects_uppercase_digest_hex() { + let d = "A".repeat(64); + assert!(parse(&format!("img@sha256:{d}")).is_err()); + } + + #[test] + fn rejects_malformed_digests() { + let short = "a".repeat(63); + let long = "a".repeat(65); + let ok = "a".repeat(64); + for bad in [ + format!("img@sha256:{short}"), + format!("img@sha256:{long}"), + format!("img@sha512:{ok}"), + format!("img@{ok}"), + format!("img@sha256:{}", "g".repeat(64)), + format!("img@sha256:{ok}@sha256:{ok}"), + format!("@sha256:{ok}"), + ] { + assert!(parse(&bad).is_err(), "accepted {bad:?}"); + } + } + + /// v1 has no baked default, so an absent image is an error that names the + /// field rather than a silent fallback. + #[test] + fn empty_reference_names_the_field() { + for empty in ["", " "] { + let err = parse(empty).unwrap_err(); + assert!(err.contains("provider_config.image"), "got: {err}"); + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/intent.rs b/crates/buzz-backend-kubernetes/src/intent.rs new file mode 100644 index 0000000000..73218f83bf --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/intent.rs @@ -0,0 +1,309 @@ +//! The create-intent fingerprint (spec §Deploy State Machine, create-intent +//! fingerprint, `docs/remote-agents.md:796-828`). +//! +//! The fingerprint is an unkeyed SHA-256 over a canonical serialization of the +//! provider's non-secret create-intent template. A plain hash is safe *only* +//! because of the scope rule: the input covers exactly the provider-controlled +//! fields that can affect scheduling or container creation, and **never Secret +//! data or attempt identity**. Hashing low-entropy secrets into a +//! world-readable annotation would be a dictionary oracle. +//! +//! That rule is enforced structurally rather than remembered. [`IntentTemplate`] +//! is a *pre-binding* type: it has no field that can hold Secret material or a +//! generation token, so there is no expression that hashes one. The +//! per-attempt Secret name never appears — the pod's `envFrom` is represented +//! by the fixed [`SECRET_PLACEHOLDER`], because otherwise every attempt would +//! diverge from every other by construction. +//! +//! Server- and admission-produced output (UID, `resourceVersion`, timestamps, +//! defaulted fields, the annotation itself) is excluded the same way: the +//! serializer is only ever handed this template, never a live `Pod`, so the +//! exclusion is checkable by inspection. + +use crate::image::ImageRef; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +/// Stands in for the per-attempt Secret name in the `envFrom` position. +/// A real generation token here would make every attempt diverge. +const SECRET_PLACEHOLDER: &str = ""; + +/// The recorded/computed create intent: a hex SHA-256 digest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fingerprint(String); + +impl Fingerprint { + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Read a fingerprint off a pod annotation. Any recorded string is + /// accepted verbatim: comparison is equality against a freshly computed + /// value, so a malformed annotation simply reads as divergence — which is + /// the correct outcome for a pod this provider version did not write. + pub fn from_annotation(value: &str) -> Self { + Self(value.to_string()) + } + + #[cfg(test)] + pub fn for_test(seed: &str) -> Self { + Self(format!("test-{seed}")) + } +} + +impl std::fmt::Display for Fingerprint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// The non-secret, pre-binding description of the pod this deploy would +/// create. Every field is provider-controlled and scheduling-relevant; there +/// is deliberately no field for env values, Secret data, or the generation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IntentTemplate { + /// Schema version of the template itself. Bumping it re-fingerprints every + /// pod, which is the intended way to roll out a pod-shape change. + pub template_version: u32, + pub namespace: String, + /// Normalized, digest-qualified image reference. + pub image: String, + pub cpu_request: String, + pub memory_request: String, + pub cpu_limit: String, + pub memory_limit: String, + pub service_account: Option, + pub restart_policy: &'static str, + pub termination_grace_period_seconds: i64, + /// Env *keys* only, sorted. Keys are pod-shape (a renamed key changes the + /// container's contract); values are Secret material and must not be here. + pub env_keys: Vec, + /// Fixed placeholder for the per-attempt Secret in `envFrom`. + pub env_from_secret: &'static str, + pub workspace_mount_path: String, + pub run_as_user: i64, + pub run_as_group: i64, +} + +/// Current template schema version. +pub const TEMPLATE_VERSION: u32 = 1; + +impl IntentTemplate { + /// Compute the fingerprint. `serde_json` on a struct with declared field + /// order plus pre-sorted `env_keys` is a canonical serialization: the same + /// template always produces the same bytes. + pub fn fingerprint(&self) -> Fingerprint { + let canonical = serde_json::to_vec(self).expect("intent template is plain data"); + Fingerprint(hex::encode(Sha256::digest(&canonical))) + } + + /// Build from resolved pod-shape inputs. `env_keys` is sorted here rather + /// than at the call site so key ordering can never leak into the digest. + /// + /// The fixed pod-shape constants are read from [`crate::config`] rather + /// than passed in: `pod::build_pod` stamps the pod from those same + /// constants, so the fingerprint cannot describe a pod shape different + /// from the one actually created. Threading them through as arguments + /// would make that agreement a thing to test instead of a thing that holds. + pub fn new( + namespace: &str, + image: &ImageRef, + resources: &crate::config::Resources, + service_account: Option<&str>, + env_keys: impl IntoIterator, + ) -> Self { + let mut env_keys: Vec = env_keys.into_iter().collect(); + env_keys.sort(); + Self { + template_version: TEMPLATE_VERSION, + namespace: namespace.to_string(), + image: image.as_str().to_string(), + cpu_request: resources.cpu_request.clone(), + memory_request: resources.memory_request.clone(), + cpu_limit: resources.cpu_limit.clone(), + memory_limit: resources.memory_limit.clone(), + service_account: service_account.map(str::to_string), + restart_policy: crate::config::RESTART_POLICY, + termination_grace_period_seconds: crate::config::TERMINATION_GRACE_SECONDS, + env_keys, + env_from_secret: SECRET_PLACEHOLDER, + workspace_mount_path: crate::config::WORKSPACE_PATH.to_string(), + run_as_user: crate::config::RUN_AS_UID, + run_as_group: crate::config::RUN_AS_GID, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Resources; + + fn image(byte: char) -> ImageRef { + crate::image::parse(&format!( + "ghcr.io/block/buzz-sprig@sha256:{}", + byte.to_string().repeat(64) + )) + .unwrap() + } + + fn template() -> IntentTemplate { + IntentTemplate::new( + "buzz-agents", + &image('a'), + &Resources::default(), + None, + ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()], + ) + } + + #[test] + fn fingerprint_is_deterministic() { + assert_eq!(template().fingerprint(), template().fingerprint()); + } + + #[test] + fn fingerprint_is_hex_sha256() { + let fp = template().fingerprint(); + assert_eq!(fp.as_str().len(), 64); + assert!(fp.as_str().chars().all(|c| c.is_ascii_hexdigit())); + } + + /// Key *order* must not reach the digest, or two identical environments + /// built in different orders would look like a config change. + #[test] + fn env_key_order_does_not_affect_the_digest() { + let a = IntentTemplate::new( + "ns", + &image('a'), + &Resources::default(), + None, + ["A".to_string(), "B".to_string(), "C".to_string()], + ); + let b = IntentTemplate::new( + "ns", + &image('a'), + &Resources::default(), + None, + ["C".to_string(), "A".to_string(), "B".to_string()], + ); + assert_eq!(a.fingerprint(), b.fingerprint()); + } + + /// A mutation applied to a fresh template clone, named for its assertion + /// message. + type Mutation = (&'static str, Box); + + /// Every scheduling-relevant knob must move the digest — this is the + /// wedge escape (§Deploy State Machine never-started recoverable row). + /// Exhaustive by construction: each mutation is applied to a fresh clone. + #[test] + fn every_scheduling_field_changes_the_digest() { + let base = template(); + let baseline = base.fingerprint(); + + let mutations: Vec = vec![ + ( + "template_version", + Box::new(|t: &mut IntentTemplate| t.template_version += 1), + ), + ( + "namespace", + Box::new(|t: &mut IntentTemplate| t.namespace = "other".into()), + ), + ( + "image", + Box::new(|t: &mut IntentTemplate| t.image = image('b').as_str().into()), + ), + ( + "cpu_request", + Box::new(|t: &mut IntentTemplate| t.cpu_request = "4".into()), + ), + ( + "memory_request", + Box::new(|t: &mut IntentTemplate| t.memory_request = "8Gi".into()), + ), + ( + "cpu_limit", + Box::new(|t: &mut IntentTemplate| t.cpu_limit = "8".into()), + ), + ( + "memory_limit", + Box::new(|t: &mut IntentTemplate| t.memory_limit = "16Gi".into()), + ), + ( + "service_account", + Box::new(|t: &mut IntentTemplate| t.service_account = Some("sa".into())), + ), + ( + "restart_policy", + Box::new(|t: &mut IntentTemplate| t.restart_policy = "OnFailure"), + ), + ( + "grace_period", + Box::new(|t: &mut IntentTemplate| t.termination_grace_period_seconds = 30), + ), + ( + "env_keys", + Box::new(|t: &mut IntentTemplate| t.env_keys.push("NEW_KEY".into())), + ), + ( + "workspace_mount_path", + Box::new(|t: &mut IntentTemplate| t.workspace_mount_path = "/w".into()), + ), + ( + "run_as_user", + Box::new(|t: &mut IntentTemplate| t.run_as_user = 2000), + ), + ( + "run_as_group", + Box::new(|t: &mut IntentTemplate| t.run_as_group = 2000), + ), + ]; + + for (name, mutate) in mutations { + let mut t = base.clone(); + mutate(&mut t); + assert_ne!( + t.fingerprint(), + baseline, + "{name} did not affect the digest" + ); + } + } + + /// The scope rule, asserted on the bytes: no Secret value and no + /// generation token can appear in the serialization, because the type has + /// nowhere to put them. The placeholder is what `envFrom` contributes. + #[test] + fn serialization_contains_no_secret_material_or_attempt_identity() { + let json = serde_json::to_string(&template()).unwrap(); + for forbidden in ["nsec1", "SPOOFED", "wss://", "gen0001"] { + assert!( + !json.contains(forbidden), + "template leaked {forbidden}: {json}" + ); + } + assert!(json.contains(SECRET_PLACEHOLDER)); + } + + /// Two attempts for the same agent differ only in generation, which is + /// absent from the template — so their fingerprints must be equal, or the + /// divergence discriminator would fire on every single deploy. + #[test] + fn attempts_differing_only_by_generation_do_not_diverge() { + // There is no generation input to pass; that *is* the property. The + // test states it explicitly so a future field addition breaks here. + assert_eq!(template().fingerprint(), template().fingerprint()); + let json = serde_json::to_string(&template()).unwrap(); + assert_eq!(json.matches(SECRET_PLACEHOLDER).count(), 1); + } + + /// A recorded annotation this provider version did not write reads as + /// divergence rather than an error. + #[test] + fn unrecognized_annotation_reads_as_divergence() { + let recorded = Fingerprint::from_annotation("not-a-digest"); + assert_ne!(recorded, template().fingerprint()); + } +} diff --git a/crates/buzz-backend-kubernetes/src/main.rs b/crates/buzz-backend-kubernetes/src/main.rs new file mode 100644 index 0000000000..5d521a9d37 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/main.rs @@ -0,0 +1,199 @@ +//! Kubernetes backend provider for Buzz remote agents +//! (spec `docs/remote-agents.md`). +//! +//! One process per operation: read exactly one JSON request from stdin, write +//! exactly one JSON response to stdout, exit. The exit code carries exactly +//! one bit — 0 for a response that was produced, 1 for a failure to produce +//! one. Everything a caller needs to distinguish is *inside* the response's +//! `ok` field, because a provider that encoded outcomes in exit codes would +//! have a second, redundant error channel to keep in sync (§Provider Protocol). + +mod classify; +mod client; +mod cluster; +mod config; +mod env; +mod gc; +mod image; +mod intent; +mod naming; +mod observe; +mod pod; +mod reconcile; +mod wire; + +use std::io::Read; +use wire::{Request, Response}; + +/// The provider a shared-compute agent resolves to. Refused here as the +/// spec's backstop: a mesh agent runs on the relay's compute, so deploying it +/// as a pod would create a second, contending consumer of the same agent +/// identity (`:214-219`). +const RELAY_MESH_PROVIDER: &str = "relay-mesh"; + +fn main() { + // rustls needs a process-level provider before the first TLS connection. + // The release build compiles every sidecar in one cargo invocation, which + // unifies the `ring` and `aws-lc-rs` features and leaves rustls unable to + // auto-select — so this is an explicit install, not a default. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + // No request means no request_id and no response contract to honor. + // This is the one path that exits nonzero. + eprintln!("could not read the request from stdin: {e}"); + std::process::exit(1); + } + + let response = respond(&input); + println!( + "{}", + serde_json::to_string(&response).unwrap_or_else(|e| { + // The response types are plain data; this cannot fail in practice, + // and a hand-built object is still a conforming response. + format!(r#"{{"ok":false,"error":"could not serialize a response: {e}"}}"#) + }) + ); +} + +/// Produce the single response for one request. Separated from `main` so the +/// whole dispatch is testable without a process. +fn respond(input: &str) -> Response { + // Parsed as raw JSON first: the relay-mesh refusal below MUST see the wire + // value, and `AgentPayload` deliberately does not carry `provider`. + let raw: serde_json::Value = match serde_json::from_str(input) { + Ok(value) => value, + Err(e) => return Response::error(format!("request is not valid JSON: {e}")), + }; + + if let Some(refusal) = refuse_relay_mesh(&raw) { + return Response::error(refusal); + } + + let request: Request = match serde_json::from_value(raw) { + Ok(request) => request, + Err(e) => return Response::error(format!("could not understand the request: {e}")), + }; + + match request { + Request::Info => Response::info(), + Request::Deploy(deploy) => { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(e) => return Response::error(format!("could not start the runtime: {e}")), + }; + match runtime.block_on(deploy_agent(&deploy)) { + Ok(agent_id) => Response::deployed(agent_id), + Err(e) => Response::error(e), + } + } + } +} + +/// Refuse a shared-compute agent, reading the **raw wire value**. +/// +/// Trimmed before comparing: the desktop's own layers disagree about padding +/// (`relay_mesh.rs:17` and `effective_config/mod.rs:46` trim; the deploy guard +/// at `agents_deploy.rs:116` did not), and `non_blank` preserves surrounding +/// whitespace on a non-blank value. A backstop that shares its bypass with the +/// layer it backs is not a backstop. +fn refuse_relay_mesh(raw: &serde_json::Value) -> Option { + let provider = raw.get("agent")?.get("provider")?.as_str()?; + (provider.trim() == RELAY_MESH_PROVIDER).then(|| { + "deploy refused: this agent is configured for shared compute \ + (relay-mesh), which runs on the relay rather than in a pod. \ + Switch the agent to a local runtime before deploying it to \ + Kubernetes." + .to_string() + }) +} + +/// Run one deploy to a terminal outcome. +async fn deploy_agent(request: &wire::DeployRequest) -> Result { + let cfg = config::parse(&request.provider_config)?; + // Identity before any cluster contact: a malformed nsec is a refusal, not + // a failed connection (§Deploy State Machine step 0). + let identity = naming::AgentIdentity::from_nsec(&request.agent.private_key_nsec)?; + + // One generation for this operation's first attempt; the reconciler mints + // its own per attempt and restamps the correlator to match. + let env = env::build_env( + &request.agent, + env::AuthoritativeInputs { + generation: &naming::new_generation(), + inactivity_seconds: cfg.inactivity_seconds, + }, + )?; + + let client = client::connect(cfg.context.as_deref()).await?; + let substrate = cluster::Cluster::new(client, &cfg.namespace); + reconcile::deploy(&substrate, &identity, &cfg, env).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn error_of(response: &Response) -> String { + let json = serde_json::to_value(response).unwrap(); + assert_eq!(json["ok"], false, "expected a refusal: {json}"); + json["error"].as_str().unwrap().to_string() + } + + /// The spec's backstop for the relay-mesh MUST. The desktop refuses first + /// (`agents_deploy.rs:116`); this is the layer that owes the obligation. + #[test] + fn refuses_a_relay_mesh_agent() { + let request = r#"{"op":"deploy","agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x","provider":"relay-mesh"}, + "provider_config":{"namespace":"ns"}}"#; + assert!(error_of(&respond(request)).contains("relay-mesh")); + } + + /// Padding must not bypass the backstop. Reachable by construction: + /// `GlobalConfig.provider` is a bare `Option` with no trim on + /// write, and `non_blank` rejects whitespace-only while preserving + /// surrounding whitespace on everything else. + #[test] + fn refuses_a_padded_relay_mesh_agent() { + let request = r#"{"op":"deploy","agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x","provider":" relay-mesh "}, + "provider_config":{"namespace":"ns"}}"#; + assert!(error_of(&respond(request)).contains("relay-mesh")); + } + + /// The refusal must not fire on a normal agent — a guard that refuses + /// everything passes its own test and ships a provider that deploys + /// nothing. + #[test] + fn does_not_refuse_a_normal_provider() { + let raw: serde_json::Value = + serde_json::from_str(r#"{"agent":{"provider":"openai"}}"#).unwrap(); + assert!(refuse_relay_mesh(&raw).is_none()); + // …nor when the field is absent entirely, which is the common case: + // `AgentPayload` does not carry `provider`. + let bare: serde_json::Value = serde_json::from_str(r#"{"agent":{}}"#).unwrap(); + assert!(refuse_relay_mesh(&bare).is_none()); + } + + /// Malformed input still produces exactly one conforming response. + #[test] + fn malformed_input_is_an_in_band_error() { + assert!(error_of(&respond("not json")).contains("valid JSON")); + assert!(error_of(&respond(r#"{"op":"undeploy"}"#)).contains("understand")); + } + + /// `info` answers without touching a cluster — it is what the desktop + /// calls to render the config form, before any kubeconfig exists. + #[test] + fn info_answers_with_the_protocol_version_and_schema() { + let json = serde_json::to_value(respond(r#"{"op":"info"}"#)).unwrap(); + assert_eq!(json["ok"], true); + assert_eq!(json["protocol_version"], wire::PROTOCOL_VERSION); + assert!(json["config_schema"]["properties"]["namespace"].is_object()); + } +} diff --git a/crates/buzz-backend-kubernetes/src/naming.rs b/crates/buzz-backend-kubernetes/src/naming.rs new file mode 100644 index 0000000000..4b9d7ea035 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/naming.rs @@ -0,0 +1,223 @@ +//! Identity derivation and the object-naming contract (spec §Pod shape). +//! +//! Every name, label, and annotation below is derived from the pubkey the +//! provider decoded itself from `private_key_nsec` — never from a +//! caller-supplied pubkey (§Deploy State Machine step 0). + +use nostr::nips::nip19::FromBech32; + +/// `app.kubernetes.io/managed-by` value: the management marker's identity half. +pub const MANAGED_BY: &str = "buzz-backend-kubernetes"; + +/// Label key carrying [`MANAGED_BY`]. +pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by"; + +/// Label key carrying [`BINDING_VERSION`] — the marker's schema half. +pub const LABEL_BINDING_VERSION: &str = "buzz.block.xyz/binding-version"; + +/// Schema version of the object layout this provider writes. Bumped when the +/// pod/Secret shape changes in a way a older provider would mis-handle. +pub const BINDING_VERSION: &str = "1"; + +/// Label key: truncated pubkey, the reconciliation and GC selector. +pub const LABEL_AGENT_PUBKEY: &str = "buzz.block.xyz/agent-pubkey"; + +/// Annotation key: full pubkey. Load-bearing — the truncated label is +/// collision-*resistant*, this is what makes it safe (§Deploy State Machine +/// step 1). +pub const ANNOTATION_PUBKEY_FULL: &str = "buzz.block.xyz/agent-pubkey-full"; + +/// Annotation key: the recorded create-intent fingerprint. +pub const ANNOTATION_CREATE_INTENT: &str = "buzz.block.xyz/create-intent"; + +/// Annotation key: the image reference this generation actually resolved to, +/// for post-hoc attribution (§Image). +pub const ANNOTATION_IMAGE: &str = "buzz.block.xyz/image"; + +/// An agent identity the provider derived itself, plus every name it implies. +/// +/// Constructing this type is the *only* way to obtain the names — so a +/// caller-supplied pubkey cannot reach a selector by any path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentIdentity { + pubkey_hex: String, +} + +impl AgentIdentity { + /// Derive from the payload's `private_key_nsec`. + /// + /// Accepts bech32 `nsec1…`; a malformed or undecodable key is an + /// immediate error, before any substrate read or mutation + /// (§Deploy State Machine step 0). + pub fn from_nsec(nsec: &str) -> Result { + let secret = nostr::SecretKey::from_bech32(nsec.trim()) + .map_err(|_| "private_key_nsec is not a decodable nsec1 key".to_string())?; + let keys = nostr::Keys::new(secret); + Ok(Self { + pubkey_hex: keys.public_key().to_hex(), + }) + } + + /// Full 64-hex public key — the annotation value and the comparison + /// operand for candidate authentication. + pub fn pubkey_hex(&self) -> &str { + &self.pubkey_hex + } + + /// Selector label value: first 32 hex chars (128 bits). A full hex pubkey + /// is 64 chars and label values cap at 63, which is why this is truncated + /// and why the annotation check is normative rather than decorative. + pub fn label_pubkey(&self) -> &str { + &self.pubkey_hex[..32] + } + + /// Deterministic pod name, also the returned `agent_id`. + pub fn pod_name(&self) -> String { + format!("buzz-agent-{}", &self.pubkey_hex[..12]) + } + + /// Per-attempt Secret name. `generation` is a fresh random token per + /// create attempt — never reused — which is what makes payload and Secret + /// atomic at the pod-spec boundary (§K8s Secrets). + pub fn secret_name(&self, generation: &str) -> String { + format!("buzz-agent-{}-{}", &self.pubkey_hex[..12], generation) + } + + /// Label selector matching this identity's objects *and* our management + /// marker. Selecting on the marker as well as the identity means an + /// unmarked look-alike never even enters the candidate list. + pub fn selector(&self) -> String { + format!( + "{LABEL_AGENT_PUBKEY}={},{LABEL_MANAGED_BY}={MANAGED_BY}", + self.label_pubkey() + ) + } + + /// The label set stamped on every object this provider creates. + pub fn labels(&self) -> std::collections::BTreeMap { + [ + ( + LABEL_AGENT_PUBKEY.to_string(), + self.label_pubkey().to_string(), + ), + (LABEL_MANAGED_BY.to_string(), MANAGED_BY.to_string()), + ( + LABEL_BINDING_VERSION.to_string(), + BINDING_VERSION.to_string(), + ), + ] + .into_iter() + .collect() + } +} + +/// A fresh generation token: 8 lowercase hex chars from the OS RNG. +/// +/// Appears in the Secret name and as `BUZZ_MANAGED_AGENT_START_NONCE`, so the +/// Secret generation and the harness's lifecycle-frame correlator are one +/// identity (§Launch data tier 3). +pub fn new_generation() -> String { + use rand::RngExt; + let n: u32 = rand::rng().random(); + format!("{n:08x}") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fixed test key. Deriving the pubkey (rather than hardcoding both + /// halves) is the point: the test exercises the same derivation the + /// reconciler depends on. + fn identity() -> AgentIdentity { + let keys = nostr::Keys::generate(); + let nsec = { + use nostr::nips::nip19::ToBech32; + keys.secret_key().to_bech32().unwrap() + }; + let id = AgentIdentity::from_nsec(&nsec).unwrap(); + assert_eq!(id.pubkey_hex(), keys.public_key().to_hex()); + id + } + + #[test] + fn rejects_malformed_nsec() { + for bad in ["", "nsec1", "not-a-key", "npub1abc"] { + assert!( + AgentIdentity::from_nsec(bad).is_err(), + "accepted malformed key {bad:?}" + ); + } + } + + #[test] + fn tolerates_surrounding_whitespace() { + let keys = nostr::Keys::generate(); + use nostr::nips::nip19::ToBech32; + let nsec = keys.secret_key().to_bech32().unwrap(); + let padded = format!(" {nsec}\n"); + assert_eq!( + AgentIdentity::from_nsec(&padded).unwrap().pubkey_hex(), + keys.public_key().to_hex() + ); + } + + /// Kubernetes label *values* cap at 63 chars; a full hex pubkey is 64, + /// one over. That one-char overflow is the whole reason the selector is + /// truncated, so it gets an explicit test. + #[test] + fn label_value_fits_kubernetes_limit() { + let id = identity(); + assert_eq!(id.pubkey_hex().len(), 64); + assert_eq!(id.label_pubkey().len(), 32); + assert!(id.label_pubkey().len() <= 63); + } + + #[test] + fn pod_name_is_deterministic_and_dns_safe() { + let id = identity(); + assert_eq!(id.pod_name(), id.pod_name()); + assert_eq!( + id.pod_name(), + format!("buzz-agent-{}", &id.pubkey_hex()[..12]) + ); + assert!(id.pod_name().len() <= 253); + assert!(id + .pod_name() + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); + } + + /// Two attempts must never share a Secret name — that uniqueness is what + /// stops a losing contender from overwriting the winner's identity. + #[test] + fn secret_names_are_per_attempt() { + let id = identity(); + let a = id.secret_name(&new_generation()); + let b = id.secret_name(&new_generation()); + assert_ne!(a, b); + assert!(a.starts_with(&id.pod_name())); + assert!(a.len() <= 253); + } + + #[test] + fn selector_requires_the_management_marker() { + let id = identity(); + let sel = id.selector(); + assert!(sel.contains(&format!("{LABEL_AGENT_PUBKEY}={}", id.label_pubkey()))); + assert!(sel.contains(&format!("{LABEL_MANAGED_BY}={MANAGED_BY}"))); + } + + #[test] + fn every_created_object_carries_the_marker() { + let labels = identity().labels(); + assert_eq!( + labels.get(LABEL_MANAGED_BY).map(String::as_str), + Some(MANAGED_BY) + ); + assert_eq!( + labels.get(LABEL_BINDING_VERSION).map(String::as_str), + Some(BINDING_VERSION) + ); + } +} diff --git a/crates/buzz-backend-kubernetes/src/observe.rs b/crates/buzz-backend-kubernetes/src/observe.rs new file mode 100644 index 0000000000..1c6c883563 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/observe.rs @@ -0,0 +1,592 @@ +//! Decoding API objects into verified observations (spec §Deploy State +//! Machine step 1). +//! +//! Pure: `Pod` in, [`VerifiedPod`] out. Keeping the decode here means the +//! conformance tests drive the *shipped* decoder with real API types rather +//! than a test-only stand-in, and it keeps `classify.rs` free of API types. +//! +//! Verification is the gate, not a filter: [`verify`] returns `None` for any +//! object whose full-pubkey annotation does not equal the derived pubkey or +//! that lacks the management marker, so an unverified object cannot reach +//! classification, deletion, or the returned `agent_id`. + +use crate::classify::{Fence, PullFailure, Startup, VerifiedPod}; +use crate::intent::Fingerprint; +use crate::naming::{ + AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, BINDING_VERSION, + LABEL_BINDING_VERSION, LABEL_MANAGED_BY, MANAGED_BY, +}; +use k8s_openapi::api::core::v1::{Pod, Secret}; + +/// Container name the provider creates; status is read from this container. +pub const CONTAINER_NAME: &str = "agent"; + +/// The startup state, or a state that cannot be settled without one more read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StartupObservation { + Resolved(Startup), + /// `CreateContainerConfigError` — recoverable *unless* the referenced + /// Secret is confirmed absent by a most-recent read. The kubelet's reason + /// string is a hint; the provider verifies before treating it as fatal + /// (§Deploy State Machine: "provably" means a verified absence, never a + /// reason string). + ConfigErrorPendingSecretCheck { + secret_name: String, + }, +} + +/// Does this object carry the management marker (§Pod shape)? +/// +/// Identity labels prove identity; the marker asserts protocol ownership. +/// Without it an object that merely matches our schema fails closed. +fn has_marker(labels: Option<&std::collections::BTreeMap>) -> bool { + let Some(labels) = labels else { return false }; + labels.get(LABEL_MANAGED_BY).map(String::as_str) == Some(MANAGED_BY) + && labels.get(LABEL_BINDING_VERSION).map(String::as_str) == Some(BINDING_VERSION) +} + +/// Does the full-pubkey annotation equal the derived pubkey? +/// +/// The 32-hex label is collision-*resistant*, not collision-free, which is +/// why this check is normative rather than decorative (`:1152-1166`). +fn annotation_matches( + annotations: Option<&std::collections::BTreeMap>, + identity: &AgentIdentity, +) -> bool { + annotations + .and_then(|a| a.get(ANNOTATION_PUBKEY_FULL)) + .map(|v| v == identity.pubkey_hex()) + .unwrap_or(false) +} + +/// Is this Secret ours and this identity's? The same fence GC applies before +/// deleting anything. +pub fn secret_is_ours(secret: &Secret, identity: &AgentIdentity) -> bool { + has_marker(secret.metadata.labels.as_ref()) + && annotation_matches(secret.metadata.annotations.as_ref(), identity) +} + +/// Decode a pod's startup state from its status. +/// +/// "Started" means `state.running` on our container — not pod phase. A pod can +/// sit in phase `Running` with a container that never started, and a pod being +/// gracefully deleted stays in phase `Running` for its whole grace period. +pub fn decode_startup(pod: &Pod) -> StartupObservation { + use StartupObservation::Resolved; + + let status = pod.status.as_ref(); + let phase = status.and_then(|s| s.phase.as_deref()); + + let container = status + .and_then(|s| s.container_statuses.as_ref()) + .and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME)); + + if let Some(state) = container.and_then(|c| c.state.as_ref()) { + if state.running.is_some() { + return Resolved(Startup::Started); + } + if state.terminated.is_some() { + return Resolved(Startup::Terminated); + } + if let Some(waiting) = state.waiting.as_ref() { + let reason = waiting.reason.as_deref().unwrap_or_default(); + let message = waiting.message.as_deref().unwrap_or_default(); + return match reason { + // Structurally invalid reference: no retry can fix it. + "InvalidImageName" => Resolved(Startup::NeverStartedProvablyBroken), + "ErrImagePull" | "ImagePullBackOff" => match classify_pull_failure(message) { + Some(failure) => Resolved(Startup::NeverStartedPullFailing(failure)), + None => Resolved(Startup::NeverStartedRecoverable), + }, + "CreateContainerConfigError" => match referenced_secret(pod) { + Some(secret_name) => { + StartupObservation::ConfigErrorPendingSecretCheck { secret_name } + } + None => Resolved(Startup::NeverStartedRecoverable), + }, + _ => Resolved(Startup::NeverStartedRecoverable), + }; + } + } + + // No container status yet (unscheduled, image pulling before the kubelet + // reports, quota-blocked). A terminal phase without container status still + // means the pod is done. + match phase { + Some("Succeeded") | Some("Failed") => Resolved(Startup::Terminated), + _ => Resolved(Startup::NeverStartedRecoverable), + } +} + +/// The Secret name this pod's `envFrom` references, if any. +pub fn referenced_secret(pod: &Pod) -> Option { + pod.spec + .as_ref()? + .containers + .iter() + .flat_map(|c| c.env_from.iter().flatten()) + .find_map(|source| source.secret_ref.as_ref().map(|r| r.name.clone())) +} + +/// Classify a pull failure from the kubelet's message. +/// +/// Reporting only — [`PullFailure`] is structurally excluded from +/// `Action::Delete`, so a wrong guess here can delay a report but can never +/// destroy anything. `None` means "no permanent cause recognized", which +/// leaves the pod on the ordinary observational path. +fn classify_pull_failure(message: &str) -> Option { + let m = message.to_ascii_lowercase(); + if m.contains("401") + || m.contains("unauthorized") + || m.contains("403") + || m.contains("denied") + || m.contains("authentication required") + { + return Some(PullFailure::Unauthorized); + } + if m.contains("manifest unknown") + || m.contains("not found") + || m.contains("manifest_unknown") + || m.contains("repository does not exist") + { + return Some(PullFailure::ManifestUnknown); + } + if m.contains("no match for platform") || m.contains("no matching manifest") { + return Some(PullFailure::ArchMismatch); + } + None +} + +/// The redacted, actionable condition text for a pull failure. +/// +/// Names the registry and the immutable reference — never credentials, and +/// never the kubelet's raw message, which can echo a registry token. +pub fn pull_failure_message(failure: PullFailure, image: &str) -> String { + let registry = image.split('/').next().unwrap_or(image); + match failure { + PullFailure::Unauthorized => format!( + "the cluster is not authorized to pull {image} from {registry}. \ + This pull retries indefinitely and will not succeed on its own: \ + grant the cluster's nodes access to that registry." + ), + PullFailure::ManifestUnknown => { + format!("{registry} has no image at {image}. Check the digest and repository.") + } + PullFailure::ArchMismatch => format!( + "{image} has no variant for the architecture of the nodes it was \ + scheduled on." + ), + } +} + +/// The latest actionable condition for a pod that has not started, redacted. +/// +/// Two sources, deliberately treated differently: +/// +/// * The container's waiting **reason** is included; its **message** is not. +/// Waiting messages are kubelet-composed and echo the thing that failed — +/// for a pull that is the registry request, which can carry credential +/// material. The reason token alone (`ImagePullBackOff`, +/// `CreateContainerConfigError`) is the diagnostic; the message adds +/// exposure, not information the user can act on. +/// * Pod-condition messages **are** included. They are scheduler- and +/// kubelet-composed from the pod's own spec and cluster capacity +/// ("0/3 nodes are available: Insufficient memory"), which is precisely the +/// actionable half and contains nothing derived from Secret data. +pub fn condition(pod: &Pod) -> Option { + let status = pod.status.as_ref()?; + + if let Some(state) = status + .container_statuses + .as_ref() + .and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME)) + .and_then(|c| c.state.as_ref()) + { + if let Some(waiting) = state.waiting.as_ref() { + if let Some(reason) = waiting.reason.as_deref() { + return Some(format!("the container is waiting, reason {reason}")); + } + } + // Exit code and reason only — the terminated `message` is + // process-composed output and falls under the same redaction rule as + // waiting messages. + if let Some(terminated) = state.terminated.as_ref() { + return Some(match terminated.reason.as_deref() { + Some(reason) => format!( + "the container exited with code {} ({reason})", + terminated.exit_code + ), + None => format!("the container exited with code {}", terminated.exit_code), + }); + } + } + + if let Some((type_, reason, message)) = status.conditions.as_ref().and_then(|cs| { + cs.iter().find(|c| c.status == "False").map(|c| { + ( + c.type_.clone(), + c.reason.clone().unwrap_or_default(), + c.message.clone().unwrap_or_default(), + ) + }) + }) { + let detail = [reason, message] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(": "); + return Some(if detail.is_empty() { + format!("pod condition {type_} is false") + } else { + format!("pod condition {type_} is false: {detail}") + }); + } + + status.phase.as_deref().map(|p| format!("the pod is {p}")) +} + +/// Verify a label-selected pod and decode it, or reject it. +/// +/// `startup` is supplied by the caller because settling +/// `CreateContainerConfigError` needs a most-recent Secret read the pure layer +/// must not perform. +pub fn verify(pod: &Pod, identity: &AgentIdentity, startup: Startup) -> Option { + if !has_marker(pod.metadata.labels.as_ref()) { + return None; + } + if !annotation_matches(pod.metadata.annotations.as_ref(), identity) { + return None; + } + Some(VerifiedPod { + name: pod.metadata.name.clone()?, + fence: Fence { + uid: pod.metadata.uid.clone()?, + resource_version: pod.metadata.resource_version.clone()?, + }, + deletion_marked: pod.metadata.deletion_timestamp.is_some(), + startup, + recorded_intent: pod + .metadata + .annotations + .as_ref() + .and_then(|a| a.get(ANNOTATION_CREATE_INTENT)) + .map(|v| Fingerprint::from_annotation(v)), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use k8s_openapi::api::core::v1::{ + ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting, + ContainerStatus, PodStatus, + }; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time}; + use std::collections::BTreeMap; + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn base_pod(id: &AgentIdentity) -> Pod { + Pod { + metadata: ObjectMeta { + name: Some(id.pod_name()), + uid: Some("uid-1".into()), + resource_version: Some("100".into()), + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into_iter() + .collect::>(), + ), + ..Default::default() + }, + ..Default::default() + } + } + + fn with_container_state(mut pod: Pod, state: ContainerState) -> Pod { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: CONTAINER_NAME.into(), + state: Some(state), + ..Default::default() + }]), + ..Default::default() + }); + pod + } + + fn waiting(reason: &str, message: &str) -> ContainerState { + ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some(reason.into()), + message: Some(message.into()), + }), + ..Default::default() + } + } + + #[test] + fn running_container_is_started() { + let id = identity(); + let pod = with_container_state( + base_pod(&id), + ContainerState { + running: Some(ContainerStateRunning::default()), + ..Default::default() + }, + ); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::Started) + ); + } + + /// Pod phase is not the criterion. A pod in phase `Running` whose + /// container never started must NOT read as started, or the reconciler + /// no-ops on a pod that will never serve. + #[test] + fn phase_running_with_waiting_container_is_not_started() { + let id = identity(); + let pod = with_container_state(base_pod(&id), waiting("ContainerCreating", "")); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedRecoverable) + ); + } + + #[test] + fn terminated_container_is_terminated() { + let id = identity(); + let pod = with_container_state( + base_pod(&id), + ContainerState { + terminated: Some(ContainerStateTerminated { + exit_code: 0, + ..Default::default() + }), + ..Default::default() + }, + ); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::Terminated) + ); + } + + /// A terminal phase with no container status (evicted before the kubelet + /// reported) is still terminated — otherwise the residue is never GC'd. + #[test] + fn terminal_phase_without_container_status_is_terminated() { + let id = identity(); + for phase in ["Succeeded", "Failed"] { + let mut pod = base_pod(&id); + pod.status = Some(PodStatus { + phase: Some(phase.into()), + ..Default::default() + }); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::Terminated), + "phase {phase}" + ); + } + } + + #[test] + fn invalid_image_name_is_provably_broken() { + let id = identity(); + let pod = with_container_state(base_pod(&id), waiting("InvalidImageName", "bad ref")); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedProvablyBroken) + ); + } + + /// Permanent pull failures are recognized from the message; anything + /// unrecognized stays on the ordinary observational path rather than + /// being guessed at. + #[test] + fn permanent_pull_failures_are_classified() { + let id = identity(); + let cases = [ + ("401 Unauthorized", PullFailure::Unauthorized), + ("pull access denied", PullFailure::Unauthorized), + ("manifest unknown", PullFailure::ManifestUnknown), + ( + "no match for platform in manifest", + PullFailure::ArchMismatch, + ), + ]; + for (message, expected) in cases { + let pod = with_container_state(base_pod(&id), waiting("ErrImagePull", message)); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedPullFailing(expected)), + "message {message:?}" + ); + } + + let pod = with_container_state( + base_pod(&id), + waiting("ImagePullBackOff", "dial tcp: i/o timeout"), + ); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedRecoverable), + "a transient network failure must not be reported as permanent" + ); + } + + /// The kubelet's reason string is a hint, not proof: a config error defers + /// to a most-recent Secret read before anything is called broken. + #[test] + fn config_error_defers_to_a_secret_read() { + let id = identity(); + let mut pod = + with_container_state(base_pod(&id), waiting("CreateContainerConfigError", "")); + pod.spec = Some(k8s_openapi::api::core::v1::PodSpec { + containers: vec![k8s_openapi::api::core::v1::Container { + name: CONTAINER_NAME.into(), + env_from: Some(vec![k8s_openapi::api::core::v1::EnvFromSource { + secret_ref: Some(k8s_openapi::api::core::v1::SecretEnvSource { + name: "buzz-agent-abc-gen1".into(), + optional: Some(false), + }), + ..Default::default() + }]), + ..Default::default() + }], + ..Default::default() + }); + assert_eq!( + decode_startup(&pod), + StartupObservation::ConfigErrorPendingSecretCheck { + secret_name: "buzz-agent-abc-gen1".into() + } + ); + } + + /// The auto-repair fence: an object that matches our schema but lacks the + /// marker, or carries someone else's pubkey, is never verified — so it can + /// never be no-op'd against, deleted, or returned as an `agent_id`. + #[test] + fn unmarked_or_mismatched_objects_fail_verification() { + let id = identity(); + let other = identity(); + + let mut unmarked = base_pod(&id); + unmarked.metadata.labels = Some(BTreeMap::new()); + assert!( + verify(&unmarked, &id, Startup::Started).is_none(), + "unmarked pod verified" + ); + + let mut wrong_version = base_pod(&id); + let mut labels = id.labels(); + labels.insert(LABEL_BINDING_VERSION.to_string(), "999".to_string()); + wrong_version.metadata.labels = Some(labels); + assert!(verify(&wrong_version, &id, Startup::Started).is_none()); + + let mut mismatched = base_pod(&id); + mismatched.metadata.annotations = Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ); + assert!( + verify(&mismatched, &id, Startup::Started).is_none(), + "collision verified" + ); + + let mut missing = base_pod(&id); + missing.metadata.annotations = Some(BTreeMap::new()); + assert!(verify(&missing, &id, Startup::Started).is_none()); + + assert!( + verify(&base_pod(&id), &id, Startup::Started).is_some(), + "own pod rejected" + ); + } + + /// The fence must come from the observed object, and the deletion mark + /// must be read even though the phase says `Running`. + #[test] + fn verified_pod_carries_the_fence_and_deletion_mark() { + let id = identity(); + let mut pod = base_pod(&id); + pod.metadata.deletion_timestamp = Some(Time(chrono::Utc::now())); + let verified = verify(&pod, &id, Startup::Started).unwrap(); + assert_eq!(verified.fence.uid, "uid-1"); + assert_eq!(verified.fence.resource_version, "100"); + assert!(verified.deletion_marked); + } + + /// A pod with no recorded intent reads as `None`, which the classifier + /// groups with divergence. + #[test] + fn missing_intent_annotation_decodes_as_none() { + let id = identity(); + assert!(verify(&base_pod(&id), &id, Startup::Started) + .unwrap() + .recorded_intent + .is_none()); + } + + /// A pull-failure report must name the registry and the immutable + /// reference and nothing else — never the kubelet's raw message, which + /// can echo a registry token. + #[test] + fn pull_failure_messages_are_actionable_and_redacted() { + let image = format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)); + for failure in [ + PullFailure::Unauthorized, + PullFailure::ManifestUnknown, + PullFailure::ArchMismatch, + ] { + let msg = pull_failure_message(failure, &image); + assert!(msg.contains("ghcr.io"), "{msg}"); + assert!(msg.contains(&image), "{msg}"); + for secret in ["Bearer", "password", "nsec1", "token"] { + assert!(!msg.contains(secret), "leaked {secret}: {msg}"); + } + } + } + + #[test] + fn secret_ownership_requires_marker_and_annotation() { + let id = identity(); + let other = identity(); + let ours = Secret { + metadata: ObjectMeta { + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into(), + ), + ..Default::default() + }, + ..Default::default() + }; + assert!(secret_is_ours(&ours, &id)); + assert!(!secret_is_ours(&ours, &other)); + + let mut unmarked = ours.clone(); + unmarked.metadata.labels = Some(BTreeMap::new()); + assert!(!secret_is_ours(&unmarked, &id)); + } +} diff --git a/crates/buzz-backend-kubernetes/src/pod.rs b/crates/buzz-backend-kubernetes/src/pod.rs new file mode 100644 index 0000000000..725f98fc7d --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/pod.rs @@ -0,0 +1,446 @@ +//! Pod and Secret construction (spec §Pod shape, §K8s Secrets). +//! +//! The builder is pure: it turns resolved inputs into API objects and performs +//! no I/O, so every normative field is a unit assertion. + +use crate::config::{ + ProviderConfig, RESTART_POLICY, RUN_AS_GID, RUN_AS_UID, TERMINATION_GRACE_SECONDS, + WORKSPACE_PATH, +}; +use crate::intent::{Fingerprint, IntentTemplate}; +use crate::naming::{ + AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_IMAGE, ANNOTATION_PUBKEY_FULL, +}; +use k8s_openapi::api::core::v1::{ + Capabilities, Container, EmptyDirVolumeSource, EnvFromSource, Pod, PodSecurityContext, PodSpec, + ResourceRequirements, SeccompProfile, Secret, SecretEnvSource, SecurityContext, Volume, + VolumeMount, +}; +use k8s_openapi::apimachinery::pkg::api::resource::Quantity; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use std::collections::BTreeMap; + +/// Volume name for the agent's writable workspace. +const WORKSPACE_VOLUME: &str = "workspace"; + +/// The container name. Fixed: log and exec tooling addresses it by name. +const CONTAINER_NAME: &str = "agent"; + +/// Build the per-attempt Secret holding the resolved environment. +/// +/// `immutable: true` — the Secret is written once per attempt and never +/// updated, which is what lets the pod's `envFrom` reference be treated as an +/// atomic binding to this exact payload (§K8s Secrets). +pub fn build_secret( + identity: &AgentIdentity, + namespace: &str, + generation: &str, + env: BTreeMap, +) -> Secret { + Secret { + metadata: ObjectMeta { + name: Some(identity.secret_name(generation)), + namespace: Some(namespace.to_string()), + labels: Some(identity.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + identity.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ), + ..Default::default() + }, + string_data: Some(env), + immutable: Some(true), + ..Default::default() + } +} + +/// Build the pod for one create attempt. +/// +/// The `fingerprint` is computed from [`intent_template`] over a type that +/// cannot contain the generation or any Secret value, so it is stable across +/// attempts of the same configuration. +pub fn build_pod( + identity: &AgentIdentity, + cfg: &ProviderConfig, + generation: &str, + fingerprint: &Fingerprint, +) -> Pod { + let annotations: BTreeMap = [ + ( + ANNOTATION_PUBKEY_FULL.to_string(), + identity.pubkey_hex().to_string(), + ), + ( + ANNOTATION_CREATE_INTENT.to_string(), + fingerprint.as_str().to_string(), + ), + (ANNOTATION_IMAGE.to_string(), cfg.image.as_str().to_string()), + ] + .into_iter() + .collect(); + + let requests: BTreeMap = [ + ( + "cpu".to_string(), + Quantity(cfg.resources.cpu_request.clone()), + ), + ( + "memory".to_string(), + Quantity(cfg.resources.memory_request.clone()), + ), + ] + .into_iter() + .collect(); + let limits: BTreeMap = [ + ("cpu".to_string(), Quantity(cfg.resources.cpu_limit.clone())), + ( + "memory".to_string(), + Quantity(cfg.resources.memory_limit.clone()), + ), + ] + .into_iter() + .collect(); + + let container = Container { + name: CONTAINER_NAME.to_string(), + image: Some(cfg.image.as_str().to_string()), + // No `command`/`args`: the image's entrypoint execs the harness as + // PID 1 (§Entrypoint). Overriding it here would be how a provider + // accidentally puts a shell in front of the signal receiver. + env_from: Some(vec![EnvFromSource { + secret_ref: Some(SecretEnvSource { + name: identity.secret_name(generation), + optional: Some(false), + }), + ..Default::default() + }]), + resources: Some(ResourceRequirements { + requests: Some(requests), + limits: Some(limits), + ..Default::default() + }), + volume_mounts: Some(vec![VolumeMount { + name: WORKSPACE_VOLUME.to_string(), + mount_path: WORKSPACE_PATH.to_string(), + ..Default::default() + }]), + security_context: Some(SecurityContext { + allow_privilege_escalation: Some(false), + capabilities: Some(Capabilities { + drop: Some(vec!["ALL".to_string()]), + ..Default::default() + }), + // `readOnlyRootFilesystem` is deliberately unset: the sprig + // toolchain writes outside the workspace mount (§Pod shape). + ..Default::default() + }), + ..Default::default() + }; + + Pod { + metadata: ObjectMeta { + name: Some(identity.pod_name()), + namespace: Some(cfg.namespace.clone()), + labels: Some(identity.labels()), + annotations: Some(annotations), + ..Default::default() + }, + spec: Some(PodSpec { + containers: vec![container], + restart_policy: Some(RESTART_POLICY.to_string()), + termination_grace_period_seconds: Some(TERMINATION_GRACE_SECONDS), + // The agent runs prompted, untrusted code while holding an nsec; + // an ambient ServiceAccount token would be an API-stealable + // credential it never needs (§Pod shape hardening). Naming a + // service account selects a scheduling/RBAC identity and MUST NOT + // re-enable token mounting. + automount_service_account_token: Some(false), + service_account_name: cfg.service_account.clone(), + security_context: Some(PodSecurityContext { + run_as_non_root: Some(true), + run_as_user: Some(RUN_AS_UID), + run_as_group: Some(RUN_AS_GID), + fs_group: Some(RUN_AS_GID), + seccomp_profile: Some(SeccompProfile { + type_: "RuntimeDefault".to_string(), + ..Default::default() + }), + ..Default::default() + }), + volumes: Some(vec![Volume { + name: WORKSPACE_VOLUME.to_string(), + empty_dir: Some(EmptyDirVolumeSource::default()), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + } +} + +/// The create-intent template for this configuration (§Deploy State Machine). +pub fn intent_template( + cfg: &ProviderConfig, + env_keys: impl IntoIterator, +) -> IntentTemplate { + IntentTemplate::new( + &cfg.namespace, + &cfg.image, + &cfg.resources, + cfg.service_account.as_deref(), + env_keys, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config; + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn provider_config() -> ProviderConfig { + config::parse(&serde_json::json!({ + "namespace": "buzz-agents-test", + "image": format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)), + })) + .unwrap() + } + + fn pod() -> Pod { + let cfg = provider_config(); + build_pod( + &identity(), + &cfg, + "gen00001", + &intent_template(&cfg, ["BUZZ_RELAY_URL".to_string()]).fingerprint(), + ) + } + + fn spec(pod: &Pod) -> &PodSpec { + pod.spec.as_ref().unwrap() + } + + /// Every hardening default from §Pod shape, asserted individually so a + /// dropped one names itself. + #[test] + fn hardening_defaults_are_all_present() { + let pod = pod(); + let spec = spec(&pod); + assert_eq!(spec.automount_service_account_token, Some(false)); + + let sc = spec + .security_context + .as_ref() + .expect("pod security context"); + assert_eq!(sc.run_as_non_root, Some(true)); + assert_eq!(sc.run_as_user, Some(RUN_AS_UID)); + assert_ne!(sc.run_as_user, Some(0), "root UID"); + assert_eq!(sc.run_as_group, Some(RUN_AS_GID)); + assert_eq!( + sc.seccomp_profile.as_ref().map(|p| p.type_.as_str()), + Some("RuntimeDefault") + ); + + let csc = spec.containers[0] + .security_context + .as_ref() + .expect("container sc"); + assert_eq!(csc.allow_privilege_escalation, Some(false)); + assert_eq!( + csc.capabilities.as_ref().and_then(|c| c.drop.clone()), + Some(vec!["ALL".to_string()]) + ); + assert_ne!(csc.privileged, Some(true)); + } + + /// The forbidden host-namespace and hostPath escapes, asserted as absence. + #[test] + fn never_uses_host_namespaces_or_host_paths() { + let pod = pod(); + let spec = spec(&pod); + assert!(spec.host_pid.is_none() || spec.host_pid == Some(false)); + assert!(spec.host_network.is_none() || spec.host_network == Some(false)); + assert!(spec.host_ipc.is_none() || spec.host_ipc == Some(false)); + for volume in spec.volumes.as_ref().unwrap() { + assert!( + volume.host_path.is_none(), + "hostPath volume {}", + volume.name + ); + } + } + + /// `Never` only. `OnFailure` is gated on the harness exit-code contract + /// *and* a crash-loop classification row (`:1121-1139`); the config layer + /// refuses `inactivity_seconds: 0` so this arm is unreachable, and the + /// assertion keeps it that way. + #[test] + fn restart_policy_is_never() { + assert_eq!(spec(&pod()).restart_policy.as_deref(), Some("Never")); + } + + /// 60s, not Kubernetes' default 30s — which would SIGKILL the harness + /// mid-drain and leave presence stale-online (§Pod shape). + #[test] + fn declares_the_sixty_second_grace_budget() { + assert_eq!(spec(&pod()).termination_grace_period_seconds, Some(60)); + } + + /// The pod must not override the image's entrypoint: the image execs the + /// harness as PID 1, and a `command` here is how a shell ends up in front + /// of the signal receiver (§Entrypoint). + #[test] + fn does_not_override_the_image_entrypoint() { + let pod = pod(); + let container = &spec(&pod).containers[0]; + assert!(container.command.is_none(), "overrode the entrypoint"); + assert!(container.args.is_none()); + } + + #[test] + fn workspace_is_an_emptydir_mounted_at_home() { + let pod = pod(); + let spec = spec(&pod); + let volume = &spec.volumes.as_ref().unwrap()[0]; + assert!(volume.empty_dir.is_some()); + assert!(volume.persistent_volume_claim.is_none()); + let mount = &spec.containers[0].volume_mounts.as_ref().unwrap()[0]; + assert_eq!(mount.name, volume.name); + assert_eq!(mount.mount_path, WORKSPACE_PATH); + } + + #[test] + fn resources_carry_the_configured_requests_and_limits() { + let mut cfg = provider_config(); + cfg.resources.cpu_limit = "4".into(); + let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f")); + let r = spec(&pod).containers[0].resources.as_ref().unwrap(); + assert_eq!(r.requests.as_ref().unwrap()["cpu"], Quantity("1".into())); + assert_eq!( + r.requests.as_ref().unwrap()["memory"], + Quantity("2Gi".into()) + ); + assert_eq!(r.limits.as_ref().unwrap()["cpu"], Quantity("4".into())); + assert_eq!(r.limits.as_ref().unwrap()["memory"], Quantity("4Gi".into())); + } + + /// `envFrom` must point at this attempt's Secret and must NOT be optional: + /// an optional reference starts the container with no identity at all, + /// turning a missing-Secret bug into an agent that silently cannot + /// authenticate. + #[test] + fn env_from_references_this_attempts_secret_and_is_required() { + let id = identity(); + let cfg = provider_config(); + let pod = build_pod(&id, &cfg, "gen00042", &Fingerprint::from_annotation("f")); + let source = &spec(&pod).containers[0].env_from.as_ref().unwrap()[0]; + let secret_ref = source.secret_ref.as_ref().unwrap(); + assert_eq!(secret_ref.name, id.secret_name("gen00042")); + assert_eq!(secret_ref.optional, Some(false)); + assert!(source.config_map_ref.is_none()); + } + + /// Identity, ownership marker, and the recorded intent all travel on the + /// pod — the GC and reconciliation fences read exactly these. + #[test] + fn pod_carries_identity_marker_and_recorded_intent() { + let id = identity(); + let cfg = provider_config(); + let fp = intent_template(&cfg, ["A".to_string()]).fingerprint(); + let pod = build_pod(&id, &cfg, "g", &fp); + let meta = &pod.metadata; + assert_eq!(meta.name.as_deref(), Some(id.pod_name().as_str())); + assert_eq!(meta.namespace.as_deref(), Some("buzz-agents-test")); + assert_eq!(meta.labels.as_ref().unwrap(), &id.labels()); + let ann = meta.annotations.as_ref().unwrap(); + assert_eq!(ann[ANNOTATION_PUBKEY_FULL], id.pubkey_hex()); + assert_eq!(ann[ANNOTATION_CREATE_INTENT], fp.as_str()); + assert_eq!(ann[ANNOTATION_IMAGE], cfg.image.as_str()); + } + + /// The Secret is immutable and marker-bearing: immutability is what makes + /// the pod's `envFrom` an atomic binding, and the marker is what GC + /// requires before it will delete anything. + #[test] + fn secret_is_immutable_marked_and_holds_the_env() { + let id = identity(); + let env: BTreeMap = + [("BUZZ_RELAY_URL".to_string(), "wss://r".to_string())].into(); + let secret = build_secret(&id, "ns", "gen1", env.clone()); + assert_eq!(secret.immutable, Some(true)); + assert_eq!(secret.string_data.as_ref().unwrap(), &env); + assert_eq!( + secret.metadata.name.as_deref(), + Some(id.secret_name("gen1").as_str()) + ); + assert_eq!(secret.metadata.labels.as_ref().unwrap(), &id.labels()); + assert_eq!( + secret.metadata.annotations.as_ref().unwrap()[ANNOTATION_PUBKEY_FULL], + id.pubkey_hex() + ); + // `data` must stay unset — setting both is an apiserver rejection. + assert!(secret.data.is_none()); + } + + /// Naming a service account selects a scheduling identity; it must not + /// re-enable token mounting (§Pod shape hardening, `:1221-1225`). + #[test] + fn service_account_does_not_re_enable_token_mounting() { + let mut cfg = provider_config(); + cfg.service_account = Some("agent-sa".into()); + let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f")); + assert_eq!(spec(&pod).service_account_name.as_deref(), Some("agent-sa")); + assert_eq!(spec(&pod).automount_service_account_token, Some(false)); + } + + /// The fingerprint recorded on the pod is the one the classifier will + /// recompute — pinned end-to-end so a builder change that forgets to feed + /// the template a field cannot pass silently. + #[test] + fn recorded_fingerprint_matches_a_fresh_computation() { + let cfg = provider_config(); + let keys = ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()]; + let fp = intent_template(&cfg, keys.clone()).fingerprint(); + let pod = build_pod(&identity(), &cfg, "gen-a", &fp); + let recorded = Fingerprint::from_annotation( + &pod.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT], + ); + assert_eq!(recorded, intent_template(&cfg, keys).fingerprint()); + } + + /// Two attempts differing only in generation must record the *same* + /// fingerprint, or the divergence discriminator fires on every deploy and + /// the never-started row deletes healthy pending pods. + #[test] + fn generation_does_not_change_the_recorded_fingerprint() { + let cfg = provider_config(); + let keys = ["BUZZ_RELAY_URL".to_string()]; + let a = intent_template(&cfg, keys.clone()).fingerprint(); + let b = intent_template(&cfg, keys).fingerprint(); + let id = identity(); + let pod_a = build_pod(&id, &cfg, "gen-1", &a); + let pod_b = build_pod(&id, &cfg, "gen-2", &b); + let read = + |p: &Pod| p.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT].clone(); + assert_eq!(read(&pod_a), read(&pod_b)); + // ...while the Secret they reference differs. + let secret_of = |p: &Pod| { + spec(p).containers[0].env_from.as_ref().unwrap()[0] + .secret_ref + .as_ref() + .unwrap() + .name + .clone() + }; + assert_ne!(secret_of(&pod_a), secret_of(&pod_b)); + } +} diff --git a/crates/buzz-backend-kubernetes/src/reconcile.rs b/crates/buzz-backend-kubernetes/src/reconcile.rs new file mode 100644 index 0000000000..df2f99789f --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/reconcile.rs @@ -0,0 +1,1585 @@ +//! The deploy loop: executes [`crate::classify`]'s actions against a substrate +//! and re-enters (spec §Deploy State Machine). +//! +//! The substrate is a trait so the conformance tests drive this exact +//! reconciler — the shipped code path, not a test-only reimplementation — with +//! a fake cluster and a fake clock. +//! +//! Two shapes are worth naming up front, because they are what keep the loop +//! terminating: +//! +//! * **Success means the harness container started** (`:696-699`). There is no +//! "deployed but not confirmed" success: `deploy` returns an `agent_id` or an +//! in-band error carrying the latest condition. The wire has no third form. +//! * **A create-conflict loser never repairs.** It verifies the winner, drops +//! its own Secret, and *observes* until the winner starts. Applying the +//! divergence row to the pod that just beat it is exactly the ping-pong the +//! spec forbids (`:845-850`), and the escape it names is the *next* deploy, +//! not this one. + +use crate::classify::{self, Action, Fence, Startup, VerifiedPod}; +use crate::config::ProviderConfig; +use crate::gc; +use crate::naming::AgentIdentity; +use crate::observe::{self, StartupObservation}; +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::{Pod, Secret}; +use std::collections::BTreeMap; +use std::time::Duration; + +/// Outcome of a create against the deterministic pod name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateOutcome { + Created, + /// 409 with `Status.reason: AlreadyExists` — a concurrent attempt won. + /// Discriminated on the typed reason, never on the HTTP code alone: 409 is + /// also `Conflict`, which means a failed precondition (`:780-794`). + AlreadyExists, +} + +/// Outcome of a fenced delete. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteOutcome { + Accepted, + /// Already gone. Delete-not-found is success (`:842`). + NotFound, + /// 409 with `Status.reason: Conflict` — the object changed since the + /// observation that authorized this delete. Neither an error nor + /// permission to retry: re-enter and classify what exists now + /// (`:775-777`). + PreconditionFailed, +} + +/// The cluster operations the reconciler needs. Everything here is I/O; +/// everything that decides is pure and lives in `classify`/`gc`. +#[allow(async_fn_in_trait)] +pub trait Substrate { + /// Create the namespace if absent. On RBAC denial the error MUST name the + /// literal `kubectl create namespace ` command and MUST NOT fall + /// back to `default` (`:1002-1005`). + async fn ensure_namespace(&self, namespace: &str) -> Result<(), String>; + + /// Most-recent read of the pods matching this identity's selector, plus + /// the apiserver's clock from the same call's HTTP `Date` header. `None` + /// clock means the header was absent or unparseable, which makes the + /// orphan-Secret sweep skip (`:1321-1335`). + async fn list_pods(&self, selector: &str) -> Result<(Vec, Option>), String>; + + async fn list_secrets(&self, selector: &str) -> Result, String>; + + /// Most-recent existence check (`resourceVersion` explicitly unset, not + /// `"0"`): the classifier treats a confirmed absence as proof, so a + /// possibly-stale cache read would be proof of nothing (`:761-769`). + async fn secret_exists(&self, name: &str) -> Result; + + async fn create_secret(&self, secret: &Secret) -> Result<(), String>; + + async fn create_pod(&self, pod: &Pod) -> Result; + + /// Compare-and-delete against the fence from the authorizing observation. + /// Uses the object's own grace period — never `grace_period_seconds: 0`, + /// which is a force-kill that discards the declared 60s shutdown budget + /// (`:1185-1189`). + async fn delete_pod(&self, name: &str, fence: &Fence) -> Result; + + /// Best-effort: the Secret may already be gone, which is success. + async fn delete_secret(&self, name: &str) -> Result<(), String>; + + /// Read one pod by name, most-recent. `None` is a confirmed absence. + async fn get_pod(&self, name: &str) -> Result, String>; + + async fn sleep(&self, duration: Duration); + + /// Monotonic elapsed time since the operation began. Fake-clock driven in + /// tests; the deadline must not depend on wall-clock adjustments. + fn elapsed(&self) -> Duration; +} + +/// Interval between reconciler polls. Short enough that a fast start is +/// reported promptly, long enough not to hammer the apiserver for 600s. +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// The deploy operation deadline (spec §Deploy: `timeout: 600s`). +const DEADLINE: Duration = Duration::from_secs(gc::OPERATION_DEADLINE_SECS as u64); + +/// Settle a pod's startup state, performing the one most-recent Secret read +/// that `CreateContainerConfigError` requires. +async fn settle(substrate: &impl Substrate, pod: &Pod) -> Result { + Ok(match observe::decode_startup(pod) { + StartupObservation::Resolved(startup) => startup, + StartupObservation::ConfigErrorPendingSecretCheck { secret_name } => { + // A confirmed absence is proof; anything else stays recoverable, + // because the kubelet's reason string alone is not evidence. + if substrate.secret_exists(&secret_name).await? { + Startup::NeverStartedRecoverable + } else { + Startup::NeverStartedProvablyBroken + } + } + }) +} + +/// Observe the single pod owned by this identity, verified. +/// +/// `Ok(None)` conflates "absent" with "present but not ours" on purpose *here* +/// — both mean the classifier has nothing it may act on. The create path +/// separates them, because only there is the difference actionable. +async fn observe_pod( + substrate: &impl Substrate, + identity: &AgentIdentity, +) -> Result, String> { + let Some(pod) = substrate.get_pod(&identity.pod_name()).await? else { + return Ok(None); + }; + let startup = settle(substrate, &pod).await?; + Ok(observe::verify(&pod, identity, startup)) +} + +/// The latest condition to report, read fresh at the moment of reporting. +/// +/// Reading it here rather than threading it through every loop iteration is +/// what "the *latest* redacted condition" (`:689`) asks for, and it costs a +/// read only on the paths that are already failing. +async fn latest_condition(substrate: &impl Substrate, identity: &AgentIdentity) -> String { + match substrate.get_pod(&identity.pod_name()).await { + Ok(Some(pod)) => observe::condition(&pod) + .unwrap_or_else(|| "no condition reported by the cluster".to_string()), + Ok(None) => "the pod no longer exists".to_string(), + Err(e) => format!("the pod's condition could not be read: {e}"), + } +} + +/// Preflight GC (§K8s GC). Failures are logged and swallowed: GC is hygiene, +/// and a deploy must not fail because a stale object could not be listed or +/// removed. A denial that actually blocks this deploy resurfaces at create, +/// where the message names the operation the user was denied. +async fn preflight_gc(substrate: &impl Substrate, identity: &AgentIdentity) { + if let Err(e) = try_preflight_gc(substrate, identity).await { + eprintln!("gc: preflight pass skipped: {e}"); + } +} + +async fn try_preflight_gc( + substrate: &impl Substrate, + identity: &AgentIdentity, +) -> Result<(), String> { + let selector = identity.selector(); + let (pods, server_now) = substrate.list_pods(&selector).await?; + let secrets = substrate.list_secrets(&selector).await?; + + let mut terminated: Vec = Vec::new(); + for pod in &pods { + if matches!(settle(substrate, pod).await?, Startup::Terminated) { + if let Some(name) = pod.metadata.name.clone() { + terminated.push(name); + } + } + } + + let plan = gc::plan( + identity, + &pods, + &secrets, + |pod| { + pod.metadata + .name + .as_deref() + .map(|n| terminated.iter().any(|t| t == n)) + .unwrap_or(false) + }, + server_now, + ); + + for name in &plan.pods { + // Re-read to fence the delete against the object we just observed; a + // pod that changed since the list is simply skipped this pass. + let Some(pod) = substrate.get_pod(name).await? else { + continue; + }; + let (Some(uid), Some(rv)) = ( + pod.metadata.uid.clone(), + pod.metadata.resource_version.clone(), + ) else { + continue; + }; + if !matches!(settle(substrate, &pod).await?, Startup::Terminated) { + continue; + } + let fence = Fence { + uid, + resource_version: rv, + }; + if let Err(e) = substrate.delete_pod(name, &fence).await { + eprintln!("gc: could not delete terminated pod {name}: {e}"); + } + } + for name in &plan.secrets { + if let Err(e) = substrate.delete_secret(name).await { + eprintln!("gc: could not delete secret {name}: {e}"); + } + } + Ok(()) +} + +/// Wait for a pod to actually disappear. +/// +/// Mandatory before recreating: `DELETE` returns success while the object +/// still exists, and the deterministic name stays taken for the whole grace +/// period (`:1177-1195`). +async fn await_disappearance(substrate: &impl Substrate, name: &str) -> Result<(), String> { + while substrate.elapsed() < DEADLINE { + if substrate.get_pod(name).await?.is_none() { + return Ok(()); + } + substrate.sleep(POLL_INTERVAL).await; + } + Err(format!( + "timed out after {}s waiting for {name} to finish terminating", + DEADLINE.as_secs() + )) +} + +/// Is `secret` referenced by any pod that currently exists under this +/// identity's selector? +/// +/// Protection deliberately spans *all* our pods, not just the winner: an +/// `envFrom` reference from a pod still pulling its image is exactly as +/// load-bearing as one from a running pod (`:1261-1264`). +async fn secret_is_referenced( + substrate: &impl Substrate, + identity: &AgentIdentity, + secret: &str, +) -> Result { + let (pods, _) = substrate.list_pods(&identity.selector()).await?; + Ok(pods + .iter() + .filter_map(observe::referenced_secret) + .any(|name| name == secret)) +} + +/// Drop this attempt's own Secret once nothing references it. +/// +/// Only ever called with a name this process generated, and gated on the +/// reference check: "never the winner's, never any Secret referenced by an +/// existing pod" (`:1259-1264`). Failure is logged, not fatal — a leaked +/// Secret is collected by the age-gated sweep. +async fn drop_own_secret(substrate: &impl Substrate, identity: &AgentIdentity, secret: &str) { + match secret_is_referenced(substrate, identity, secret).await { + Ok(false) => { + if let Err(e) = substrate.delete_secret(secret).await { + eprintln!("could not clean up own unreferenced secret {secret}: {e}"); + } + } + Ok(true) => {} + Err(e) => eprintln!("could not check whether {secret} is still referenced: {e}"), + } +} + +/// Lost the create race: adopt the elected winner. +/// +/// Observe-only by construction — this function has no delete edge for the +/// pod. A winner that is terminated or provably broken is reported, not +/// repaired; the spec's escape is "a *subsequent* deploy that walks in and +/// observes that never-started divergent winner replaces it normally" +/// (`:849-850`). +async fn adopt_winner( + substrate: &impl Substrate, + identity: &AgentIdentity, + own_secret: &str, +) -> Result { + let name = identity.pod_name(); + + // Verify before adopting. A pod under our deterministic name that fails + // the marker/annotation check is not ours to adopt, wait for, or touch — + // and it will never become ours, so this is terminal rather than a retry. + match substrate.get_pod(&name).await? { + None => {} + Some(pod) => { + let startup = settle(substrate, &pod).await?; + if observe::verify(&pod, identity, startup).is_none() { + drop_own_secret(substrate, identity, own_secret).await; + return Err(format!( + "a pod named {name} already exists in this namespace but is not \ + managed by this provider for this agent (it lacks the management \ + marker or carries a different agent identity). Remove it, or \ + deploy this agent to a different namespace." + )); + } + } + } + + drop_own_secret(substrate, identity, own_secret).await; + + // Then wait for the winner exactly as we would wait for our own pod: + // success still means the harness container started. + loop { + if substrate.elapsed() >= DEADLINE { + return Err(format!( + "startup not confirmed within {}s for {name} (another deploy of this \ + agent created it): {}", + DEADLINE.as_secs(), + latest_condition(substrate, identity).await + )); + } + match observe_pod(substrate, identity).await? { + Some(pod) if matches!(pod.startup, Startup::Started) => return Ok(pod.name), + Some(pod) if pod.deletion_marked => { + return Err(format!( + "{name} was created by another deploy of this agent and is already \ + being deleted; try again" + )) + } + Some(pod) + if matches!( + pod.startup, + Startup::Terminated | Startup::NeverStartedProvablyBroken + ) => + { + return Err(format!( + "{name} was created by another deploy of this agent and did not \ + start: {}", + latest_condition(substrate, identity).await + )) + } + // Gone again, or still coming up: keep observing under this + // operation's deadline. + _ => substrate.sleep(POLL_INTERVAL).await, + } + } +} + +/// Run the deploy state machine to a terminal outcome: the started pod's name, +/// or an in-band error carrying the latest condition. +pub async fn deploy( + substrate: &impl Substrate, + identity: &AgentIdentity, + cfg: &ProviderConfig, + env: BTreeMap, +) -> Result { + substrate.ensure_namespace(&cfg.namespace).await?; + preflight_gc(substrate, identity).await; + + let desired = crate::pod::intent_template(cfg, env.keys().cloned()).fingerprint(); + + // Has THIS call created a pod? Set once its create lands. The replacement + // rows below are for residue from a previous life; once this call has made + // its own attempt, a replace-classification means that attempt failed — + // and startup verification is part of create, so the failure is reported + // in-band rather than retried. Without this bound a deterministic startup + // failure (the harness starts, rejects its configuration, exits) is + // delete-recreated every poll for the whole deadline, minting an immutable + // Secret per cycle — measured live at 107 Secrets in one 600s call, every + // one younger than the orphan sweep's age gate. + let mut created_this_call = false; + + loop { + if substrate.elapsed() >= DEADLINE { + return Err(format!( + "startup not confirmed within {}s for {}: {}", + DEADLINE.as_secs(), + identity.pod_name(), + latest_condition(substrate, identity).await + )); + } + + let observed = observe_pod(substrate, identity).await?; + match classify::classify(observed.as_ref(), &desired) { + // The only success edge: the harness container is running. + Action::NoOp { agent_id } => return Ok(agent_id), + + // Self-healing states. Never delete, on this call or any later one + // — what replaces a never-started pod is a config change, never a + // deadline (`:717-729`). + Action::Observe { .. } => substrate.sleep(POLL_INTERVAL).await, + + // A pull that will not self-heal: report now rather than spend the + // remaining deadline on it. Still no delete authority. + Action::Report { name, failure } => { + return Err(format!( + "{name} did not start: {}", + observe::pull_failure_message(failure, cfg.image.as_str()) + )) + } + + Action::AwaitDisappearance { name } => await_disappearance(substrate, &name).await?, + + Action::Delete { name, fence } => { + // This call already made its own attempt, and that attempt is + // what the classification wants replaced: it terminated (the + // deterministic startup failure — the harness starts, rejects + // its configuration, exits) or was proven broken. Replacing it + // here retries the identical configuration against the same + // cluster: a hot delete/mint/create cycle every poll for the + // whole deadline, an immutable Secret per cycle — measured + // live at 107 Secrets in one 600s call, all younger than the + // orphan sweep's age gate. Report in-band instead. The residue + // is deliberate: the next Start's preflight GC collects the + // terminated pod and its referenced Secret together, so retry + // is gated on fresh owner intent and litter stays bounded at + // one pod + one Secret per press. + if created_this_call { + return Err(format!( + "{name} was created by this deploy and did not stay \ + running: {}. Not retrying in this call — an immediate \ + exit recurs until its cause is fixed. Check the \ + agent's configuration and press Start to try again.", + latest_condition(substrate, identity).await + )); + } + match substrate.delete_pod(&name, &fence).await? { + // Accepted or already gone: both need the disappearance + // poll before the name is free again. + DeleteOutcome::Accepted | DeleteOutcome::NotFound => { + await_disappearance(substrate, &name).await? + } + // The object changed since the observation that authorized + // this delete. Discard the action and re-classify — never + // retry with a fresher fence, which would delete something + // we never examined. Sleep before re-entering: the losing + // race is against another writer, and re-reading at full + // speed is a busy-retry with no better odds than a paced + // one. + DeleteOutcome::PreconditionFailed => substrate.sleep(POLL_INTERVAL).await, + } + } + + Action::Create => { + let generation = crate::naming::new_generation(); + let secret_name = identity.secret_name(&generation); + + // The generation is minted *per attempt*, and it is two things + // at once: the Secret's name suffix and the lifecycle + // correlator the harness reports. `build_env` stamped the + // caller's generation, so on any attempt after the first the + // two would name different generations — pod logs correlating + // to a Secret that is not the one mounted. Restamp so there is + // exactly one generation per attempt (§K8s Secrets). + let mut env = env.clone(); + env.insert(crate::env::START_NONCE_KEY.to_string(), generation.clone()); + + // Secret first: the pod's spec references this exact name, so + // payload and Secret are atomic at the pod-spec boundary. + let secret = crate::pod::build_secret(identity, &cfg.namespace, &generation, env); + substrate.create_secret(&secret).await?; + + let pod = crate::pod::build_pod(identity, cfg, &generation, &desired); + match substrate.create_pod(&pod).await? { + // Re-enter rather than wait inline: the next iteration + // observes what we just created and runs the same rows + // every other state runs through. One loop, one table. + // + // Sleep first. A just-created pod cannot already be + // started, so the immediate observation has no outcome but + // "still coming up" — and if it ever came back + // unverifiable, re-entering without advancing the clock + // would hot-spin creates against the apiserver for the + // whole deadline. + CreateOutcome::Created => { + created_this_call = true; + substrate.sleep(POLL_INTERVAL).await + } + CreateOutcome::AlreadyExists => { + return adopt_winner(substrate, identity, &secret_name).await + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Resources; + use crate::naming::{ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY}; + use k8s_openapi::api::core::v1::{ + ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting, + ContainerStatus, PodStatus, + }; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; + use std::cell::RefCell; + use std::future::Future; + use std::pin::pin; + use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + /// Mutates the pod map after a poll, so a test can script a pod that + /// starts (or vanishes) partway through an observation loop. + type PollHook = Box)>; + + /// A scripted cluster. Single-threaded on purpose: the reconciler is one + /// process per operation, and `RefCell` keeps the assertions readable. + /// + /// This drives the *shipped* `deploy` — the point of the `Substrate` seam. + /// Every fake here answers with real `k8s_openapi` objects, so the decode + /// and verification layers under test are the ones that run in a cluster. + #[derive(Default)] + struct Fake { + pods: RefCell>, + secrets: RefCell>, + /// Server clock for the GC age gate; `None` models a missing `Date`. + server_now: Option>, + /// Elapsed time, advanced only by `sleep` — a fake clock, so a 600s + /// deadline test runs instantly and cannot flake on a slow machine. + elapsed: RefCell, + /// Queued create outcomes; the default is `Created`. + create_outcomes: RefCell>, + /// Installed when a create loses the race. A winner must be *absent* + /// at the observation that decides to create and *present* by the time + /// the create lands — pre-seeding it instead makes the loop no-op + /// before it ever reaches the create edge. + winner: RefCell>, + /// Every Secret ever created, retained across deletion. + created_secrets: RefCell>, + /// Queued delete outcomes; the default is `Accepted`. + delete_outcomes: RefCell>, + /// Every mutating call, in order — the anti-mutation assertions read + /// this rather than guessing from final state. + calls: RefCell>, + /// Applied to the pod map after each poll, so a test can script a pod + /// that starts (or vanishes) partway through an observation loop. + on_poll: RefCell>, + /// `ensure_namespace` fails with this, if set. + namespace_error: Option, + } + + impl Fake { + fn with_pod(self, pod: Pod) -> Self { + self.pods + .borrow_mut() + .insert(pod.metadata.name.clone().unwrap(), pod); + self + } + fn log(&self, entry: impl Into) { + self.calls.borrow_mut().push(entry.into()); + } + fn mutations(&self) -> Vec { + self.calls.borrow().clone() + } + } + + impl Substrate for Fake { + async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> { + match &self.namespace_error { + Some(e) => Err(e.clone()), + None => { + self.log(format!("ensure_namespace {namespace}")); + Ok(()) + } + } + } + + async fn list_pods( + &self, + _selector: &str, + ) -> Result<(Vec, Option>), String> { + Ok(( + self.pods.borrow().values().cloned().collect(), + self.server_now, + )) + } + + async fn list_secrets(&self, _selector: &str) -> Result, String> { + Ok(self.secrets.borrow().clone()) + } + + async fn secret_exists(&self, name: &str) -> Result { + Ok(self + .secrets + .borrow() + .iter() + .any(|s| s.metadata.name.as_deref() == Some(name))) + } + + async fn create_secret(&self, secret: &Secret) -> Result<(), String> { + let name = secret.metadata.name.clone().unwrap(); + self.log(format!("create_secret {name}")); + self.secrets.borrow_mut().push(secret.clone()); + // Kept even after the Secret is deleted: assertions about what an + // attempt *wrote* must not be silently vacuous once cleanup runs. + self.created_secrets.borrow_mut().push(secret.clone()); + Ok(()) + } + + async fn create_pod(&self, pod: &Pod) -> Result { + let name = pod.metadata.name.clone().unwrap(); + self.log(format!("create_pod {name}")); + let outcome = if self.create_outcomes.borrow().is_empty() { + CreateOutcome::Created + } else { + self.create_outcomes.borrow_mut().remove(0) + }; + if outcome == CreateOutcome::Created { + // The apiserver stamps these on admission; a builder never + // carries them. Without them the pod fails `verify`'s fence + // extraction and the loop can never see what it just created. + let mut pod = pod.clone(); + pod.metadata.uid = Some(format!("uid-created-{}", self.calls.borrow().len())); + pod.metadata.resource_version = Some("1".into()); + self.pods.borrow_mut().insert(name, pod); + } else if let Some(winner) = self.winner.borrow_mut().take() { + // The concurrent attempt's pod becomes visible exactly when our + // create is rejected — the ordering a real race produces. + self.pods.borrow_mut().insert(name, winner); + } + Ok(outcome) + } + + async fn delete_pod(&self, name: &str, fence: &Fence) -> Result { + self.log(format!( + "delete_pod {name} uid={} rv={}", + fence.uid, fence.resource_version + )); + let outcome = if self.delete_outcomes.borrow().is_empty() { + DeleteOutcome::Accepted + } else { + self.delete_outcomes.borrow_mut().remove(0) + }; + if outcome == DeleteOutcome::Accepted { + self.pods.borrow_mut().remove(name); + } + Ok(outcome) + } + + async fn delete_secret(&self, name: &str) -> Result<(), String> { + self.log(format!("delete_secret {name}")); + self.secrets + .borrow_mut() + .retain(|s| s.metadata.name.as_deref() != Some(name)); + Ok(()) + } + + async fn get_pod(&self, name: &str) -> Result, String> { + Ok(self.pods.borrow().get(name).cloned()) + } + + async fn sleep(&self, duration: Duration) { + *self.elapsed.borrow_mut() += duration; + let hooks = self.on_poll.borrow(); + let mut pods = self.pods.borrow_mut(); + for hook in hooks.iter() { + hook(&mut pods); + } + } + + fn elapsed(&self) -> Duration { + *self.elapsed.borrow() + } + } + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn config() -> ProviderConfig { + ProviderConfig { + context: None, + namespace: "buzz-agents-test".into(), + image: crate::image::parse(&format!( + "ghcr.io/block/buzz-sprig@sha256:{}", + "a".repeat(64) + )) + .unwrap(), + resources: Resources::default(), + inactivity_seconds: Some(7200), + service_account: None, + } + } + + fn env() -> BTreeMap { + [("BUZZ_RELAY_URL".to_string(), "wss://r".to_string())] + .into_iter() + .collect() + } + + /// A pod exactly as this provider would have created it — same builder the + /// reconciler uses, so verification is exercised rather than bypassed. + fn our_pod(id: &AgentIdentity, cfg: &ProviderConfig, state: Option) -> Pod { + let fp = crate::pod::intent_template(cfg, env().keys().cloned()).fingerprint(); + let mut pod = crate::pod::build_pod(id, cfg, "gen-existing", &fp); + pod.metadata.uid = Some("uid-1".into()); + pod.metadata.resource_version = Some("100".into()); + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: state.map(|s| { + vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(s), + ..Default::default() + }] + }), + ..Default::default() + }); + pod + } + + fn running() -> ContainerState { + ContainerState { + running: Some(ContainerStateRunning::default()), + ..Default::default() + } + } + + fn terminated() -> ContainerState { + ContainerState { + terminated: Some(ContainerStateTerminated::default()), + ..Default::default() + } + } + + fn waiting(reason: &str) -> ContainerState { + ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some(reason.into()), + message: None, + }), + ..Default::default() + } + } + + fn run(fake: &Fake, id: &AgentIdentity, cfg: &ProviderConfig) -> Result { + block_on(deploy(fake, id, cfg, env())) + } + + /// Minimal executor: the fake never yields to a reactor (no timers, no + /// I/O — `sleep` just advances a counter), so polling to completion is + /// sufficient and avoids pulling a runtime into the unit job. + fn block_on(fut: impl Future) -> T { + fn noop_waker() -> Waker { + fn nop(_: *const ()) {} + fn clone(_: *const ()) -> RawWaker { + RawWaker::new(std::ptr::null(), &VTABLE) + } + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, nop, nop, nop); + unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } + } + + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + let mut fut = pin!(fut); + match fut.as_mut().poll(&mut cx) { + Poll::Ready(v) => v, + Poll::Pending => panic!("fake substrate future parked — it has no reactor"), + } + } + + // ---- the state machine's rows, end to end ------------------------------- + + /// First deploy: Secret before pod, and the returned id is the pod name. + #[test] + fn creates_secret_then_pod_and_returns_the_pod_name() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + + let calls = fake.mutations(); + let secret_at = calls + .iter() + .position(|c| c.starts_with("create_secret")) + .unwrap(); + let pod_at = calls + .iter() + .position(|c| c.starts_with("create_pod")) + .unwrap(); + assert!( + secret_at < pod_at, + "pod created before its Secret: {calls:?}" + ); + } + + /// The strict no-op row: a started pod returns its id having mutated + /// nothing at all. Asserted on the *call log*, not on final state — a + /// delete-then-recreate would leave identical final state. + #[test] + fn started_pod_is_a_zero_mutation_no_op() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(running()))); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert_eq!( + fake.mutations(), + [format!("ensure_namespace {}", cfg.namespace)], + "the no-op row mutated something" + ); + } + + /// ...including when the desired intent has diverged. Edits reach a + /// started pod only via the next generation (`:861-865`). + #[test] + fn started_pod_no_ops_under_divergent_intent() { + let id = identity(); + let cfg = config(); + let mut pod = our_pod(&id, &cfg, Some(running())); + pod.metadata.annotations.as_mut().unwrap().insert( + ANNOTATION_CREATE_INTENT.to_string(), + "stale-fingerprint".into(), + ); + let fake = Fake::default().with_pod(pod); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "divergence deleted a started pod" + ); + } + + /// Terminated → fenced delete → disappearance → recreate. The normal + /// restart path, and the fence must carry the observed uid/rv. + #[test] + fn terminated_pod_is_replaced_with_a_fenced_delete() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(terminated()))); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + if pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .is_none() + { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + let calls = fake.mutations(); + assert!( + calls + .iter() + .any(|c| c == &format!("delete_pod {} uid=uid-1 rv=100", id.pod_name())), + "delete was not fenced to the observed uid+resourceVersion: {calls:?}" + ); + assert!(calls.iter().any(|c| c.starts_with("create_pod"))); + } + + /// A failed precondition is neither an error nor permission to retry the + /// delete: re-enter and classify what exists now (`:775-777`). Here the + /// object has become a *started* pod, so the correct outcome is the no-op + /// row — never a second delete with a fresher fence. + #[test] + fn precondition_failure_reclassifies_instead_of_retrying() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(terminated()))); + fake.delete_outcomes + .borrow_mut() + .push(DeleteOutcome::PreconditionFailed); + // The writer we lost the race to: between our observation and our + // delete, the pod under this name became a *started* one with a new + // resourceVersion. Installed on the poll that follows the failed + // precondition, so the sequence is observe → delete → lose → re-observe. + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + let started = { + let mut p = our_pod(&id, &cfg, Some(running())); + p.metadata.resource_version = Some("200".into()); + p + }; + Box::new(move |pods: &mut BTreeMap| { + pods.insert(name.clone(), started.clone()); + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + let deletes: Vec<_> = fake + .mutations() + .into_iter() + .filter(|c| c.starts_with("delete_pod")) + .collect(); + // Two call sites legitimately target a terminated pod: preflight GC's + // sweep (which is the one that loses the precondition here) and the + // state machine's terminated row. Both are fenced on their own + // observation. What must never happen is a *third* — a retry of the + // failed delete — so the invariant is the fence, not the count: every + // delete carries rv=100, the version we observed. A retry would carry + // the racing writer's rv=200. + assert_eq!(deletes.len(), 2, "unexpected delete traffic: {deletes:?}"); + assert!( + deletes.iter().all(|d| d.contains("rv=100")), + "retried with a fresher fence: {deletes:?}" + ); + } + + /// The anti-livelock rule, at the loop level: a recoverable pod whose + /// intent matches is observed until the deadline and **never** deleted — + /// the case that would otherwise reset the pod age Cluster Autoscaler + /// keys on (`:689`). + #[test] + fn recoverable_pod_with_matching_intent_is_never_deleted() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(waiting("Unschedulable")))); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("startup not confirmed"), "got: {err}"); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted a recoverable pod: {:?}", + fake.mutations() + ); + assert!(fake.elapsed() >= DEADLINE, "gave up before the deadline"); + } + + /// The same pod, provisioned late: the observation loop must *succeed* + /// when the autoscaler eventually lands the node, not merely avoid + /// deleting. + #[test] + fn recoverable_pod_that_starts_late_succeeds() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(waiting("Unschedulable")))); + let polls = RefCell::new(0); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + *polls.borrow_mut() += 1; + if *polls.borrow() >= 5 { + if let Some(pod) = pods.get_mut(&name) { + pod.status.as_mut().unwrap().container_statuses = + Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!(fake.elapsed() < DEADLINE); + } + + /// A permanent pull failure reports immediately rather than burning the + /// deadline — and still never deletes. + #[test] + fn permanent_pull_failure_reports_without_deleting() { + let id = identity(); + let cfg = config(); + let mut pod = our_pod(&id, &cfg, None); + pod.status = Some(PodStatus { + phase: Some("Pending".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some("ImagePullBackOff".into()), + message: Some("manifest unknown".into()), + }), + ..Default::default() + }), + ..Default::default() + }]), + ..Default::default() + }); + let fake = Fake::default().with_pod(pod); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("no image at"), "got: {err}"); + assert!( + fake.elapsed() < DEADLINE, + "burned the deadline on a permanent failure" + ); + assert!(!fake.mutations().iter().any(|c| c.starts_with("delete_pod"))); + } + + /// The kubelet's pull *message* can echo a registry request; only the + /// classified outcome and the image reference reach the user. + #[test] + fn pull_failure_message_is_not_echoed_verbatim() { + let id = identity(); + let cfg = config(); + let mut pod = our_pod(&id, &cfg, None); + pod.status = Some(PodStatus { + phase: Some("Pending".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some("ErrImagePull".into()), + message: Some( + "unauthorized: authentication required, token=SUPERSECRET".into(), + ), + }), + ..Default::default() + }), + ..Default::default() + }]), + ..Default::default() + }); + let fake = Fake::default().with_pod(pod); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!( + !err.contains("SUPERSECRET"), + "kubelet message echoed: {err}" + ); + } + + /// A pod being gracefully deleted stays in phase `Running` for its whole + /// grace period. The reconciler must wait it out and recreate, not return + /// an id that evaporates. + #[test] + fn deletion_marked_pod_is_awaited_then_recreated() { + let id = identity(); + let cfg = config(); + let mut dying = our_pod(&id, &cfg, Some(running())); + dying.metadata.deletion_timestamp = Some(Time(Utc::now())); + let fake = Fake::default().with_pod(dying); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + match pods + .get(&name) + .map(|p| p.metadata.deletion_timestamp.is_some()) + { + // The grace period elapses: the object disappears. + Some(true) => { + pods.remove(&name); + } + // The replacement we create then starts. + Some(false) => { + let pod = pods.get_mut(&name).unwrap(); + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + None => {} + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted a pod that was already terminating" + ); + } + + /// `CreateContainerConfigError` is settled by a most-recent Secret read, + /// never by the reason string: Secret present → recoverable (observe). + #[test] + fn config_error_with_a_present_secret_is_recoverable() { + let id = identity(); + let cfg = config(); + let pod = our_pod(&id, &cfg, Some(waiting("CreateContainerConfigError"))); + let referenced = crate::observe::referenced_secret(&pod).unwrap(); + let fake = Fake::default().with_pod(pod); + fake.secrets.borrow_mut().push(crate::pod::build_secret( + &id, + &cfg.namespace, + "gen-existing", + env(), + )); + assert_eq!(referenced, id.secret_name("gen-existing")); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("startup not confirmed"), "got: {err}"); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted a pod whose Secret exists" + ); + } + + /// ...and Secret confirmed absent → provably broken → fenced replace. + #[test] + fn config_error_with_a_confirmed_absent_secret_is_replaced() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod( + &id, + &cfg, + Some(waiting("CreateContainerConfigError")), + )); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + if pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .is_none() + { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!(fake.mutations().iter().any(|c| c.starts_with("delete_pod"))); + } + + /// The generation is the Secret's name suffix *and* the lifecycle + /// correlator inside it. They must name the same generation, or pod logs + /// point at a Secret that was never mounted. The caller stamps one + /// generation into the env once; each create attempt mints its own, so + /// only a restamp keeps the pair together across a retry. + #[test] + fn each_attempt_stamps_its_own_generation_into_its_own_secret() { + let id = identity(); + let cfg = config(); + // First attempt loses the create race and is cleaned up; the operation + // then adopts. Two creates, two generations. + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(our_pod(&id, &cfg, Some(running()))); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + run(&fake, &id, &cfg).unwrap(); + + let created = fake.created_secrets.borrow(); + // Guard the guard: this assertion is over a list that cleanup empties, + // so an empty list would make every check below vacuously true. + assert_eq!(created.len(), 1, "expected one create attempt"); + for secret in created.iter() { + let name = secret.metadata.name.as_deref().unwrap(); + let nonce = secret + .string_data + .as_ref() + .and_then(|d| d.get(crate::env::START_NONCE_KEY)) + .expect("no lifecycle correlator in the Secret"); + assert!( + name.ends_with(nonce.as_str()), + "secret {name} carries a correlator for a different generation ({nonce})" + ); + } + } + + // ---- bounded replacement ------------------------------------------------ + + /// Installs a hook that makes every pod under `name` crash-exit before the + /// next poll — a deterministic startup failure (the harness starts, rejects + /// its configuration, and exits) as observed live: the container reaches + /// `state.terminated` with a nonzero exit code. + fn crash_exits_immediately(fake: &Fake, name: String) { + fake.on_poll + .borrow_mut() + .push(Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + if pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .is_none() + { + pod.status = Some(PodStatus { + phase: Some("Failed".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(ContainerState { + terminated: Some(ContainerStateTerminated { + exit_code: 1, + reason: Some("Error".into()), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }]), + ..Default::default() + }); + } + } + })); + } + + /// A pod that this call created and that crash-exits deterministically is + /// reported in-band after exactly ONE attempt — never hot-replaced until + /// the deadline. The live failure this pins: one delete/create cycle every + /// ~4s minted 107 immutable Secrets in a single 600s deploy call, all + /// younger than the orphan sweep's age gate. + #[test] + fn a_deterministic_crash_exit_is_reported_not_hot_replaced() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + crash_exits_immediately(&fake, id.pod_name()); + + let err = run(&fake, &id, &cfg).unwrap_err(); + + let creates = fake + .mutations() + .iter() + .filter(|c| c.starts_with("create_secret")) + .count(); + assert_eq!( + creates, 1, + "hot replacement loop: {creates} Secrets minted in one deploy call" + ); + assert!( + err.contains("exited with code 1"), + "error does not carry the exit: {err}" + ); + // The failed attempt is left in place as evidence — no cleanup on the + // error path. Its Secret stays referenced by the terminated pod, so + // the next deploy's preflight GC collects both together. + assert!( + !fake + .mutations() + .iter() + .any(|c| c.starts_with("delete_pod") || c.starts_with("delete_secret")), + "the failed attempt was cleaned up on the error path: {:?}", + fake.mutations() + ); + assert!( + fake.elapsed() < DEADLINE, + "burned the whole deadline on a deterministic failure" + ); + } + + /// The revive path is untouched: terminated residue from a *previous* + /// life is still replaced — the bound is on pods this call created, not + /// on the row. + #[test] + fn pre_existing_terminated_residue_is_still_replaced_once() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(terminated()))); + crash_exits_immediately(&fake, id.pod_name()); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("exited with code 1"), "got: {err}"); + + let calls = fake.mutations(); + let deletes = calls.iter().filter(|c| c.starts_with("delete_pod")).count(); + let creates = calls + .iter() + .filter(|c| c.starts_with("create_secret")) + .count(); + // Exactly one residue delete (the normal restart path) and one fresh + // attempt — then report, not another cycle. + assert_eq!(deletes, 1, "unexpected delete traffic: {calls:?}"); + assert_eq!(creates, 1, "unexpected create traffic: {calls:?}"); + } + + /// Retry is gated on fresh owner intent: the *next* deploy call clears + /// the crashed attempt (preflight GC collects the terminated pod and its + /// referenced Secret together) and makes exactly one new attempt — total + /// litter stays one pod + one Secret however many times Start is pressed. + #[test] + fn the_next_deploy_collects_the_crashed_attempt_before_its_own_attempt() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + crash_exits_immediately(&fake, id.pod_name()); + + run(&fake, &id, &cfg).unwrap_err(); + // Second Start: fresh call, fresh clock. + *fake.elapsed.borrow_mut() = Duration::ZERO; + run(&fake, &id, &cfg).unwrap_err(); + + assert_eq!( + fake.secrets.borrow().len(), + 1, + "crashed attempts accumulated Secrets across calls" + ); + assert_eq!(fake.pods.borrow().len(), 1, "crashed pods accumulated"); + } + + // ---- the auto-repair fence ---------------------------------------------- + + /// An object under our deterministic name that lacks the management + /// marker is not ours. The reconciler must not adopt it, delete it, or + /// return its name — it fails closed to the operator (`:1156-1162`). + #[test] + fn an_unmarked_look_alike_is_never_touched_or_adopted() { + let id = identity(); + let cfg = config(); + let mut look_alike = our_pod(&id, &cfg, Some(running())); + look_alike + .metadata + .labels + .as_mut() + .unwrap() + .remove(LABEL_MANAGED_BY); + let fake = Fake::default(); + // Our create loses to the object already sitting on the name. + *fake.winner.borrow_mut() = Some(look_alike); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("not managed by this provider"), "got: {err}"); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted an object we do not own" + ); + } + + /// Same fence, other direction: the marker is present but the annotation + /// carries a different agent's pubkey. The 32-hex label is + /// collision-resistant, not collision-free (`:1152-1155`). + #[test] + fn a_pubkey_mismatch_is_never_adopted() { + let id = identity(); + let other = identity(); + let cfg = config(); + let mut foreign = our_pod(&id, &cfg, Some(running())); + foreign.metadata.annotations.as_mut().unwrap().insert( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + ); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(foreign); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("different agent identity"), "got: {err}"); + assert!(!fake.mutations().iter().any(|c| c.starts_with("delete_pod"))); + } + + // ---- create-conflict convergence --------------------------------------- + + /// The loser adopts the winner, returns the winner's id, and deletes + /// **only its own** Secret — never the winner's (`:1259-1264`). + #[test] + fn create_loser_adopts_the_winner_and_drops_only_its_own_secret() { + let id = identity(); + let cfg = config(); + let winner = our_pod(&id, &cfg, Some(running())); + let winners_secret = crate::observe::referenced_secret(&winner).unwrap(); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(winner); + fake.secrets.borrow_mut().push(crate::pod::build_secret( + &id, + &cfg.namespace, + "gen-existing", + env(), + )); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + + let deleted: Vec<_> = fake + .mutations() + .into_iter() + .filter(|c| c.starts_with("delete_secret")) + .collect(); + assert_eq!( + deleted.len(), + 1, + "expected exactly our own Secret dropped: {deleted:?}" + ); + assert!( + !deleted[0].contains(&winners_secret), + "deleted the winner's Secret: {deleted:?}" + ); + assert!( + fake.secrets + .borrow() + .iter() + .any(|s| s.metadata.name.as_deref() == Some(winners_secret.as_str())), + "the winner's Secret is gone" + ); + } + + /// The loser must not apply the divergence row to the pod that just beat + /// it — that is the ping-pong the spec forbids (`:845-850`). A divergent + /// *started* winner is adopted as-is. + #[test] + fn create_loser_does_not_replace_a_divergent_winner() { + let id = identity(); + let cfg = config(); + let mut winner = our_pod(&id, &cfg, Some(running())); + winner.metadata.annotations.as_mut().unwrap().insert( + ANNOTATION_CREATE_INTENT.to_string(), + "a-different-intent".into(), + ); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(winner); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "the create loser deleted the winner" + ); + } + + /// The loser waits for the winner to *start* — adopting a not-yet-started + /// winner as success would report an agent that is not running. + #[test] + fn create_loser_waits_for_the_winner_to_start() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(our_pod(&id, &cfg, Some(waiting("ContainerCreating")))); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + let polls = RefCell::new(0); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + *polls.borrow_mut() += 1; + if *polls.borrow() >= 3 { + if let Some(pod) = pods.get_mut(&name) { + pod.status.as_mut().unwrap().container_statuses = + Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!(fake.elapsed() > Duration::ZERO, "did not wait at all"); + } + + // ---- deadline and namespace -------------------------------------------- + + /// Deadline expiry reports the *latest* condition, not a generic timeout + /// (`:699-701`), and triggers no cleanup (`:720-724`). + #[test] + fn deadline_expiry_reports_the_condition_and_cleans_up_nothing() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(waiting("ContainerCreating")))); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("ContainerCreating"), "generic timeout: {err}"); + let calls = fake.mutations(); + assert!( + !calls.iter().any(|c| c.starts_with("delete_")), + "deadline expiry triggered cleanup: {calls:?}" + ); + } + + /// An RBAC denial on namespace create fails the deploy with the literal + /// command to run, before any Secret is written (`:1002-1005`). + #[test] + fn namespace_denial_fails_before_writing_any_secret() { + let id = identity(); + let cfg = config(); + let fake = Fake { + namespace_error: Some(format!( + "not authorized to create namespaces: run `kubectl create namespace {}`", + cfg.namespace + )), + ..Default::default() + }; + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("kubectl create namespace"), "got: {err}"); + assert!( + fake.mutations().is_empty(), + "wrote something after a namespace denial: {:?}", + fake.mutations() + ); + } + + /// GC failures are hygiene, not deploy failures: a list the user cannot + /// perform must not block a deploy they can. + #[test] + fn a_gc_failure_does_not_fail_the_deploy() { + struct GcDenied(Fake); + impl Substrate for GcDenied { + async fn ensure_namespace(&self, ns: &str) -> Result<(), String> { + self.0.ensure_namespace(ns).await + } + async fn list_pods( + &self, + _s: &str, + ) -> Result<(Vec, Option>), String> { + Err("forbidden: cannot list pods".into()) + } + async fn list_secrets(&self, _s: &str) -> Result, String> { + Err("forbidden: cannot list secrets".into()) + } + async fn secret_exists(&self, n: &str) -> Result { + self.0.secret_exists(n).await + } + async fn create_secret(&self, s: &Secret) -> Result<(), String> { + self.0.create_secret(s).await + } + async fn create_pod(&self, p: &Pod) -> Result { + self.0.create_pod(p).await + } + async fn delete_pod(&self, n: &str, f: &Fence) -> Result { + self.0.delete_pod(n, f).await + } + async fn delete_secret(&self, n: &str) -> Result<(), String> { + self.0.delete_secret(n).await + } + async fn get_pod(&self, n: &str) -> Result, String> { + self.0.get_pod(n).await + } + async fn sleep(&self, d: Duration) { + self.0.sleep(d).await + } + fn elapsed(&self) -> Duration { + self.0.elapsed() + } + } + + let id = identity(); + let cfg = config(); + let inner = Fake::default().with_pod(our_pod(&id, &cfg, Some(running()))); + let fake = GcDenied(inner); + + assert_eq!( + block_on(deploy(&fake, &id, &cfg, env())).unwrap(), + id.pod_name() + ); + } +} diff --git a/crates/buzz-backend-kubernetes/src/wire.rs b/crates/buzz-backend-kubernetes/src/wire.rs new file mode 100644 index 0000000000..89b78b364d --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/wire.rs @@ -0,0 +1,250 @@ +//! The stdin/stdout JSON protocol (spec §Provider Protocol). +//! +//! One process per operation: one JSON object in, one JSON object out. +//! These types are this provider's view of the contract; the golden fixtures +//! in `tests/fixtures/provider-wire/` are the arbiter shared with the desktop. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// The wire-contract version this provider speaks (spec §Info). +pub const PROTOCOL_VERSION: u32 = 1; + +/// Request envelope. `op` discriminates; unknown ops are an in-band error. +/// +/// `request_id` is deliberately absent from every variant. The desktop sends +/// it, but the exchange is one request and one response per process, so there +/// is nothing to correlate and nothing in the response schema to echo it into +/// (`:387`, `:416` — neither response shape carries it). Serde ignores it on +/// the way in, so a caller that sends it is accepted; typing it would only +/// create a field nothing reads. +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "lowercase")] +pub enum Request { + Info, + Deploy(Box), +} + +#[derive(Debug, Deserialize)] +pub struct DeployRequest { + pub agent: AgentPayload, + #[serde(default)] + pub provider_config: serde_json::Value, +} + +/// The agent payload (spec §Deploy). +/// +/// Only the fields this binding actually consumes are typed. `name` (display +/// name), `model`, `provider`, and `turn_timeout_seconds` are deliberately +/// absent: object names derive from the pubkey, not the display name +/// (`:1150-1152`), the model/provider pair arrives already resolved inside +/// `launch`, and the timeout is ignored upstream — typing any of them would +/// invite a provider-side remap the spec forbids. +#[derive(Debug, Deserialize)] +pub struct AgentPayload { + pub relay_url: String, + pub private_key_nsec: String, + #[serde(default)] + pub auth_tag: Option, + #[serde(default)] + pub respond_to: Option, + #[serde(default)] + pub respond_to_allowlist: Option>, + /// User env, already merged global < persona < agent by the desktop and + /// already stripped of reserved keys. Superseded by `launch.env` when + /// `launch` is present — a provider MUST NOT re-merge it on top + /// (§Launch data, precedence tier 2). + #[serde(default)] + pub env_vars: BTreeMap, + /// The desktop-resolved launch contract. Absent only from a desktop + /// predating Known Defect 3's fix. + #[serde(default)] + pub launch: Option, +} + +/// Desktop-resolved launch data (spec §Launch data). +#[derive(Debug, Default, Deserialize)] +pub struct LaunchBlock { + /// Command *name*, resolved against the image's PATH — never a host path. + #[serde(default)] + pub command: Option, + #[serde(default)] + pub args: Vec, + /// Layered env: baked → runtime metadata → definition → global → persona + /// → agent. Precedence tier 2. + #[serde(default)] + pub env: BTreeMap, + /// Overridable behavior defaults. Precedence tier 1 — user env beats + /// these, matching the local spawn. + #[serde(default)] + pub policy_env: BTreeMap, + /// Resolved workspace owner (hex). The respond-to gate's one + /// irreducible input; without it or `auth_tag` the harness cannot match + /// `!shutdown`. + #[serde(default)] + pub owner_pubkey: Option, +} + +/// Response envelope. Serialized flat — `{"ok": true, …}` — because the +/// desktop reads `ok`, `error`, and `agent_id` off the top level. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum Response { + Info(InfoResponse), + Deploy(DeployResponse), + Error(ErrorResponse), +} + +#[derive(Debug, Serialize)] +pub struct InfoResponse { + pub ok: bool, + pub name: &'static str, + pub version: &'static str, + pub protocol_version: u32, + pub description: &'static str, + pub config_schema: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub struct DeployResponse { + pub ok: bool, + pub agent_id: String, +} + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub ok: bool, + pub error: String, +} + +impl Response { + pub fn error(message: impl Into) -> Self { + Response::Error(ErrorResponse { + ok: false, + error: message.into(), + }) + } + + /// The provider's self-description (spec §Info). Pure — no cluster + /// contact — because the desktop calls it to render the config form + /// before a kubeconfig is known to exist. + pub fn info() -> Self { + Response::Info(InfoResponse { + ok: true, + name: "kubernetes", + version: env!("CARGO_PKG_VERSION"), + protocol_version: PROTOCOL_VERSION, + description: "Runs agents as pods in a Kubernetes cluster", + config_schema: crate::config::config_schema(), + }) + } + + pub fn deployed(agent_id: impl Into) -> Self { + Response::Deploy(DeployResponse { + ok: true, + agent_id: agent_id.into(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_info_request() { + let r: Request = serde_json::from_str(r#"{"op":"info","request_id":"abc"}"#).unwrap(); + assert!(matches!(r, Request::Info)); + } + + /// The desktop sends `request_id` on every call, but the exchange is 1:1 + /// per process — a provider that hard-required it would fail a + /// conforming-but-minimal caller for no safety gain. + #[test] + fn request_id_is_optional() { + let r: Request = serde_json::from_str(r#"{"op":"info"}"#).unwrap(); + assert!(matches!(r, Request::Info)); + } + + #[test] + fn rejects_unknown_op() { + assert!(serde_json::from_str::(r#"{"op":"undeploy"}"#).is_err()); + } + + /// Payload fields this binding does not consume must not break parsing: + /// the desktop sends `model`, `provider`, `system_prompt` and more, and a + /// provider that rejected them would break on every real deploy. + #[test] + fn ignores_unconsumed_payload_fields() { + let json = r#"{ + "op":"deploy","request_id":"r1", + "agent":{ + "name":"a","relay_url":"wss://r","private_key_nsec":"nsec1x", + "model":"gpt-5","provider":"openai","system_prompt":"hi", + "turn_timeout_seconds":30,"parallelism":10, + "agent_command":"goose","agent_args":[] + }, + "provider_config":{"namespace":"ns"} + }"#; + let r: Request = serde_json::from_str(json).unwrap(); + let Request::Deploy(d) = r else { + panic!("wrong op") + }; + assert_eq!(d.agent.relay_url, "wss://r"); + assert!(d.agent.launch.is_none()); + } + + #[test] + fn parses_launch_block() { + let json = r#"{ + "op":"deploy", + "agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x", + "launch":{ + "command":"goose","args":["run","--x"], + "env":{"GOOSE_MODEL":"m"}, + "policy_env":{"GOOSE_MODE":"auto"}, + "owner_pubkey":"deadbeef" + } + } + }"#; + let Request::Deploy(d) = serde_json::from_str::(json).unwrap() else { + panic!("wrong op") + }; + let l = d.agent.launch.unwrap(); + assert_eq!(l.command.as_deref(), Some("goose")); + assert_eq!(l.args, ["run", "--x"]); + assert_eq!(l.env["GOOSE_MODEL"], "m"); + assert_eq!(l.policy_env["GOOSE_MODE"], "auto"); + assert_eq!(l.owner_pubkey.as_deref(), Some("deadbeef")); + } + + /// A null `owner_pubkey`/`auth_tag` must parse (the refusal is a policy + /// decision made later, with a specific message), not fail as a type error. + #[test] + fn null_owner_fields_parse() { + let json = r#"{"op":"deploy","agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x","auth_tag":null, + "launch":{"owner_pubkey":null} + }}"#; + let Request::Deploy(d) = serde_json::from_str::(json).unwrap() else { + panic!("wrong op") + }; + assert!(d.agent.auth_tag.is_none()); + assert!(d.agent.launch.unwrap().owner_pubkey.is_none()); + } + + /// The desktop reads `ok`/`error`/`agent_id` off the top level, so the + /// enum must serialize flat with no variant tag. + #[test] + fn responses_serialize_flat() { + let v = serde_json::to_value(Response::deployed("buzz-agent-abc")).unwrap(); + assert_eq!(v["ok"], true); + assert_eq!(v["agent_id"], "buzz-agent-abc"); + assert!(v.get("Deploy").is_none()); + + let v = serde_json::to_value(Response::error("boom")).unwrap(); + assert_eq!(v["ok"], false); + assert_eq!(v["error"], "boom"); + } +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md new file mode 100644 index 0000000000..56a972f894 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md @@ -0,0 +1,39 @@ +# Provider wire fixtures + +The shared arbiter for the stdin/stdout contract between the desktop +(`agents_deploy.rs`) and this provider (spec §Provider Protocol). + +Each `*.request.json` is a request the desktop can emit; each matching +`*.response.json` is the exact response this provider produces for it. The +provider side is asserted by `tests/wire_fixtures.rs`; the desktop side should +assert that its emitted payloads parse as the corresponding request. + +Three rules keep these useful rather than decorative: + +* **Requests are recorded, not invented.** A fixture that no caller emits + tests a contract nobody has. "Recorded" means *executed and transcribed* — + `deploy-full-launch.request.json` is the output of the desktop's real + `build_launch_block` → `deploy_payload_json` path, not a shape derived by + reading those functions. Deriving it is how this fixture acquired four + impossible values at once: a `respond_to` that was a pubkey where the + desktop serializes a kebab-case `RespondTo` enum, allowlist and owner + values failing `validate_respond_to_allowlist`'s 64-hex rule + (`types.rs:897`), an invented `BUZZ_ACP_PARALLELISM` where the emitter + writes `BUZZ_ACP_AGENTS` (`runtime.rs:729`), and a `launch.env` key from + no layer of `resolve_effective_harness_descriptor`. +* **The provider cannot police this file, so the desktop must.** Every field + above is one this provider is deliberately indifferent to — `respond_to` is + an opaque `Option`, the allowlist an opaque `Vec`, + `policy_env` an arbitrary map — so `the_full_desktop_payload_is_accepted` + passes on invented data exactly as happily as on recorded data. The + enforcement is the desktop's whole-object equality test, which *builds* the + payload and compares it to this file. A completeness guard (the case-list + directory scan in `wire_fixtures.rs`) stops a case from going missing; it + cannot tell you a case is false. +* **Responses are byte-compared after key-sorted re-serialization**, so a + field rename or a type change fails here rather than in a desktop that + silently reads `undefined`. + +`deploy-*` fixtures cover only responses reachable without a cluster — +refusals and malformed input. A successful deploy needs an apiserver and is +covered by the conformance suite, not by a static fixture. diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json new file mode 100644 index 0000000000..28fe6ce90e --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -0,0 +1,52 @@ +{ + "op": "deploy", + "request_id": "req-6", + "agent": { + "agent_args": [], + "agent_command": "goose", + "auth_tag": "tag-1", + "env_vars": { + "USER_KEY": "user-value" + }, + "idle_timeout_seconds": null, + "launch": { + "args": [ + "acp" + ], + "command": "goose", + "env": { + "GOOSE_MODEL": "gpt-5", + "GOOSE_PROVIDER": "openai", + "USER_KEY": "user-value" + }, + "owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "policy_env": { + "BUZZ_ACP_AGENTS": "10", + "BUZZ_ACP_LAZY_POOL": "true", + "BUZZ_ACP_MODEL": "gpt-5", + "BUZZ_ACP_RELAY_OBSERVER": "true", + "BUZZ_ACP_SESSION_TITLE": "worker", + "GOOSE_MODE": "auto" + } + }, + "max_turn_duration_seconds": null, + "model": "gpt-5", + "name": "worker", + "parallelism": 10, + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "provider": "openai", + "relay_url": "wss://relay.example", + "respond_to": "allowlist", + "respond_to_allowlist": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "system_prompt": null, + "turn_timeout_seconds": 300 + }, + "provider_config": { + "namespace": "buzz-agents-test", + "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "inactivity_seconds": 3600 + } +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.request.json new file mode 100644 index 0000000000..4f56082bbb --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.request.json @@ -0,0 +1,9 @@ +{ + "op": "deploy", + "request_id": "req-5", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5" + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.response.json new file mode 100644 index 0000000000..c6011b899a --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"deploy refused: neither auth_tag nor launch.owner_pubkey resolved — without an owner the agent cannot honor !shutdown"} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.request.json new file mode 100644 index 0000000000..2160909b8f --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.request.json @@ -0,0 +1,11 @@ +{ + "op": "deploy", + "request_id": "req-3", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "tag-1", + "provider": " relay-mesh " + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.response.json new file mode 100644 index 0000000000..0f0c995fd8 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.request.json new file mode 100644 index 0000000000..71ce9f067e --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.request.json @@ -0,0 +1,12 @@ +{ + "op": "deploy", + "request_id": "req-2", + "agent": { + "name": "mesh-agent", + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "tag-1", + "provider": "relay-mesh" + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.response.json new file mode 100644 index 0000000000..0f0c995fd8 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.request.json new file mode 100644 index 0000000000..232d9dcf43 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.request.json @@ -0,0 +1,10 @@ +{ + "op": "deploy", + "request_id": "req-4", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "tag-1" + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig:latest"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.response.json new file mode 100644 index 0000000000..e67fa6a47f --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"provider_config.image \"ghcr.io/block/buzz-sprig:latest\" is not digest-pinned: a tag is a mutable pointer, and this object runs with the agent's private key. Use name@sha256:<64 hex chars>"} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/info.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/info.request.json new file mode 100644 index 0000000000..db99c86c00 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/info.request.json @@ -0,0 +1 @@ +{"op":"info","request_id":"req-1"} diff --git a/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs b/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs new file mode 100644 index 0000000000..2c3af82b1f --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs @@ -0,0 +1,214 @@ +//! Golden wire fixtures (spec §Provider Protocol). +//! +//! These drive the **built binary** over a real pipe rather than calling an +//! in-process function: the contract the desktop depends on is +//! `stdin → one JSON object on stdout → exit code`, and an in-process test +//! would assert the shape of a value while skipping the three things that +//! actually break — the process writing nothing, writing two objects, or +//! signalling the outcome through the exit code. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/provider-wire") +} + +/// Feed one request to the binary; return `(stdout, exit code)`. +fn run(request: &str) -> (String, i32) { + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-backend-kubernetes")) + // A kubeconfig that does not exist, so a fixture that accidentally + // reaches the cluster fails loudly here instead of depending on + // whatever cluster the developer is pointed at. + .env("KUBECONFIG", "/nonexistent/kubeconfig-for-fixture-tests") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("could not run the provider binary"); + child + .stdin + .take() + .expect("no stdin") + .write_all(request.as_bytes()) + .expect("could not write the request"); + let out = child.wait_with_output().expect("provider did not exit"); + ( + String::from_utf8(out.stdout).expect("stdout was not UTF-8"), + out.status.code().unwrap_or(-1), + ) +} + +fn read(name: &str) -> String { + std::fs::read_to_string(fixtures().join(name)) + .unwrap_or_else(|e| panic!("could not read fixture {name}: {e}")) +} + +/// Every response fixture, byte-compared after key-sorted re-serialization so +/// a field rename fails here rather than in a desktop reading `undefined`. +#[test] +fn responses_match_their_fixtures() { + let cases = [ + "deploy-relay-mesh", + "deploy-relay-mesh-padded", + "deploy-tag-image", + "deploy-no-owner", + ]; + // The list must cover every response fixture on disk. A literal array is + // never empty, so `!is_empty()` would assert nothing; what can actually go + // wrong is a fixture added to the directory and never added here, which + // reads as a passing suite that exercises one case fewer than it appears to. + let mut on_disk: Vec = std::fs::read_dir(fixtures()) + .expect("could not read the fixture directory") + .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) + .filter_map(|name| Some(name.strip_suffix(".response.json")?.to_string())) + .collect(); + on_disk.sort(); + let mut listed: Vec = cases.iter().map(|c| c.to_string()).collect(); + listed.sort(); + assert_eq!(on_disk, listed, "response fixtures and cases disagree"); + + for case in cases { + let (stdout, code) = run(&read(&format!("{case}.request.json"))); + assert_eq!(code, 0, "{case}: a produced response must exit 0"); + + // Exactly one object, terminated by exactly one newline. Two responses + // would leave the desktop's reader holding a second one forever. + assert_eq!( + stdout.matches('\n').count(), + 1, + "{case}: expected exactly one line, got {stdout:?}" + ); + + let actual: serde_json::Value = + serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("{case}: {e}: {stdout:?}")); + let expected: serde_json::Value = + serde_json::from_str(&read(&format!("{case}.response.json"))).unwrap(); + assert_eq!( + actual, expected, + "{case}: response drifted from its fixture" + ); + } +} + +/// `info` is checked on the fields the desktop reads rather than byte-for-byte: +/// the namespace default is randomly generated per call (§K8s Namespace), so a +/// golden copy of it would be a test that fails every run. +#[test] +fn info_response_carries_the_contract_fields() { + let (stdout, code) = run(&read("info.request.json")); + assert_eq!(code, 0); + let info: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(info["ok"], true); + assert_eq!(info["protocol_version"], 1); + assert_eq!(info["name"], "kubernetes"); + let schema = &info["config_schema"]; + assert_eq!( + schema["required"], + serde_json::json!(["namespace", "image"]) + ); + let default = schema["properties"]["namespace"]["default"] + .as_str() + .expect("no generated namespace default"); + assert!( + default.starts_with("buzz-agents-"), + "unexpected namespace default: {default}" + ); +} + +/// The desktop's richest payload must parse. No response fixture: this one +/// reaches the cluster, so its outcome depends on a kubeconfig. What it +/// guards is that every field the desktop sends is *accepted* — a payload the +/// provider rejects at parse time is a deploy that never starts. +#[test] +fn the_full_desktop_payload_is_accepted() { + let (stdout, code) = run(&read("deploy-full-launch.request.json")); + assert_eq!(code, 0); + let response: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let error = response["error"].as_str().unwrap_or_default(); + // It fails — there is no cluster — but it must fail at the *connection*, + // having accepted every field above it. + assert!( + error.contains("kubeconfig"), + "the full payload was rejected before reaching the cluster: {error}" + ); +} + +/// Sami's pre-registered respond-to matrix, driven through the built binary. +/// +/// `build_env` runs at `main.rs:124`, `client::connect` at `:132`, so under a +/// kubeconfig that cannot exist the error string *is* the ordering assertion: +/// "kubeconfig" means the gate passed and we reached the cluster, anything +/// else means we refused before writing a Secret. A test asserting only +/// `ok: false` would pass on the connection error and prove nothing. +/// +/// The cases are applied to the real full-launch request so each one differs +/// from a known-good deploy in exactly the field under test. +#[test] +fn the_respond_to_gate_matches_the_harness_acceptance_surface() { + let key_a = "a".repeat(64); + let padded_upper = format!(" {} ", "A".repeat(64)); + // (name, respond_to, allowlist, must reach the cluster) + let cases: Vec<(&str, &str, Option>, bool)> = vec![ + ("allowlist + []", "allowlist", Some(vec![]), false), + ("allowlist + absent", "allowlist", None, false), + ( + "allowlist + junk", + "allowlist", + Some(vec!["beefcafe".into()]), + false, + ), + ("unparseable mode", "npub1abc", None, false), + ("padded mode", " allowlist ", None, false), + ( + "allowlist + two valid", + "allowlist", + Some(vec![key_a.clone(), "b".repeat(64)]), + true, + ), + ( + "owner-only + junk list", + "owner-only", + Some(vec!["beefcafe".into()]), + true, + ), + ( + "allowlist + padded upper", + "allowlist", + Some(vec![padded_upper]), + true, + ), + ("nobody", "nobody", None, true), + ("anyone", "anyone", None, true), + ]; + + let base: serde_json::Value = + serde_json::from_str(&read("deploy-full-launch.request.json")).unwrap(); + + for (name, mode, allowlist, reaches_cluster) in cases { + let mut request = base.clone(); + let agent = &mut request["agent"]; + agent["respond_to"] = serde_json::json!(mode); + agent["respond_to_allowlist"] = match &allowlist { + Some(list) => serde_json::json!(list), + None => serde_json::Value::Null, + }; + + let (stdout, code) = run(&request.to_string()); + assert_eq!(code, 0, "{name}: provider did not exit cleanly"); + let response: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let error = response["error"].as_str().unwrap_or_default(); + let reached = error.contains("kubeconfig"); + + assert_eq!( + reached, reaches_cluster, + "{name}: expected reaches_cluster={reaches_cluster}, got error: {error}" + ); + if !reaches_cluster { + assert!( + error.contains("deploy refused"), + "{name}: refused, but not by the gate: {error}" + ); + } + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8691590636..1ccc37a702 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -12,9 +12,37 @@ pub mod notes; pub mod pack; pub mod patches; pub mod pr; +pub mod projects; pub mod reactions; pub mod repos; pub mod social; pub mod upload; pub mod users; pub mod workflows; + +use crate::{client::normalize_write_response, error::CliError}; + +/// Parse a relay write-response JSON blob, mapping a duplicate (dominated) +/// write to [`CliError::Conflict`] with the caller-supplied message. +/// +/// Used by every command that publishes an NIP-33 addressable event and +/// needs to tell accepted from duplicate/dominated. +pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result { + let response: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("relay response is not JSON: {e} ({raw})")))?; + let accepted = response + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let message = response + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if !accepted { + return Err(CliError::Other(format!("relay rejected event: {message}"))); + } + if message == "duplicate" || message.starts_with("duplicate:") { + return Err(CliError::Conflict(conflict_msg.to_string())); + } + Ok(normalize_write_response(raw)) +} diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs new file mode 100644 index 0000000000..e6798dbfc4 --- /dev/null +++ b/crates/buzz-cli/src/commands/projects.rs @@ -0,0 +1,1198 @@ +//! `buzz projects` commands — NIP-MP kind:30621 write path. +//! +//! All mutations follow a read-modify-write pattern: +//! 1. Fetch the caller's own live head via `kinds:[30621] + authors:[self] + #d:[slug]`. +//! 2. Mutate the tag set (strip `auth`, apply change). +//! 3. Re-validate the full envelope through Layer A before submitting. +//! 4. Set `created_at = head.created_at + 1` (never wall-clock) to avoid +//! overwriting a concurrently advancing head. +//! +//! Limitations recorded in this phase: +//! - Relay hints are read-preserved but not authored (`--repo` carries +//! a coordinate only; existing hinted tags survive RMW unchanged). +//! - `delete` targets signer-self only (NIP-OA owner-delete path deferred). +//! - Deletion durability against later arrival (watermark follow-up) is +//! not in scope. + +use buzz_core::kind::KIND_PROJECT; +use buzz_sdk::{ + build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord, + PROJECT_D_MAX_LEN, +}; +use nostr::{Event, EventBuilder, Tag, Timestamp}; + +use crate::client::BuzzClient; +use crate::commands::parse_write_response; +use crate::error::CliError; + +// ── Buzz repo-ID grammar (bare --repo shorthand) ───────────────────────────── + +/// Pattern for a Buzz-hosted repo identifier (bare `--repo` shorthand). +/// `[a-zA-Z0-9._-]{1,64}` — no colons, so guaranteed collision-free with +/// `30617::` full coordinates. +fn is_bare_repo_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} + +/// Expand a CLI `--repo` argument into a full `30617::` coordinate. +/// +/// Bare form (`[a-zA-Z0-9._-]{1,64}`): owner defaults to the caller's pubkey. +/// Full form (`30617::`): used verbatim. +fn expand_repo_coord(s: &str, caller_pubkey: &str) -> Result { + if is_bare_repo_id(s) { + // Bare form: expand to full coordinate with caller as owner. + let full = format!("30617:{caller_pubkey}:{s}"); + ProjectMemberCoord::parse_full(&full) + .map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}"))) + } else { + // Full form: must be parseable as a complete coordinate. + ProjectMemberCoord::parse_full(s) + .map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}"))) + } +} + +// ── Head-fetch helper ───────────────────────────────────────────────────────── + +fn parse_events(json: &str) -> Result, CliError> { + serde_json::from_str(json) + .map_err(|e| CliError::Other(format!("failed to parse relay response: {e}"))) +} + +/// Fetch the caller's own live kind:30621 head for `slug`. +async fn fetch_own_project(client: &BuzzClient, slug: &str) -> Result, CliError> { + fetch_project(client, slug, None).await +} + +/// Fetch a project head by slug and optional owner pubkey. +async fn fetch_project( + client: &BuzzClient, + slug: &str, + owner: Option<&str>, +) -> Result, CliError> { + let pubkey = match owner { + Some(pk) => { + crate::validate::validate_hex64(pk)?; + pk.to_string() + } + None => client.keys().public_key().to_hex(), + }; + let filter = serde_json::json!({ + "kinds": [KIND_PROJECT], + "authors": [pubkey], + "#d": [slug], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let mut events = parse_events(&raw)?; + events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); + Ok(events.into_iter().next()) +} + +// ── Tag helpers ─────────────────────────────────────────────────────────────── + +fn tag_name(tag: &Tag) -> Option<&str> { + tag.as_slice().first().map(String::as_str) +} + +fn tag_value(tag: &Tag) -> Option<&str> { + tag.as_slice().get(1).map(String::as_str) +} + +fn make_tag(parts: &[&str]) -> Result { + Tag::parse(parts.iter().copied()) + .map_err(|e| CliError::Other(format!("tag construction failed: {e}"))) +} + +// ── Submit helper ───────────────────────────────────────────────────────────── + +async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { + let event = client.sign_event(builder)?; + let raw = client.submit_event(event).await?; + println!( + "{}", + parse_write_response(&raw, "project changed concurrently; retry")? + ); + Ok(()) +} + +// ── Build helpers ───────────────────────────────────────────────────────────── + +/// Advance the `created_at` counter off an observed head. +fn next_timestamp(head: &Event) -> Result { + head.created_at + .as_secs() + .checked_add(1) + .map(Timestamp::from) + .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into())) +} + +/// Strip `auth` from a tag list and pass the resulting envelope through +/// Layer A validation. Returns a validated `EventBuilder` at `next_ts`. +fn rebuild_project( + content: &str, + tags: Vec, + next_ts: Timestamp, +) -> Result { + // Strip auth tags. + let clean_tags: Vec = tags + .into_iter() + .filter(|t| tag_name(t) != Some("auth")) + .collect(); + + build_project_with_tags(content, clean_tags) + .map_err(|e| CliError::Other(format!("envelope validation failed: {e}"))) + .map(|b| b.custom_created_at(next_ts)) +} + +// ── Command implementations ─────────────────────────────────────────────────── + +/// `buzz projects create` +pub async fn cmd_create( + client: &BuzzClient, + slug: &str, + repos: &[String], + name: Option<&str>, + description: Option<&str>, + channel: Option<&str>, + visibility: Option<&str>, +) -> Result<(), CliError> { + // ── Local validation (all checks before any .await) ─────────────────── + validate_project_slug(slug)?; + + let caller_pubkey = client.keys().public_key().to_hex(); + + // Expand and validate repo coordinates. + let members: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // Dedupe: preserve first occurrence, reject duplicates with Usage. + let mut seen = std::collections::HashSet::new(); + for m in &members { + if !seen.insert(m.coord.clone()) { + return Err(CliError::Usage(format!( + "duplicate --repo coordinate in this invocation: {:?}", + m.coord + ))); + } + } + + // Validate optional metadata (early, before any network call). + if let Some(ch) = channel { + crate::validate::validate_uuid(ch)?; + } + if let Some(vis) = visibility { + validate_visibility(vis)?; + } + if let Some(n) = name { + if n.len() > 256 { + return Err(CliError::Usage(format!( + "project name must not exceed 256 bytes (got {})", + n.len() + ))); + } + } + + // ── Network: collision preflight ────────────────────────────────────── + if fetch_own_project(client, slug).await?.is_some() { + return Err(CliError::Conflict(format!( + "project {slug:?} already exists; use 'buzz projects update' to modify it" + ))); + } + + // ── Build via Layer B (enforces all writer policy) ──────────────────── + let builder = build_project(slug, name, description, &members, channel, visibility) + .map_err(|e| CliError::Usage(e.to_string()))?; + submit_project(client, builder).await +} + +/// `buzz projects get` +pub async fn cmd_get(client: &BuzzClient, slug: &str, owner: Option<&str>) -> Result<(), CliError> { + validate_project_slug(slug)?; + let resp = match fetch_project(client, slug, owner).await? { + Some(event) => serde_json::json!({ + "event_id": event.id.to_hex(), + "pubkey": event.pubkey.to_hex(), + "created_at": event.created_at.as_secs(), + "kind": event.kind.as_u16(), + "tags": event.tags.iter().map(|t| t.as_slice().to_vec()).collect::>(), + "content": event.content, + }), + None => { + let owner_desc = owner.unwrap_or("current identity"); + return Err(CliError::NotFound(format!( + "project {slug:?} not found for {owner_desc}" + ))); + } + }; + println!("{resp}"); + Ok(()) +} + +/// `buzz projects list` +pub async fn cmd_list( + client: &BuzzClient, + owner: Option<&str>, + limit: Option, +) -> Result<(), CliError> { + let pubkey = match owner { + Some(pk) => { + crate::validate::validate_hex64(pk)?; + pk.to_string() + } + None => client.keys().public_key().to_hex(), + }; + let mut filter = serde_json::json!({ + "kinds": [KIND_PROJECT], + "authors": [pubkey], + }); + if let Some(n) = limit { + filter["limit"] = serde_json::json!(n); + } + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) +} + +/// `buzz projects add-repo` +pub async fn cmd_add_repo( + client: &BuzzClient, + slug: &str, + repos: &[String], +) -> Result<(), CliError> { + validate_project_slug(slug)?; + let caller_pubkey = client.keys().public_key().to_hex(); + + // ── Local validation before any .await ──────────────────────────────── + let new_members: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // Dedupe within this invocation: first occurrence wins, duplicate → Usage. + let mut seen = std::collections::HashSet::new(); + for m in &new_members { + if !seen.insert(m.coord.clone()) { + return Err(CliError::Usage(format!( + "duplicate --repo coordinate in this invocation: {:?}", + m.coord + ))); + } + } + + // ── Network: fetch head ─────────────────────────────────────────────── + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Build the new tag set: keep existing tags (including hinted members), + // append new members only if not already present (by coordinate). + let mut tags: Vec = head.tags.iter().cloned().collect(); + let existing_coords: std::collections::HashSet = head + .tags + .iter() + .filter(|t| tag_name(t) == Some("a")) + .filter_map(|t| tag_value(t).map(String::from)) + .collect(); + let mut added = 0usize; + for m in &new_members { + if !existing_coords.contains(m.coord.as_str()) { + let parts = m.to_tag_parts(); + let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect(); + tags.push( + Tag::parse(parts_ref.iter().copied()) + .map_err(|e| CliError::Other(format!("member tag construction failed: {e}")))?, + ); + added += 1; + } + } + + // All requested coordinates were already present — no change to publish. + if added == 0 { + return Err(CliError::Conflict(format!( + "all requested repositories are already members of project {slug:?}" + ))); + } + + let builder = rebuild_project(&head.content, tags, next_ts)?; + submit_project(client, builder).await +} + +/// `buzz projects remove-repo` +pub async fn cmd_remove_repo( + client: &BuzzClient, + slug: &str, + repos: &[String], +) -> Result<(), CliError> { + validate_project_slug(slug)?; + let caller_pubkey = client.keys().public_key().to_hex(); + + // ── Local validation before any .await ──────────────────────────────── + let to_remove: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // ── Network: fetch head ─────────────────────────────────────────────── + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Verify all requested repos exist in the project. + let existing_coords: std::collections::HashSet = head + .tags + .iter() + .filter(|t| tag_name(t) == Some("a")) + .filter_map(|t| tag_value(t).map(String::from)) + .collect(); + for m in &to_remove { + if !existing_coords.contains(m.coord.as_str()) { + return Err(CliError::NotFound(format!( + "project {slug:?} does not contain member {:?}", + m.coord + ))); + } + } + + let remove_coords: std::collections::HashSet<&str> = + to_remove.iter().map(|m| m.coord.as_str()).collect(); + + // Keep all tags except auth and the removed members. + let tags: Vec = head + .tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + if tag_name(t) == Some("a") { + if let Some(coord) = tag_value(t) { + return !remove_coords.contains(coord); + } + } + true + }) + .cloned() + .collect(); + + // Single rebuild validates the full envelope and strips any remaining auth. + let builder = rebuild_project(&head.content, tags, next_ts)?; + submit_project(client, builder).await +} + +/// `buzz projects update` +/// +/// Requires at least one setter or clearer; a no-op call is a usage error. +#[allow(clippy::too_many_arguments)] +pub async fn cmd_update( + client: &BuzzClient, + slug: &str, + name: Option<&str>, + clear_name: bool, + description: Option<&str>, + clear_description: bool, + channel: Option<&str>, + clear_channel: bool, + visibility: Option<&str>, + clear_visibility: bool, +) -> Result<(), CliError> { + // Guard: at least one mutation required. The clap `ArgGroup` with + // `required(true).multiple(true)` enforces this at parse time; this + // runtime check is a defense-in-depth safety net for callers that invoke + // `cmd_update` directly (e.g. tests and future programmatic callers). + let has_mutation = name.is_some() + || clear_name + || description.is_some() + || clear_description + || channel.is_some() + || clear_channel + || visibility.is_some() + || clear_visibility; + if !has_mutation { + return Err(CliError::Usage( + "buzz projects update requires at least one of: \ + --name, --clear-name, --description, --clear-description, \ + --channel, --clear-channel, --visibility, --clear-visibility" + .into(), + )); + } + + validate_project_slug(slug)?; + if let Some(ch) = channel { + crate::validate::validate_uuid(ch)?; + } + if let Some(vis) = visibility { + validate_visibility(vis)?; + } + + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Build the new tag set. For each singleton metadata field: + // - setter present: replace value (strip old, append new) + // - clear flag set: drop the tag + // - neither: keep existing + // Non-singleton / non-metadata tags (d, a, unknown) are preserved as-is. + let singleton_fields = ["name", "description", "buzz-channel", "buzz-visibility"]; + let mut tags: Vec = head + .tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + // Drop singletons we're replacing or clearing. + if let Some(field) = tag_name(t) { + if singleton_fields.contains(&field) { + let clear = match field { + "name" => clear_name || name.is_some(), + "description" => clear_description || description.is_some(), + "buzz-channel" => clear_channel || channel.is_some(), + "buzz-visibility" => clear_visibility || visibility.is_some(), + _ => false, + }; + return !clear; + } + } + true + }) + .cloned() + .collect(); + + // Append new singleton values. + if let Some(n) = name { + tags.push(make_tag(&["name", n])?); + } + if let Some(d) = description { + tags.push(make_tag(&["description", d])?); + } + if let Some(ch) = channel { + tags.push(make_tag(&["buzz-channel", ch])?); + } + if let Some(vis) = visibility { + tags.push(make_tag(&["buzz-visibility", vis])?); + } + + let builder = build_project_with_tags(&head.content, tags) + .map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))? + .custom_created_at(next_ts); + submit_project(client, builder).await +} + +/// `buzz projects delete` +/// +/// Head-based and verified: +/// 1. Fetch own live head — `NotFound` if absent. +/// 2. Build tombstone at `head.created_at + 1`. +/// 3. Submit. +/// 4. Re-query the coordinate; if a newer head survived → `Conflict`. +pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> { + validate_project_slug(slug)?; + + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + let pubkey_hex = client.keys().public_key().to_hex(); + let tombstone = build_delete_addressable(KIND_PROJECT, &pubkey_hex, slug) + .map_err(|e| CliError::Other(format!("failed to build delete event: {e}")))? + .custom_created_at(next_ts); + + let event = client.sign_event(tombstone)?; + let raw = client.submit_event(event).await?; + parse_write_response(&raw, "delete event was dominated; a newer head exists")?; + + // Post-submit verification: re-query to confirm the head is gone. + if let Some(survivor) = fetch_own_project(client, slug).await? { + // A newer head survived the tombstone. + return Err(CliError::Conflict(format!( + "project {slug:?} still exists (head at {}); a concurrent write raced the delete", + survivor.created_at.as_secs() + ))); + } + + println!("{}", serde_json::json!({ "deleted": slug, "status": "ok" })); + Ok(()) +} + +// ── Validation helpers ──────────────────────────────────────────────────────── + +/// Validate a project slug: non-empty, ≤1024 bytes, verbatim. +/// Does NOT impose the Buzz repo-ID grammar — project slugs are more permissive. +fn validate_project_slug(slug: &str) -> Result<(), CliError> { + if slug.is_empty() { + return Err(CliError::Usage("project slug must not be empty".into())); + } + if slug.len() > PROJECT_D_MAX_LEN { + return Err(CliError::Usage(format!( + "project slug must not exceed {PROJECT_D_MAX_LEN} bytes (got {})", + slug.len() + ))); + } + Ok(()) +} + +/// Validate a `buzz-visibility` value at the writer level. +fn validate_visibility(vis: &str) -> Result<(), CliError> { + if vis != "listed" && vis != "unlisted" { + return Err(CliError::Usage(format!( + "visibility must be 'listed' or 'unlisted' (got {vis:?})" + ))); + } + Ok(()) +} + +// ── Dispatch ────────────────────────────────────────────────────────────────── + +pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<(), CliError> { + use crate::ProjectsCmd; + match cmd { + ProjectsCmd::Create { + slug, + repo, + name, + description, + channel, + visibility, + } => { + cmd_create( + client, + &slug, + &repo, + name.as_deref(), + description.as_deref(), + channel.as_deref(), + visibility.map(|v| v.as_str()), + ) + .await + } + ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await, + ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await, + ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await, + ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await, + ProjectsCmd::Update { + slug, + name, + clear_name, + description, + clear_description, + channel, + clear_channel, + visibility, + clear_visibility, + } => { + cmd_update( + client, + &slug, + name.as_deref(), + clear_name, + description.as_deref(), + clear_description, + channel.as_deref(), + clear_channel, + visibility.map(|v| v.as_str()), + clear_visibility, + ) + .await + } + ProjectsCmd::Delete { slug } => cmd_delete(client, &slug).await, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use buzz_sdk::{validate_project_envelope, PROJECT_MEMBER_CAP}; + use nostr::Tag; + + use super::*; + + // ── Coordinate expansion ────────────────────────────────────────────────── + + const OWNER_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OWNER_B_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + #[test] + fn expand_repo_coord_bare_expands_with_caller_pubkey() { + let coord = expand_repo_coord("my-repo", OWNER_HEX).unwrap(); + assert_eq!(coord.coord, format!("30617:{OWNER_HEX}:my-repo")); + } + + #[test] + fn expand_repo_coord_full_passes_through() { + let full = format!("30617:{OWNER_HEX}:some-repo"); + let coord = expand_repo_coord(&full, OWNER_B_HEX).unwrap(); + // Owner from the full coord, not the caller. + assert_eq!(coord.coord, full); + } + + #[test] + fn expand_repo_coord_full_cross_owner() { + let full = format!("30617:{OWNER_B_HEX}:infra"); + let coord = expand_repo_coord(&full, OWNER_HEX).unwrap(); + assert_eq!(coord.coord, full); + } + + #[test] + fn expand_repo_coord_rejects_uppercase_owner() { + let upper = "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz"; + assert!(expand_repo_coord(upper, OWNER_HEX).is_err()); + } + + #[test] + fn expand_repo_coord_rejects_coordinate_shaped_bare_value() { + // A value with a colon is never a bare id. + let not_bare = "30617:something"; + // parse_full will fail because it's not a valid full coordinate either. + assert!(expand_repo_coord(not_bare, OWNER_HEX).is_err()); + } + + // ── validate_project_slug ───────────────────────────────────────────────── + + #[test] + fn validate_project_slug_accepts_normal() { + assert!(validate_project_slug("my-project").is_ok()); + assert!(validate_project_slug("platform:v2").is_ok()); // colons allowed — more permissive than repo-id + } + + #[test] + fn validate_project_slug_rejects_empty() { + assert!(validate_project_slug("").is_err()); + } + + #[test] + fn validate_project_slug_rejects_over_1024() { + let long = "a".repeat(1025); + assert!(validate_project_slug(&long).is_err()); + } + + #[test] + fn validate_project_slug_accepts_1024() { + let at_limit = "a".repeat(1024); + assert!(validate_project_slug(&at_limit).is_ok()); + } + + // ── validate_visibility ─────────────────────────────────────────────────── + + #[test] + fn validate_visibility_accepts_listed_and_unlisted() { + assert!(validate_visibility("listed").is_ok()); + assert!(validate_visibility("unlisted").is_ok()); + } + + #[test] + fn validate_visibility_rejects_unknown_token() { + assert!(validate_visibility("chartreuse").is_err()); + assert!(validate_visibility("").is_err()); + } + + // ── is_bare_repo_id ─────────────────────────────────────────────────────── + + #[test] + fn bare_repo_id_accepts_valid() { + assert!(is_bare_repo_id("buzz")); + assert!(is_bare_repo_id("my-repo_1.0")); + } + + #[test] + fn bare_repo_id_rejects_colon() { + assert!(!is_bare_repo_id("30617:something")); + assert!(!is_bare_repo_id("has:colon")); + } + + #[test] + fn bare_repo_id_rejects_empty() { + assert!(!is_bare_repo_id("")); + } + + #[test] + fn bare_repo_id_rejects_over_64() { + let long = "a".repeat(65); + assert!(!is_bare_repo_id(&long)); + } + + // ── tag helpers ─────────────────────────────────────────────────────────── + + fn make_test_tag(parts: &[&str]) -> Tag { + Tag::parse(parts.iter().copied()).unwrap() + } + + // ── rebuild_project: hinted / unknown tag preservation ─────────────────── + + #[test] + fn rebuild_project_preserves_hinted_member_tags() { + // A member 'a' tag with a relay hint must survive RMW untouched. + let coord = format!("30617:{OWNER_HEX}:buzz"); + let hint = "wss://relay.example.com"; + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, hint]).unwrap(), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + let a_tag = ev + .tags + .iter() + .find(|t| tag_name(t) == Some("a")) + .expect("a tag present"); + assert_eq!( + a_tag.as_slice(), + &["a".to_string(), coord, hint.to_string()], + "relay hint must survive rebuild" + ); + } + + #[test] + fn rebuild_project_preserves_unknown_tags() { + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["future-metadata", "value"]), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + assert!(ev + .tags + .iter() + .any(|t| tag_name(t) == Some("future-metadata"))); + } + + #[test] + fn rebuild_project_strips_auth_tag() { + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + assert!( + !ev.tags.iter().any(|t| tag_name(t) == Some("auth")), + "auth tag must be stripped" + ); + } + + #[test] + fn rebuild_project_rejects_over_cap_foreign_head() { + // A foreign head with 65 members must fail Layer A on republish. + let mut tags = vec![make_test_tag(&["d", "wide"])]; + for i in 0..=64u32 { + let coord = format!("30617:{OWNER_HEX}:repo-{i:02}"); + tags.push(make_test_tag(&["a", &coord])); + } + assert_eq!( + tags.iter().filter(|t| tag_name(t) == Some("a")).count(), + 65, + "65 a-tags" + ); + let ts = Timestamp::from(1_700_000_001u64); + // rebuild_project strips auth, but 65 a-tags still exceeds cap. + assert!( + rebuild_project("", tags, ts).is_err(), + "over-cap foreign head must fail rebuild" + ); + } + + #[test] + fn rebuild_project_at_exact_cap_succeeds() { + let mut tags = vec![make_test_tag(&["d", "wide"])]; + for i in 0..PROJECT_MEMBER_CAP { + let coord = format!("30617:{OWNER_HEX}:repo-{i:02}"); + tags.push(make_test_tag(&["a", &coord])); + } + let ts = Timestamp::from(1_700_000_001u64); + assert!(rebuild_project("", tags, ts).is_ok()); + } + + // ── clear-flag semantics ────────────────────────────────────────────────── + + /// Build a minimal head Event for testing update semantics without the relay. + fn make_head_tags(extra: &[Tag]) -> Vec { + let mut tags = vec![make_test_tag(&["d", "platform"])]; + tags.extend_from_slice(extra); + tags + } + + #[allow(clippy::too_many_arguments)] + fn apply_update_tags( + head_tags: Vec, + name: Option<&str>, + clear_name: bool, + description: Option<&str>, + clear_description: bool, + channel: Option<&str>, + clear_channel: bool, + visibility: Option<&str>, + clear_visibility: bool, + ) -> Vec { + // Replicate the tag-mutation logic from cmd_update (sans relay I/O). + let singleton_fields = ["name", "description", "buzz-channel", "buzz-visibility"]; + let mut tags: Vec = head_tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + if let Some(field) = tag_name(t) { + if singleton_fields.contains(&field) { + let clear = match field { + "name" => clear_name || name.is_some(), + "description" => clear_description || description.is_some(), + "buzz-channel" => clear_channel || channel.is_some(), + "buzz-visibility" => clear_visibility || visibility.is_some(), + _ => false, + }; + return !clear; + } + } + true + }) + .cloned() + .collect(); + if let Some(n) = name { + tags.push(make_test_tag(&["name", n])); + } + if let Some(d) = description { + tags.push(make_test_tag(&["description", d])); + } + if let Some(ch) = channel { + tags.push(make_test_tag(&["buzz-channel", ch])); + } + if let Some(vis) = visibility { + tags.push(make_test_tag(&["buzz-visibility", vis])); + } + tags + } + + #[test] + fn update_omission_preserves_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags(head, None, false, None, false, None, false, None, false); + assert!(result.iter().any(|t| tag_value(t) == Some("Old Name"))); + } + + #[test] + fn update_setter_replaces_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags( + head, + Some("New Name"), + false, + None, + false, + None, + false, + None, + false, + ); + assert!(result.iter().any(|t| tag_value(t) == Some("New Name"))); + assert!(!result.iter().any(|t| tag_value(t) == Some("Old Name"))); + } + + #[test] + fn update_clear_drops_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags(head, None, true, None, false, None, false, None, false); + assert!(!result.iter().any(|t| tag_name(t) == Some("name"))); + } + + #[test] + fn update_clear_visibility_drops_tag() { + let head = make_head_tags(&[make_test_tag(&["buzz-visibility", "unlisted"])]); + let result = apply_update_tags(head, None, false, None, false, None, false, None, true); + assert!(!result + .iter() + .any(|t| tag_name(t) == Some("buzz-visibility"))); + } + + #[test] + fn update_exactly_one_singleton_after_replace() { + // Start with a buzz-channel; replace with a new one; must have exactly one. + let uuid1 = "3580ca9b-47b4-4af9-b22a-1068778f26c6"; + let uuid2 = "00000000-0000-0000-0000-000000000000"; + let head = make_head_tags(&[make_test_tag(&["buzz-channel", uuid1])]); + let result = apply_update_tags( + head, + None, + false, + None, + false, + Some(uuid2), + false, + None, + false, + ); + let channels: Vec<_> = result + .iter() + .filter(|t| tag_name(t) == Some("buzz-channel")) + .collect(); + assert_eq!(channels.len(), 1); + assert_eq!(tag_value(channels[0]), Some(uuid2)); + } + + // ── duplicate-member rejection on republish ─────────────────────────────── + + #[test] + fn duplicate_member_in_foreign_head_fails_rebuild() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["a", &coord]), + make_test_tag(&["a", &coord]), // duplicate + ]; + let ts = Timestamp::from(1_700_000_001u64); + assert!(rebuild_project("", tags, ts).is_err()); + } + + // ── validate_project_envelope integration ──────────────────────────────── + + #[test] + fn validate_project_envelope_accepts_hinted_member() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, "wss://relay.example.com"]).unwrap(), + ]; + assert!(validate_project_envelope(&tags, "").is_ok()); + } + + #[test] + fn validate_project_envelope_rejects_four_element_member() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, "wss://relay.example.com", "extra"]).unwrap(), + ]; + assert!(validate_project_envelope(&tags, "").is_err()); + } + + // ── next_timestamp ordering ─────────────────────────────────────────────── + + /// `next_timestamp` must return `head.created_at + 1` regardless of the wall + /// clock. NIP-MP Deletion rule: a tombstone older than the live head does + /// NOT remove it, so we must advance strictly off the observed head — never + /// use wall-clock time, which could be behind a head that was bumped + /// multiple times in the same second. + #[test] + fn next_timestamp_returns_head_plus_one_when_head_is_ahead_of_wall_clock() { + // Build a minimal signed event with a created_at far in the future. + let keys = nostr::Keys::generate(); + let far_future_ts = Timestamp::from(9_999_999_999u64); // year 2286 + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["a", &format!("30617:{OWNER_HEX}:buzz")]), + ]; + let builder = rebuild_project("", tags, far_future_ts).expect("valid head envelope"); + let head = builder.sign_with_keys(&keys).expect("sign"); + // Verify the event actually has our future timestamp. + assert_eq!(head.created_at, far_future_ts); + + // next_timestamp must return far_future + 1, not now(). + let next = next_timestamp(&head).expect("no overflow"); + assert_eq!( + next.as_secs(), + far_future_ts.as_secs() + 1, + "tombstone must be strictly after head, even when head is far in the future" + ); + } + + // ── empty update guard ──────────────────────────────────────────────────── + + /// `cmd_update` with no setters or clearers must return `CliError::Usage` + /// before making any network call. The guard is synchronous (before the + /// first `.await`) so we can drive it with a dummy client whose address + /// would reject any real connection attempt. + #[tokio::test] + async fn empty_update_returns_usage_error_before_any_network_call() { + let keys = nostr::Keys::generate(); + // Port 9 is the discard protocol — any real connect will be refused + // immediately, but the guard fires before the first await so this + // never reaches the network. + let client = crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) + .expect("client construction"); + + let err = cmd_update( + &client, "my-slug", None, false, // name / clear_name + None, false, // description / clear_description + None, false, // channel / clear_channel + None, false, // visibility / clear_visibility + ) + .await + .expect_err("empty update must fail"); + + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage, got {err:?}" + ); + } + + // ── no-network malformed-input tests ───────────────────────────────────── + // + // All three cases use port 9 (discard protocol): any real connection is + // refused immediately, but local validation fires before the first .await + // so the network is never touched. + + fn discard_client() -> crate::client::BuzzClient { + let keys = nostr::Keys::generate(); + crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) + .expect("client construction") + } + + /// Invalid visibility token must return Usage before touching the relay. + #[tokio::test] + async fn create_invalid_visibility_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_create( + &client, + "my-slug", + &["buzz".to_string()], + None, + None, + None, + Some("chartreuse"), + ) + .await + .expect_err("invalid visibility must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for invalid visibility, got {err:?}" + ); + } + + /// A name longer than 256 bytes must return Usage before touching the relay. + #[tokio::test] + async fn create_overlong_name_returns_usage_before_any_network_call() { + let client = discard_client(); + let long_name = "a".repeat(257); + let err = cmd_create( + &client, + "my-slug", + &["buzz".to_string()], + Some(&long_name), + None, + None, + None, + ) + .await + .expect_err("overlong name must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for overlong name, got {err:?}" + ); + } + + /// A malformed --repo coordinate must return Usage before touching the relay. + #[tokio::test] + async fn create_malformed_repo_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_create( + &client, + "my-slug", + &["nope:bad".to_string()], + None, + None, + None, + None, + ) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo, got {err:?}" + ); + } + + /// A malformed --repo coordinate on add-repo must return Usage before touching the relay. + #[tokio::test] + async fn add_repo_malformed_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_add_repo(&client, "my-slug", &["nope:bad".to_string()]) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo on add-repo, got {err:?}" + ); + } + + /// A malformed --repo coordinate on remove-repo must return Usage before touching the relay. + #[tokio::test] + async fn remove_repo_malformed_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_remove_repo(&client, "my-slug", &["nope:bad".to_string()]) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo on remove-repo, got {err:?}" + ); + } + + // ── duplicate --repo within one invocation ──────────────────────────────── + + /// Supplying the same coordinate twice in one create call must return Usage + /// (names the duplicate) before any network call. + #[tokio::test] + async fn create_duplicate_repo_returns_usage_before_any_network_call() { + let client = discard_client(); + let coord = format!("30617:{OWNER_HEX}:buzz"); + let err = cmd_create( + &client, + "my-slug", + &[coord.clone(), coord.clone()], + None, + None, + None, + None, + ) + .await + .expect_err("duplicate repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for duplicate repo, got {err:?}" + ); + // Error message must name the duplicate coordinate. + assert!( + format!("{err}").contains("buzz"), + "Usage message must name the duplicate coordinate, got {err:?}" + ); + } + + /// Supplying the same coordinate twice in one add-repo call must return Usage + /// (names the duplicate) before any network call. + #[tokio::test] + async fn add_repo_duplicate_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let coord = format!("30617:{OWNER_HEX}:buzz"); + let err = cmd_add_repo(&client, "my-slug", &[coord.clone(), coord.clone()]) + .await + .expect_err("duplicate repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for duplicate repo on add-repo, got {err:?}" + ); + } + + // ── create collision guard ──────────────────────────────────────────────── + + // The create-collision Conflict path is pinned by the live transcript + // (step: duplicate create → Conflict, exit=5). No relay mock is available + // for a unit test; the no-network tests above cover all pre-await paths. + + // ── add-repo no-op guard ────────────────────────────────────────────────── + + // The add-repo no-op Conflict path is pinned by the live transcript + // (step 7: buzz already present → exit=5). No relay mock is available + // for a unit test; the async no-network tests above cover all pre-await paths. +} diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 608d495055..15e064d9c3 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -4,7 +4,8 @@ use buzz_core::{ }; use nostr::{Event, EventBuilder, Tag, Timestamp}; -use crate::client::{normalize_write_response, BuzzClient}; +use crate::client::BuzzClient; +use crate::commands::parse_write_response; use crate::error::CliError; use crate::validate::validate_repo_id; @@ -186,25 +187,10 @@ fn protection_rules_json(event: &Event) -> Result { } fn validate_write_response(raw: &str) -> Result { - let response: serde_json::Value = serde_json::from_str(raw) - .map_err(|error| CliError::Other(format!("relay response is not JSON: {error} ({raw})")))?; - let accepted = response - .get("accepted") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let message = response - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - if !accepted { - return Err(CliError::Other(format!("relay rejected event: {message}"))); - } - if message == "duplicate" || message.starts_with("duplicate:") { - return Err(CliError::Conflict( - "repository changed concurrently; fetch the latest rules and retry".into(), - )); - } - Ok(normalize_write_response(raw)) + parse_write_response( + raw, + "repository changed concurrently; fetch the latest rules and retry", + ) } async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0726406d29..f745e7b280 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -212,6 +212,9 @@ enum Cmd { /// Announce and discover git repositories (NIP-34) #[command(subcommand)] Repos(ReposCmd), + /// Create and manage multi-repo projects (NIP-MP) + #[command(subcommand)] + Projects(ProjectsCmd), /// Send, get, list, and set status on git patches (NIP-34) #[command(subcommand)] Patches(PatchesCmd), @@ -1227,6 +1230,122 @@ pub enum RepoPushRole { Member, } +/// Visibility of a multi-repo project listing. +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +pub enum ProjectVisibility { + /// Project appears in public listings (default). + Listed, + /// Project is hidden from public listings. + Unlisted, +} + +impl ProjectVisibility { + pub fn as_str(self) -> &'static str { + match self { + ProjectVisibility::Listed => "listed", + ProjectVisibility::Unlisted => "unlisted", + } + } +} + +#[derive(Subcommand)] +pub enum ProjectsCmd { + /// Create a new multi-repo project (NIP-MP kind:30621) + /// + /// Requires at least one --repo. Fails with Conflict if the project already exists. + Create { + /// Project identifier (slug), up to 1024 bytes + slug: String, + /// Member repository coordinate: bare Buzz repo id (e.g. `buzz`) or full + /// `30617::` for cross-owner or colon-bearing repo ids. + /// At least one --repo is required. + #[arg(long = "repo", required = true)] + repo: Vec, + /// Display name (≤256 bytes) + #[arg(long)] + name: Option, + /// Description (≤2048 bytes) + #[arg(long)] + description: Option, + /// Associated Buzz channel UUID + #[arg(long)] + channel: Option, + /// Visibility: `listed` (default) or `unlisted` + #[arg(long)] + visibility: Option, + }, + /// Get a project by slug + Get { + /// Project slug + slug: String, + /// Owner pubkey (64-char hex). Defaults to the current identity. + #[arg(long)] + owner: Option, + }, + /// List projects + List { + /// Owner pubkey (64-char hex). Defaults to the current identity. + #[arg(long)] + owner: Option, + /// Maximum number of results + #[arg(long)] + limit: Option, + }, + /// Add one or more member repositories to a project + #[command(name = "add-repo")] + AddRepo { + /// Project slug + slug: String, + /// Member repository coordinate (bare id or full `30617::`) + #[arg(long = "repo", required = true)] + repo: Vec, + }, + /// Remove one or more member repositories from a project + #[command(name = "remove-repo")] + RemoveRepo { + /// Project slug + slug: String, + /// Member repository coordinate to remove (bare id or full `30617::`) + #[arg(long = "repo", required = true)] + repo: Vec, + }, + /// Update project metadata (at least one setter or clearer required) + #[command(group = clap::ArgGroup::new("mutation").required(true).multiple(true))] + Update { + /// Project slug + slug: String, + /// Set the display name + #[arg(long, group = "mutation")] + name: Option, + /// Remove the display name + #[arg(long, group = "mutation", conflicts_with = "name")] + clear_name: bool, + /// Set the description + #[arg(long, group = "mutation")] + description: Option, + /// Remove the description + #[arg(long, group = "mutation", conflicts_with = "description")] + clear_description: bool, + /// Set the associated Buzz channel UUID + #[arg(long, group = "mutation")] + channel: Option, + /// Remove the associated channel + #[arg(long, group = "mutation", conflicts_with = "channel")] + clear_channel: bool, + /// Set visibility: `listed` or `unlisted` + #[arg(long, group = "mutation")] + visibility: Option, + /// Remove the visibility tag (absence defaults to `listed`) + #[arg(long, group = "mutation", conflicts_with = "visibility")] + clear_visibility: bool, + }, + /// Delete a project (head-based tombstone; verified after submit) + Delete { + /// Project slug + slug: String, + }, +} + #[derive(Subcommand)] pub enum PatchesCmd { /// Send a git patch (NIP-34 kind:1617) @@ -1768,6 +1887,41 @@ pub enum ModerationCmd { }, } +/// Normalize hand-authored `BUZZ_AUTH_TAG` input to strict JSON. +/// +/// `.env` files and shell exports sometimes carry the tag in the unquoted +/// shorthand `[auth,,,]` (quotes dropped by hand). +/// When the input is not valid JSON but is bracket-delimited, rewrite it as +/// a JSON array of the comma-separated fields (an empty field `,,` becomes +/// `""`, matching the canonical form `["auth","hex","","hex"]`). +/// +/// This is presentation-layer leniency at the configuration edge only: the +/// output is always fed through the SDK's strict `parse_auth_tag` / +/// `verify_auth_tag`, which enforce structure, hex, the conditions grammar, +/// and the BIP-340 signature. Inputs that are already valid JSON — or not +/// recognizable as the shorthand — are returned unchanged so the strict +/// parser reports the error on the original bytes. +fn normalize_auth_tag_input(input: &str) -> String { + let trimmed = input.trim(); + if serde_json::from_str::(trimmed).is_ok() { + return trimmed.to_owned(); + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let fields: Vec<&str> = trimmed[1..trimmed.len() - 1] + .split(',') + .map(str::trim) + .collect(); + // Only a plausible 4-field auth tag is rewritten; anything else is + // passed through untouched for the strict parser to reject with an + // error that references the caller's original input. + if fields.len() == 4 && !fields.iter().any(|f| f.contains('"')) { + // serde_json cannot fail serializing a Vec<&str>. + return serde_json::to_string(&fields).expect("string array serializes"); + } + } + trimmed.to_owned() +} + async fn run(cli: Cli) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); @@ -1788,17 +1942,28 @@ async fn run(cli: Cli) -> Result<(), CliError> { .map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; // NIP-OA: parse and verify the auth tag if provided. + // + // `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw + // shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input + // edge only. The SDK grammar and the `x-auth-tag` wire format stay strict + // JSON; all validation and signature verification happen on the strict + // path below, unchanged. let (auth_tag, auth_tag_json) = match cli.auth_tag { - Some(ref json) if !json.is_empty() => { - let tag = buzz_sdk::nip_oa::parse_auth_tag(json) + Some(ref input) if !input.is_empty() => { + let json = normalize_auth_tag_input(input); + let tag = buzz_sdk::nip_oa::parse_auth_tag(&json) .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?; - buzz_sdk::nip_oa::verify_auth_tag(json, &keys.public_key()).map_err(|e| { + buzz_sdk::nip_oa::verify_auth_tag(&json, &keys.public_key()).map_err(|e| { CliError::Auth(format!( "BUZZ_AUTH_TAG verification failed for pubkey {}: {e}", keys.public_key().to_hex() )) })?; - (Some(tag), Some(json.clone())) + // Canonical wire form derives from the parsed-and-verified tag + // (same shape as buzz-acp's RestClient), never from raw input. + let canonical = serde_json::to_string(tag.as_slice()) + .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?; + (Some(tag), Some(canonical)) } _ => (None, None), }; @@ -1819,6 +1984,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Social(sub) => commands::social::dispatch(sub, &client).await, Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await, Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await, + Cmd::Projects(sub) => commands::projects::dispatch(sub, &client).await, Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await, Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await, Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await, @@ -1835,6 +2001,51 @@ mod tests { use super::*; use clap::CommandFactory; + /// Raw shorthand `[auth,hex,,hex]` normalizes to strict JSON; the empty + /// conditions field becomes `""`. + #[test] + fn normalize_auth_tag_raw_shorthand() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + + let raw = format!("[auth,{owner},,{sig}]"); + let json = normalize_auth_tag_input(&raw); + let parsed: Vec = serde_json::from_str(&json).expect("output must be JSON"); + assert_eq!(parsed, vec!["auth", &owner, "", &sig]); + + // With conditions and surrounding whitespace (shell/.env artifacts). + let raw = format!(" [auth, {owner} , kind=9, {sig}] \n"); + let json = normalize_auth_tag_input(&raw); + let parsed: Vec = serde_json::from_str(&json).expect("output must be JSON"); + assert_eq!(parsed, vec!["auth", &owner, "kind=9", &sig]); + } + + /// Valid JSON input passes through byte-identical (modulo outer trim) — + /// the normalizer must never rewrite well-formed input. + #[test] + fn normalize_auth_tag_json_passthrough() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + let json_in = serde_json::json!(["auth", owner, "kind=9", sig]).to_string(); + assert_eq!(normalize_auth_tag_input(&json_in), json_in); + } + + /// Inputs that are neither JSON nor a plausible 4-field shorthand pass + /// through unchanged, so the strict parser rejects the original bytes. + #[test] + fn normalize_auth_tag_leaves_garbage_untouched() { + for garbage in [ + "not a tag", + "[auth,too,few]", + "[a,b,c,d,e]", + r#"[auth,"quoted",x,y]"#, // quote chars => not the shorthand + "[]", + "{\"auth\":1}", + ] { + assert_eq!(normalize_auth_tag_input(garbage), garbage.trim()); + } + } + /// Smoke test: CLI definition is valid and parseable. #[test] fn cli_definition_is_valid() { @@ -1883,6 +2094,7 @@ mod tests { "pack", "patches", "pr", + "projects", "reactions", "repos", "social", @@ -2038,6 +2250,18 @@ mod tests { names(&cmd, "patches"), vec!["get", "list", "send", "status"] ); + assert_eq!( + names(&cmd, "projects"), + vec![ + "add-repo", + "create", + "delete", + "get", + "list", + "remove-repo", + "update" + ] + ); assert_eq!( names(&cmd, "issues"), vec!["create", "get", "list", "status"] @@ -2075,6 +2299,7 @@ mod tests { ("pack", 2), ("patches", 4), ("pr", 5), + ("projects", 7), ("reactions", 3), ("repos", 5), ("social", 7), @@ -2142,4 +2367,111 @@ mod tests { .join("\n") ); } + + // ── projects update mutation group ──────────────────────────────────────── + + /// Multiple independent fields must be accepted in the same invocation. + #[test] + fn projects_update_multi_field_is_accepted() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--description", + "Y", + ]) + .is_ok(), + "--name and --description together must be accepted" + ); + } + + /// A setter for one field and a clearer for a different field must be accepted. + #[test] + fn projects_update_setter_with_other_clearer_is_accepted() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--clear-description", + ]) + .is_ok(), + "--name with --clear-description must be accepted" + ); + } + + /// A setter and its own clearer are mutually exclusive — clap must reject this. + #[test] + fn projects_update_setter_with_own_clearer_is_rejected() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--clear-name", + ]) + .is_err(), + "--name and --clear-name together must be rejected by clap" + ); + } + + /// Providing no mutation options at all must be rejected by clap (required group). + #[test] + fn projects_update_no_mutation_is_rejected_by_clap() { + // Without credentials, a valid parse would reach authentication and fail + // with auth_error — but a clap-level rejection happens before any I/O. + // We verify it's a clap error (not just any error) by checking the error + // kind is not a runtime/auth failure — Cli::try_parse_from returns Err + // immediately for argument violations. + assert!( + Cli::try_parse_from(["buzz", "projects", "update", "my-slug"]).is_err(), + "update with no setters or clearers must be rejected at parse time" + ); + } + + /// An unrecognised visibility token must be rejected by clap before any I/O. + #[test] + fn projects_create_invalid_visibility_is_rejected_by_clap() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "create", + "my-slug", + "--repo", + "buzz", + "--visibility", + "chartreuse", + ]) + .is_err(), + "--visibility chartreuse must be rejected at parse time" + ); + } + + /// An unrecognised visibility token on update must be rejected by clap before any I/O. + #[test] + fn projects_update_invalid_visibility_is_rejected_by_clap() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--visibility", + "chartreuse", + ]) + .is_err(), + "--visibility chartreuse on update must be rejected at parse time" + ); + } } diff --git a/crates/buzz-relay/src/api/git/cas_publish.rs b/crates/buzz-relay/src/api/git/cas_publish.rs index c213e2913e..50bb36d818 100644 --- a/crates/buzz-relay/src/api/git/cas_publish.rs +++ b/crates/buzz-relay/src/api/git/cas_publish.rs @@ -1370,6 +1370,15 @@ mod tests { ); } + #[test] + fn published_head_moves_to_surviving_branch_after_current_branch_deletion() { + let refs = BTreeMap::from([("refs/heads/master".to_string(), "1".repeat(40))]); + assert_eq!( + resolve_published_head(&refs, "refs/heads/main".to_string(), "refs/heads/main"), + "refs/heads/master" + ); + } + #[test] fn digest_from_key_strips_prefix() { let k = format!("manifests/{}", "a".repeat(64)); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index f8e0300277..d3118d8a76 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -1064,7 +1064,7 @@ pub async fn receive_pack( state.config.bind_addr.port() ); let hooks_dir = repo.path().join("hooks").display().to_string(); - let hook_env = vec![ + let mut hook_env = vec![ ("BUZZ_HOOK_URL", hook_url), ( "BUZZ_HOOK_SECRET", @@ -1077,13 +1077,8 @@ pub async fn receive_pack( auth.tenant.community().as_uuid().to_string(), ), ("BUZZ_PUSHER_PUBKEY", pusher_hex.clone()), - // Override any repo-local core.hooksPath setting; defense in - // depth even though the hydrated workspace has no inherited - // config. - ("GIT_CONFIG_COUNT", "1".to_string()), - ("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()), - ("GIT_CONFIG_VALUE_0", hooks_dir), ]; + hook_env.extend(receive_pack_git_config(hooks_dir)); // Run receive-pack against the tempdir. Returns the *owned* subprocess // output (PackOutput) — crucially NOT a Response, so the post-push @@ -1111,6 +1106,23 @@ pub async fn receive_pack( Ok(finalize_push(&state, ctx).await) } +/// Per-process git configuration for the hydrated receive-pack workspace. +fn receive_pack_git_config(hooks_dir: String) -> Vec<(&'static str, String)> { + vec![ + // Override any repo-local core.hooksPath setting; defense in depth + // even though the hydrated workspace has no inherited config. + ("GIT_CONFIG_COUNT", "2".to_string()), + ("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()), + ("GIT_CONFIG_VALUE_0", hooks_dir), + // A bare repository rejects deletion of its symbolic HEAD branch by + // default. Hydrated repositories are ephemeral, and cas_publish + // selects a surviving branch for the next manifest HEAD, so allow + // receive-pack to apply the deletion before that selection runs. + ("GIT_CONFIG_KEY_1", "receive.denyDeleteCurrent".to_string()), + ("GIT_CONFIG_VALUE_1", "ignore".to_string()), + ] +} + /// Buffered output of a `git --stateless-rpc` subprocess. /// /// The handler holds this as an owned value between subprocess completion @@ -1921,11 +1933,148 @@ mod track_c_tests { use buzz_core::CommunityId; use nostr::{EventBuilder, Keys, Kind, Tag}; use std::collections::BTreeMap; + use std::io::Write; + use std::process::Output; fn oid_sha1() -> String { "cb09a769da1c01f458fa6959d4e8eded38fac8d3".to_string() } + fn run_test_git(cwd: &Path, args: &[&str], extra_env: &[(&str, String)]) -> Output { + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(cwd) + .args(args) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("HOME", "/dev/null"); + for (key, value) in extra_env { + cmd.env(key, value); + } + cmd.output().expect("run git") + } + + fn run_test_receive_pack(repo: &Path, request: &[u8], extra_env: &[(&str, String)]) -> Output { + let mut cmd = std::process::Command::new("git"); + cmd.arg("receive-pack") + .arg("--stateless-rpc") + .arg(repo) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("HOME", "/dev/null"); + for (key, value) in extra_env { + cmd.env(key, value); + } + + let mut child = cmd.spawn().expect("spawn receive-pack"); + child + .stdin + .take() + .expect("receive-pack stdin") + .write_all(request) + .expect("write receive-pack request"); + child.wait_with_output().expect("wait for receive-pack") + } + + fn assert_git_success(output: Output, operation: &str) { + assert!( + output.status.success(), + "{operation} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn receive_pack_config_allows_deleting_current_branch() { + let root = tempfile::TempDir::new().expect("tempdir"); + let remote = root.path().join("remote.git"); + let source = root.path().join("source"); + let remote_arg = remote.to_str().expect("utf-8 remote path"); + let source_arg = source.to_str().expect("utf-8 source path"); + + assert_git_success( + run_test_git( + root.path(), + &["init", "--bare", "--initial-branch=main", remote_arg], + &[], + ), + "initialize bare remote", + ); + assert_git_success( + run_test_git( + root.path(), + &["init", "--initial-branch=main", source_arg], + &[], + ), + "initialize source repository", + ); + assert_git_success( + run_test_git(source.as_path(), &["config", "user.name", "Buzz Test"], &[]), + "configure user name", + ); + assert_git_success( + run_test_git( + source.as_path(), + &["config", "user.email", "buzz-test@example.com"], + &[], + ), + "configure user email", + ); + std::fs::write(source.join("README.md"), "test\n").expect("write fixture"); + assert_git_success( + run_test_git(source.as_path(), &["add", "README.md"], &[]), + "stage fixture", + ); + assert_git_success( + run_test_git(source.as_path(), &["commit", "-m", "fixture"], &[]), + "commit fixture", + ); + assert_git_success( + run_test_git( + source.as_path(), + &["push", remote_arg, "main:main", "main:master"], + &[], + ), + "seed main and master", + ); + + let oid_output = run_test_git(remote.as_path(), &["rev-parse", "refs/heads/main"], &[]); + assert!(oid_output.status.success()); + let old_oid = String::from_utf8(oid_output.stdout) + .expect("utf-8 oid") + .trim() + .to_string(); + let command = format!( + "{old_oid} {} refs/heads/main\0report-status\n", + "0".repeat(40) + ); + let mut request = format!("{:04x}", command.len() + 4).into_bytes(); + request.extend_from_slice(command.as_bytes()); + request.extend_from_slice(b"0000"); + + let git_config = receive_pack_git_config(remote.join("hooks").display().to_string()); + let output = run_test_receive_pack(remote.as_path(), &request, &git_config); + assert!( + output.status.success(), + "receive-pack failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !receive_pack_report_rejected(&output.stdout), + "receive-pack rejected the deletion: {}", + String::from_utf8_lossy(&output.stdout) + ); + + assert!(!remote.join("refs/heads/main").exists()); + assert!(remote.join("refs/heads/master").exists()); + } + /// A gzip-encoded request body is transparently inflated before it /// reaches the git subprocess. Git's smart-HTTP client gzips the /// upload-pack/receive-pack request body past a size threshold (fires diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 8cc9c8650a..9a139f0377 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -11,8 +11,8 @@ use buzz_core::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_USER_STATUS, - KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_PROJECT, + KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1499,12 +1499,7 @@ pub fn build_workflow_delete( author_pubkey: &str, workflow_id: Uuid, ) -> Result { - let pk = check_pubkey_hex(author_pubkey, "author_pubkey")?; - let tags = vec![tag(&[ - "a", - &format!("{}:{pk}:{workflow_id}", KIND_WORKFLOW_DEF), - ])?]; - Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags)) + build_delete_addressable(KIND_WORKFLOW_DEF, author_pubkey, &workflow_id.to_string()) } /// Build a workflow trigger event (kind 46020). @@ -1838,6 +1833,364 @@ pub fn build_unarchive_identity_request( ) } +// ─── NIP-MP: Multi-repo projects (kind:30621) ──────────────────────────────── +// +// Public surface: +// • `validate_project_envelope` — Layer A protocol validator (8 ingest rules) +// • `build_project_with_tags` — Layer A raw builder (content + tags, no canonicalization) +// • `ProjectMemberCoord` — parsed member coordinate + optional relay hint +// • `build_project` — Layer B writer-policy builder +// • `build_delete_addressable` — generic NIP-09 kind:5 coordinate delete +// +// Byte-length bounds from NIP-MP §Relay Processing: +/// Maximum byte length of a project `d` tag value. +pub const PROJECT_D_MAX_LEN: usize = 1024; +/// Maximum byte length of a project `name` tag value. +pub const PROJECT_NAME_MAX: usize = 256; +/// Maximum byte length of a project `description` tag value. +pub const PROJECT_DESCRIPTION_MAX: usize = 2048; +/// Maximum byte length of a project `buzz-channel` tag value. +pub const PROJECT_CHANNEL_MAX: usize = 256; +/// Maximum byte length of a project `buzz-visibility` tag value. +pub const PROJECT_VISIBILITY_MAX: usize = 256; +/// Maximum number of `a` member tags per project event (checked before dedup). +pub const PROJECT_MEMBER_CAP: usize = 64; + +/// A validated NIP-MP member `a`-tag coordinate with an optional relay hint. +/// +/// Equality and `Hash` are by `coord` only (per spec: duplicate detection ignores hint). +#[derive(Clone, Debug)] +pub struct ProjectMemberCoord { + /// The full `30617::` coordinate string. + pub coord: String, + /// Optional opaque relay hint (third `a`-tag element, never validated by content). + pub hint: Option, +} + +impl PartialEq for ProjectMemberCoord { + fn eq(&self, other: &Self) -> bool { + self.coord == other.coord + } +} + +impl Eq for ProjectMemberCoord {} + +impl std::hash::Hash for ProjectMemberCoord { + fn hash(&self, state: &mut H) { + self.coord.hash(state); + } +} + +impl ProjectMemberCoord { + /// Parse a full `30617::` coordinate string. + /// + /// Accepts an optional relay hint as the third colon-separated element + /// after the split, but the split is always first-two-colons: kind, owner, + /// everything-else-as-repo-d. + /// + /// Rules enforced: + /// - Exactly three segments after splitting on the first two colons + /// - First segment must be the literal string `"30617"` + /// - Second segment must be exactly 64 lowercase hex characters + /// - Third segment (repo-d) must be non-empty + /// - Uppercase owners are rejected (never normalized) + pub fn parse_full(coord: &str) -> Result { + // Split on first two colons only: kind:owner:rest + let mut parts = coord.splitn(3, ':'); + let kind_part = parts.next().unwrap_or(""); + let owner_part = parts.next().unwrap_or(""); + let rest = parts.next().unwrap_or(""); + + if kind_part != "30617" { + return Err(SdkError::InvalidInput(format!( + "member coordinate must start with '30617:' (got kind {kind_part:?})" + ))); + } + if owner_part.len() != 64 || !owner_part.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput(format!( + "member owner must be a 64-character hex pubkey (got {owner_part:?})" + ))); + } + // Reject uppercase (spec: lowercase hex required) + if owner_part.chars().any(|c| c.is_ascii_uppercase()) { + return Err(SdkError::InvalidInput( + "member owner hex must be lowercase".into(), + )); + } + if rest.is_empty() { + return Err(SdkError::InvalidInput( + "member coordinate repo-d must not be empty".into(), + )); + } + Ok(ProjectMemberCoord { + coord: format!("30617:{owner_part}:{rest}"), + hint: None, + }) + } + + /// Returns the `a`-tag element slice: `[coord]` or `[coord, hint]`. + pub fn to_tag_parts(&self) -> Vec { + let mut parts = vec!["a".to_string(), self.coord.clone()]; + if let Some(h) = &self.hint { + parts.push(h.clone()); + } + parts + } +} + +/// **Layer A**: Validate a complete kind:30621 envelope against the 8 NIP-MP +/// ingest rules. This is the single source of protocol truth used by both +/// `build_project_with_tags` (raw path) and `build_project` (policy path). +/// +/// Rules enforced (matches relay `buzz-db` ingest logic): +/// 1. `d` cardinality: exactly one `d` tag. +/// 2. `d` value: non-empty, ≤1024 bytes. +/// 3. Member cap: raw count of every `a` tag ≤ 64 (checked **before** per-tag +/// parsing, matching relay rule order). +/// 4. Member tag arity: every `a` tag has 2 or 3 elements (no more, no fewer). +/// 5. Member coordinate grammar: first-two-colons split; kind literal `"30617"`; +/// owner lowercase 64-hex; repo-d non-empty verbatim. +/// 6. Member deduplication: coordinate equality only (hint ignored); any +/// coordinate that appears more than once is a duplicate. +/// 7. Singleton metadata: each of `name`, `description`, `buzz-channel`, +/// `buzz-visibility` appears at most once. +/// 8. Metadata byte lengths: `name` ≤256, `description` ≤2048, +/// `buzz-channel` ≤256, `buzz-visibility` ≤256. +pub fn validate_project_envelope(tags: &[Tag], _content: &str) -> Result<(), SdkError> { + // --- Rule 1 & 2: d tag --- + let d_tags: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some("d")).collect(); + match d_tags.len() { + 0 => { + return Err(SdkError::InvalidInput( + "project must have exactly one 'd' tag (rule: d-cardinality)".into(), + )) + } + 1 => {} + _ => { + return Err(SdkError::InvalidInput( + "project must have exactly one 'd' tag (rule: d-cardinality)".into(), + )) + } + } + let d_val = tag_value(d_tags[0]).unwrap_or(""); + if d_val.is_empty() { + return Err(SdkError::InvalidInput( + "project 'd' tag must not be empty (rule: d-empty)".into(), + )); + } + if d_val.len() > PROJECT_D_MAX_LEN { + return Err(SdkError::InvalidInput(format!( + "project 'd' tag exceeds {PROJECT_D_MAX_LEN} bytes (rule: d-empty)" + ))); + } + + let a_tags: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some("a")).collect(); + + // --- Rule 3: member cap (checked before per-tag parsing, matching relay rule order) --- + if a_tags.len() > PROJECT_MEMBER_CAP { + return Err(SdkError::InvalidInput(format!( + "project exceeds member cap of {PROJECT_MEMBER_CAP} (got {}) (rule: member-cap)", + a_tags.len() + ))); + } + + // --- Rule 4: member arity --- + for a in &a_tags { + let len = a.as_slice().len() - 1; // exclude the "a" name element + if !(1..=2).contains(&len) { + return Err(SdkError::InvalidInput(format!( + "member 'a' tag must have 1 or 2 value elements (got {len}) (rule: member-tag-arity)" + ))); + } + } + + // --- Rules 5 & 6: coordinate grammar + deduplication --- + let mut seen_coords: std::collections::HashSet = std::collections::HashSet::new(); + for a in &a_tags { + let coord_val = tag_value(a).unwrap_or(""); + ProjectMemberCoord::parse_full(coord_val).map_err(|e| { + SdkError::InvalidInput(format!("{e} (rule: member-coordinate-malformed)")) + })?; + if !seen_coords.insert(coord_val.to_string()) { + return Err(SdkError::InvalidInput(format!( + "duplicate member coordinate {coord_val:?} (rule: member-duplicate)" + ))); + } + } + + // --- Rules 7 & 8: singleton metadata + byte bounds --- + let singleton_fields = [ + ( + "name", + PROJECT_NAME_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "description", + PROJECT_DESCRIPTION_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "buzz-channel", + PROJECT_CHANNEL_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "buzz-visibility", + PROJECT_VISIBILITY_MAX, + "metadata-cardinality", + "metadata-length", + ), + ]; + for (field, max_bytes, card_rule, len_rule) in singleton_fields { + let matches: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some(field)).collect(); + if matches.len() > 1 { + return Err(SdkError::InvalidInput(format!( + "project must have at most one '{field}' tag (rule: {card_rule})" + ))); + } + if let Some(t) = matches.first() { + let val = tag_value(t).unwrap_or(""); + if val.len() > max_bytes { + return Err(SdkError::InvalidInput(format!( + "'{field}' tag exceeds {max_bytes} bytes (rule: {len_rule})" + ))); + } + } + } + + Ok(()) +} + +/// Helper: tag name (first element). +fn tag_name(tag: &Tag) -> Option<&str> { + tag.as_slice().first().map(String::as_str) +} + +/// Helper: tag value (second element). +fn tag_value(tag: &Tag) -> Option<&str> { + tag.as_slice().get(1).map(String::as_str) +} + +/// **Layer A raw builder**: Build a kind:30621 project event from a raw +/// `content` string and a raw `tags` slice, without any canonicalization. +/// +/// Validates the entire envelope through `validate_project_envelope` before +/// accepting it. The caller is responsible for supplying the correct `d` tag. +/// This is the path exercised by fixture conformance tests and by read-modify- +/// write mutations in the CLI. +pub fn build_project_with_tags(content: &str, tags: Vec) -> Result { + validate_project_envelope(&tags, content)?; + Ok(EventBuilder::new(Kind::Custom(KIND_PROJECT as u16), content).tags(tags)) +} + +/// **Layer B writer-policy builder**: Build a kind:30621 project event with +/// enforced writer policy: +/// - The `d` tag is constructed from `slug`; `check_project_slug` rejects +/// an empty or over-length slug. +/// - `channel` must be a valid UUID string. +/// - `visibility` must be `"listed"` or `"unlisted"`. +/// - Content is always empty. +/// - Member coordinates are parsed through `ProjectMemberCoord::parse_full`. +/// +/// The resulting envelope is validated through Layer A before the builder is +/// returned. +pub fn build_project( + slug: &str, + name: Option<&str>, + description: Option<&str>, + members: &[ProjectMemberCoord], + channel: Option<&str>, + visibility: Option<&str>, +) -> Result { + // Slug validation + if slug.is_empty() { + return Err(SdkError::InvalidInput( + "project slug must not be empty".into(), + )); + } + if slug.len() > PROJECT_D_MAX_LEN { + return Err(SdkError::InvalidInput(format!( + "project slug must not exceed {PROJECT_D_MAX_LEN} bytes (got {})", + slug.len() + ))); + } + + // Channel UUID validation + if let Some(ch) = channel { + uuid::Uuid::parse_str(ch).map_err(|_| { + SdkError::InvalidInput(format!("buzz-channel must be a valid UUID (got {ch:?})")) + })?; + } + + // Visibility enum validation + if let Some(vis) = visibility { + if vis != "listed" && vis != "unlisted" { + return Err(SdkError::InvalidInput(format!( + "buzz-visibility must be 'listed' or 'unlisted' (got {vis:?})" + ))); + } + } + + let mut tags: Vec = Vec::new(); + tags.push(tag(&["d", slug])?); + + if let Some(n) = name { + tags.push(tag(&["name", n])?); + } + if let Some(d) = description { + tags.push(tag(&["description", d])?); + } + for m in members { + let tag_parts = m.to_tag_parts(); + let parts: Vec<&str> = tag_parts.iter().map(|s| s.as_str()).collect(); + // Safety: to_tag_parts always produces ["a", coord, ...hint] + tags.push( + Tag::parse(parts.iter().copied()).map_err(|e| SdkError::InvalidTag(e.to_string()))?, + ); + } + if let Some(ch) = channel { + tags.push(tag(&["buzz-channel", ch])?); + } + if let Some(vis) = visibility { + tags.push(tag(&["buzz-visibility", vis])?); + } + + build_project_with_tags("", tags) +} + +/// **Generic NIP-09 coordinate delete**: Build a kind:5 deletion event with +/// a single `a`-tag addressing `::`. +/// +/// Validates: +/// - `kind` is an addressable kind (10000–19999 or 30000–39999). +/// - `pubkey` is a 64-character lowercase hex string. +/// - `d` is non-empty. +/// +/// `build_workflow_delete` delegates to this function. +pub fn build_delete_addressable( + kind: u32, + pubkey: &str, + d: &str, +) -> Result { + let is_addressable = (10000..20000).contains(&kind) || (30000..40000).contains(&kind); + if !is_addressable { + return Err(SdkError::InvalidInput(format!( + "kind {kind} is not an addressable kind (must be 10000–19999 or 30000–39999)" + ))); + } + let pk = check_pubkey_hex(pubkey, "pubkey")?; + if d.is_empty() { + return Err(SdkError::InvalidInput("d must not be empty".into())); + } + let coord = format!("{kind}:{pk}:{d}"); + let tags = vec![tag(&["a", &coord])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags)) +} + #[cfg(test)] mod tests { use super::*; @@ -3884,4 +4237,280 @@ mod tests { .iter() .any(|t| t.as_slice().first().map(String::as_str) == Some("replaced-by"))); } + + // ── NIP-MP cap-before-arity ordering ───────────────────────────────────── + + /// When an envelope exceeds the member cap AND contains a malformed `a` tag, + /// the validator must fire `member-cap` (rule 3) — not `member-tag-arity` + /// (rule 4). This matches the relay's ingest ordering and means a client + /// sending an oversized list never receives a per-tag parse error. + #[test] + fn validate_project_envelope_cap_wins_over_arity_when_both_fail() { + let owner = "a".repeat(64); + // Build 65 well-formed `a` tags — enough to trigger the cap. + let mut tags = vec![Tag::parse(["d", "platform"]).unwrap()]; + for i in 0..65usize { + let coord = format!("30617:{owner}:repo-{i}"); + tags.push(Tag::parse(["a", &coord]).unwrap()); + } + // Also add one malformed tag (four elements) that would fire + // member-tag-arity if evaluated before the cap check. + let coord_extra = format!("30617:{owner}:repo-extra"); + tags.push( + Tag::parse([ + "a", + &coord_extra, + "wss://relay.example.com", + "extra-element", + ]) + .unwrap(), + ); + + let err = validate_project_envelope(&tags, "").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("member-cap"), + "expected member-cap to win, got: {msg}" + ); + assert!( + !msg.contains("member-tag-arity"), + "arity rule must not fire before cap rule, got: {msg}" + ); + } + + // ── Layer B writer-policy builder ─────────────────────────────────────── + + const OWNER64: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const VALID_UUID: &str = "3580ca9b-47b4-4af9-b22a-1068778f26c6"; + + fn member_coord(repo: &str) -> ProjectMemberCoord { + ProjectMemberCoord::parse_full(&format!("30617:{OWNER64}:{repo}")).unwrap() + } + + #[test] + fn build_project_emitted_envelope_has_correct_shape() { + // slug, name, description, channel, visibility, and one member. + let m = member_coord("buzz"); + let ev = sign( + build_project( + "my-proj", + Some("My Project"), + Some("A description"), + &[m], + Some(VALID_UUID), + Some("listed"), + ) + .expect("Layer B must accept valid inputs"), + ); + + // Kind must be 30621. + assert_eq!(ev.kind.as_u16(), KIND_PROJECT as u16); + // Content must be empty (Layer B policy). + assert!(ev.content.is_empty(), "content must be empty"); + + let all_tags: Vec> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + + // d tag must be present exactly once. + let d_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "d").collect(); + assert_eq!(d_tags.len(), 1); + assert_eq!(d_tags[0][1], "my-proj"); + + // name, description, buzz-channel, buzz-visibility present. + let name_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "name").collect(); + assert_eq!(name_tags.len(), 1); + assert_eq!(name_tags[0][1], "My Project"); + + let desc_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "description").collect(); + assert_eq!(desc_tags.len(), 1); + assert_eq!(desc_tags[0][1], "A description"); + + let ch_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "buzz-channel").collect(); + assert_eq!(ch_tags.len(), 1); + assert_eq!(ch_tags[0][1], VALID_UUID); + + let vis_tags: Vec<_> = all_tags + .iter() + .filter(|t| t[0] == "buzz-visibility") + .collect(); + assert_eq!(vis_tags.len(), 1); + assert_eq!(vis_tags[0][1], "listed"); + + // member a tag. + let a_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "a").collect(); + assert_eq!(a_tags.len(), 1); + assert_eq!(a_tags[0][1], format!("30617:{OWNER64}:buzz")); + } + + #[test] + fn build_project_optional_fields_absent_when_not_supplied() { + let m = member_coord("core"); + let ev = sign( + build_project("my-proj", None, None, &[m], None, None) + .expect("minimal build must succeed"), + ); + let names: Vec<_> = ev + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("name")) + .collect(); + assert!(names.is_empty(), "name tag must not be emitted when absent"); + } + + #[test] + fn build_project_rejects_empty_slug() { + let m = member_coord("r"); + let err = build_project("", None, None, &[m], None, None).unwrap_err(); + assert!( + matches!(err, SdkError::InvalidInput(_)), + "empty slug must be InvalidInput, got: {err:?}" + ); + assert!(err.to_string().contains("empty")); + } + + #[test] + fn build_project_rejects_overlong_slug() { + let long_slug = "a".repeat(PROJECT_D_MAX_LEN + 1); + let m = member_coord("r"); + let err = build_project(&long_slug, None, None, &[m], None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn build_project_rejects_invalid_channel_uuid() { + let m = member_coord("r"); + let err = build_project("slug", None, None, &[m], Some("not-a-uuid"), None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!(err.to_string().contains("UUID") || err.to_string().contains("uuid")); + } + + #[test] + fn build_project_rejects_invalid_visibility_token() { + let m = member_coord("r"); + let err = build_project("slug", None, None, &[m], None, Some("chartreuse")).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!(err.to_string().contains("listed") || err.to_string().contains("unlisted")); + } + + #[test] + fn build_project_rejects_over_cap_members() { + let members: Vec<_> = (0..=PROJECT_MEMBER_CAP) + .map(|i| member_coord(&format!("repo-{i}"))) + .collect(); + let err = build_project("slug", None, None, &members, None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!( + err.to_string().contains("member-cap"), + "over-cap must report member-cap, got: {err}" + ); + } + + #[test] + fn build_project_rejects_duplicate_members() { + let m = member_coord("same"); + let err = build_project("slug", None, None, &[m.clone(), m], None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!( + err.to_string().contains("dedup") || err.to_string().contains("duplicate"), + "duplicate member must report dedup, got: {err}" + ); + } + + #[test] + fn build_project_content_is_always_empty() { + // build_project forces content="" regardless; Layer A also enforces + // that the envelope is valid. Any non-empty content would be dropped. + // This test pins the Layer B content-forced-empty policy. + let m = member_coord("r"); + let ev = sign(build_project("slug", None, None, &[m], None, None).unwrap()); + assert!( + ev.content.is_empty(), + "Layer B must always emit empty content" + ); + } + + // ── NIP-MP conformance fixtures ────────────────────────────────────────── + // `build_project_with_tags` directly. Accept cases must build; reject + // cases must fail with an error message containing the expected rule name. + // A count assertion guards against silent omissions. + // + // `include_str!` path is relative to this source file. + fn nip_mp_fixture_tags(json_tags: &serde_json::Value) -> Vec { + json_tags + .as_array() + .unwrap() + .iter() + .map(|t| { + let parts: Vec = t + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect(); + Tag::parse(parts_ref.iter().copied()) + .unwrap_or_else(|e| panic!("fixture tag parse error: {e}\n raw: {t}")) + }) + .collect() + } + + #[test] + fn nip_mp_fixtures_all_31_cases_exercised() { + const FIXTURE_JSON: &str = include_str!("../../../docs/nips/NIP-MP.fixtures.json"); + + let data: serde_json::Value = + serde_json::from_str(FIXTURE_JSON).expect("fixture JSON must parse"); + let cases = data["cases"].as_array().expect("cases must be array"); + + // Count gate: the spec says "required to test against this one file" + // with the exact count as-shipped. + assert_eq!( + cases.len(), + 31, + "expected 31 fixture cases, got {} — was NIP-MP.fixtures.json edited?", + cases.len() + ); + + let mut accept_count = 0usize; + let mut reject_count = 0usize; + + for case in cases { + let name = case["name"].as_str().unwrap(); + let expect = case["expect"].as_str().unwrap(); + let template = &case["template"]; + let content = template["content"].as_str().unwrap_or(""); + let tags = nip_mp_fixture_tags(&template["tags"]); + + match expect { + "accept" => { + build_project_with_tags(content, tags).unwrap_or_else(|e| { + panic!("fixture '{name}' (accept) must build successfully, got: {e}") + }); + accept_count += 1; + } + "reject" => { + let reject_rules = case["reject_rules"] + .as_array() + .expect("reject case must have reject_rules") + .iter() + .map(|r| r.as_str().unwrap().to_string()) + .collect::>(); + + let err = build_project_with_tags(content, tags).unwrap_err(); + let err_msg = err.to_string(); + + // The error must mention at least one of the expected rules. + let rule_matched = reject_rules.iter().any(|r| err_msg.contains(r.as_str())); + assert!( + rule_matched, + "fixture '{name}' rejected with wrong rule.\n expected one of: {reject_rules:?}\n got error: {err_msg}" + ); + reject_count += 1; + } + other => panic!("fixture '{name}' has unknown expect value: {other:?}"), + } + } + + assert_eq!(accept_count, 11, "expected 11 accept cases"); + assert_eq!(reject_count, 20, "expected 20 reject cases"); + } } diff --git a/desktop/scripts/build-release-config.mjs b/desktop/scripts/build-release-config.mjs index 389d18aec5..d1cd8181eb 100644 --- a/desktop/scripts/build-release-config.mjs +++ b/desktop/scripts/build-release-config.mjs @@ -52,6 +52,15 @@ const releaseConfig = { }, }; +// Tauri applies --config after platform-specific config using RFC 7396. +// Any externalBin value here would therefore replace the platform sidecar list, +// while null would silently delete it. This delta must never own that key. +if (Object.hasOwn(releaseConfig.bundle, "externalBin")) { + throw new Error( + "Release config must not define bundle.externalBin; sidecars are platform-specific", + ); +} + console.log(`Updater enabled -> ${updaterEndpoint}`); writeFileSync(outputConfigPath, `${JSON.stringify(releaseConfig, null, 2)}\n`); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 00d3fba3b5..1ca7075f11 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1044,6 +1044,7 @@ dependencies = [ "audioadapter-buffers", "axum", "base64 0.22.1", + "block2", "buzz-agent", "buzz-core", "buzz-media", @@ -1106,6 +1107,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "tauri-utils", "tempfile", "tokio", "tokio-tungstenite 0.29.0", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b80684f955..fd58f27878 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -46,8 +46,9 @@ notify-rust = "4" webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } [target.'cfg(target_os = "macos")'.dependencies] +block2 = { version = "0.6", default-features = false, features = ["std"] } objc2 = { version = "0.6.4", default-features = false } -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSProcessInfo", "NSString"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } @@ -138,6 +139,7 @@ strip-ansi-escapes = "0.2" tracing = "0.1" [dev-dependencies] +tauri-utils = "2" # `test-util` enables tokio's paused-clock (`start_paused`) so the relay # admission gate tests can assert exact wait durations without real sleeps. tokio = { version = "1", features = ["test-util"] } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac..3b114b0474 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1362,7 +1362,7 @@ use deploy::build_deploy_payload; #[cfg(test)] use deploy::deploy_payload_json; #[cfg(test)] -pub(crate) use deploy::resolve_deploy_model_provider; +use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; #[path = "agents_profile.rs"] mod profile; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index af785711d5..9bb0f6230d 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -1,6 +1,8 @@ //! Provider deploy payload construction, split from `agents.rs` (file-size -//! guard). `build_deploy_payload` gathers live state; `deploy_payload_json` -//! is the pure serialization half so payload completeness stays testable. +//! guard). The launch block is derived from the same effective descriptor and +//! policy helpers as local spawn so remote execution does not reimplement them. + +use std::collections::BTreeMap; use tauri::AppHandle; @@ -13,17 +15,6 @@ use crate::{ }; /// Resolve the deploy-specific structured model/provider for a managed agent. -/// -/// Delegates to the single effective-config resolver which enforces -/// definition-authoritative semantics for linked instances: -/// - **Linked:** definition → global. Stale record bytes are never consulted. -/// - **Definition-less:** instance → global. -/// - **Orphaned:** returns `(None, None)` — spawn is blocked elsewhere. -/// -/// Both local spawn and deploy now use the same resolver, so they can never -/// disagree on what model/provider an agent runs with. -/// -/// Exported `pub(crate)` for unit testing. #[cfg(test)] pub(crate) fn resolve_deploy_model_provider( record: &ManagedAgentRecord, @@ -36,58 +27,116 @@ pub(crate) fn resolve_deploy_model_provider( .unwrap_or((None, None)) } -/// Build the standard agent JSON payload for provider deploy calls. -/// -/// Like local spawn, provider deploy re-reads live persona env vars and -/// structured model/provider so remote agents receive current credentials -/// and the same authoritative values that local spawn derives from -/// `runtime_metadata_env_vars`. The only field still pinned is -/// `agent_command`/`agent_args` — those were captured at create time. -/// The only read-time resolution is `relay_url`: a blank pin resolves to -/// the active workspace relay here, matching the create-path contract. +/// Serialize the portable launch contract shared with provider-backed agents. /// -/// Fails closed when the private key is unavailable (keyring outage leaves -/// it empty after hydration): without this guard a provider deploy would -/// serialize `"private_key_nsec": ""` and launch the agent with no -/// identity — the same hazard the local spawn path refuses via -/// `spawn_key_refusal`. +/// `descriptor.env` is the authoritative six-layer environment. Policy values +/// are deliberately separate because providers apply them below that layered +/// environment, preserving the local spawn's power-user override semantics. +pub(super) fn build_launch_block( + record: &ManagedAgentRecord, + descriptor: &crate::managed_agents::readiness::EffectiveHarnessDescriptor, + teams: &[crate::managed_agents::TeamRecord], + effective_prompt: Option<&str>, + effective_model: Option<&str>, + owner_pubkey: &str, +) -> serde_json::Value { + use crate::managed_agents::{known_acp_runtime, resolve_session_title, SESSION_TITLE_ENV_VAR}; + + let runtime = known_acp_runtime(&descriptor.command); + let mut policy_env = BTreeMap::new(); + + if let Some(runtime) = runtime { + policy_env.extend( + runtime + .default_env + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())), + ); + if runtime.mcp_hooks { + policy_env.insert("MCP_HOOK_SERVERS".into(), "*".into()); + } + } + policy_env.insert("BUZZ_ACP_RELAY_OBSERVER".into(), "true".into()); + policy_env.insert("BUZZ_ACP_LAZY_POOL".into(), "true".into()); + policy_env.insert("BUZZ_ACP_AGENTS".into(), record.parallelism.to_string()); + + if let Some(value) = effective_prompt { + policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); + } + if let Some(value) = effective_model { + policy_env.insert("BUZZ_ACP_MODEL".into(), value.to_string()); + } + if let Some(value) = record.idle_timeout_seconds { + policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string()); + } + if let Some(value) = record.max_turn_duration_seconds { + policy_env.insert("BUZZ_ACP_MAX_TURN_DURATION".into(), value.to_string()); + } + if let Some(value) = resolve_session_title(record.display_name.as_deref(), &record.name) { + policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value); + } + if let Some(value) = + crate::managed_agents::spawn_hash::effective_team_instructions(record, teams) + { + policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); + } + + serde_json::json!({ + "command": descriptor.command, + "args": descriptor.args, + "env": descriptor.env, + "policy_env": policy_env, + "owner_pubkey": owner_pubkey, + }) +} + +pub(super) fn ensure_remote_provider_supported(provider: Option<&str>) -> Result<(), String> { + if provider.map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { + return Err( + "shared-compute agents cannot be deployed remotely because the mesh endpoint is local to the desktop" + .to_string(), + ); + } + Ok(()) +} + +/// Build the standard agent JSON payload for provider deploy calls. pub(super) fn build_deploy_payload( app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, ) -> Result { - // Fails closed when the private key is unavailable — same guard as local - // spawn. Without this, a keyring outage would serialize `"private_key_nsec": ""` - // and launch the agent with no identity. if let Some(err) = crate::managed_agents::spawn_key_refusal(record) { return Err(err); } - // Merge global + persona + agent env_vars for provider deploy — the same - // live-persona-under-overrides semantics as local spawn. Global env vars - // are the lowest user-settable layer: global < persona < agent (last-wins - // on key collision). Without this, provider-backed agents wouldn't receive - // credentials saved on the persona or the agent itself. - let global_config = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let global_env = global_config.env_vars.clone(); - let persona_env = - crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?; - // Merge: global < persona (persona wins over global). - let global_persona_merged = crate::managed_agents::merged_user_env(&global_env, &persona_env); - // Merge: global+persona < agent (agent wins over everything). - let merged_env = - crate::managed_agents::merged_user_env(&global_persona_merged, &record.env_vars); - + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let personas = load_personas(app).unwrap_or_default(); - let cfg = crate::managed_agents::effective_config::resolve_effective_config( - record, - &personas, - &global_config, + let teams = crate::managed_agents::load_teams(app).unwrap_or_default(); + let persona_env = + crate::managed_agents::live_persona_env(&personas, record.persona_id.as_deref()); + let global_persona_env = crate::managed_agents::merged_user_env(&global.env_vars, &persona_env); + let merged_user_env = + crate::managed_agents::merged_user_env(&global_persona_env, &record.env_vars); + let effective = crate::managed_agents::effective_config::resolve_effective_config( + record, &personas, &global, ) .require_resolved()?; - let effective_model = cfg.model.value; - let effective_provider = cfg.provider.value; - let effective_prompt = cfg.system_prompt.value; + + ensure_remote_provider_supported(effective.provider.value.as_deref())?; + + let descriptor = + crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) + .map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?; + let owner_pubkey = super::workspace_owner_hex(state)?; + let launch = build_launch_block( + record, + &descriptor, + &teams, + effective.system_prompt.value.as_deref(), + effective.model.value.as_deref(), + &owner_pubkey, + ); Ok(deploy_payload_json( record, @@ -95,23 +144,24 @@ pub(super) fn build_deploy_payload( &record.relay_url, &relay_ws_url_with_override(state), ), - effective_model, - effective_provider, - effective_prompt, - merged_env, + effective.model.value, + effective.provider.value, + effective.system_prompt.value, + merged_user_env, + launch, )) } -/// Pure serialization half of [`build_deploy_payload`] — every field the -/// provider harness receives is deliberately listed here, so payload -/// completeness is testable without an `AppHandle`. +/// Pure serialization half of [`build_deploy_payload`]. Legacy top-level fields +/// remain for display/bookkeeping; providers execute the resolved `launch` block. pub(super) fn deploy_payload_json( record: &ManagedAgentRecord, relay_url: String, effective_model: Option, effective_provider: Option, effective_prompt: Option, - merged_env: std::collections::BTreeMap, + merged_env: BTreeMap, + launch: serde_json::Value, ) -> serde_json::Value { serde_json::json!({ "name": &record.name, @@ -130,5 +180,81 @@ pub(super) fn deploy_payload_json( "respond_to": record.respond_to, "respond_to_allowlist": &record.respond_to_allowlist, "env_vars": merged_env, + "launch": launch, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::{readiness::EffectiveHarnessDescriptor, RespondTo, TeamRecord}; + + fn record() -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "abcd1234", + "name": "agent-handle", + "display_name": "Agent\u{0000} Name", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://relay.example", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "idle_timeout_seconds": 17, + "max_turn_duration_seconds": 23, + "parallelism": 4, + "respond_to": RespondTo::OwnerOnly, + "respond_to_allowlist": [], + "team_id": "team-1", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) + .unwrap() + } + + #[test] + fn launch_block_preserves_descriptor_and_spawn_policy() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec!["acp".into()], + env: BTreeMap::from([ + ("GOOSE_MODE".into(), "custom".into()), + ("SECRET_FROM_PERSONA".into(), "secret".into()), + ]), + }; + let teams: Vec = serde_json::from_value(serde_json::json!([{ + "id": "team-1", "name": "Team", "instructions": "Coordinate", "persona_ids": [], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" + }])).unwrap(); + + let launch = build_launch_block( + &record, + &descriptor, + &teams, + Some("prompt"), + Some("model"), + "owner-hex", + ); + + assert_eq!(launch["command"], "goose"); + assert_eq!(launch["args"], serde_json::json!(["acp"])); + assert_eq!(launch["env"]["GOOSE_MODE"], "custom"); + // policy_env is applied first, so this default remains separate from + // the descriptor value that wins in launch.env. + assert_eq!(launch["policy_env"]["GOOSE_MODE"], "auto"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_LAZY_POOL"], "true"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_RELAY_OBSERVER"], "true"); + assert_eq!( + launch["policy_env"]["BUZZ_ACP_TEAM_INSTRUCTIONS"], + "Coordinate" + ); + assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_TITLE"], "Agent Name"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_SYSTEM_PROMPT"], "prompt"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_MAX_TURN_DURATION"], "23"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4"); + assert_eq!(launch["owner_pubkey"], "owner-hex"); + } +} diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 03389d1d18..df135298c4 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -263,6 +263,19 @@ fn normalize_relay_mesh_trims_and_preserves_valid_config() { ); } +#[test] +fn deploy_refuses_resolved_relay_mesh_provider_with_padding() { + let record = bare_agent_record(Some("p1"), None, None); + let personas = vec![persona_record("p1", None, Some(" relay-mesh "))]; + let global = crate::managed_agents::GlobalAgentConfig::default(); + + let (_, provider) = resolve_deploy_model_provider(&record, &personas, &global); + let error = ensure_remote_provider_supported(provider.as_deref()) + .expect_err("resolved shared-compute provider must not deploy remotely"); + + assert!(error.contains("cannot be deployed remotely"), "{error}"); +} + #[test] fn created_avatar_prefers_explicit_input() { let resolved = resolve_created_avatar_url( @@ -398,50 +411,93 @@ fn legacy_avatar_empty_when_nothing_resolves() { // ── Provider deploy payload completeness ───────────────────────────────────── -/// Regression (PR #1667 review, Thufir): the provider deploy payload must -/// carry every behavioral field the local spawn path applies — a field -/// missing here silently strips it from provider-backed agents. +/// The shared provider fixture is the contract arbiter: it must be the exact +/// richest deploy request produced by the real desktop serializers. #[test] -fn deploy_payload_carries_the_full_behavioral_quad() { - let allow = "a".repeat(64); - let record: ManagedAgentRecord = serde_json::from_str(&format!( - r#"{{ - "pubkey": "abcd1234", - "name": "test-agent", - "private_key_nsec": "nsec1fake", - "relay_url": "wss://localhost:3000", - "acp_command": "buzz-acp", - "agent_command": "goose", - "agent_args": [], - "mcp_command": "", - "turn_timeout_seconds": 320, - "system_prompt": null, - "parallelism": 4, - "respond_to": "allowlist", - "respond_to_allowlist": ["{allow}"], - "created_at": "2026-01-01T00:00:00Z", - "updated_at": "2026-01-01T00:00:00Z", - "last_started_at": null, - "last_stopped_at": null, - "last_exit_code": null, - "last_error": null - }}"# - )) - .expect("sample record"); - - let payload = deploy_payload_json( +fn deploy_payload_matches_the_shared_full_launch_fixture() { + let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join( + "../../crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json", + ); + let fixture: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&fixture_path) + .unwrap_or_else(|error| panic!("read {}: {error}", fixture_path.display())), + ) + .expect("parse shared provider fixture"); + let record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "abcd1234", + "name": "worker", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "relay_url": "wss://localhost:3000", + "auth_tag": "tag-1", + "acp_command": "buzz-acp", + "agent_command": "goose", + "runtime": "goose", + "model": "gpt-5", + "provider": "openai", + "env_vars": {"USER_KEY": "user-value"}, + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 300, + "system_prompt": null, + "idle_timeout_seconds": null, + "max_turn_duration_seconds": null, + "parallelism": 10, + "respond_to": "allowlist", + "respond_to_allowlist": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) + .expect("fixture source record"); + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + &record, + &[], + &crate::managed_agents::GlobalAgentConfig::default(), + ) + .expect("resolve fixture source record descriptor"); + let launch = super::deploy::build_launch_block( &record, - "wss://relay.example".to_string(), - Some("gpt-x".to_string()), - Some("openai".to_string()), + &descriptor, + &[], None, - std::collections::BTreeMap::new(), + Some("gpt-5"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let agent = deploy_payload_json( + &record, + "wss://relay.example".into(), + Some("gpt-5".into()), + Some("openai".into()), + None, + std::collections::BTreeMap::from([("USER_KEY".into(), "user-value".into())]), + launch, ); - assert_eq!(payload["parallelism"], 4); - assert_eq!(payload["respond_to"], "allowlist"); - assert_eq!(payload["respond_to_allowlist"][0], "a".repeat(64)); - assert_eq!(payload["model"], "gpt-x"); - assert_eq!(payload["provider"], "openai"); - assert_eq!(payload["relay_url"], "wss://relay.example"); + assert_eq!( + agent, fixture["agent"], + "desktop payload drifted from the shared provider fixture" + ); +} + +#[test] +fn tauri_platform_configs_bundle_kubernetes_only_on_supported_hosts() { + use tauri_utils::{config::parse::read_from, platform::Target}; + + let config_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + for (target, expected) in [ + (Target::MacOS, true), + (Target::Linux, true), + (Target::Windows, false), + ] { + let (config, paths) = read_from(target, config_root).expect("read Tauri config"); + let external_bins = config["bundle"]["externalBin"] + .as_array() + .expect("bundle.externalBin array"); + let has_kubernetes = external_bins + .iter() + .any(|value| value == "binaries/buzz-backend-kubernetes"); + assert_eq!( + has_kubernetes, expected, + "unexpected Kubernetes externalBin for {target}; merged {paths:?}" + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 5debae41cb..84dd7e99da 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -1,3 +1,4 @@ +use sha2::{Digest, Sha256}; use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::mpsc; @@ -7,6 +8,61 @@ const STDERR_CAP: usize = 65536; /// Provider responses should be small JSON objects. Cap stdout to prevent a /// buggy or malicious provider from OOM-ing the desktop process. const STDOUT_CAP: usize = 1_048_576; // 1 MB +const PROVIDER_PROTOCOL_VERSION: u64 = 1; + +fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { + let object = info + .as_object() + .ok_or_else(|| "provider info response must be a JSON object".to_string())?; + let actual_version = object + .get("protocol_version") + .and_then(serde_json::Value::as_u64); + if actual_version != Some(PROVIDER_PROTOCOL_VERSION) { + return Err(match actual_version { + Some(version) => format!( + "unsupported provider protocol version {version}; desktop requires {PROVIDER_PROTOCOL_VERSION}" + ), + None => "provider info response missing integer protocol_version".to_string(), + }); + } + if object.get("ok") != Some(&serde_json::Value::Bool(true)) { + return Err("provider info response must contain ok: true".to_string()); + } + for field in ["name", "version", "description"] { + if object + .get(field) + .is_none_or(|value| value.as_str().is_none_or(str::is_empty)) + { + return Err(format!( + "provider info response missing non-empty string {field}" + )); + } + } + if !object + .get("config_schema") + .is_some_and(serde_json::Value::is_object) + { + return Err("provider info response missing object config_schema".to_string()); + } + + const FIELDS: &[&str] = &[ + "ok", + "name", + "version", + "protocol_version", + "description", + "config_schema", + ]; + if let Some(field) = object + .keys() + .find(|field| !FIELDS.contains(&field.as_str())) + { + return Err(format!( + "provider info response contains unknown field {field}" + )); + } + Ok(()) +} /// Invoke a provider binary: write JSON to stdin, read JSON from stdout. /// @@ -333,23 +389,29 @@ pub(crate) fn redact_secrets_with(s: &str, extras: &[&str]) -> String { result } -/// Collect string values from `request["agent"]["env_vars"]` (if present) -/// to feed into [`redact_secrets_with`]. Returns an empty Vec if the -/// request shape doesn't match, which is fine — falls back to the default -/// prefix-based scrubbing. +/// Collect string values from every environment map a deploy request can +/// carry. Providers may echo any of these values in diagnostics, including +/// definition/baked values that exist only in the resolved launch block. fn env_secrets_from_request(request: &serde_json::Value) -> Vec { - request - .get("agent") - .and_then(|a| a.get("env_vars")) - .and_then(|e| e.as_object()) - .map(|obj| { - obj.values() - .filter_map(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(String::from) - .collect() - }) - .unwrap_or_default() + let agent = request.get("agent"); + let maps = [ + agent.and_then(|value| value.get("env_vars")), + agent + .and_then(|value| value.get("launch")) + .and_then(|value| value.get("env")), + agent + .and_then(|value| value.get("launch")) + .and_then(|value| value.get("policy_env")), + ]; + + maps.into_iter() + .flatten() + .filter_map(serde_json::Value::as_object) + .flat_map(|map| map.values()) + .filter_map(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(String::from) + .collect() } /// Public-in-crate helper: redact every non-empty value from `env` (plus @@ -368,22 +430,102 @@ pub(crate) fn redact_env_values_in( redact_secrets_with(s, &values) } -/// Deploy an agent via provider binary. Returns the provider-assigned agent_id. -/// -/// `request_id` is included for provider-side logging/correlation but is not -/// validated in the response — the stdin→stdout exchange is 1:1 per process. +/// Copy a resolved provider into a private staging directory while hashing +/// exactly the bytes copied. The staged file becomes non-writable before either +/// invocation, closing the path replacement and in-place rewrite races. +fn stage_provider( + binary: &Path, +) -> Result<(tempfile::TempDir, PathBuf, String, std::fs::File), String> { + let directory = tempfile::Builder::new() + .prefix("buzz-provider-") + .tempdir() + .map_err(|error| format!("failed to create provider staging directory: {error}"))?; + let suffix = if cfg!(windows) { ".exe" } else { "" }; + let staged_path = directory.path().join(format!("provider{suffix}")); + let mut source = std::fs::File::open(binary) + .map_err(|error| format!("failed to open provider for staging: {error}"))?; + let mut staged = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&staged_path) + .map_err(|error| format!("failed to create staged provider: {error}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = source + .read(&mut buffer) + .map_err(|error| format!("failed to read provider for staging: {error}"))?; + if count == 0 { + break; + } + staged + .write_all(&buffer[..count]) + .map_err(|error| format!("failed to write staged provider: {error}"))?; + hasher.update(&buffer[..count]); + } + staged + .sync_all() + .map_err(|error| format!("failed to sync staged provider: {error}"))?; + + let mut permissions = staged + .metadata() + .map_err(|error| format!("failed to inspect staged provider: {error}"))? + .permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o500); + } + #[cfg(not(unix))] + permissions.set_readonly(true); + std::fs::set_permissions(&staged_path, permissions) + .map_err(|error| format!("failed to protect staged provider: {error}"))?; + drop(staged); + + #[cfg(windows)] + let execution_guard = { + use std::os::windows::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + // Permit CreateProcess to read the image while denying replacement, + // writes, and deletion until both invocations finish. + .share_mode(windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ) + .open(&staged_path) + }; + #[cfg(not(windows))] + let execution_guard = std::fs::File::open(&staged_path); + let execution_guard = execution_guard + .map_err(|error| format!("failed to lock staged provider for execution: {error}"))?; + Ok(( + directory, + staged_path, + hex::encode(hasher.finalize()), + execution_guard, + )) +} + +/// Deploy through one immutable staged copy: negotiate protocol v1 before the +/// secret-bearing request, then invoke deploy on those exact same bytes. pub fn provider_deploy( binary: &Path, agent: &serde_json::Value, provider_config: &serde_json::Value, ) -> Result { + let (_directory, staged, _digest, _execution_guard) = stage_provider(binary)?; + let info_request = serde_json::json!({ + "op": "info", + "request_id": uuid::Uuid::new_v4().to_string(), + }); + let info = invoke_provider(&staged, &info_request, Duration::from_secs(10))?; + validate_provider_info(&info)?; + let request = serde_json::json!({ "op": "deploy", "request_id": uuid::Uuid::new_v4().to_string(), "agent": agent, "provider_config": provider_config, }); - let resp = invoke_provider(binary, &request, Duration::from_secs(600))?; + let resp = invoke_provider(&staged, &request, Duration::from_secs(600))?; resp["agent_id"] .as_str() .map(String::from) @@ -423,6 +565,24 @@ pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String Ok(()) } +/// Derive a provider id from the filename Tauri stages at runtime. Tauri +/// removes its target-triple suffix while copying an external binary, but on +/// Windows leaves the executable/script extension, which is not part of the +/// provider id. +fn provider_id_from_filename(name: &str) -> Option<&str> { + let raw = name.strip_prefix("buzz-backend-")?; + let id = [".exe", ".bat", ".cmd"] + .into_iter() + .find_map(|extension| { + raw.get(raw.len().saturating_sub(extension.len())..) + .filter(|suffix| suffix.eq_ignore_ascii_case(extension)) + .map(|_| &raw[..raw.len() - extension.len()]) + }) + .unwrap_or(raw); + + (!id.is_empty()).then_some(id) +} + /// Enumerate PATH for buzz-backend-* executables. Returns (id, path) pairs. /// Only includes files that are executable. Does NOT execute any binaries. /// @@ -464,10 +624,12 @@ pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> { }; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); - if let Some(id) = name.strip_prefix(prefix) { - if !id.is_empty() && !seen.contains(&name) && is_executable(&entry.path()) { - seen.insert(name.clone()); - results.push((id.to_string(), entry.path())); + if name.starts_with(prefix) { + if let Some(id) = provider_id_from_filename(&name) { + if !seen.contains(&name) && is_executable(&entry.path()) { + seen.insert(name.clone()); + results.push((id.to_string(), entry.path())); + } } } } @@ -538,203 +700,5 @@ pub struct BackendProviderInfo { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn redact_secrets_replaces_nsec() { - let s = "key=nsec1abc123def456 other"; - let r = redact_secrets(s); - assert!(r.contains("[REDACTED]")); - assert!(!r.contains("nsec1abc123def456")); - } - - #[test] - fn redact_secrets_replaces_token() { - let s = r#"{"token":"sprt_tok_xyz789"}"#; - let r = redact_secrets(s); - assert!(r.contains("[REDACTED]")); - assert!(!r.contains("sprt_tok_xyz789")); - } - - #[test] - fn redact_secrets_with_extras_scrubs_user_env_values() { - // If a provider echoes back a user-supplied API key in its error - // output, the desktop must not surface that secret unredacted via - // `last_error`. We scrub the literal values that came from the - // request's `agent.env_vars`. - let secret = "sk-ant-api03-abc123def456"; - let stderr = format!("auth failed with key {secret} on host api.anthropic.com"); - let r = redact_secrets_with(&stderr, &[secret]); - assert!(r.contains("[REDACTED]")); - assert!(!r.contains(secret)); - } - - #[test] - fn redact_secrets_with_extras_skips_short_values() { - // Don't scrub values shorter than 4 chars — too noisy. - let r = redact_secrets_with("error code: 42", &["42"]); - assert!(r.contains("42")); - } - - /// GitHub tokens are recognised by shape, so one that never passed through - /// our environment — embedded in a remote URL an installer echoes — is - /// still scrubbed. The scan runs to the next whitespace or quote, so the - /// rest of the URL goes with it; over-redaction is the safe direction. - #[test] - fn redact_secrets_with_scrubs_github_token_prefixes() { - for token in [ - "ghp_abcdefghij0123456789", - "gho_abcdefghij0123456789", - "ghu_abcdefghij0123456789", - "ghs_abcdefghij0123456789", - "ghr_abcdefghij0123456789", - "github_pat_abcdefghij0123456789", - ] { - let r = - redact_secrets_with(&format!("cloning https://{token}@github.com/o/r now"), &[]); - assert!(!r.contains(token), "leaked {token}: {r}"); - assert!(r.contains("[REDACTED]"), "got: {r}"); - assert!(r.contains("cloning"), "scan must stop at whitespace: {r}"); - assert!(r.ends_with(" now"), "scan must stop at whitespace: {r}"); - } - } - - #[test] - fn redact_secrets_with_extras_terminates_when_value_substring_of_marker() { - // Regression: an earlier impl used `while let Some(pos) = find(value)` - // which never terminates if the user's env value is a substring of - // the replacement marker `[REDACTED]` — each replacement - // reintroduces the same text. Now uses `str::replace` (single-pass). - for value in ["REDACTED", "EDACTE", "REDA", "ACTED"] { - let r = redact_secrets_with(&format!("leak={value}"), &[value]); - assert!(r.contains("[REDACTED]")); - } - } - - #[test] - fn redact_secrets_with_extras_handles_overlapping_secrets() { - // Longer entries get scrubbed first so the substring "abc12" isn't - // matched before "abc123" is consumed. - let s = "key1=abc123 key2=abc12"; - let r = redact_secrets_with(s, &["abc12", "abc123"]); - assert!(!r.contains("abc123")); - assert!(!r.contains("abc12 ")); - } - - #[test] - fn env_secrets_from_request_extracts_string_values() { - let req = serde_json::json!({ - "op": "deploy", - "agent": { - "env_vars": { - "ANTHROPIC_API_KEY": "sk-ant-test", - "EMPTY": "", - "NUMERIC": 42, - }, - }, - }); - let secrets = env_secrets_from_request(&req); - assert!(secrets.iter().any(|v| v == "sk-ant-test")); - // Empty and non-string values are filtered out. - assert_eq!(secrets.len(), 1); - } - - #[test] - fn env_secrets_from_request_handles_missing_shape() { - assert!(env_secrets_from_request(&serde_json::json!({})).is_empty()); - assert!(env_secrets_from_request(&serde_json::json!({"agent": {}})).is_empty()); - assert!( - env_secrets_from_request(&serde_json::json!({"agent": {"env_vars": null}})).is_empty() - ); - } - - #[test] - fn redact_env_values_in_scrubs_map_values() { - let mut env = std::collections::BTreeMap::new(); - env.insert("ANTHROPIC_API_KEY".to_string(), "sk-ant-real".to_string()); - env.insert("EMPTY".to_string(), String::new()); - let stderr = "auth=sk-ant-real failed; other context"; - let r = redact_env_values_in(stderr, &env); - assert!(!r.contains("sk-ant-real")); - assert!(r.contains("[REDACTED]")); - } - - #[test] - fn validate_provider_config_rejects_secret_key() { - let cfg = serde_json::json!({"api_key": "val"}); - assert!(validate_provider_config(&cfg).is_err()); - } - - #[test] - fn validate_provider_config_rejects_nested() { - let cfg = serde_json::json!({"region": {"us": "east"}}); - assert!(validate_provider_config(&cfg).is_err()); - } - - #[test] - fn validate_provider_config_accepts_scalars() { - let cfg = serde_json::json!({"region": "us-east-1", "tier": "standard"}); - assert!(validate_provider_config(&cfg).is_ok()); - } - - #[test] - fn validate_provider_config_allows_key_as_substring() { - // "keyboard", "monkey" contain "key" as substring but not as a word segment. - let cfg = serde_json::json!({"keyboard_layout": "us", "monkey_wrench": "tight"}); - assert!(validate_provider_config(&cfg).is_ok()); - } - - #[test] - fn validate_provider_config_rejects_camel_case_secrets() { - assert!(validate_provider_config(&serde_json::json!({"apiKey": "val"})).is_err()); - assert!(validate_provider_config(&serde_json::json!({"accessToken": "val"})).is_err()); - assert!(validate_provider_config(&serde_json::json!({"clientSecret": "val"})).is_err()); - // ALL-CAPS variants - assert!(validate_provider_config(&serde_json::json!({"apiKEY": "val"})).is_err()); - assert!(validate_provider_config(&serde_json::json!({"accessTOKEN": "val"})).is_err()); - } - - #[test] - fn split_config_key_handles_all_styles() { - assert_eq!(split_config_key("apiKey"), vec!["api", "key"]); - assert_eq!(split_config_key("access_token"), vec!["access", "token"]); - assert_eq!(split_config_key("keyboard"), vec!["keyboard"]); - assert_eq!(split_config_key("client-secret"), vec!["client", "secret"]); - // Acronym runs stay together - assert_eq!(split_config_key("APIKey"), vec!["api", "key"]); - assert_eq!(split_config_key("apiKEY"), vec!["api", "key"]); - assert_eq!(split_config_key("accessTOKEN"), vec!["access", "token"]); - assert_eq!(split_config_key("MyAPIKey"), vec!["my", "api", "key"]); - } - - #[test] - fn resolve_provider_binary_rejects_invalid_ids() { - // Path traversal - assert!(resolve_provider_binary("../evil").is_err()); - // Empty - assert!(resolve_provider_binary("").is_err()); - // Uppercase - assert!(resolve_provider_binary("MyProvider").is_err()); - // Spaces - assert!(resolve_provider_binary("my provider").is_err()); - // Shell metacharacters - assert!(resolve_provider_binary("foo;rm -rf /").is_err()); - // Valid format but not on PATH — should fail with "not found" - assert!(resolve_provider_binary("nonexistent-test-id-12345").is_err()); - } - - #[test] - fn resolve_provider_binary_accepts_valid_id_format() { - // Valid ID format should pass validation. If the binary happens to - // exist on PATH, Ok is returned; otherwise Err contains "not found" - // (not "invalid provider ID"). Either outcome proves validation passed. - match resolve_provider_binary("zzz-nonexistent-test-provider") { - Ok(_) => {} // unlikely but fine — binary exists - Err(e) => assert!( - e.contains("not found"), - "expected 'not found' error, got: {e}" - ), - } - } -} +#[path = "backend_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/backend_tests.rs b/desktop/src-tauri/src/managed_agents/backend_tests.rs new file mode 100644 index 0000000000..ce1f81466f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/backend_tests.rs @@ -0,0 +1,452 @@ +use super::*; + +#[test] +fn redact_secrets_replaces_nsec() { + let s = "key=nsec1abc123def456 other"; + let r = redact_secrets(s); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains("nsec1abc123def456")); +} + +#[test] +fn redact_secrets_replaces_token() { + let s = r#"{"token":"sprt_tok_xyz789"}"#; + let r = redact_secrets(s); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains("sprt_tok_xyz789")); +} + +#[test] +fn redact_secrets_with_extras_scrubs_user_env_values() { + // If a provider echoes back a user-supplied API key in its error + // output, the desktop must not surface that secret unredacted via + // `last_error`. We scrub the literal values that came from the + // request's `agent.env_vars`. + let secret = "sk-ant-api03-abc123def456"; + let stderr = format!("auth failed with key {secret} on host api.anthropic.com"); + let r = redact_secrets_with(&stderr, &[secret]); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains(secret)); +} + +#[test] +fn redact_secrets_with_extras_skips_short_values() { + // Don't scrub values shorter than 4 chars — too noisy. + let r = redact_secrets_with("error code: 42", &["42"]); + assert!(r.contains("42")); +} + +/// GitHub tokens are recognised by shape, so one that never passed through +/// our environment — embedded in a remote URL an installer echoes — is +/// still scrubbed. The scan runs to the next whitespace or quote, so the +/// rest of the URL goes with it; over-redaction is the safe direction. +#[test] +fn redact_secrets_with_scrubs_github_token_prefixes() { + for token in [ + "ghp_abcdefghij0123456789", + "gho_abcdefghij0123456789", + "ghu_abcdefghij0123456789", + "ghs_abcdefghij0123456789", + "ghr_abcdefghij0123456789", + "github_pat_abcdefghij0123456789", + ] { + let r = redact_secrets_with(&format!("cloning https://{token}@github.com/o/r now"), &[]); + assert!(!r.contains(token), "leaked {token}: {r}"); + assert!(r.contains("[REDACTED]"), "got: {r}"); + assert!(r.contains("cloning"), "scan must stop at whitespace: {r}"); + assert!(r.ends_with(" now"), "scan must stop at whitespace: {r}"); + } +} + +#[test] +fn redact_secrets_with_extras_terminates_when_value_substring_of_marker() { + // Regression: an earlier impl used `while let Some(pos) = find(value)` + // which never terminates if the user's env value is a substring of + // the replacement marker `[REDACTED]` — each replacement + // reintroduces the same text. Now uses `str::replace` (single-pass). + for value in ["REDACTED", "EDACTE", "REDA", "ACTED"] { + let r = redact_secrets_with(&format!("leak={value}"), &[value]); + assert!(r.contains("[REDACTED]")); + } +} + +#[test] +fn redact_secrets_with_extras_handles_overlapping_secrets() { + // Longer entries get scrubbed first so the substring "abc12" isn't + // matched before "abc123" is consumed. + let s = "key1=abc123 key2=abc12"; + let r = redact_secrets_with(s, &["abc12", "abc123"]); + assert!(!r.contains("abc123")); + assert!(!r.contains("abc12 ")); +} + +#[test] +fn env_secrets_from_request_extracts_string_values() { + let req = serde_json::json!({ + "op": "deploy", + "agent": { + "env_vars": { + "ANTHROPIC_API_KEY": "sk-ant-test", + "EMPTY": "", + "NUMERIC": 42, + }, + }, + }); + let secrets = env_secrets_from_request(&req); + assert!(secrets.iter().any(|v| v == "sk-ant-test")); + // Empty and non-string values are filtered out. + assert_eq!(secrets.len(), 1); +} + +#[test] +fn env_secrets_from_request_handles_missing_shape() { + assert!(env_secrets_from_request(&serde_json::json!({})).is_empty()); + assert!(env_secrets_from_request(&serde_json::json!({"agent": {}})).is_empty()); + assert!(env_secrets_from_request(&serde_json::json!({"agent": {"env_vars": null}})).is_empty()); +} + +#[test] +fn redact_env_values_in_scrubs_map_values() { + let mut env = std::collections::BTreeMap::new(); + env.insert("ANTHROPIC_API_KEY".to_string(), "sk-ant-real".to_string()); + env.insert("EMPTY".to_string(), String::new()); + let stderr = "auth=sk-ant-real failed; other context"; + let r = redact_env_values_in(stderr, &env); + assert!(!r.contains("sk-ant-real")); + assert!(r.contains("[REDACTED]")); +} + +#[test] +fn env_secrets_from_request_includes_resolved_launch_maps() { + let req = serde_json::json!({ + "agent": { + "env_vars": {"LEGACY": "legacy-secret"}, + "launch": { + "env": {"PERSONA": "persona-secret"}, + "policy_env": {"POLICY": "policy-secret"} + } + } + }); + let secrets = env_secrets_from_request(&req); + assert_eq!(secrets.len(), 3); + for secret in ["legacy-secret", "persona-secret", "policy-secret"] { + assert!(secrets.iter().any(|candidate| candidate == secret)); + } +} + +#[cfg(unix)] +fn write_test_provider(path: &Path, body: &str) { + use std::os::unix::fs::PermissionsExt; + std::fs::write(path, format!("#!/bin/sh\nset -eu\n{body}\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_negotiates_and_deploys_the_same_staged_bytes() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let log = directory.path().join("invocations"); + let body = format!( + r#"read request +printf '%s\n' "$0" >> '{}' +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{{"ok":true,"name":"test","version":"1.0.0","protocol_version":1,"description":"test provider","config_schema":{{}}}}' ;; + *\"op\":\"deploy\"*) printf '%s\n' '{{"ok":true,"agent_id":"remote-1"}}' ;; +esac"#, + log.display() + ); + write_test_provider(&provider, &body); + + let id = provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})) + .expect("staged deploy"); + assert_eq!(id, "remote-1"); + let paths: Vec<_> = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(str::to_owned) + .collect(); + assert_eq!(paths.len(), 2); + assert_eq!(paths[0], paths[1]); + assert_ne!(Path::new(&paths[0]), provider); + assert!( + !Path::new(&paths[0]).exists(), + "staging directory must be deleted" + ); +} + +#[cfg(unix)] +fn replacement_provider() -> &'static str { + r#"#!/bin/sh +set -eu +read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{"ok":true,"name":"replacement","version":"9.9.9","protocol_version":1,"description":"replacement provider","config_schema":{}}' ;; + *\"op\":\"deploy\"*) printf '%s\n' '{"ok":true,"agent_id":"replacement-bytes-ran"}' ;; +esac +"# +} + +#[cfg(unix)] +#[test] +fn provider_deploy_uses_staged_bytes_after_same_inode_source_rewrite() { + use std::os::unix::fs::MetadataExt; + + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let replacement = directory.path().join("replacement"); + std::fs::write(&replacement, replacement_provider()).unwrap(); + let body = format!( + r#"read request +case "$request" in + *\"op\":\"info\"*) + cat '{}' > '{}' + chmod 700 '{}' + printf '%s\n' '{{"ok":true,"name":"original","version":"1.0.0","protocol_version":1,"description":"original provider","config_schema":{{}}}}' + ;; + *\"op\":\"deploy\"*) printf '%s\n' '{{"ok":true,"agent_id":"original-staged-bytes"}}' ;; +esac"#, + replacement.display(), + provider.display(), + provider.display(), + ); + write_test_provider(&provider, &body); + let inode_before = std::fs::metadata(&provider).unwrap().ino(); + + let id = provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})) + .expect("deploy from immutable staged copy"); + + assert_eq!(id, "original-staged-bytes"); + assert_eq!( + std::fs::metadata(&provider).unwrap().ino(), + inode_before, + "test must rewrite the source binary in place" + ); + assert_eq!( + std::fs::read_to_string(&provider).unwrap(), + replacement_provider(), + "source pathname must contain replacement bytes before deploy" + ); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_uses_staged_bytes_after_source_pathname_replacement() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let replacement = directory.path().join("replacement"); + std::fs::write(&replacement, replacement_provider()).unwrap(); + std::fs::set_permissions(&replacement, std::fs::Permissions::from_mode(0o700)).unwrap(); + let body = format!( + r#"read request +case "$request" in + *\"op\":\"info\"*) + mv '{}' '{}' + printf '%s\n' '{{"ok":true,"name":"original","version":"1.0.0","protocol_version":1,"description":"original provider","config_schema":{{}}}}' + ;; + *\"op\":\"deploy\"*) printf '%s\n' '{{"ok":true,"agent_id":"original-staged-bytes"}}' ;; +esac"#, + replacement.display(), + provider.display(), + ); + write_test_provider(&provider, &body); + let inode_before = std::fs::metadata(&provider).unwrap().ino(); + + let id = provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})) + .expect("deploy from immutable staged copy"); + + assert_eq!(id, "original-staged-bytes"); + assert_ne!( + std::fs::metadata(&provider).unwrap().ino(), + inode_before, + "test must replace the source pathname with a different inode" + ); + assert_eq!( + std::fs::read_to_string(&provider).unwrap(), + replacement_provider(), + "source pathname must contain replacement bytes before deploy" + ); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_refuses_mismatch_before_sending_agent_secret() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let marker = directory.path().join("deploy-received"); + let body = format!( + r#"read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{{"ok":true,"name":"test","version":"2.0.0","protocol_version":2,"description":"test provider","config_schema":{{}}}}' ;; + *\"op\":\"deploy\"*) touch '{}'; printf '%s\n' '{{"ok":true,"agent_id":"bad"}}' ;; +esac"#, + marker.display() + ); + write_test_provider(&provider, &body); + + let error = provider_deploy( + &provider, + &serde_json::json!({"private_key_nsec": "nsec1must-not-cross"}), + &serde_json::json!({}), + ) + .unwrap_err(); + assert!(error.contains("protocol version 2"), "{error}"); + assert!(!marker.exists()); + assert!(!error.contains("nsec1must-not-cross")); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_requires_an_explicit_integer_protocol_version() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + write_test_provider( + &provider, + r#"read request +printf '%s\n' '{"ok":true,"version":"1.0.0"}'"#, + ); + + let error = + provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})).unwrap_err(); + assert!( + error.contains("missing integer protocol_version"), + "{error}" + ); +} + +#[test] +fn provider_info_requires_the_complete_flat_wire_shape() { + let complete = serde_json::json!({ + "ok": true, + "name": "kubernetes", + "version": "1.0.0", + "protocol_version": 1, + "description": "Kubernetes provider", + "config_schema": {} + }); + assert!(validate_provider_info(&complete).is_ok()); + + let mut missing = complete.clone(); + missing.as_object_mut().unwrap().remove("config_schema"); + assert!(validate_provider_info(&missing) + .unwrap_err() + .contains("config_schema")); + + let mut nested = complete; + nested.as_object_mut().unwrap().insert( + "provider".into(), + serde_json::json!({"protocol_version": 1}), + ); + assert!(validate_provider_info(&nested) + .unwrap_err() + .contains("unknown field provider")); +} + +#[test] +fn validate_provider_config_rejects_secret_key() { + let cfg = serde_json::json!({"api_key": "val"}); + assert!(validate_provider_config(&cfg).is_err()); +} + +#[test] +fn validate_provider_config_rejects_nested() { + let cfg = serde_json::json!({"region": {"us": "east"}}); + assert!(validate_provider_config(&cfg).is_err()); +} + +#[test] +fn validate_provider_config_accepts_scalars() { + let cfg = serde_json::json!({"region": "us-east-1", "tier": "standard"}); + assert!(validate_provider_config(&cfg).is_ok()); +} + +#[test] +fn validate_provider_config_allows_key_as_substring() { + // "keyboard", "monkey" contain "key" as substring but not as a word segment. + let cfg = serde_json::json!({"keyboard_layout": "us", "monkey_wrench": "tight"}); + assert!(validate_provider_config(&cfg).is_ok()); +} + +#[test] +fn validate_provider_config_rejects_camel_case_secrets() { + assert!(validate_provider_config(&serde_json::json!({"apiKey": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"accessToken": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"clientSecret": "val"})).is_err()); + // ALL-CAPS variants + assert!(validate_provider_config(&serde_json::json!({"apiKEY": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"accessTOKEN": "val"})).is_err()); +} + +#[test] +fn split_config_key_handles_all_styles() { + assert_eq!(split_config_key("apiKey"), vec!["api", "key"]); + assert_eq!(split_config_key("access_token"), vec!["access", "token"]); + assert_eq!(split_config_key("keyboard"), vec!["keyboard"]); + assert_eq!(split_config_key("client-secret"), vec!["client", "secret"]); + // Acronym runs stay together + assert_eq!(split_config_key("APIKey"), vec!["api", "key"]); + assert_eq!(split_config_key("apiKEY"), vec!["api", "key"]); + assert_eq!(split_config_key("accessTOKEN"), vec!["access", "token"]); + assert_eq!(split_config_key("MyAPIKey"), vec!["my", "api", "key"]); +} + +#[test] +fn provider_filename_strips_the_windows_extension() { + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.exe"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.EXE"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.bat"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.CMD"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-my-provider"), + Some("my-provider") + ); + assert_eq!(provider_id_from_filename("other"), None); +} + +#[test] +fn resolve_provider_binary_rejects_invalid_ids() { + // Path traversal + assert!(resolve_provider_binary("../evil").is_err()); + // Empty + assert!(resolve_provider_binary("").is_err()); + // Uppercase + assert!(resolve_provider_binary("MyProvider").is_err()); + // Spaces + assert!(resolve_provider_binary("my provider").is_err()); + // Shell metacharacters + assert!(resolve_provider_binary("foo;rm -rf /").is_err()); + // Valid format but not on PATH — should fail with "not found" + assert!(resolve_provider_binary("nonexistent-test-id-12345").is_err()); +} + +#[test] +fn resolve_provider_binary_accepts_valid_id_format() { + // Valid ID format should pass validation. If the binary happens to + // exist on PATH, Ok is returned; otherwise Err contains "not found" + // (not "invalid provider ID"). Either outcome proves validation passed. + match resolve_provider_binary("zzz-nonexistent-test-provider") { + Ok(_) => {} // unlikely but fine — binary exists + Err(e) => assert!( + e.contains("not found"), + "expected 'not found' error, got: {e}" + ), + } +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 592a5cbbd9..1653371e7f 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -77,6 +77,10 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", "BUZZ_ACP_AGENT_OWNER", + // Remote lifetime/presence policy: user env must not disable the + // desktop/provider-owned bounds while the saved record still promises them. + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + "BUZZ_ACP_NO_PRESENCE", // Readiness handoff: desktop is the ONLY readiness source. A saved or // ambient env var must not be able to forge setup mode (NotReady) on a // Ready agent or suppress it (empty/stale payload) on a NotReady one. @@ -307,28 +311,5 @@ pub(crate) fn live_persona_env( .unwrap_or_default() } -/// Resolve live env_vars for a linked persona, loading personas from disk. -/// -/// Returns the persona's `env_vars` map if a persona_id is provided and found; -/// returns an empty map if no persona is linked. Errors if the linked persona -/// is missing. Used by the provider deploy path, which has no pre-loaded -/// persona slice. -pub(crate) fn resolve_persona_env( - app: &tauri::AppHandle, - persona_id: Option<&str>, -) -> Result, String> { - let Some(pid) = persona_id else { - return Ok(std::collections::BTreeMap::new()); - }; - let personas = super::load_personas(app).map_err(|e| { - format!("failed to load personas while resolving env for persona `{pid}`: {e}") - })?; - let persona = personas - .into_iter() - .find(|p| p.id == pid) - .ok_or_else(|| format!("persona `{pid}` not found while resolving env"))?; - Ok(persona.env_vars) -} - #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index cf57b12546..534c2e0835 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -158,6 +158,15 @@ fn reserved_keys_include_respond_to_gate() { } } +#[test] +fn reserved_keys_include_remote_lifetime_policy() { + for key in ["BUZZ_ACP_EXIT_AFTER_INACTIVITY", "BUZZ_ACP_NO_PRESENCE"] { + assert!(is_reserved_env_key(key), "{key} should be reserved"); + let agent = map(&[(key, "0")]); + assert!(merged_user_env(&BTreeMap::new(), &agent).is_empty()); + } +} + #[test] fn reserved_keys_include_code_execution_surface() { // The agent/MCP command + args are what Buzz actually exec's. diff --git a/desktop/src-tauri/src/mouse_nav.rs b/desktop/src-tauri/src/mouse_nav.rs new file mode 100644 index 0000000000..cd729f7304 --- /dev/null +++ b/desktop/src-tauri/src/mouse_nav.rs @@ -0,0 +1,142 @@ +//! Native macOS handler for back/forward navigation inputs (mouse X1/X2 +//! buttons and horizontal swipe gestures). +//! +//! WKWebView never delivers these inputs to the web content layer, so a DOM +//! listener can't see them (Safari itself handles them natively in the app +//! layer, not in the page). This module installs an NSEvent local monitor +//! and emits a `mouse-nav` Tauri event that `useBackForwardControls` acts on +//! in the frontend. Two event shapes map to navigation: +//! +//! - `otherMouseUp` with button 3/4 — mice whose X1/X2 buttons reach the app +//! as plain mouse buttons. +//! - `swipe` with a horizontal delta — AppKit's page-swipe gesture +//! (`swipeWithEvent:`): `deltaX > 0` is back, `deltaX < 0` is forward. +//! Sent by mouse drivers that synthesize a page-swipe gesture for the +//! back/forward buttons instead of button-3/4 events (the hardware this +//! was verified on). Stock Apple trackpad and Magic Mouse swipes arrive +//! as phased scroll-wheel events instead, which this module does not +//! handle — that path (`ScrollWheel` + `trackSwipeEventWithOptions:`, +//! which also needs scroll-edge detection) is a follow-up. +//! +//! Compiled macOS-only (via `tray_menu`). Non-macOS X1/X2 behavior is left +//! to the underlying webview. + +/// Maps an `otherMouseUp` button number to a navigation direction. +/// Buttons 3 and 4 are X1 (back) and X2 (forward). +fn direction_for_button(button: isize) -> Option<&'static str> { + match button { + 3 => Some("back"), + 4 => Some("forward"), + _ => None, + } +} + +/// Maps a swipe gesture's horizontal delta to a navigation direction, +/// following the AppKit `swipeWithEvent:` convention: positive is back, +/// negative is forward. A swipe arrives as a begin/end pair and only the +/// end event carries the direction, so `deltaX == 0` maps to `None`. +fn direction_for_swipe(delta_x: f64) -> Option<&'static str> { + if delta_x > 0.0 { + Some("back") + } else if delta_x < 0.0 { + Some("forward") + } else { + None + } +} + +pub fn init(app_handle: &tauri::AppHandle) { + use block2::RcBlock; + use objc2_app_kit::{NSEvent, NSEventMask, NSEventType}; + use tauri::Emitter; + + let app = app_handle.clone(); + let block = RcBlock::new(move |event: std::ptr::NonNull| -> *mut NSEvent { + // SAFETY: the monitor hands us a valid NSEvent for the matched mask. + let ev = unsafe { event.as_ref() }; + + match ev.r#type() { + NSEventType::OtherMouseUp => { + if let Some(direction) = direction_for_button(ev.buttonNumber()) { + // Emit to the main window explicitly instead of + // broadcasting (`emit`) so navigation stays scoped if + // multi-window ever lands. "main" is the default label + // for the single configured window (see deep_link.rs). + let _ = app.emit_to("main", "mouse-nav", direction); + // Swallow the release: nothing downstream should also act + // on it. The matching press deliberately passes through: + // WKWebView never delivers X1/X2 to the page, so the + // unmatched down is inert, and swallowing presses risks + // interfering with AppKit behaviors keyed off mouse-down. + return std::ptr::null_mut(); + } + } + NSEventType::Swipe => { + if let Some(direction) = direction_for_swipe(ev.deltaX()) { + let _ = app.emit_to("main", "mouse-nav", direction); + } + // Pass swipes through: nothing else navigates on them, and + // swallowing mid-gesture events could confuse AppKit's + // gesture tracking. + } + _ => {} + } + + event.as_ptr() + }); + + // SAFETY: the block returns either null or the pointer it was given, both + // valid per the monitor contract. The returned monitor token is + // deliberately leaked: the monitor must live for the whole app lifetime. + let monitor = unsafe { + NSEvent::addLocalMonitorForEventsMatchingMask_handler( + NSEventMask::OtherMouseUp | NSEventMask::Swipe, + &block, + ) + }; + + if let Some(monitor) = monitor { + std::mem::forget(monitor); + } else { + eprintln!("buzz-desktop: mouse-nav: failed to install NSEvent monitor"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn button_3_is_back() { + assert_eq!(direction_for_button(3), Some("back")); + } + + #[test] + fn button_4_is_forward() { + assert_eq!(direction_for_button(4), Some("forward")); + } + + #[test] + fn other_buttons_do_not_navigate() { + for button in [0, 1, 2, 5, -1] { + assert_eq!(direction_for_button(button), None); + } + } + + #[test] + fn positive_swipe_delta_is_back() { + assert_eq!(direction_for_swipe(1.0), Some("back")); + assert_eq!(direction_for_swipe(0.5), Some("back")); + } + + #[test] + fn negative_swipe_delta_is_forward() { + assert_eq!(direction_for_swipe(-1.0), Some("forward")); + assert_eq!(direction_for_swipe(-0.5), Some("forward")); + } + + #[test] + fn zero_delta_swipe_begin_event_is_ignored() { + assert_eq!(direction_for_swipe(0.0), None); + } +} diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index d733cd1f13..6dcef0ecf7 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -3,6 +3,11 @@ //! The webview owns the live agent-turn state. It sends the small display //! projection here so the native menu can remain useful while Buzz is hidden. +// Mouse back/forward (X1/X2 buttons and swipe) is also macOS-only native I/O; +// group it here so both platform-layer init paths share one call site in lib.rs. +#[path = "mouse_nav.rs"] +pub(crate) mod mouse_nav; + use std::{ sync::{Mutex, OnceLock}, time::{Duration, Instant}, @@ -488,6 +493,7 @@ pub fn init(app: &AppHandle) -> tauri::Result<()> { if let Err(error) = apply_activity_presentation(&tray, activities, recent_activities) { eprintln!("buzz-desktop: failed to apply tray menu presentation: {error}"); } + mouse_nav::init(app); Ok(()) } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 1ff8bd20ef..93326b9968 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -55,6 +55,7 @@ "externalBin": [ "binaries/buzz-acp", "binaries/buzz-agent", + "binaries/buzz-backend-kubernetes", "binaries/buzz-dev-mcp", "binaries/git-credential-nostr", "binaries/buzz" diff --git a/desktop/src-tauri/tauri.windows.conf.json b/desktop/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000000..abf09b5bfd --- /dev/null +++ b/desktop/src-tauri/tauri.windows.conf.json @@ -0,0 +1,11 @@ +{ + "bundle": { + "externalBin": [ + "binaries/buzz-acp", + "binaries/buzz-agent", + "binaries/buzz-dev-mcp", + "binaries/git-credential-nostr", + "binaries/buzz" + ] + } +} diff --git a/desktop/src/app/navigation/backForwardChords.test.mjs b/desktop/src/app/navigation/backForwardChords.test.mjs new file mode 100644 index 0000000000..cb60203d04 --- /dev/null +++ b/desktop/src/app/navigation/backForwardChords.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { matchBackForwardChord } from "./backForwardChords.ts"; + +function chord(overrides = {}) { + return { + altKey: false, + code: "", + ctrlKey: false, + key: "", + metaKey: false, + shiftKey: false, + ...overrides, + }; +} + +// ── macOS: ⌘[ / ⌘] ─────────────────────────────────────────────────────────── + +test("mac: ⌘[ matches back", () => { + assert.equal( + matchBackForwardChord(chord({ key: "[", metaKey: true }), true), + "back", + ); +}); + +test("mac: ⌘] matches forward", () => { + assert.equal( + matchBackForwardChord(chord({ key: "]", metaKey: true }), true), + "forward", + ); +}); + +test("mac: matches by code for non-US layouts", () => { + assert.equal( + matchBackForwardChord( + chord({ code: "BracketLeft", key: "Dead", metaKey: true }), + true, + ), + "back", + ); + assert.equal( + matchBackForwardChord( + chord({ code: "BracketRight", key: "Dead", metaKey: true }), + true, + ), + "forward", + ); +}); + +test("mac: requires meta", () => { + assert.equal(matchBackForwardChord(chord({ key: "[" }), true), null); +}); + +test("mac: rejects extra modifiers", () => { + for (const extra of [ + { altKey: true }, + { ctrlKey: true }, + { shiftKey: true }, + ]) { + assert.equal( + matchBackForwardChord(chord({ key: "[", metaKey: true, ...extra }), true), + null, + ); + } +}); + +test("mac: Alt+arrows do not match (that is the win/linux chord)", () => { + assert.equal( + matchBackForwardChord(chord({ altKey: true, key: "ArrowLeft" }), true), + null, + ); +}); + +test("mac: ⌘←/⌘→ never match — they are line start/end in text editing", () => { + // Deliberately unbound: editable targets must keep receiving ⌘←/⌘→ so + // line-start/line-end editing still works. Only ⌘[ / ⌘] navigate. + assert.equal( + matchBackForwardChord(chord({ key: "ArrowLeft", metaKey: true }), true), + null, + ); + assert.equal( + matchBackForwardChord(chord({ key: "ArrowRight", metaKey: true }), true), + null, + ); +}); + +// ── Windows/Linux: Alt+← / Alt+→ ───────────────────────────────────────────── + +test("win/linux: Alt+ArrowLeft matches back", () => { + assert.equal( + matchBackForwardChord(chord({ altKey: true, key: "ArrowLeft" }), false), + "back", + ); +}); + +test("win/linux: Alt+ArrowRight matches forward", () => { + assert.equal( + matchBackForwardChord(chord({ altKey: true, key: "ArrowRight" }), false), + "forward", + ); +}); + +test("win/linux: requires alt", () => { + assert.equal(matchBackForwardChord(chord({ key: "ArrowLeft" }), false), null); +}); + +test("win/linux: rejects extra modifiers", () => { + for (const extra of [ + { ctrlKey: true }, + { metaKey: true }, + { shiftKey: true }, + ]) { + assert.equal( + matchBackForwardChord( + chord({ altKey: true, key: "ArrowLeft", ...extra }), + false, + ), + null, + ); + } +}); + +test("win/linux: ⌘[ does not match (that is the mac chord)", () => { + assert.equal( + matchBackForwardChord(chord({ key: "[", metaKey: true }), false), + null, + ); +}); + +// ── Non-chord keys never match ──────────────────────────────────────────────── + +test("plain bracket / arrow keys without the platform modifier never match", () => { + for (const isMac of [true, false]) { + for (const key of ["[", "]", "ArrowLeft", "ArrowRight", "a", "Enter"]) { + assert.equal(matchBackForwardChord(chord({ key }), isMac), null); + } + } +}); diff --git a/desktop/src/app/navigation/backForwardChords.ts b/desktop/src/app/navigation/backForwardChords.ts new file mode 100644 index 0000000000..ac81f52ea7 --- /dev/null +++ b/desktop/src/app/navigation/backForwardChords.ts @@ -0,0 +1,51 @@ +/** + * Global back/forward navigation chords. + * + * macOS: ⌘[ / ⌘] — matching Safari, Chrome, Finder, and Slack. + * Windows/Linux: Alt+← / Alt+→ — matching browsers and Slack. + * + * Kept pure (no DOM access) so chord matching can be unit tested; the + * window listener wiring lives in `useBackForwardControls`. + */ + +export type BackForwardDirection = "back" | "forward"; + +export type BackForwardChordEvent = Pick< + KeyboardEvent, + "altKey" | "code" | "ctrlKey" | "key" | "metaKey" | "shiftKey" +>; + +export function matchBackForwardChord( + event: BackForwardChordEvent, + isMac: boolean, +): BackForwardDirection | null { + if (isMac) { + if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { + return null; + } + + if (event.key === "[" || event.code === "BracketLeft") { + return "back"; + } + + if (event.key === "]" || event.code === "BracketRight") { + return "forward"; + } + + return null; + } + + if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) { + return null; + } + + if (event.key === "ArrowLeft") { + return "back"; + } + + if (event.key === "ArrowRight") { + return "forward"; + } + + return null; +} diff --git a/desktop/src/app/navigation/useBackForwardControls.ts b/desktop/src/app/navigation/useBackForwardControls.ts index 7f7d84f6d7..e5513247d5 100644 --- a/desktop/src/app/navigation/useBackForwardControls.ts +++ b/desktop/src/app/navigation/useBackForwardControls.ts @@ -4,7 +4,10 @@ import { useRouter, useRouterState, } from "@tanstack/react-router"; +import { isTauri } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { matchBackForwardChord } from "@/app/navigation/backForwardChords"; import { isMacPlatform } from "@/shared/lib/platform"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; @@ -14,19 +17,6 @@ type RouterHistoryState = { key?: string; }; -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - - return ( - target.isContentEditable || - target.closest( - 'input, textarea, select, [contenteditable=""], [contenteditable="true"]', - ) !== null - ); -} - export function useBackForwardControls() { const router = useRouter(); const canGoBack = useCanGoBack(); @@ -81,42 +71,34 @@ export function useBackForwardControls() { }, [canGoForward, router.history]); const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => { - if (isEditableTarget(event.target)) { + // Note: the chords deliberately fire even when focus is inside an + // editable element. The composer autofocuses on every channel switch + // (`useComposerAutofocus`), so in steady state focus almost always + // lives in a contenteditable — an editable-target guard here made the + // shortcuts effectively dead (#3775). Safe because neither ⌘[ / ⌘] + // (macOS) nor Alt+←/→ (Windows/Linux) carry text-editing semantics, + // and the TipTap editor binds no conflicting shortcuts. + const direction = matchBackForwardChord(event, isMacPlatform()); + + if (direction === "back") { + event.preventDefault(); + goBack(); return; } - const isMac = isMacPlatform(); - const isBackShortcut = isMac - ? event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - (event.key === "[" || event.code === "BracketLeft") - : event.altKey && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - event.key === "ArrowLeft"; - const isForwardShortcut = isMac - ? event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - (event.key === "]" || event.code === "BracketRight") - : event.altKey && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - event.key === "ArrowRight"; - - if (isBackShortcut) { + if (direction === "forward") { event.preventDefault(); + goForward(); + } + }); + + const handleMouseNav = React.useEffectEvent((direction: string) => { + if (direction === "back") { goBack(); return; } - if (isForwardShortcut) { - event.preventDefault(); + if (direction === "forward") { goForward(); } }); @@ -128,6 +110,23 @@ export function useBackForwardControls() { }; }, []); + // macOS: WKWebView never delivers X1/X2 button events or horizontal + // swipe gestures to the DOM, so the native layer catches them + // (`mouse_nav.rs`) and forwards them as a Tauri event. + React.useEffect(() => { + if (!isTauri()) { + return; + } + + const unlistenPromise = listen("mouse-nav", (event) => { + handleMouseNav(event.payload); + }); + + return () => { + void unlistenPromise.then((unlisten) => unlisten()); + }; + }, []); + return { canGoBack, canGoForward, diff --git a/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs b/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs new file mode 100644 index 0000000000..0dfd80d444 --- /dev/null +++ b/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { coerceConfigValues } from "./ProviderConfigFields.tsx"; + +const schema = { + properties: { + inactivity_seconds: { type: "integer" }, + threshold: { type: "number" }, + label: { type: "string" }, + }, +}; + +describe("coerceConfigValues", () => { + it("omits cleared numeric fields without losing explicit zero", () => { + assert.deepEqual( + coerceConfigValues( + { inactivity_seconds: "", threshold: "0", label: "" }, + schema, + ), + { threshold: 0, label: "" }, + ); + }); + + it("preserves nonempty invalid numeric input for provider validation", () => { + assert.deepEqual( + coerceConfigValues({ inactivity_seconds: "not-a-number" }, schema), + { inactivity_seconds: "not-a-number" }, + ); + }); +}); diff --git a/desktop/src/features/agents/ui/ProviderConfigFields.tsx b/desktop/src/features/agents/ui/ProviderConfigFields.tsx index e922dd9c1a..031b0b7277 100644 --- a/desktop/src/features/agents/ui/ProviderConfigFields.tsx +++ b/desktop/src/features/agents/ui/ProviderConfigFields.tsx @@ -14,7 +14,8 @@ export function coerceConfigValues( for (const [key, value] of Object.entries(config)) { const prop = properties[key] as Record | undefined; const schemaType = prop?.type; - if ((schemaType === "integer" || schemaType === "number") && value !== "") { + if (schemaType === "integer" || schemaType === "number") { + if (value === "") continue; const num = Number(value); result[key] = Number.isNaN(num) ? value : num; } else if (schemaType === "boolean") { diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index f7a96cd568..18db55a50a 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -48,6 +48,37 @@ test("global back and forward move across channel routes", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("random"); }); +test("back/forward keyboard chords work while the composer has focus", async ({ + page, +}) => { + const backChord = process.platform === "darwin" ? "Meta+[" : "Alt+ArrowLeft"; + const forwardChord = + process.platform === "darwin" ? "Meta+]" : "Alt+ArrowRight"; + + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + // The composer autofocuses on channel switch; make the regression + // condition explicit by clicking into it. The chords must still fire + // from inside the contenteditable (#3775). + await page.getByTestId("message-input").click(); + await expect(page.getByTestId("message-input")).toBeFocused(); + + await page.keyboard.press(backChord); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.keyboard.press(forwardChord); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + // preventDefault kept the chord out of the editor — no stray characters. + await expect(page.getByTestId("message-input")).toHaveText(""); +}); + // FIXME: the forum post "Back to posts" header renders under the fixed top // chrome drag region, which intercepts the click. Pre-existing breakage — // this spec file was never registered in playwright.config.ts until now. diff --git a/docs/remote-agents.md b/docs/remote-agents.md new file mode 100644 index 0000000000..48ce85f5e5 --- /dev/null +++ b/docs/remote-agents.md @@ -0,0 +1,1770 @@ +# Remote Agents and Their Management: A Formal Specification + +`draft` + +## Abstract + +This document specifies the protocol by which Buzz Desktop delegates the +execution of a managed agent to a **remote substrate** — any compute +environment other than the local machine — through a **backend provider +binary**, and specifies the lifecycle contract every provider and every +remotely-run agent must satisfy. It covers three layers: + +1. **The provider protocol** — a zero-registration plugin contract between the + desktop and any executable named `buzz-backend-`: discovery, the `info` + and `deploy` operations, payload schema, and the security obligations on + both sides of that boundary. +2. **The remote lifecycle model** — how a remote agent is started, observed, + stopped, and reaped, given the deliberate design constraint that **the + desktop holds no management channel to the remote process**. Relay + presence is the sole status signal; shutdown is a relay message; liveness + bounds are enforced by the agent harness itself, not by the desktop. +3. **The Kubernetes binding** — the first conforming provider, + `buzz-backend-kubernetes`, which realizes the contract as a bare Pod + running the `sprig` image. + +We state five invariants — **identity fail-closed**, **no secrets in +configuration**, **presence-is-status**, **at-most-one-live-instance**, and +**intentional-termination-is-final** — and argue each from the protocol +rules. + +A scoping note that governs the whole document: the desktop is **one +launcher among many**. What makes a process a live Buzz agent is a keypair, +a NIP-OA auth tag, and a relay URL, handed as environment to the `buzz-acp` +harness; anything that can set that environment and exec the harness — a +bash script, a systemd unit, a CI job, or this document's provider protocol +— is a conforming launcher. §Launchers states which obligations bind whom. + +As with the git +specification (`git-on-object-storage.md`), naming the trust boundary is part +of the claim: a provider binary is arbitrary code that is handed an agent's +private key, and this document states exactly which properties hold *despite* +that, which hold only if the provider is honest, and which are explicitly the +user's acceptance. + +## Scope and Non-Goals + +This specification defines **management-plane behavior**: how agents get to a +substrate, how their state is observed, and how their lifetime is bounded. It +deliberately does **not** specify: + +- **Agent conversational behavior.** What the agent does with events is + governed by the ACP harness (`buzz-acp`) and the NIPs it implements + (NIP-OA, NIP-AE, NIP-AA, …), unchanged by where the harness runs. +- **Malicious-provider containment.** A provider binary receives the agent's + `nsec` by design — that is its job. The protocol *bounds the desktop's + exposure* (discovery-only resolution, output caps, secret redaction, + anti-secret config validation, an explicit UI trust warning) but cannot make + a hostile provider safe. Choosing to run a provider is a trust decision the + UI surfaces to the user; this document does not claim otherwise. +- **Substrate security.** Kubernetes RBAC, namespace isolation, and secret + encryption at rest are cluster-operator concerns. The Kubernetes binding + states its residual exposure (§K8s Secrets) rather than claiming isolation + it does not provide. +- **Liveness of the substrate.** That a pod schedules, that an image pulls, + that a cluster is reachable — empirical, not formal. The protocol specifies + only how such failures are *reported* (structured error, redacted, + fail-closed). + +## System Model + +Five principals: + +- **Desktop** `D` — the Buzz Desktop app. Holds the agent's identity (nsec in + the OS keyring), its configuration record, and the only UI. Trusted. +- **Provider** `P` — an executable `buzz-backend-` on `D`'s machine. + Invoked one process per operation: JSON request on stdin, JSON response on + stdout, exit code carrying one bit (zero = output trustworthy, nonzero = + failure regardless of stdout — §Invocation). **Untrusted by `D`** for everything except + the job it is explicitly given (deploying the agent, which requires the + key). All of `P`'s output is treated as hostile (§Provider Output). +- **Substrate** `S` — the remote compute environment `P` deploys into (a + Kubernetes cluster for the binding in this document). Opaque to `D`; + `D` never talks to `S`. +- **Agent** `A` — a `buzz-acp` harness process (plus the ACP agent under it) + running on `S`, holding the nsec it was given, connected to the relay. +- **Relay** `R` — the Buzz relay. The *only* channel that connects `D` to a + running `A`. Everything `D` knows about a live remote agent, it learns + from `R`. + +The defining constraint, stated as a design axiom: + +- **(M1) No management channel.** After a successful `deploy`, `D` holds no + **persistent management session** to `A` on `S`, and the desktop↔provider + protocol contains **no substrate API**: no status query, no exec, no log + fetch, no kill. All post-deploy observation and control flows through `R`: + status is relay presence (kind:20001), stop is a relay message + (`!shutdown`), and reconfiguration is a future re-deploy. The reduction M1 + buys is **protocol surface, not credential absence**: ambient substrate + credentials may well exist on `D`'s machine (the Kubernetes binding uses + the user's kubeconfig by design), and `D` can always re-invoke `P`. What + M1 guarantees is that nothing in *this protocol* — its persisted records, + its wire operations, its stored `backend_agent_id` — constitutes or + requires a channel to the substrate. The price is the staleness bounds in + §Presence. + +An agent's identity is a Nostr keypair. The **agent record** on `D` carries: +`name`, `relay_url`, the nsec (keyring-hydrated), the NIP-OA `auth` tag +attesting owner authorization, `agent_command`/`agent_args` (the ACP agent the +harness spawns — `goose`, `claude-agent-acp`, `codex-acp`, `buzz-agent`, or +any user-supplied command: this is the **configurable harness** requirement), +effective `system_prompt`/`model`/`provider`, timeout and parallelism knobs, +the `respond_to` gate, merged `env_vars`, and a `backend` discriminator: +`Local` or `Provider { id, config }`. + +### Launchers {#launchers} + +The five principals above describe the **provider-managed** launch path. +That path is not the definition of a remote agent, and this section states +the actual layering, because the obligations in this document do not all +bind at the same layer. Three contracts, nested: + +1. **The agent/harness contract — binds every launcher.** A live Buzz agent + is a `buzz-acp` process holding a keypair, a NIP-OA auth tag (or resolved + owner pubkey), and a relay URL, delivered as environment. The relay + authenticates the keypair and the auth tag — never the launcher. At this + layer live: fail-closed identity (I1's property, enforced wherever the + env is assembled), presence publication (I3), owner-verified `!shutdown`, + and **intentional clean exit is terminal to automatic supervisor + restart** (I5). A bash script that exports `BUZZ_PRIVATE_KEY`, + `BUZZ_RELAY_URL`, `BUZZ_AUTH_TAG` and execs the harness is a conforming + launcher at this layer — today, with no code change. +2. **The provider/deployer contract — binds provider-managed launches + only.** The two operations (`info`/`deploy`), the reconciliation loop, + and at-most-one-live-instance *per deploy scope* (I4). Hand-launched + agents sit outside it by construction: a launcher that bypasses the + provider protocol takes on the uniqueness discipline itself, exactly as + the cross-scope boundary in I4 already states. The protocol cannot and + does not promise a global singleton across unrelated launchers of the + same nsec. +3. **The binding policy — per substrate.** Fingerprints, fenced deletes and + 409 discrimination, restart-policy selection, the default idle bound, + and the grace budget are Kubernetes-binding policy (§The Kubernetes + Binding). A different substrate (the systemd/SSH deployer of PR #3449 is + the live example) conforms to layers 1–2 and writes its own layer 3; it + is not "non-conforming" for lacking pods. + +The desktop is therefore one launcher among many, and the provider protocol +is the *desktop's* door to substrates, not the only door. §Conformance +carries one checklist per layer. + +## Invariants + +The protocol maintains five invariants. Each is stated with the mechanism +that enforces it and the boundary beyond which it does not hold. + +A design obligation governs the whole list: **the complexity budget is +spent in this document, not in the code**. Every guarantee here was chosen +because its enforcing mechanism is one small, boring thing — a refusal at +payload construction (I1), a key-shape validator (I2), an ephemeral event +the agent already publishes (I3), a deterministic name plus one annotation +compare (I4), a timer that fires an existing shutdown channel (I5). The +same rule holds below: the deploy state machine is one loop over seven +ordered rows; the Secret scheme is "unique name, write first, reference +exactly"; GC is one label-select with two filters (annotation, same-clock +age). Where +a richer property would have demanded machinery — Leases, controllers, +ownerReferences, a management channel — the spec either found a +name-and-timestamp argument that makes the machinery unnecessary or +dropped the property and said so (§Non-Goals, M1). A conforming +implementation that is not small is evidence of a spec bug; report it as +one. + +- **(I1) Identity fail-closed.** No agent is ever launched with an empty or + missing private key: whatever assembles the harness environment — desktop, + provider, bash script — MUST refuse rather than launch identityless + (§Launchers, layer 1). In the provider path this is enforced at payload + construction: if keyring hydration left the nsec empty, + `build_deploy_payload` refuses (mirroring local spawn's + `spawn_key_refusal`), so no deploy request is ever emitted with an empty + key. Boundary: a provider that *discards* the key and launches an + identityless pod is a broken provider; the payload rule governs what `D` + sends, not what `P` does with it — which is why the property also binds + at `P`'s env assembly and at every non-provider launcher. + +- **(I2) No secrets in configuration.** `provider_config` — the persisted, + schema-rendered, UI-visible settings object — MUST NOT carry secrets. + Enforced by validation: flat object, scalar values only, ≤20 fields, ≤64KB, + and any key whose word-split contains `secret|password|token|key|credential` + is rejected. The match is against key *names*, so it is a lint with false + positives: a field like `ssh_key_path` holds a path, not a credential, + and is refused anyway — a provider author hitting this MUST rename the + field (e.g. `identity_file`), not weaken the validator; the rule's job is + making "put the secret in config" fail closed, and cheap false positives + are the accepted price. Secrets flow exclusively inside the `deploy` payload + (`private_key_nsec`, `auth_tag`, `env_vars`), which is never persisted by + `D` and never rendered. Corollary for providers: cluster credentials MUST + come from ambient substrate config (e.g. kubeconfig resolution), never from + `provider_config`. + +- **(I3) Presence is the status.** `D` derives a remote agent's live state + exclusively from relay presence events self-signed by the agent key: + `online`/`away`/`offline` (kind:20001, ephemeral, WS-published). The + deployment axis (`deployed`/`not_deployed`, from the stored + `backend_agent_id`) is bookkeeping, not liveness. Staleness bound: presence + can be wrong for the window between an abnormal agent death (SIGKILL, node + loss) and the relay's presence expiry — **180 seconds** + (`PRESENCE_TTL_SECS`, `buzz-pubsub/src/presence.rs:16`; the vision's + "a bounded wrong dot, never an indefinite one"), the accepted + cost of M1. The specific number is a relay-wide constant, not a + remote-agent choice: #3783 raised it from 90s to keep a three-heartbeat + expiry window after the desktop heartbeat moved to 60s. What I3 promises + is that the window is *bounded*, not its width. + The Kubernetes binding minimizes the *avoidable* part of that window by + sizing the termination grace period to the harness's full graceful-shutdown + path (§K8s Grace). Two consequences the bound imposes: (a) the harness's + presence-suppression knob, `BUZZ_ACP_NO_PRESENCE`, MUST join + `RESERVED_ENV_KEYS` — locally the knob is cosmetic (the process and UI + remain visible), but remotely M1 makes presence the *only* signal, so an + unreserved user env var would convert "wrong for ≤180s" into "wrong + indefinitely" and silently disarm the one bound in print; (b) presence is + scoped to a **community**: the relay derives community from its host, so + the deploy-time `relay_url` binds the body to one community for its whole + life, and a workspace observing through a different community sees the + agent offline while a deploy against it correctly no-ops — a known UX + boundary (the cross-scope boundary I4 admits, seen from the status side), + stated here so the two honest-but-conflicting readouts are diagnosable. + +- **(I4) At most one live instance per agent key per deployment scope.** + Within one provider's deployment scope (for Kubernetes: one namespace), + there is never more than one Running instance of a given agent pubkey. + Enforced by the deploy reconciliation loop (§Deploy State Machine): + deploy is keyed on the derived pubkey, a live instance maps to strict + no-op, and — because two deploys can race — create/delete conflicts MUST + converge (re-read and return the winner) rather than fail; deterministic + instance naming makes the substrate itself reject a second live instance. + Boundary: the protocol cannot prevent the same nsec being + deployed to two different scopes (two namespaces, two clusters, or remote + + local simultaneously) — the relay tolerates multiple connections per key, + and preventing this would require the global registry M1 forbids. Deploying + one key twice is user error with confusing-but-safe results (both instances + answer), not a safety violation. + +- **(I5) Intentional termination is final.** A remote agent **stops when + told, stays down when it stops, and is never silently resurrected**: an + instance whose harness is live terminates on owner `!shutdown` or when a + configured inactivity bound expires, and no supervisor restarts an + instance that exited *intentionally*. "Final" means **terminal to + automatic supervisor restart** — the owner may always issue a fresh + Start; that is resurrection working as designed, not a violation. + + **Lifetime is owner policy, not law.** The inactivity bound is the + harness's opt-in self-stop (§Auto-Stop, default disabled). An owner may + always choose **no inactivity bound** — declaring an indefinitely-lived + agent. How that choice is expressed is per-binding: the Kubernetes + binding opts in with a 2h schema default because a pod is metered + compute with nobody watching it, and spells "no bound" as its + `inactivity_seconds: 0` field (§Pod shape); a hand launcher simply never + sets the reaper env. Either way it is + a legitimate, explicit choice, not a conformance failure: the invariant + was never "every instance terminates" (a continuously active agent is + intentionally unbounded — that is the product); it is "termination, once + intended, sticks". + + **Restart policy follows lifetime policy.** The distinction that makes + indefinite agents safe is *intent vs accident*: dying on purpose + (`!shutdown`, inactivity reap) is final; dying by accident (node + eviction, OOM) may restart the body — same key, same agent, the + resurrection story working *for* the owner. Stated launcher-neutrally: + **if a supervisor exists, its restart policy MAY revive an abnormal + death and MUST NOT revive an intentional clean exit.** A launcher with + no supervisor at all — a hand-launched process on a VPS — satisfies + this vacuously: nothing restarts anything. How bounded vs indefinite + lifetime maps onto a concrete supervisor policy is binding policy + ([L3]), realized and documented by each binding — this binding's + mapping lives in §Pod shape; a systemd binding's in its unit + directives. Any revive-on-abnormal-death policy carries a universal + precondition: the supervisor can distinguish intent from accident only + if the harness formally promises *clean exit = exit code 0* on every + intentional path and nonzero otherwise, pinned by test. At `28ae6cd21` + that property is emergent, not defended (Known Defect 6); + restart-on-failure before the pinned contract is how a refactor + silently converts every clean stop into a restart loop with no failing + test. Ordering is normative: exit-code contract first, + restart-on-failure second — the identical seam in every supervisor that + offers the distinction. An always-restart policy remains non-conforming + at any layer: it resurrects after a *clean* exit, defeating + `!shutdown`. + + Enforcement: the self-stop lives *inside the harness* (the only place + that can see activity, per M1) (§Auto-Stop), and each binding makes it + effective on its substrate by requiring that harness exit terminates + the substrate's unit of execution (this binding's realization — the + harness as the container's signal-receiving process — is §Pod shape, + [L3]) and that any supervisor's restart policy respects intent as + above. + Boundaries: (a) the guarantee is conditional on a live harness event + loop — a wedged process that cannot run its reaper timer cannot reap + itself, and M1 means nothing else will (the mitigation is the substrate + operator's, e.g. a namespace-level TTL policy, out of scope per + §Non-Goals); (b) restart policy prevents resurrection, it does not prove + process exit; (c) I5 bounds *agent* lifetime, not substrate residue — + residue (in this binding, a Completed pod object) persists for + forensics until the next deploy's GC (§K8s GC). + +## Provider Protocol + +### Discovery + +`D` scans, in order: the directory containing the desktop executable, every +entry of `PATH`, and `~/.local/bin`, for executables named +`buzz-backend-`. The suffix after the prefix is the provider id and MUST +match `[a-z0-9][a-z0-9_-]*`. On Windows, an `.exe`/`.bat`/`.cmd` extension +MUST be stripped before the id is derived (see §Known Defects — as of +`28ae6cd21` it is not, so Windows providers probe but cannot deploy). First +hit per filename wins. Discovery executes nothing. + +**Shadowing and invalid candidates are diagnosable, not silent.** First-hit +wins is the right selection rule (it is kubectl's), but kubectl also warns +when a later-PATH plugin is shadowed, and Docker's CLI reports invalid +plugin candidates with reasons. Discovery MUST retain, and the UI and +deploy-time errors MUST be able to surface: the selected binary's full +path, any shadowed candidates for the same id (later-PATH duplicates), and +candidates rejected for malformed names. A deploy error that names which +binary ran answers the first question a user with two copies of +`buzz-backend-kubernetes` will ask. (At `28ae6cd21` discovery records only +the winning path — a desktop change alongside Known Defect 3's.) + +**Resolution rule.** Every subsequent operation resolves the provider id +against the *current* discovery set. A stored binary path on an agent record +is a cache, revalidated against both the current candidates and the recorded +id before every use. A record edit can therefore never redirect an operation +to a binary discovery would not have found. + +**Pre-secret negotiation gate (normative).** Declaring `protocol_version` +is worthless if nothing checks it before the nsec crosses the trust +boundary — and at `28ae6cd21` nothing does: `provider_deploy` invokes +`deploy` directly, so a stale UI-time probe (or a binary replaced on PATH +since that probe) can receive `private_key_nsec` unchecked (Known Defect +5). The deploy path MUST: resolve the provider id **once**; copy the +resolved candidate into a desktop-owned, private, non-writable **staging +file**, computing its digest during the copy; invoke `info` **on the +staged artifact**; validate an explicit, supported `protocol_version` +(§Info — absence is an error); invoke `deploy` on the **same staged +artifact**; delete it afterward. Staged bytes are what "same executable +identity" means here: the nsec goes to the exact bytes that answered +`info`. Path-plus-metadata comparison (dev/inode, size, mtime) is NOT an +acceptable substitute for this guarantee — unchanged metadata can miss an +in-place content rewrite, and a pathname can be swapped between the check +and the moment `Command` opens it, which is precisely the +check-then-exec race the gate exists to close. A UI-time probe result +MUST NOT satisfy this gate. If a platform makes staged execution +impossible for some provider (e.g. an executable that only runs from its +install location due to relative dependencies or signing constraints), +the implementation MUST NOT silently fall back to metadata and still +claim this gate: it degrades explicitly to *accidental-replacement +detection* (path + file-identity compare), surfaces that weaker level in +the deploy diagnostics, and the spec text for that platform carries the +narrower claim. Remembered digest-based approval (Terraform-lock style) +is a stronger follow-up, not a v1 requirement. + +### Invocation + +One process per operation. `D` spawns `P` with cwd = the agent workdir, +writes exactly one JSON object to stdin, closes stdin. `P` writes exactly one +JSON object to stdout and exits. Requirements on `D` (all implemented): + +- Bounded reads: stdout capped (1MB), stderr capped (64KB), no `read_to_end` + on pipes a daemonizing child could hold open; deadline polling with + `try_wait`. +- **Non-zero exit is failure even if stdout parsed.** Partial output from a + crashed operation is never trusted. +- `{"ok": false, "error": …}` is the in-band failure form. +- **Environment**: `P` inherits `D`'s environment. On macOS a GUI launch + means launchd's minimal PATH; providers whose substrate credentials invoke + helper binaries (kubeconfig `exec` plugins) MUST self-augment their PATH + (§K8s Auth) rather than assume a login shell. + +### Provider Output Is Untrusted + +Everything `P` emits — stderr, error strings, the response object — is +scrubbed before storage or display: every value from the request's +`env_vars` (longest-first, length ≥4) and every `nsec1…`/`sprt_tok_…` token +is redacted. Rationale: `P` legitimately holds secrets during deploy; `P` +echoing them (in a stack trace, a kubectl error, a debug line) must not +propagate them into `D`'s persisted `last_error` or logs. + +### `info` + +``` +request: {"op": "info", "request_id": ""} +response: {"ok": true, "name": str, "version": str, + "protocol_version": int, "description": str, + "config_schema": } +timeout: 10s +``` + +`version` is the provider's *software* version — useful in error reports, +useless for compatibility. `protocol_version` (this document: `1`) is the +wire-contract version, following the pattern Docker's CLI plugins +(`SchemaVersion`) and HashiCorp go-plugin (negotiated protocol version) both +converged on: the desktop rejects a provider whose `protocol_version` it +does not speak, with an error naming both versions and the binary path, +instead of failing later inside a half-understood `deploy`. A missing +`protocol_version` is an **error, not a presumed `1`**: there is no +deployed provider population to grandfather, and a gate that infers +compatibility for exactly the class of binary that never declared any +defeats its own pre-secret guarantee (§Discovery). Fail closed — it is +also simpler: no migration clock, no "major cycle" to define. + +`config_schema` drives the UI form: `properties[*].default` prefill, +string/number/boolean coercion, `required` gating. A provider MAY compute +defaults freshly per call (the Kubernetes binding generates a random +namespace default this way — §K8s Namespace). The schema's fields are +subject to I2 validation when the user's values come back in `deploy`. + +### `deploy` + +``` +request: {"op": "deploy", "request_id": "", + "agent": , "provider_config": {…}} +response: {"ok": true, "agent_id": str} +timeout: 600s +``` + +The agent payload (field list per +`commands/agents_deploy.rs: deploy_payload_json` at `28ae6cd21`; the +`launch` block is a normative addition not yet emitted — Known Defect 3): + +| field | meaning | +|---|---| +| `name` | display name | +| `relay_url` | concrete WS URL (workspace fallback materialized — the remote side has no workspace notion) | +| `private_key_nsec` | **the identity** (I1: never empty) | +| `auth_tag` | NIP-OA owner attestation | +| `agent_command`, `agent_args` | the ACP agent under the harness (configurable-harness support). At `28ae6cd21` these are raw record bytes — see Known Defect 3: the normative source is the resolved descriptor in `launch` | +| `system_prompt`, `model`, `provider` | effective values, live-persona-first resolution | +| `turn_timeout_seconds`, `idle_timeout_seconds`, `max_turn_duration_seconds` | harness timeout knobs | +| `parallelism` | concurrent-turn bound | +| `respond_to`, `respond_to_allowlist` | inbound author gate | +| `env_vars` | merged user env: global < persona < agent | +| `launch` | **normative addition** (§Launch data): the desktop-resolved launch contract — `command` (name, not path), normalized `args`, layered `env`, overridable `policy_env`, and `owner_pubkey` | + +**Reserved-key rule (normative for providers).** `D` strips +`BUZZ_PRIVATE_KEY`, `NOSTR_PRIVATE_KEY`, `BUZZ_AUTH_TAG`, `BUZZ_RELAY_URL`, +and the other reserved keys from `env_vars` before merge. A provider MUST +construct the agent environment's identity variables from the **top-level** +payload fields (`private_key_nsec` → `BUZZ_PRIVATE_KEY`/`NOSTR_PRIVATE_KEY`, +`auth_tag` → `BUZZ_AUTH_TAG`, `relay_url` → `BUZZ_RELAY_URL`); reading +`env_vars` for them yields an identityless agent. A related hardening `D` +performs is part of the contract's rationale: env keys are validated as +POSIX-shaped names before merge, because a key like `BUZZ_AUTH_TAG=x` +smuggled through `Command::env` would bypass the reserved-key strip +entirely. A provider materializing `env_vars` into a substrate object +(e.g. a Kubernetes Secret) MUST likewise never let a user-supplied key +collide with or reconstruct a reserved key. + +`agent_id` is `P`'s stable handle for the deployment (the Kubernetes binding +returns the pod name). `D` stores it as `backend_agent_id`; its presence is +the `deployed` axis of I3. + +**There is no `undeploy` op in v1.** Deletion of a remote agent from `D` +orphans the substrate objects; the UI therefore requires an explicit +`force_remote_delete` confirmation, and the binding's GC + I5 bound the +orphan's cost (the agent self-stops; the pod residue is reaped on the next +deploy of the same key, or manually). + +### Launch data (`launch`) {#launch-data} + +Reproducing the local spawn's launch semantics requires state only the +desktop can resolve: the runtime-metadata table (`model_env_var`, +`provider_env_var`, `provider_locked`, `default_env` — +`discovery.rs:75-207`), the six-layer env resolution, harness-definition +command/args fallback, team instructions, session title, the respond-to +gate's legacy owner fallback, and the mesh rewrite. A provider MUST NOT +reimplement that derivation — it would be a second copy of desktop runtime +discovery, drifting from the first. Instead the payload carries a typed +`launch` block that `D` resolves with **the same code paths as local +spawn**, and the provider applies it mechanically. + +``` +"launch": { + "command": str, // command NAME (e.g. "goose"), never a host path + "args": [str], // normalized args, definition fallback applied + "env": {str: str}, // layered env: baked → runtime metadata → + // definition → global → persona → agent + // (resolve_effective_harness_descriptor) + "policy_env": {str: str}, // overridable behavior defaults (tier 1, below): + // runtime default_env (e.g. GOOSE_MODE=auto), + // BUZZ_ACP_RELAY_OBSERVER, BUZZ_ACP_LAZY_POOL=true, + // BUZZ_ACP_SESSION_TITLE (resolved), + // BUZZ_ACP_TEAM_INSTRUCTIONS, BUZZ_ACP_MODEL, + // MCP_HOOK_SERVERS=* (mcp_hooks runtimes only) + "owner_pubkey": str | null // resolved workspace owner (hex) — legacy + // BUZZ_ACP_AGENT_OWNER fallback, non-secret +} +``` + +`launch.command`/`launch.args` come from +`resolve_effective_harness_descriptor` (`readiness.rs:125`) — the same +resolver local spawn uses — which fixes two silent divergences the raw +record fields carry: a persona-derived `agent_command` is a blank record +byte, and definition-provided `agent_args` are lost when the instance's own +args are empty. `launch.env` is that descriptor's layered env, which is +where per-runtime model/provider injection lives (`GOOSE_MODEL`/ +`GOOSE_PROVIDER` for goose; nothing for `provider_locked` runtimes like +Claude; `BUZZ_AGENT_MODEL`/`BUZZ_AGENT_PROVIDER` for buzz-agent). A fixed +`provider → BUZZ_AGENT_PROVIDER` mapping is wrong for three of the four +built-in runtimes and is why this block exists. + +**What `policy_env` carries — and deliberately does not.** Its irreducible +wire fields are exactly three scalars plus the metadata-derived defaults — +plus the four record-derived behavior knobs that would otherwise be +mis-tiered (below): + +- `BUZZ_ACP_TEAM_INSTRUCTIONS` — the only truly non-reconstructible policy + value: `effective_team_instructions` (`spawn_hash.rs:41-52`) needs the + desktop's `TeamRecord` store, which no pod can reach. +- `BUZZ_ACP_SESSION_TITLE` — sent **resolved** (`resolve_session_title`, + `runtime/metadata.rs:45`), not as its `display_name`/`name` inputs. The + resolution strips control characters, and that property transfers: an + interior NUL fails a local spawn at the env boundary, and would make the + Kubernetes apiserver reject the whole pod spec — a rename must degrade, + not turn into a deploy failure. +- `owner_pubkey` (block-level, not env) — the respond-to gate is otherwise + fully reconstructible from payload fields (`build_respond_to_env`, + `runtime.rs:380-421`); this is its one irreducible input. +- Runtime `default_env` (e.g. `GOOSE_MODE=auto`) — computed **from the + runtime metadata table only, unconditionally**. The local spawn applies + each default only `if std::env::var(key).is_err()` (`runtime.rs:733-737`) + — a test of the *desktop's own* ambient environment. That makes "the + resolved local env" not a pure function of the record; serializing it + verbatim would bake a host accident into the pod. Launch data MUST be + computed from record + config alone. +- `BUZZ_ACP_LAZY_POOL=true` — a **deliberate pick, not a transcription**: + the two local paths disagree (manual Start is eager, `runtime.rs:1001`; + launch restore is lazy, `restore.rs:333`, precisely to avoid "N idle + brains on every launch"). Remote pods take the lazy arm: an idle LLM pool + in a cluster is billable waste with no user watching it warm up. +- `MCP_HOOK_SERVERS=*` when the resolved runtime has `mcp_hooks` + (`runtime.rs:594-598`; buzz-agent only at `28ae6cd21`) — gates the + `_Stop`/`_PostCompact` hook tools. +- `BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_IDLE_TIMEOUT`, + `BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS` — resolved by the desktop + from the record's `system_prompt` / `idle_timeout_seconds` / + `max_turn_duration_seconds` / `parallelism` (each omitted when null, + matching the local spawn's conditional emission). These are **tier-1 by + local fact, not by choice**: the local spawn writes them before the user + env layer (`runtime.rs:716-729,763` vs `:860`) and none is in + `RESERVED_ENV_KEYS`, so a power user's env override beats them today. A + provider that independently mapped the top-level payload copies after + `launch.env` would invert that — the structured field silently defeating + an override that works locally — which is why the provider MUST NOT remap + them (§Entrypoint mapping table). + +`BUZZ_ACP_DEDUP` and `BUZZ_ACP_MULTIPLE_EVENT_HANDLING` are **deliberately +unset**: the local spawn writes `queue`/`steer` (`runtime.rs:730-731`), and +those are exactly the harness's clap defaults (`config.rs:344,356`) — a pod +that omits both is behaviorally identical, and adding rows for them would +imply a divergence that does not exist. `BUZZ_MANAGED_AGENT` is likewise +deliberately absent remotely: it brands local harness processes so the +desktop's orphan sweep and instance reaper can prove ownership by scanning +process env (`orphan_sweep.rs`, `instance_reaper.rs`) — there is no local +process to sweep. + +**Environment precedence (normative) — three tiers, later wins:** + +1. **Overridable behavior defaults** — `launch.policy_env`. These keys are + deliberately non-reserved (`env_vars.rs:54-57` says so outright: power + users may bypass the dedicated UI fields), and locally the user env is + written after them (`runtime.rs:860` and its comment). A policy-wins + order here would make remote agents ignore overrides local agents honor. +2. **User/layered env** — `launch.env`. User `env_vars` need no separate + slot: the descriptor's layering already merged them (global < persona < + agent), so a provider applies `launch.env` and MUST NOT re-merge the + legacy `env_vars` field on top. +3. **Authoritative** — unoverridable at every layer, written last and + backed by the reserved-key strip: the identity variables from top-level + payload fields (§Reserved-key rule), the respond-to gate values, + `BUZZ_ACP_AGENT_OWNER`, the inactivity bound, `BUZZ_ACP_MCP_COMMAND`, + and `BUZZ_MANAGED_AGENT_START_NONCE`. For the nonce, the provider MUST + set it to the attempt's **generation token** (§K8s Secrets): the harness + stamps it into every observer lifecycle frame (`buzz-acp/lib.rs:1501`), + so the Secret generation and the lifecycle correlator become one + identity instead of an empty string. + +**Host-resolved values MUST NOT be forwarded and MUST be re-derived +in-image.** The local spawn sets several variables to absolute paths on the +desktop's filesystem; forwarding them into a container is a guaranteed +failure. The provider/image re-derives: + +- the harness and agent binaries: `launch.command` is a *name*, resolved + against the image's own `PATH` (`BUZZ_ACP_AGENT_COMMAND`), and + `BUZZ_ACP_MCP_COMMAND=buzz-dev-mcp` likewise; +- `CLAUDE_CODE_EXECUTABLE` — a `resolve_command()` host path + (`configure_runtime_cli`, `runtime.rs:424-446`), same class as the + command paths: image-local resolution or unset; +- `PATH` itself (the desktop's augmented PATH is meaningless in the image); +- git credential/signing helper locations — the relay-URL *scoping* of the + credential config is normative (never a global helper), the helper *path* + is image-local (§Image); +- `BUZZ_ACP_SETUP_PAYLOAD` is desktop-computed readiness state and MUST NOT + appear in a remote pod. + +**Owner resolution (normative):** the provider MUST have either a non-null +`auth_tag` (→ `BUZZ_AUTH_TAG`) or a non-null `launch.owner_pubkey` +(→ `BUZZ_ACP_AGENT_OWNER`) before any mutation; if both are null it MUST +refuse the deploy. Without an owner the harness cannot match `!shutdown` +(`buzz-acp/src/lib.rs: resolve_agent_owner`, main-loop owner check) and the +agent answers its own stop command conversationally — §Stop would be +describing a mechanism that does not work. `BUZZ_ACP_AGENT_OWNER` is a +reserved key, so this value can only arrive as authoritative launch data, +never through user env. + +**Buzz shared compute (relay-mesh) is non-deployable, and this is forced, +not chosen.** The mesh rewrite resolves to an OpenAI-compatible transport at +`http://127.0.0.1:9337/v1` (`relay_mesh.rs: RELAY_MESH_API_BASE_URL`) — a +loopback proxy on the desktop. Serializing that policy into a pod points the +agent at its own localhost, where nothing listens. `D` already rejects +mesh-configured creates on non-local backends +(`agents.rs: normalize_relay_mesh`); the deploy path MUST equally fail +closed — before any mutation — when the effective provider resolves to +`relay-mesh`, rather than passing `relay-mesh` through as if it were a +runtime provider. Remote mesh transport is a possible v2 (an in-image mesh +client), not a v1 silent breakage. + +**The governing invariant:** a remote agent's environment differs from the +same record's local spawn **only where the substrate forces it** (paths, +PATH, readiness). Anyone adding a local behavior knob adds it to the shared +resolver, and both spawn paths inherit it; there are not two derivations to +keep in sync. + +### Deploy State Machine + +`start` on any non-Local agent unconditionally issues `deploy` — the desktop +does not track substrate state (M1). Deploy is therefore **not** "create": it +is *converge to at-most-one-live-instance* (I4), implemented as a +**reconciliation loop** keyed on the agent's identity within the provider's +scope. + +**Step 0 — derive and verify identity.** The payload carries the nsec, not +the pubkey. Before any substrate read or mutation, the provider MUST parse +`private_key_nsec` and derive the public key from it; a malformed or +undecodable key is an immediate in-band error. Every selector, name, and +comparison below uses the *derived* pubkey — never a caller-supplied one. + +**Step 1 — select and authenticate candidates.** Candidate objects are +selected by the (truncated) identity label, then each candidate's +**full-pubkey annotation MUST be compared against the derived pubkey** +before it is treated as belonging to this agent. Truncated selectors are +collision-*resistant*, not collision-*free*: the annotation check is what +makes them safe. An object whose annotation does not match MUST NOT be +no-op'd against, deleted, GC'd, or have its Secret touched; the provider +MUST either ignore it or fail with an explicit collision error. Only +annotation-verified objects proceed. + +**Auto-repair is fenced to Buzz-authored, positively identified residue +(normative).** The destructive rows below (delete residue, replace a +never-started body, GC a Secret) are legitimate *only because* every object +they touch carries positive **protocol ownership evidence** — and identity +evidence alone is not ownership evidence. The identity label, the +full-pubkey annotation, and the create-intent fingerprint prove "matches +our schema for this public identity"; all three are public, so any cluster +writer can reproduce them on an object this provider never created. Every +object this provider creates therefore also carries an explicit +management marker — `app.kubernetes.io/managed-by: buzz-backend-kubernetes` +plus a binding schema-version label (§Pod shape) — and **no destructive +repair or GC action fires unless the marker is present**, on top of the +annotation check and the UID+`resourceVersion` fence every delete already +requires. This is protocol evidence, not cryptographic proof: a cluster +writer can forge metadata by definition, and an actor with write access to +the namespace can already delete the pod outright — the marker's job is +making *accidental* schema collisions and third-party objects fail closed, +not defeating a hostile admin. The vision's rule that a never-started body +is substrate-operator residue survives with one qualifier: *Buzz-authored* +create-state (a Secret our provider wrote, a pod carrying our verified +annotations and marker) is the reconciler's to clear, because it is state +the user cannot reasonably clear themselves; *substrate* wreckage — +anything unowned, unmarked, unannotated, or ambiguously identified — still +fails closed to the operator. A provider that cannot positively identify an +object as its own output does not repair around it; it reports it. + +**Step 2 — reconcile.** Ordered rules, evaluated against the verified +observation; on any conflict, *re-enter from step 1* rather than fail: + +| observed | action | rationale | +|---|---|---| +| instance marked for deletion (`deletionTimestamp` set, any phase) | wait for actual disappearance, then re-enter | the user pressed Start and the old instance is unrecoverable; returning the dying instance's id records a success that evaporates. **Note: in Kubernetes there is no `Terminating` phase — a pod being gracefully deleted stays in phase `Running` for its whole grace period.** The deletion mark MUST be checked *before* phase, or this row is mistaken for the no-op row | +| no instance | create, then verify startup (below) | first deploy / after GC | +| terminated (Succeeded/Failed) | delete residue, wait for disappearance, re-enter (→ create) | the **normal restart path**: how a user revives a reaped or shut-down agent | +| live and **started** (harness container running) | **strict no-op; return existing `agent_id`** | Start must never silently kill a live agent mid-turn; "already running" is the honest answer, consistent with I3 | +| exists but **never started**, provably non-recoverable — referenced Secret confirmed absent (by a consistent read, below), or invalid image reference | delete (preconditioned, below), wait for disappearance, re-enter (→ create) | a pod whose harness never ran is not a live agent: nothing can be killed mid-turn (I3), it never held the identity (I4), auto-stop cannot bound it (I5's reaper lives in the harness), and no-op'ing it would return a permanently inert instance as success on every future Start. "Provably" means the provider verified the referenced object's absence or the spec-level defect itself — never a reason string alone | +| exists, **never started**, recoverable, **fingerprint matches** current desired create intent (below) — self-healable startup states: `Unschedulable` (scale-from-zero autoscaling), image pull / `ImagePullBackOff`, transient `CreateContainerConfigError` | observe until started or the operation deadline expires, then return the latest redacted condition — **never delete, on this call or any later one** | these states routinely self-heal — an autoscaler provisions the node, the pull retries, the kubelet re-resolves the Secret (it retries a never-created container regardless of `restartPolicy`). And recoverable-timeout MUST stay observational *across calls*: any finite pod-age threshold can collide with the cluster's own pod-age thresholds (Cluster Autoscaler's `--new-pod-scale-up-delay` / per-pod `pod-scale-up-delay` annotation — the FAQ's example is `"600s"`), and delete-recreate resets exactly the age the autoscaler keys on, converting a slow cold start into a livelock in which every individual decision is correct. A later deploy re-reads: started → strict no-op; still recoverable and same intent → observe under the new call's deadline without resetting pod age; provably non-recoverable → the non-recoverable row. Repeated **identical** Starts can therefore never delete anything, whatever the pod's age — a genuinely slow cluster persists until it heals or an operator acts, and M1 already makes substrate residue the operator's boundary | +| exists, **never started**, recoverable, **fingerprint differs or absent** — the recorded create-intent fingerprint does not match what *this* deploy would create | delete (preconditioned, below), wait for disappearance, re-enter (→ create) | this is not the same generation the user is waiting on — it is a pod built from configuration the user has since *changed*, and without this row the change can never materialize: the pod name is deterministic, GC only reaps terminated pods, Stop needs a live harness, I5's reaper lives in the harness, and there is no `undeploy` — so a never-started pod wedged by its own config (a `memory_request` no node satisfies, a quota-blocked namespace) would swallow every future edit while reporting only "startup not confirmed", indistinguishable from a slow cluster. Divergence is evidence, not a clock — but it has **two** sources, not one: a user config change, and a provider upgrade that moves the baked default image digest (§K8s image; the default is compile-time provider state, so upgrading the provider changes the computed intent with no user action). Both are deliberate: the second is the *only* escape from a wedge caused by a bad baked default (unpullable digest, wrong arch) — a fingerprint blind to the default resolution would hand that wedge back to exactly the population that cannot override `image`. The accepted cost is that a provider upgrade mid-cold-pull discards in-flight startup progress; neither source is clocked to anything the cluster keys on, so no threshold exists to collide with the autoscaler. A **started** pod is never touched by this row: live → strict no-op regardless of divergence (edits reach it via the documented next-generation consequence) | + +**Startup is part of create — phase is not readiness.** `Pending` (and even +`Running` at the pod level) does not mean the harness started: a pod can sit +in `ImagePullBackOff`, `CreateContainerConfigError` (e.g. a missing +`envFrom` Secret), or unschedulable `Pending` forever, and I5's inactivity +reaper cannot bound a harness that never began. Therefore `deploy` MUST NOT +report success at pod acceptance: it succeeds only when the harness +container has actually started (container `state.running`), bounded by the +operation deadline. On failure or deadline expiry it MUST return an in-band +error carrying the actionable condition (the container waiting `reason` / +pod condition), not a generic timeout. "Live" in the no-op row above means +**started**, for the same reason — this is the lesson ephemeral-runner +controllers learned upstream (inspect container state, not pod phase). +Classification MUST combine container state, pod conditions, +referenced-object existence, and the recorded create-intent fingerprint — +**reason strings alone are not a +stable fatality taxonomy**, and pod age is never one (age triggers nothing +destructive; see the recoverable rows and the controlled-view rule below). +In particular, `Unschedulable` is not fatal: a +scale-from-zero pod reports it while the autoscaler provisions capacity, +and the kubelet retries a container that never got a container status +regardless of `restartPolicy: Never` (`ShouldContainerBeRestarted` returns +true for a nil status *before* the restart-policy check — +`kubelet/container/helpers.go`), which is exactly why a briefly-missing +Secret self-heals. `restartPolicy: Never` suppresses restarting a container +that ran and died; it says nothing about one that never started. +A consequence to state plainly: once success includes container start, the +600s operation deadline **is** the cold-start budget — image pull on a +fresh node, scale-from-zero scheduling, all of it. But the deadline bounds +**how long one Start waits synchronously**, nothing more. Deadline expiry +on a still-progressing startup is reported as "startup not confirmed within +the deadline", and MUST NOT trigger cleanup or forced recycle — on this +call *or any later one* (the recoverable rows above — what replaces a +never-started pod is a *config change*, never a deadline): the next deploy's +reconciler observes whatever the startup became and takes the matching row, +preserving the pod's `creationTimestamp` for whatever cluster machinery +keys on it. Whether ten minutes fits the intended cluster class is a +product ruling, +not a correctness one. + +**One create attempt per call (normative).** The replacement rows above — +terminated, never-started provably broken, never-started divergent — exist +to clear residue from a *previous* life. Once a deploy call has created its +own pod, a classification that would replace that pod means the attempt this +call just made has already failed: the harness started, rejected its +configuration, and exited (the deterministic startup failure), or the pod +was proven broken. Re-running the identical create against the same cluster +inside the same call cannot produce a different outcome; what it produces is +a hot delete/mint/create cycle every poll interval for the whole operation +deadline, one immutable Secret per cycle (measured live: 107 Secrets in a +single 600s call), every one younger than §K8s GC's orphan age gate — a +bounded-call resource DoS and nsec-bearing-Secret amplifier. A binding MUST +NOT delete-recreate a pod created by the same deploy call: it MUST return +the in-band error carrying the latest condition (for a terminated container, +the exit code and reason — never the terminated `message`, which is +process-composed output under the same redaction rule as pull messages). +The failed attempt's pod and Secret are deliberately left in place: the pod +is terminated, so the *next* Start's preflight GC collects the pod and its +referenced Secret together before that call's own single attempt — retry is +thereby gated on fresh owner intent, and litter is bounded at one pod plus +one Secret per press, not per poll. This bounds attempts, not observation: +the recoverable rows still observe a slow startup for the full deadline, and +residue from previous lives is still replaced exactly once on the way to +this call's attempt. + +**Destructive decisions come from views you control — reads and writes +both (normative).** This is one rule with three instances, stated once so +nobody optimizes an instance away. §K8s GC's same-clock rule is the time +instance. The other two live here: + +- *Reads*: every read whose result can authorize a deletion — the + Secret-absence confirmation above, and the candidate list the GC pass + filters — MUST use most-recent semantics (`resourceVersion` **unset**, + a quorum read). `resourceVersion: "0"` is served from the watch cache, + which the Kubernetes API contract explicitly allows to be much older + than anything the client has already observed; a stale + Secret-absence read would delete a pod whose Secret exists and whose + container was about to start — the GC race again, arriving through read + consistency instead of a clock. +- *Writes*: a fresh read is necessary but not sufficient — the kubelet can + start the container between observation and delete. Every DELETE + authorized by a classification MUST carry `preconditions.uid` **and** + `preconditions.resourceVersion` from that exact observation + (`metav1.Preconditions` supports both), making the edge a + compare-and-delete. A failed precondition is neither an error nor + permission to retry the delete: re-enter from step 1 and classify the + object that exists now. The full-pubkey annotation check remains — the + precondition pins *when*, the annotation pins *whose*. + +**The 409 discriminator is `Status.reason`, never the status code +(normative).** Two rules in this section require *opposite* actions on +the same HTTP status: a failed delete precondition and a create-conflict +are **both 409** (`NewConflict` and `NewAlreadyExists` each carry +`Code: http.StatusConflict` — `apimachinery/pkg/api/errors/errors.go`). +The discriminator is the Kubernetes API `Status.reason` field: +`Conflict` → abandon the delete and re-enter from step 1; +`AlreadyExists` on create → the convergence rule below (clean up only the +losing attempt's Secret, re-read, adopt the winner). An implementation +that branches on the code alone will eventually take the adoption path on +a failed delete or vice versa. This does not contradict the +reason-strings warning above: API `Status.reason` is a machine-readable +contract token defined by `metav1.Status`; *container waiting* reasons +are kubelet-produced strings with no such contract — the spec distrusts +the latter, not the former. + +**Create-intent fingerprint (normative).** The divergence discriminator +in the never-started rows is a recorded annotation, +`buzz.block.xyz/create-intent`, written at pod create — the same shape as +the image-reference and pubkey annotations the pod already carries. Its +value is an **unkeyed SHA-256** over a canonical serialization of the +provider's **non-secret create-intent template**, computed *before* the +create call. The scope rule that makes a plain hash safe: the input +covers exactly the provider-controlled fields that can affect scheduling +or container creation — resolved image reference, resource +requests/limits, service account, PodSpec command/args, volumes/mounts, +security context, and the provider's other pod-shape knobs — and **never +Secret data or attempt identity**. Secret *values* cannot cause the +never-started wedge this discriminator exists to clear (scheduling reads +pod fields, not Secret values; a bad launch value produces a started +container that fails at the relay — a different row), so hashing them +buys nothing and a plain hash over low-entropy secrets published in a +world-readable annotation would be a dictionary oracle; excluding them +removes the oracle and with it any need for an HMAC key or nsec-derived +key material. Two normalization requirements, or every attempt diverges +by construction: the per-attempt Secret *name* in `envFrom` MUST be +replaced by a fixed placeholder (or the pre-binding template serialized +instead of the concrete PodSpec), and API metadata / server- and +admission-produced output (UID, `resourceVersion`, timestamps, defaulted +fields, the fingerprint annotation itself) is excluded structurally — +the serializer never sees it, an invariant checkable by inspection. +Comparison is always recorded-annotation vs freshly-computed intent, +**never** a diff against the live pod spec: admission defaulting and +mutation would make every pod look divergent, which is why the +fingerprint is computed pre-create. A missing referenced Secret stays +handled by the most-recent absence check (the non-recoverable row), not +by fingerprint divergence; and divergence authorizes deletion only +through the never-started recoverable row — a started pod is strict +no-op whatever its fingerprint says. + +**No-op means zero mutation.** The live-instance row MUST NOT replace or +patch the Secret, patch metadata, or delete anything belonging to the +observed live generation. Configuration and environment edits apply only to +the *next* fresh generation (see the documented consequence below). + +**Conflicts converge, never fail.** Two provider processes can concurrently +observe "no instance" or "terminated" — the deterministic instance name +prevents two live instances, but one caller loses the race. The provider +MUST treat create-conflict (already exists) by re-reading and, if the winner +is an annotation-verified live instance, returning it as the no-op row +would — cleaning up only its own losing attempt's residue, never the +winner's (the Kubernetes binding makes this concrete via per-attempt Secret +names, §K8s Secrets); it MUST treat delete-not-found as success; and it +MUST loop until a +stable outcome or the operation deadline (600s) expires. One deliberate +asymmetry: the **create loser does not apply the fingerprint-divergence +row to the pod that just beat it**, even when the winner's fingerprint +differs from its own intent — it adopts or reconciles the elected winner. +Two contenders with different payloads would otherwise ping-pong deletes +through the conflict path. A *subsequent* deploy that walks in and +observes that never-started divergent winner replaces it normally. +Without this rule, +"two deploys return an `agent_id`" (the idempotency claim below) is false +under concurrency. + +**Documented consequence.** Because live → no-op, configuration edits to a +running remote agent do not take effect until it next exits (unlike local +agents, which re-resolve on every spawn). This is an accepted v1 tradeoff; +a deliberate "recycle" affordance (stop-then-start) is the v2 path to +immediate application. [DECISION E, ruled: per-binding policy — this +binding keeps no-op; the universal property is that no sequence of Starts +yields two live instances in one scope.] Note the asymmetry is deliberate and points the +right way: an edit *cannot* reach a started pod until it exits, but it +*can* reach a never-started one immediately (fingerprint divergence) — +the never-started pod is the one the user is editing *because* it did not +start. + +Idempotency in the protocol sense: any number of concurrent or sequential +`deploy`s with the same payload converge to one live instance, and every +non-erroring call returns an `agent_id` naming it; no sequence of `deploy`s +can yield two live instances in one scope. + +### Stop and Delete + +- **Stop** is not a provider operation. `D` publishes `!shutdown` mentioning + the agent on `R`; the harness verifies the sender is the owner and exits + through its graceful path: agent-pool shutdown, drain of in-flight turns, + publish presence `offline`, close relay connection. **The spec does not + derive an upper bound for this path from its segment timeouts, because + review proved that arithmetic wrong twice**: the visible constants (30s + drain, 2s presence, 5s relay close) omit terms that are *variable*, not + constant — at `28ae6cd21` the post-drain reap segment + (late-arriving reap `lib.rs:2664`, idle-slot reap loop `:2670`, respawn + drain `:2684-2688`) runs *outside* the 30s drain timeout (opened at + `:2636`, closed at `:2657`) and serially awaits a 5s post-SIGKILL wait + per occupied pool slot (`acp.rs:436`). **That segment alone can reach + `30 + 5×parallelism + 7` — ~87s at the desktop's default parallelism + of 10** (`DEFAULT_AGENT_PARALLELISM`, `types.rs:814`; lowered from 24 by + #3038), ~197s at the harness cap of 32 (`config.rs:293`) — already + exceeding a 60s grace. And it is a *lower* bound on the tail, not the + worst case: the same path runs earlier segments before the prompt drain + even opens — a separate 30s wake-task drain (`:2612`) followed by + serial shutdown of any awakened pools (`:2620-2624`), whose per-slot + `acp.shutdown()` loop (`:3747-3751`) has no timeout of its own. The + total tail is not bounded by today's segment timeouts at all. + The requirement is therefore stated as a budget, not a sum (Known + Defect 7): **the harness MUST bound its total shutdown tail — every + post-signal segment, including per-slot reaping — under one shared + deadline no greater than the declared grace budget**, and the budget + MUST include a **reserved finalization slice** held back for presence + `offline` publish and relay close, **no smaller than those finalizers' + declared bounds — currently 2s + 5s = 7s** — which child cleanup may + never consume: child reaping degrades first (skip remaining per-slot + waits, force-kill), because a shared deadline without the reservation + can legally spend all 60s reaping children and hit SIGKILL before the + one action the grace period exists to protect. The binding declares the + budget (§K8s Grace: 60s); anyone re-deriving "~37s" from the segment + constants is reading numbers without their variables. The desktop's + local stop command rejects remote agents. +- **Delete** with a live `backend_agent_id` requires `force_remote_delete: + true` from the UI's orphan-warning confirmation — a buggy IPC caller + cannot silently orphan substrate objects. + +### Auto-Stop (Inactivity Self-Termination) + +I5's enforcement point. A new harness knob: + +``` +--exit-after-inactivity / BUZZ_ACP_EXIT_AFTER_INACTIVITY +``` + +- **Default 0 = disabled.** The flag ships in the harness every *local* + agent also runs; a reaper bug must not be able to kill a laptop agent. + Remote providers opt in (the Kubernetes binding's `inactivity_seconds` + config field, schema default 7200 = 2h, feeds this env var directly). + **`inactivity_seconds: 0` is likewise a legal, blessed value meaning "no + inactivity bound"** — the explicit opt-in to an indefinitely-lived agent + (I5's lifetime-is-policy rule); it is not a misconfiguration and MUST NOT + be rejected by provider-side validation. +- **"Inactivity" is defined as**: no events dispatched to the agent and no + turns in flight. Raw relay traffic does not count — an agent lurking in a + busy channel it never answers is exactly the waste this bounds. +- **Mechanism**: on expiry of the bound, the harness fires the same shutdown + channel `!shutdown` uses — so inactivity exit gets in-flight drain, + presence→offline, and graceful relay close identically to an owner stop. + **The expiry check MUST NOT depend on pool readiness.** This is a design + constraint learned by inspection, not a transcription: the harness's + existing 30s maintenance tick is gated on `pool_ready` (`lib.rs:1743`), + which under `lazy_pool` starts false (`:1320`) and flips true only on a + wake (`:2570`) — and wakes require pending work (`pool_lifecycle.rs:42`). + A reaper riding that tick composes with the mandated + `BUZZ_ACP_LAZY_POOL=true` (§Launch data) into a deadlock in I5's single + most important case: a never-mentioned lazy pod never runs the tick, so + the idle agent the reaper exists to kill is exactly the one it can never + evaluate. The reaper therefore runs on its own timer, independent of pool + state (an idle-pool check needs no pool). Check granularity makes the + effective bound `t ∈ [T, T+interval)`, immaterial at T=7200. +- **Reserved keys**: `BUZZ_ACP_EXIT_AFTER_INACTIVITY` MUST join + `RESERVED_ENV_KEYS` (`env_vars.rs`) when it lands — it is tier-3 + authoritative (§Launch data), and without reservation a user env var + could disable the reaper and reopen unbounded lifetime through the front + door. `BUZZ_ACP_NO_PRESENCE` (`config.rs:378`) MUST join in the same + change, for the same shape of reason at I3 instead of I5: unreserved, it + lets user env silently defeat the 180s presence bound (I3). One knob + guards "knows when to leave", the other "you can see that it left"; + both are promises users must not be able to un-make by typo. +- Distinctness note: this is a **fourth** timeout concept, deliberately named + away from the existing three (`--idle-timeout` = per-turn ACP wire silence, + 900s; `turn_timeout`; `max_turn_duration` = 7200s — numerically equal to + the default inactivity bound and semantically unrelated). Sharing a flag or + env name with any of them is how the bug ships. + +The harness exiting MUST terminate the substrate's unit of execution, and — +equally load-bearing — the substrate's termination signal MUST reach the +harness process itself; any wrapper MUST forward it. A wrapper that runs +the harness as a child without forwarding signals silently voids both I5's +substrate half *and* the graceful-shutdown budget: the termination signal +lands on the wrapper, the harness never learns to shut down, and the +force-kill leaves presence stale-online — exactly the staleness window the +grace period exists to close. This binding's realization — the harness as +the container's signal-receiving process (PID 1 or the signal target) — is +§K8s Entrypoint's `exec` rule and §Pod shape ([L3], L1 item 3 for the +universal form). +With the supervisor policy that matches the lifetime policy (this +binding's [L3] mapping — bounded → `Never`, indefinite → `OnFailure` +after both prerequisites, §Pod shape; the universal rule is I5's), +harness exit completes the pod on every intentional path — turning +agent-level I5 into substrate-level I5. + +## The Kubernetes Binding (`buzz-backend-kubernetes`) + +The first conforming provider: a Rust crate in `block/buzz`, distributed as a +standalone binary. Everything above is the contract; this section is its +realization. + +### Cluster auth {#k8s-auth} + +Standard kubeconfig resolution (`$KUBECONFIG` → `~/.kube/config`) via +`kube-rs`. `provider_config` carries **`context`** and **`namespace`** only +(I2: credentials never transit config). Because kubeconfigs at Block +near-universally use `exec` credential plugins (`aws eks get-token`, +`gke-gcloud-auth-plugin`) that resolve via PATH, and the provider inherits a +Finder-launched desktop's minimal PATH, the provider MUST prepend +`/opt/homebrew/bin`, `/usr/local/bin`, and `~/.local/bin` to its own PATH +before building the client, and on exec-plugin failure MUST name the missing +plugin binary in the error rather than surfacing a kube-rs stack. + +### Namespace {#k8s-namespace} + +One stable namespace per user-visible choice; the provider emits a freshly +generated `buzz-agents-` as the `namespace` field's schema *default* +on every `info` call, so the UI prefills a visible, editable random name with +zero UI changes ("random default" satisfied at the schema layer). If the +namespace does not exist the provider attempts to create it; on RBAC denial +it MUST fail with the literal `kubectl create namespace ` command to +run — it MUST NOT fall back to `default`. + +### Image + +`ghcr.io/block/buzz-sprig`: Alpine base + `bash` (required by the dev-MCP +shell tool) + `git` + CA certificates + the static musl `sprig` multicall +binary with its personality links (`buzz-acp`, `buzz-agent`, `buzz-dev-mcp`, +`rg`, `tree`, `buzz`, `git-credential-nostr`, `git-sign-nostr`) + a baked +system gitconfig wiring the nostr signing and credential helpers. The baked +credential-helper config MUST be scoped to the relay's git URL — mirroring +the local spawn's `credential./git.helper` scoping — never a +global `credential.helper`: a global nostr helper would answer for every +remote, including github.com. ~15–25MB; +not FROM-scratch (bash and git preclude it). Sprig-only: alternate-harness +dependencies (node for Claude Code / Codex) come via the `image` override +field, not a fatter default. Tagging follows the relay image's matrix — +`sha-` on main, semver on `sprig-v*` tags (the sprig tarball's +`+git.` version string is not a legal Docker tag). **The default image +reference MUST be pinned by digest, not tag**: the provider bakes, at +compile time, the multi-arch manifest digest of the image built from its +own commit and defaults `image` to +`ghcr.io/block/buzz-sprig@sha256:` — a `sha-` *tag* +is traceable but still movable (registry tags are mutable pointers; +Kubernetes distinguishes movable tags from immutable digests for exactly +this reason), and the object holding it runs with an nsec. The provider +records the reference it used in a pod annotation, and rejects `:latest`. +User `image` overrides accept tag, digest, or full custom registry +reference — visibly the user's trust decision, with the resolved image ID +recorded in the same annotation for post-hoc attribution. +**An image override MUST contain the runtime ABI** — the `buzz-acp` +entrypoint and everything §Entrypoint and launch ABI requires — not merely +alternate-harness dependencies. A conforming custom image is "buzz-sprig +plus your tools", never "your tools instead". + +### Entrypoint and launch ABI {#k8s-entrypoint} + +Two conforming implementations must produce interchangeable pods, so the +launch contract is normative. + +**Entrypoint.** The container runs the harness as its signal-receiving +process. Sprig is a multicall binary with no supervisor personality — +nothing reaps children or forwards signals — so the entrypoint MUST end in +`exec`: + +```bash +#!/bin/bash +set -e +# nest scaffolding, if DECISION A lands, goes here +exec buzz-acp # exec, not a call — buzz-acp must be PID 1 +``` + +`bash -c "setup && buzz-acp"` (no `exec`) is non-conforming: bash becomes +PID 1, and a PID-1 bash with no trap never delivers SIGTERM to the harness +(PID 1 receives kernel-level default-handler signal immunity), so the pod +rides out the entire grace period and is SIGKILLed with presence still +online — voiding I5's substrate half and the very staleness window +`terminationGracePeriodSeconds: 60` was sized to close. The entrypoint +shape and the grace period are one requirement, not two. + +**Payload → environment mapping.** The provider builds the pod environment +(via the per-agent Secret, §K8s Secrets) by applying the §Launch data +three-tier precedence — `launch.policy_env` (overridable defaults), then +`launch.env` (user/layered), then the authoritative tier from top-level +fields per the reserved-key rule. Only the +non-`launch` scalars and the substrate-forced re-derivations are mapped +individually: + +| source | env var | +|---|---| +| `relay_url` | `BUZZ_RELAY_URL` | +| `private_key_nsec` | `BUZZ_PRIVATE_KEY` and `NOSTR_PRIVATE_KEY` (the git helpers read the latter) | +| `auth_tag` | `BUZZ_AUTH_TAG` (omitted when null; then `launch.owner_pubkey` → `BUZZ_ACP_AGENT_OWNER` is REQUIRED — §Launch data owner rule) | +| `launch.command` | `BUZZ_ACP_AGENT_COMMAND` — the *name*, resolved against the image's own PATH; never a forwarded host path | +| `launch.args` | `BUZZ_ACP_AGENT_ARGS`, comma-joined | +| `launch.env`, `launch.policy_env` | verbatim, at their precedence tiers | +| generation token (§K8s Secrets) | `BUZZ_MANAGED_AGENT_START_NONCE` — the lifecycle-frame correlator and the Secret generation are one identity (§Launch data tier 3) | +| `system_prompt`, `idle_timeout_seconds`, `max_turn_duration_seconds`, `parallelism` | **not mapped by the provider** — the desktop resolves these into `launch.policy_env` (`BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_IDLE_TIMEOUT`, `BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS`), because they are tier-1 behavior knobs: locally they are written *before* the user env layer and none is reserved (`runtime.rs:716-729,763` vs `:860`; `env_vars.rs:54-57`), so user env beats them. A provider that mapped the top-level copies after `launch.env` would silently defeat an override that works locally. The top-level fields remain as display/bookkeeping inputs only | +| `turn_timeout_seconds` | not mapped — deprecated upstream and ignored; the local spawn also does not emit it | +| `respond_to` | `BUZZ_ACP_RESPOND_TO` | +| `respond_to_allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST`, comma-joined | +| — | `BUZZ_ACP_MCP_COMMAND=buzz-dev-mcp` (image-local; the dev-MCP requirement) | +| `provider_config.inactivity_seconds` | `BUZZ_ACP_EXIT_AFTER_INACTIVITY` (schema default 7200; the I5 opt-in, §Auto-Stop — the config field and this env var are one knob, not two) | + +The top-level `model`/`provider` payload fields are display/bookkeeping +inputs; the *environment* consequence of model and provider selection +(per-runtime vars, `provider_locked` suppression, `BUZZ_ACP_MODEL`) arrives +resolved inside `launch.env`/`launch.policy_env`. A provider MUST NOT map +`provider` to any env var itself — that mapping is per-runtime and lives in +the desktop's resolver (§Launch data). + +**Encoding honesty note.** `BUZZ_ACP_AGENT_ARGS` is comma-delimited by the +harness's CLI parser, and the desktop's *local* spawn performs the same +comma-join — an argument containing a comma is unrepresentable in both +paths. This is a harness interface limitation the binding inherits and +matches, not one it introduces; a provider MUST NOT invent a private +escaping scheme the harness would not decode. + +**Working directory.** `HOME` is set to a writable path backed by the +workspace `emptyDir` (e.g. `/home/agent`), and the harness runs with cwd = +`HOME` — mirroring the local spawn's agent-workdir convention. The baked +system gitconfig references the nostr helpers by absolute path so it works +regardless of `HOME`. + +### Pod shape + +- **Bare Pod; `restartPolicy` follows lifetime policy (I5).** No Job, no + controller — controller-grade restart machinery (`Restart=always`-shaped) + would resurrect what `!shutdown` and auto-stop terminate, violating I5. + Within the bare pod, the policy is selected from `inactivity_seconds`: + - **Bounded lifetime (`inactivity_seconds > 0`, the default): `Never`.** + The reaper's clean exit must complete the pod; any restart would undo + the reap. Accidental death is handled by *intent*, not machinery: + eviction → presence `offline` (I3) → user hits Start → the + reconciler's terminated arm re-creates. That sequence is + rescheduling-after-accident gated on a fresh owner intent — apt for an + agent whose owner already accepted "not running" as its default state. + - **Indefinite lifetime (`inactivity_seconds: 0`): `OnFailure`** — once + the harness exit-code contract is pinned (I5 ordering rule; until + then the provider MUST refuse the combination rather than ship + `OnFailure` against an undefended exit convention). `OnFailure` + restarts the *in-place* abnormal deaths — process crash, container + OOM-kill — and honors the intentional ones (clean exit completes the + pod): I5's intent-vs-accident distinction, realized. **Second + prerequisite — reconciler classification:** `OnFailure` introduces a + pod state the deploy state machine's rows do not cover — a + crash-looping harness sits in phase `Running` with + `state.waiting{reason: CrashLoopBackOff}`, `restartCount > 0`: not + deletion-marked, not terminated (the kubelet keeps restarting it), + not "live and started" (`state.running` is false), and not + never-started (it started, repeatedly) — and it fails the startup + success criterion while the kubelet is actively reviving it. Before + the binding ships `OnFailure`, the state machine MUST gain a + crash-loop classification row and the started-criterion's treatment + of `restartCount > 0` MUST be specified; the exit-code contract alone + is *not* the green light. **Honest + limit:** `restartPolicy` is + kubelet-level and cannot survive *node-level* loss — a drain or + API-initiated eviction deletes a bare pod outright, and no + restart policy reschedules a deleted pod. Full "continuous need" + across node loss requires controller-grade machinery this binding + deliberately does not use in v1 (the same machinery I5 distrusts); + the v1 promise for indefinite agents is restart-on-crash, with + node loss surfacing as presence `offline` awaiting a fresh Start. +- **Naming/labeling — the exact contract** (63-char label-value limit; a hex + pubkey is 64 chars, one over): + - pod name: `buzz-agent-` — also the returned + `agent_id` + - label `buzz.block.xyz/agent-pubkey: ` — the selector key + for reconciliation and GC. 128 bits is collision-*resistant*, not + collision-free, which is why the annotation check below is normative, + not decorative + - label `app.kubernetes.io/managed-by: buzz-backend-kubernetes` and label + `buzz.block.xyz/binding-version: ` — the **management + marker** (§Deploy State Machine auto-repair fence): present on every + pod and Secret this provider creates, and **required before any + destructive repair or GC action**. Identity labels/annotations prove + identity; the marker asserts protocol ownership — without it, an object + that merely matches our schema fails closed to the operator + - annotation `buzz.block.xyz/agent-pubkey-full: ` — + **load-bearing**: per §Deploy State Machine step 1, every label-selected + object's annotation MUST equal the derived pubkey before the provider + no-ops against it, deletes it, mutates its Secret, or returns its name + - annotation `buzz.block.xyz/create-intent: ` — the + recorded create intent (§Deploy State Machine, create-intent + fingerprint), written at pod create; the divergence discriminator for + never-started pods + - Secret name: `buzz-agent--`, where `` is a random + per-create-attempt **generation token** — unique, never reused, carrying + the same labels (identity + management marker) and annotation. The + pod's `envFrom` references this exact + Secret name. Deterministic pod name + unique Secret name is what makes + payload and Secret atomic at the pod-spec boundary (§K8s Secrets) +- **Deletion semantics the reconciler must respect.** A Kubernetes `DELETE` + returns success immediately while the object still exists; the name stays + taken until the kubelet finishes the grace period. Two consequences: + (a) a pod being gracefully deleted has `deletionTimestamp` set but remains + in phase `Running` — the reconciler MUST check the deletion mark before + phase (there is no `Terminating` phase to match on); (b) after deleting a + live pod, a naive immediate create gets AlreadyExists for up to the full + grace period — the reconciler MUST poll for actual disappearance (GET → + 404) before creating. The delete call MUST use the object's own grace + period (kube-rs: `DeleteParams { grace_period_seconds: None, .. }`); the + tempting shortcut of passing `0` to skip the poll is a **force-kill** that + discards the 60s shutdown grace pinned below — the poll is mandatory + precisely because the fast path is wrong. For *terminal* + (Succeeded/Failed) pods — and for **unscheduled** pods (no assigned node: + unschedulable or quota-blocked `Pending`, a state users hit while setting + up a namespace) — the apiserver + zeroes the grace period and deletes immediately, so the normal restart + path needs no meaningful wait — do not add a fixed sleep, and do not use + zero-grace cleanup as a reason to skip the poll in the live-pod arm. +- **`terminationGracePeriodSeconds: 60` — a declared budget the harness + MUST honor, not a sum the spec derived.** Kubernetes' default 30s grace + would SIGKILL the harness mid-drain, leaving presence stale-online — the + avoidable half of I3's staleness window — so the binding declares 60s. + But the shutdown tail is *variable*, not constant (§Stop: the post-drain + reap segment alone reaches ~87s at default parallelism at `28ae6cd21`, + and earlier untimed segments precede it — the total is not bounded by + today's segment timeouts), so no fixed grace can be proven + sufficient by adding segment timeouts. The two halves of the requirement: + the binding *declares* the budget here, and the harness *enforces* it — + one shared deadline across the entire post-signal path, with a reserved + finalization slice (≥ the finalizers' declared bounds, currently 7s) for + presence `offline` and relay close, child cleanup + degrading first (§Stop, Known Defect 7). Until the harness enforcement + lands, 60s is an operational margin that the tail can exceed. +- **Hardening defaults (normative).** The workload is a prompted coding + agent running repository and tool code while holding an nsec; the pod MUST + NOT hand it ambient cluster credentials or kernel privilege on top: + `automountServiceAccountToken: false` (Kubernetes mounts a ServiceAccount + token unless told otherwise — an API-stealable credential the agent never + needs), `runAsNonRoot: true` with a fixed nonzero UID/GID, + `allowPrivilegeEscalation: false`, capabilities drop-all, + `seccompProfile.type: RuntimeDefault`; never privileged, `hostPID`, + `hostNetwork`, or `hostPath`. `readOnlyRootFilesystem` is *not* required + in v1 — the sprig toolchain writes outside the workspace mount — but is a + named candidate once the image's write surface is mapped. The + `service_account` config field selects an identity for scheduling/RBAC + purposes only; it MUST NOT silently re-enable token mounting — API-token + access, if ever wanted, is a separate explicit opt-in, not a side effect + of naming an SA. +- **Resources**: requests 1 cpu / 2Gi, limits 2 cpu / 4Gi, all four + configurable (`cargo build` in an agent workspace makes 500m/1Gi requests + unrealistic). +- **Workspace**: `emptyDir`. Checkouts and scratch die with the pod; agent + memory is relay-persisted (NIP-AE) and unaffected. PVC support is a + deferred knob. [DECISION A — how remote pods get the nest workspace + (AGENTS.md etc.) that local agents get from the desktop's `ensure_nest`; + current recommendation is a desktop-stated protocol field, not + image-side scaffolding — §Open Decisions.] + +### Secrets {#k8s-secrets} + +Per-agent `Secret` containing the identity variables (built from top-level +payload fields per the reserved-key rule) plus `env_vars`; consumed via +`envFrom`. + +**Secret creation is per-attempt, immutable, and uniquely named** +(`buzz-agent--`, §Pod shape). The rationale is a +concurrency race a deterministic shared Secret name cannot survive: two +concurrent deploys carrying *different* payloads would both write the shared +Secret, the loser's write could land last, and the winner's pod — +deterministic name, winner's spec — would resolve the **loser's** +identity/config through `envFrom`. The losing caller would have mutated the +winning generation despite strict no-op. Unique names close this: each +create attempt writes its own Secret first, then attempts the deterministic +pod create with a spec referencing exactly that Secret. Pod creation elects +the winner; payload and Secret are atomic at the pod-spec boundary, with no +Lease or CAS machinery. + +Lifecycle rules that follow: + +- **Winner**: pod + its referenced Secret live together; GC deletes them + together. +- **Losing contender** (create-conflict): annotation-verify the winning pod, + return its `agent_id` per the convergence rule, and delete **only its own + now-unreferenced Secret** — never the winner's, never any Secret + referenced by an *existing* pod. "Existing" deliberately includes + not-yet-started pods: an `envFrom` reference from a pod still pulling its + image is exactly as load-bearing as one from a running pod. +- **Live no-op arm**: no Secret is written at all (zero mutation). +- **GC**: also deletes annotation-verified **orphan Secrets** — those whose + generation token no existing pod references — covering contenders that + crashed between Secret create and their conflict cleanup. But only when + **age-eligible**: see the normative age gate in §K8s GC — "unreferenced" + is not "orphaned" while a concurrent attempt may still be between its + Secret create and its pod create. + +Fresh configuration therefore materializes exactly when a fresh generation +does. Residual exposure, stated: any principal with +pod-exec or secret-read in the namespace can read the nsec. This is the +substrate-security boundary from §Non-Goals — the namespace is the isolation +unit, and users deploying to shared namespaces accept its ambient RBAC. The +in-pod narrowing that sprig's dev-MCP shim performs (strips the key from its +own env, re-materializes as a 0600 keyfile for the git helpers) limits +accidental leakage into subprocess environments, not hostile cluster access. + +### Garbage collection {#k8s-gc} + +A **generation** is one pod-create attempt and the uniquely-named Secret it +references; the Secret's generation token is the generation's identity, and +the *current* generation is the one referenced by the existing pod's +`envFrom`. + +GC is a **preflight reconciliation pass**, not a post-deploy afterthought: +on every deploy, after identity derivation and before the state transition, +the provider deletes terminated pods (and their referenced Secrets) that +match the pubkey label, **pass the full-pubkey annotation check, and carry +the management marker** (§Pod shape; the auto-repair fence applies to GC +identically), plus +annotation-verified, marker-bearing orphan Secrets whose generation token +no existing pod +references (§K8s Secrets). It never touches the current generation. +Mismatched annotations are never GC'd (§Deploy State Machine step 1), and +an unmarked object is never GC'd regardless of its labels. + +**Orphan-Secret age gate (normative).** An unreferenced Secret is +GC-eligible only when its server-assigned `creationTimestamp` is older than +**twice the deploy operation deadline** (2 × 600s). Rationale — without the +gate, GC composes with per-attempt Secrets into a legal interleaving that +strands a deploy: attempt A creates Secret A; concurrent attempt B runs its +preflight GC *before A creates its pod*, sees Secret A unreferenced, and +deletes it as an "orphan"; A's pod is then accepted referencing a missing +Secret and sits in `CreateContainerConfigError` until a later deploy +repairs it by delete-recreate (§Deploy State Machine never-started rows) — +a stranded deploy either way. Unique Secret +names made payload↔Secret atomic *at the pod-spec boundary*, but +Secret-create→pod-create is not atomic against an independent GC pass — +the standard controller lesson that observations may be stale and +reconciliation must tolerate in-flight peers. The age bound makes +"unreferenced" mean "provably abandoned": any attempt that could still +reference the Secret has exceeded its own deadline. A losing contender's +immediate cleanup of **its own** Secret is exempt — ownership, not age, is +its safety argument. (A Lease per agent identity would also close this +race; the age gate achieves the same with no extra machinery.) + +**Same-clock rule (normative).** The age comparison has two operands and +both MUST come from the apiserver's clock. `creationTimestamp` is +server-assigned; the comparison instant MUST be derived from the HTTP +`Date` response header on the very list/get call the GC pass performs +(RFC 9110 §6.6.1 — origin-server message-origination time), never from the +provider's local `now()`. The provider runs on a user's desktop, and a +local clock fast by more than the margin doesn't *race* — it +deterministically computes every in-flight Secret as expired and deletes +them all, silently, on every pass, reopening exactly the interleaving the +gate exists to close. With both operands from one clock, skew cancels. +(`kube`'s `Client::send` returns the raw `http::Response` with headers, so +this costs one header read, not a departure from the typed API.) If the +`Date` header is absent or unparseable, the provider MUST **skip +orphan-Secret GC for that pass** — never fall back to local time. A +deferred cleanup is free; a wrong deletion is not. + +**Alternative considered — `ownerReferences`, omitted in v1.** Kubernetes' +native GC (a Secret owned by its attempt's Pod is deleted when the owner is +verified absent) cannot *replace* the age gate: an ownerReference needs the +owner's UID, which exists only after pod create, so primary reliance on it +would flip the ordering to Pod-first-then-Secret. The reason that flip +loses is **diagnostics, not repairability**: a never-started winner is +recoverable (the kubelet retries a config-failed container indefinitely, +and the amended no-op rule lets a later deploy delete-and-recreate it with +its own payload), but Pod-first routes *every healthy deploy* through +`CreateContainerConfigError` — the exact condition the startup classifier +treats as an actionable failure signal — so the classifier could no longer +believe that reason without waiting out the deadline, on every deploy. +That trades away normative diagnostics for a cleanup the age gate already +provides. A *supplementary* post-create attachment (patch the Secret with +the winning pod's UID; Secret metadata stays patchable when `immutable` and +`data` are untouched) is sound but adds no required property: the pre-pod +crash window still needs the age-gated sweep as backstop, so v1 omits it +under the complexity budget. Any future implementation that adds it MUST +set `blockOwnerDeletion: false` explicitly (true requires `update` on +`pods/finalizers` — an RBAC verb nothing else here needs — and a Secret +should never delay its pod's deletion), MUST keep owner and dependent in +the same namespace (a cross-namespace owner is treated as *absent*, turning +the safety net into an immediate-delete instruction), and MUST treat +attachment failure as non-fatal cleanup, never a deploy error. + +Running GC first +gives concurrency and Secret ownership one unambiguous order: reconcile +always observes a world with at most one candidate generation *older than +the gate*. Completed +pods from the *current* generation are left in place — their logs are the +only forensics M1 permits. That forensic window is deliberately fragile: +next-deploy GC, node loss, or namespace deletion erases it, and M1 means +there is no log operation to reach for. **Cluster-native log shipping is +therefore a production prerequisite, not an optional nicety** — the +ephemeral-runner lesson: disposable generations still need durable +diagnostics, forwarded off the pod by the cluster operator's stack. The +binding's contribution is correlation, not transport: the pod carries the +full-pubkey annotation, the generation token (doubling as +`BUZZ_MANAGED_AGENT_START_NONCE`, so lifecycle frames and pod logs share a +correlator), the provider version, and the resolved image reference +(§Image) — enough to attribute any shipped log line to an exact identity, +generation, and binary, with no secret in any of it. GC on next-deploy +also self-heals the missing +`undeploy`: delete-then-recreate converges, and a deleted-forever agent's +residue is one Completed pod that never restarts (I5) plus one Secret, +removable with `kubectl delete`. + +### `provider_config` v1 fields + +`context`, `namespace`, `image`, `cpu_request`, `memory_request`, +`cpu_limit`, `memory_limit`, `inactivity_seconds`, `service_account` — +9 of the 20-field validation cap. Node selectors, tolerations, and PVCs are +deliberately baked out of v1 to preserve budget. + +### Distribution + +Its own release workflow (macOS arm64/x64 + Linux musl; the sprig workflow's +ubuntu × musl matrix cannot produce the laptop-side binary), artifacts +attached to releases, installed to `~/.local/bin` (already on the discovery +path). v1 ships no Windows binary [DECISION B]; desktop bundling into the +.app (discovery already prepends the bundle dir) is deferred [DECISION D]. + +## Conformance + +Obligations are split by layer per §Launchers: the **[L1] agent/harness +contract** binds every launcher; the **[L2] provider/deployer contract** +binds provider-managed launches; the **[L3] binding policy** here is the +Kubernetes binding's own. A non-provider launcher (bash script, systemd +unit) owes only the L1 items; a provider on a different substrate owes +L1 + L2 and writes its own L3 realization of the generic L3 property. + +### [L1] Launcher conformance — every launcher + +A launcher — desktop, provider-deployed pod, systemd unit, bash script — +is conforming iff: + +1. It launches the harness with a **valid, nonempty identity**: a + parseable private key, a relay URL, and an auth tag or resolved owner + pubkey — refusing to launch rather than launching identityless (I1's + property, enforced wherever the env is assembled). +2. It does not suppress the harness's promises on a remote agent: + presence stays enabled (`BUZZ_ACP_NO_PRESENCE` never set — remotely, + presence is the only signal, I3), and the inactivity knob + (`BUZZ_ACP_EXIT_AFTER_INACTIVITY`) carries the owner's *deliberate* + lifetime policy, never an accidental passthrough of user env (I5; the + reserved-key rule is the provider path's realization of this). +3. The substrate's **termination signal reaches the harness process**, + with enough grace for its full graceful shutdown before force-kill (I3 + staleness minimization). "Allows" is not enough — a wrapper that + swallows the signal conforms to nothing. +4. Intentional termination (owner `!shutdown`, inactivity reap) exits + through the harness's graceful path under the **pinned clean-exit + contract** (intentional exit ⇒ exit code 0 — Known Defect 6 until the + contract lands). +5. Any supervisor the launcher configures **never restarts an intentional + clean exit** (I5). `Restart=always` and equivalents are non-conforming + at this layer no matter what the substrate calls them. + +### [L2] Provider conformance — provider-managed launches + +A provider is conforming iff, in addition to deploying only L1-conforming +invocations: + +1. `info` and `deploy` implement the wire contract (§Provider + Protocol), including one-JSON-in/one-JSON-out and in-band + `{"ok": false}` errors. **Exit codes carry exactly one bit** — zero = + the operation's output is trustworthy, nonzero = failure regardless of + stdout (§Invocation's rule restated from the provider's side): a + provider MUST exit nonzero on any crash path and MUST NOT encode + structured meaning in nonzero values, because `D` discards partial + output rather than interpreting codes. +2. It never requests or accepts credentials through + `provider_config` (I2). +3. It builds agent identity env from top-level payload fields, never + from `env_vars` (reserved-key rule), applies §Launch data mechanically — + three-tier precedence, host-resolved re-derivation, no re-merge of + legacy `env_vars`, no provider-side model/provider mapping — and refuses + a deploy that resolves neither `auth_tag` nor `launch.owner_pubkey`, or + whose provider is `relay-mesh`. +4. `deploy` implements the reconciliation loop (I4), stated + substrate-neutrally: identity derived from the nsec before any + mutation; candidates verified by **full-identity evidence** before any + action; live (= **started**: the harness process confirmed running, not + merely the body accepted) → strict no-op (zero mutation); never-started + states classified by evidence, not by substrate status strings + (provably-broken → fenced replace; recoverable + same recorded create + intent → observe, never delete, on this call or any later one; + recoverable + divergent intent → fenced replace); every read that can + authorize a destruction uses most-recent semantics; every destructive + write is **fenced to the exact observation that authorized it** + (compare-and-delete — the write fails if the object changed since the + read) and touches only objects carrying the provider's **management + marker** (the auto-repair fence, §Deploy State Machine); + same-status-code conflicts discriminated by a **machine-readable + conflict discriminator**, never the status code alone; success only on + confirmed harness start; conflicts converge by re-entry; + delete-of-absent is success. +5. It emits no secret material in any output (belt to `D`'s + redaction suspenders). +6. **Generic L3 obligation:** its binding *documents* how it realizes each + L2 term on its substrate, and how the owner's lifetime policy (bounded + vs indefinite) and clean-exit restart behavior are realized there — + stating the properties in its own vocabulary, not skipping them. + +### [L3] Kubernetes binding conformance — this binding + +The realization the two lists above require, in this binding's vocabulary: + +1. Each L2 item-4 term maps to the mechanism in §Deploy State Machine: + full-pubkey annotation for identity evidence, container `state.running` + for "started", `resourceVersion`-unset quorum reads for most-recent + semantics, UID+`resourceVersion` delete preconditions for fencing, + `Status.reason` as the 409 discriminator, and the + `app.kubernetes.io/managed-by` + binding-version labels as the + management marker. +2. The deployed invocation realizes the lifetime policy the owner chose + (I5) through this binding's `inactivity_seconds` field: `> 0` → + a working inactivity bound and `restartPolicy: Never`; `0` (the + blessed indefinite opt-in) → no bound and `restartPolicy: OnFailure`, + **only after both prerequisites land** — the pinned exit-code contract + (I5 ordering rule) *and* the crash-loop classification row (§Pod + shape); until then the provider MUST refuse the combination. +3. The harness is the deployed container's **signal-receiving process** + (PID 1 or the target of the pod's termination signal — §K8s + Entrypoint's `exec` rule), and `terminationGracePeriodSeconds` carries + the declared grace budget (§Pod shape). + +Conformance is testable without mechanization: a fake-provider harness can +exercise L2 items 1–3 and 5 over the wire contract — including the pre-secret +negotiation gate (§Discovery): an incompatible **or absent** +`protocol_version` MUST be rejected before any request carrying +`private_key_nsec` is sent; a **same-inode content rewrite** of the +resolved binary after resolution MUST NOT reach the deploy invocation +(the staged artifact still carries the bytes that answered `info`); and a +**pathname swap after validation** — the resolved path re-pointed at a +different file between the gate's checks and process spawn — likewise +MUST NOT redirect the nsec (both cases are exactly what path+metadata +comparison misses) — and an envtest/kind suite +can drive L2 item 4's reconciler against a real apiserver — concurrent +deploys, a deletion-marked pod, terminal restart, an annotation-mismatch +collision, and SIGTERM→presence-offline for the L3 items. Three families of +cases are mandatory because they were the review-found failure modes: +**startup discrimination** (slow-but-valid scheduling → poll-then-succeed; +`Unschedulable` during scale-from-zero → observed until the autoscaler +provisions capacity, then success — never delete, **including when +provisioning completes only after the 600s deadline**: the original pod +identity and `creationTimestamp` survive the expired call and become the +no-op winner on a later deploy, the case that pins the anti-livelock rule; +a label-and-annotation-matching object **without the management marker** → +never deleted, never GC'd, reported (the auto-repair fence under test); +referenced Secret *confirmed absent* → preconditioned delete-recreate or +actionable error, never silent success or no-op; a **never-started winner +is repairable** — pod exists, Secret absent, container never started: a +later deploy MUST delete-and-recreate rather than no-op, the test that pins +started-not-phase as the no-op criterion; and the **classification→DELETE +race** — the container transitions to running between the classifying read +and the delete: the UID+resourceVersion precondition MUST fail and the +live agent MUST be preserved) and the **GC/attempt interleaving** (attempt B's +preflight GC running between attempt A's Secret create and pod create MUST +NOT delete Secret A — the §K8s GC age gate under test; provider death after +Secret create → the age gate protects, then a later GC reaps; and a +**provider local clock fast beyond the margin** MUST NOT delete an +in-flight Secret — cheap with a fake clock, and the same-clock rule's +skip-on-absent-`Date` arm is exercised by stripping the header). A third +family pins the **divergence discriminator and the 409 split**: a failed +delete precondition (code 409, reason `Conflict`) → re-read and +re-classify, never the create-conflict cleanup/adoption path; a create +conflict (code 409, reason `AlreadyExists`) → loser-Secret cleanup and +winner adoption, never treated as a failed delete; identical desired +intent + permanently-Pending pod → no delete across arbitrarily many +Starts, regardless of age; a resource/image correction against a +never-started pod → fingerprint differs, preconditioned +delete-and-recreate (the wedge-escape case); same user config but the +provider's **baked default image digest** changed (provider upgrade) +against a never-started pod → divergence, replace (the second intent +source — the only escape from a bad-default-image wedge); the same correction against +a **started** pod → strict zero-mutation no-op; admission +defaulting/mutating the live pod → no false divergence (the comparison +uses the recorded annotation); the fingerprint serializer property, +asserted structurally — changing only Secret *values* or the generated +Secret *name* leaves the fingerprint unchanged, changing any +fingerprinted pod-create field changes it (equivalently: the serializer +has no access to Secret data or attempt identity); and the +conflict-path asymmetry — two no-instance contenders with different +payloads: the create loser adopts the elected winner rather than +deleting it for divergence, while a subsequent deploy observing that +never-started divergent winner replaces it. A model checker is +the wrong tool here: the failure modes found in review were wrong +*abstractions of Kubernetes* (a nonexistent `Terminating` phase, non-atomic +delete, phase-as-readiness, non-atomic Secret→pod against GC), which a +hand-written model would have reproduced convincingly. + +## Known Defects (at `28ae6cd21`) + +**Citation pin:** every `file:line` reference in this document was verified +against `28ae6cd21` — the commit at which this spec merged to `main`. +References are to that tree; a later commit may offset them. + +Desktop- and harness-side, discovered during this design: + +1. **Windows discovery id pollution**: the `.exe` suffix survives into the + provider id, which then fails id validation at deploy — dropdown-visible, + probe-fine, deploy-broken. Fix is a suffix strip in discovery. (v1 + provider scope is macOS+Linux regardless — [DECISION B].) +2. **Provider env inheritance**: `invoke_provider` passes the desktop's + environment through unmodified; combined with launchd's minimal PATH this + breaks kubeconfig exec plugins. Mitigated provider-side (§K8s Auth); + a desktop-side PATH augmentation would fix the class. +3. **Deploy payload bypasses the launch resolver** (the prerequisite this + spec names for §Launch data — a desktop code change, not spec text). + At `28ae6cd21`, `deploy_payload_json` serializes raw record bytes and a + three-layer `merged_user_env` where the local spawn uses + `resolve_effective_harness_descriptor`'s six-layer resolution. Concrete + consequences, each verified in review: (a) no per-runtime model/provider + env — a remote goose agent silently ignores the user's model choice, and + `provider_locked` runtimes would receive vars the desktop deliberately + withholds; (b) persona-derived `agent_command` and definition-provided + `agent_args` serialize as blank/empty — a different command line than the + identical local agent; (c) no `owner_pubkey` — a null-`auth_tag` agent + cannot match `!shutdown` (it *answers* it), stranding §Stop; (d) spawn + policy (`BUZZ_ACP_RELAY_OBSERVER`, runtime `default_env` such as + `GOOSE_MODE=auto`, team instructions, session title, lazy-pool selection) + is absent — remote pods run different observer/approval semantics + (`BUZZ_ACP_DEDUP`/`BUZZ_ACP_MULTIPLE_EVENT_HANDLING` are *not* on this + list: the local writes match the harness defaults, §Launch data); (e) a + mesh-provider agent deploys pointed at a loopback URL that cannot exist + in the pod instead of being refused. Until `deploy_payload_json` emits + the `launch` block, no provider can conform to §Launch data, and the + current payload MUST be treated as insufficient for a + semantics-preserving remote launch. **Security follow-through:** once + secrets can arrive via `launch.env`, desktop redaction MUST collect + candidate values from `launch.env` (and `launch.policy_env`) as well as + legacy `agent.env_vars` — at `28ae6cd21`, `env_secrets_from_request` + reads only `agent.env_vars` (`backend.rs`), leaving a + definition/persona-layer secret outside the literal-value scrub. + Conformance: a provider that echoes a launch-only secret into an error + must come back redacted. +4. **The I5 reaper does not exist, and its natural home is a trap** + (harness code prerequisite). `BUZZ_ACP_EXIT_AFTER_INACTIVITY` appears + nowhere in the harness at `28ae6cd21`; §Auto-Stop is a design, not a + description. Worse, the obvious attachment point — the existing 30s + maintenance tick — is gated on `pool_ready` (`lib.rs:1743`), which under + `lazy_pool` only becomes true when work arrives, so a never-mentioned + lazy pod would never evaluate the bound: I5 dead in its most important + case (§Auto-Stop mechanism rule). The implementation MUST run the expiry + check on a pool-independent timer and MUST add the env var to + `RESERVED_ENV_KEYS` in the same change. +5. **The deploy path never checks `protocol_version`** (desktop code + prerequisite). `provider_deploy` (`backend.rs`) sends the nsec-bearing + `deploy` request without any preceding `info` on the same resolved + executable; §Discovery's pre-secret negotiation gate is a design, not a + description, until the deploy command performs + resolve-once → stage-and-digest → `info` → explicit-version check → + `deploy`, both invocations running the staged bytes. +6. **The clean-exit contract is emergent, not defended** (harness code + prerequisite; gates `OnFailure`). At `28ae6cd21`: the graceful path + returns `Ok(())` (`lib.rs:2723`), and owner `!shutdown` (`:2045`), + Ctrl-C (`:1635`), and + SIGTERM (`:1644`) all route into the same shutdown channel — so clean + stops exit 0 *today*, but no distinguished exit code exists and no test + pins "intentional exit ⇒ 0"; every `process::exit(1)` in the crate is a + startup failure. Until a + pinned, tested exit-code contract lands, no supervisor restart policy + (`restartPolicy: OnFailure`, systemd `Restart=on-failure`) may be + deployed against the harness: a refactor returning `Err` from a drain + timeout would silently convert every clean stop into a restart loop — + I5 defeated with no failing test (I5 ordering rule). +7. **The shutdown tail overruns the declared grace budget at default + config** (harness code prerequisite). At `28ae6cd21`: the post-drain + reap segment + (`lib.rs:2664-2688`) runs *after* the 30s drain timeout closes + (`:2636,:2657`) and serially awaits a 5s post-SIGKILL wait per occupied + slot (`acp.rs:436`) — that segment alone reaches ~87s at the desktop's + default parallelism of 10 (`types.rs:814`; #3038 lowered it from 24), + ~197s at the harness cap of 32 (`config.rs:293`), against the binding's + 60s grace; and it is not the whole tail — the wake-task drain + (`:2612`) and awakened-pool shutdown (`:2620-2624`, per-slot loop + `:3747-3751`, no timeout) precede it (§Stop), so the total is + unbounded by today's segment timeouts. The fix is one shared deadline + across the entire post-signal + path with a reserved finalization slice (≥ the finalizers' declared + bounds, currently 2s presence + 5s relay close = 7s) for presence + `offline` and + relay close, child cleanup degrading first (§Stop); natural home is the + same harness change as the I5 reaper (defect 4). +8. **Cleared numeric config fields ship as strings** (desktop code + prerequisite, raised by blessing `0`). `coerceConfigValues` + (`desktop/src/features/agents/ui/ProviderConfigFields.tsx:6` at + `28ae6cd21`) skips numeric coercion when the value is `""`, so a + *cleared* numeric field reaches the provider as a JSON string instead + of a number. Blessing `inactivity_seconds: 0` makes clearing that + field a legitimate user action, so the empty-string arm now sits on a + documented path: the provider receives `""` where the schema says + integer, and "0 MUST NOT be rejected" cannot protect a value that + never parses as 0. Fix is desktop-side (map cleared numeric → + omit-or-default, never `""`); provider-side, a non-numeric value for a + numeric field is an in-band error, not a silent default. + +## Implementation Correspondence + +| spec concept | code | +|---|---| +| Discovery, resolution rule | `desktop/src-tauri/src/managed_agents/backend.rs` (`discover_provider_candidates`, `resolve_provider_binary`) | +| Invocation, output caps, exit rule | `backend.rs` (`invoke_provider`) | +| Pre-secret negotiation gate | *to be added*: `backend.rs` deploy path — resolve-once → stage-and-digest → `info` → explicit-version check → `deploy` on the staged bytes (Known Defect 5) | +| Redaction | `backend.rs` (`redact_secrets_with`) | +| I2 validation | `backend.rs` (`validate_provider_config`) | +| I1 refusal, payload | `desktop/src-tauri/src/commands/agents_deploy.rs` | +| Launch resolver (shared with local spawn) | `desktop/src-tauri/src/managed_agents/readiness.rs` (`resolve_effective_harness_descriptor`); `launch` block emission *to be added* to `agents_deploy.rs` (Known Defect 3) | +| Mesh rewrite (why relay-mesh is non-deployable) | `desktop/src-tauri/src/managed_agents/relay_mesh.rs`; create-time rejection in `commands/agents.rs` (`normalize_relay_mesh`) | +| Reserved-key strip | `desktop/src-tauri/src/managed_agents/env_vars.rs` (`RESERVED_ENV_KEYS`) | +| Unconditional deploy on Start | `desktop/src-tauri/src/commands/agents.rs` (`start_managed_agent`) | +| Presence publish / offline-on-exit | `crates/buzz-acp/src/lib.rs` (`publish_presence`, shutdown path) | +| `!shutdown` owner check | `crates/buzz-acp/src/lib.rs` (main loop) | +| Graceful shutdown path (budget enforcement *to be added* — Known Defect 7) | `crates/buzz-acp/src/lib.rs` (pool shutdown, then drain / reap / presence / relay close) | +| Clean-exit exit-code contract | *to be added*: `crates/buzz-acp` distinguished exit codes + pinning test (Known Defect 6; gates `OnFailure`) | +| Auto-stop flag | *to be added*: `crates/buzz-acp/src/config.rs` + a pool-independent timer (NOT the `pool_ready`-gated maintenance tick — Known Defect 4) + `RESERVED_ENV_KEYS` entry | +| Kubernetes binding | *to be added*: `crates/buzz-backend-kubernetes` | +| Sprig image | *to be added*: `Dockerfile.sprig` + workflow | + +## Open Decisions + +Marked `[DECISION]` inline; consolidated: + +- **A. Nest scaffolding** — should the image entrypoint scaffold the agent + workspace (AGENTS.md, RESEARCH/, …) that the desktop's `ensure_nest` + provides locally? Recommendation (revised): **workspace becomes a + protocol field the desktop states**, not an image-baked behavior — the + desktop resolves the nest content it would have written locally and + carries it in the launch data, so every substrate materializes the same + workspace from the same source of truth and the image stays + scaffold-free. An image-side template crate was the earlier + recommendation; it loses because it forks the nest definition into a + second implementation that drifts from `ensure_nest`. +- **B. Windows scope** — fix the `.exe` discovery bug in the desktop now; + ship Windows provider binaries only on demand. Recommended as stated. +- **C. Config budget** — the 9-field v1 set above. Recommended as stated. +- **D. Desktop bundling** — `~/.local/bin` install only for v1. Recommended + as stated. +- **E. Running-pod semantics** — no-op (recommended, both reviewers) vs + forcible recycle on Start. Ruled: **per-binding policy**, with one + universal property every binding must preserve — no sequence of Starts + yields two live instances in one scope (I4). The Kubernetes binding + keeps strict no-op in v1; a recycle affordance, if a binding adds one, + is stop-then-start, never delete-under-a-live-agent. +- **F. Mesh deployability** — the spec refuses relay-mesh agents + pre-mutation in v1 (§Launch data: the transport is desktop loopback; + serializing it fails identically but invisibly). Reviewer consensus is + refusal; ratification requested because it makes a visible product cut + (shared-compute agents are local-only until an in-image mesh client + exists). +- **G. Remote override semantics** — the spec keeps local semantics: user + env continues to beat Buzz behavior defaults remotely (three-tier + precedence, §Launch data), because the alternative is a quiet behavior + fork between local and remote spawns of the same record. Flagged because + it is a policy statement about what power users may do to remote pods. +- **H. Startup budget** — with deploy success now requiring container start + (§Deploy State Machine), the 600s operation deadline is the de facto + cold-pull / scale-from-zero budget. The spec fixes the semantics + narrowly: the deadline bounds how long one Start waits synchronously — + never when anything is destroyed (recoverable startup is observational + across calls, so a cluster whose autoscaler `new-pod-scale-up-delay` + exceeds 600s degrades to "Start reports unconfirmed, a later Start + adopts the now-running pod", not a livelock). The remaining SLO ruling + is UX-only: is ten minutes of synchronous waiting the right ceiling for + the intended cluster class? +- **I. Never-started escape hatch** — the create-intent fingerprint + (§Deploy State Machine) lets a config *change* replace a never-started + pod, closing the config wedge. Ruled on the vision-consistency half: + Start-time auto-repair of never-started bodies is legitimate, **fenced + to Buzz-authored, positively identified residue** (§Deploy State Machine + auto-repair rule) — the vision's "never-started body is operator + residue" line gains that qualifier rather than being waived. The + remaining product question: does v1 owe users an explicit in-product + "clear this stuck deployment" affordance for a never-started pod whose + config they have *not* changed (a genuinely slow or broken cluster)? + Both reviewers agree on the mechanism; this is the remaining product + question layered on top of it. + +## Summary + +Remote agents extend Buzz's managed-agent model across a deliberately thin +boundary: one untrusted binary, two JSON operations, and a relay. The +desktop's obligations end at a well-formed, fail-closed deploy payload; the +provider's obligations are convergence and honesty about state; the agent's +obligation is to honor its owner's lifetime choice — bounded by default, +indefinite by declaration, and in either case final when told to stop. +Everything else — status, control, +memory — was already on the relay, which is why the design holds: the relay +was the management plane all along, and the desktop was only ever one of +its doors. diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 07de477405..8ea5fe2bbe 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -4,6 +4,12 @@ set -euo pipefail SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) HOST=$(rustc -vV | sed -n 's|host: ||p') TARGET=${1:-$HOST} +if [[ "$TARGET" != *windows* ]]; then + SIDECARS+=(buzz-backend-kubernetes) + BUILD_HINT="cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli" +else + BUILD_HINT="cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli" +fi BINARIES_DIR="desktop/src-tauri/binaries" # When --target is passed explicitly to cargo (even if it matches the host), @@ -29,7 +35,7 @@ for bin in "${SIDECARS[@]}"; do done if [[ ${#missing[@]} -gt 0 ]]; then echo "Error: missing release binaries in $SRC_DIR: ${missing[*]}" >&2 - echo "Run 'cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli' first." >&2 + echo "Run '$BUILD_HINT' first." >&2 exit 1 fi diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 2e7e944482..0e5946ca18 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -106,6 +106,12 @@ run_unit_tests() { run_test_step "buzz-push-gateway tests" \ cargo test -p buzz-push-gateway -- --nocapture + + # Kubernetes backend provider: pure decision layers driven by a fake + # substrate, no cluster. Mirrors the nextest path in `just test-unit` — + # the two lists must stay in step or the fallback silently covers less. + run_test_step "buzz-backend-kubernetes tests" \ + cargo test -p buzz-backend-kubernetes -- --nocapture } # ---- DB / integration tests (infra required) -------------------------------- diff --git a/scripts/sprig-entrypoint.sh b/scripts/sprig-entrypoint.sh new file mode 100755 index 0000000000..16abf7bc51 --- /dev/null +++ b/scripts/sprig-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +# Match desktop's URL-scoped git credential configuration without installing a +# helper globally (which would make it answer for unrelated remotes). +if [[ -n "${BUZZ_RELAY_URL:-}" ]]; then + relay_http_url="${BUZZ_RELAY_URL/#ws:/http:}" + relay_http_url="${relay_http_url/#wss:/https:}" + relay_http_url="${relay_http_url%/}" + git config --global "credential.${relay_http_url}/git.helper" \ + /usr/local/bin/git-credential-nostr + git config --global "credential.${relay_http_url}/git.useHttpPath" true +fi + +# The harness must receive Kubernetes' termination signal directly. +exec buzz-acp "$@" diff --git a/scripts/test-k8s-provider-release.sh b/scripts/test-k8s-provider-release.sh new file mode 100755 index 0000000000..40c78e2688 --- /dev/null +++ b/scripts/test-k8s-provider-release.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Reproduce the desktop release's multi-package build shape, then prove the +# Kubernetes provider reports an unreachable cluster in-band rather than +# panicking when rustls has multiple compiled crypto providers available. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" +PROVIDER_BINARY="$TARGET_DIR/release/buzz-backend-kubernetes" + +CARGO="${CARGO:-cargo}" +"$CARGO" build --release \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr \ + -p buzz-cli \ + -p buzz-backend-kubernetes + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cat >"$TMP/kubeconfig" <<'YAML' +apiVersion: v1 +kind: Config +clusters: + - name: unreachable + cluster: + server: http://127.0.0.1:9 +contexts: + - name: unreachable + context: + cluster: unreachable + user: none +current-context: unreachable +users: + - name: none + user: {} +YAML + +cat >"$TMP/request.json" <<'JSON' +{ + "op": "deploy", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "owner", + "launch": {"command": "goose", "args": [], "env": {}, "policy_env": {}} + }, + "provider_config": { + "context": "unreachable", + "namespace": "never-created", + "image": "example.invalid/sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "inactivity_seconds": 60 + } +} +JSON + +set +e +KUBECONFIG="$TMP/kubeconfig" \ + "$PROVIDER_BINARY" \ + <"$TMP/request.json" >"$TMP/stdout" 2>"$TMP/stderr" +status=$? +set -e + +[[ "$status" == 0 ]] || { + echo "provider exited $status instead of returning an in-band failure" >&2 + cat "$TMP/stderr" >&2 + exit 1 +} +[[ "$(wc -l <"$TMP/stdout" | tr -d ' ')" == 1 ]] +jq -e '.ok == false and (.error | type == "string" and length > 0)' "$TMP/stdout" >/dev/null +if rg -i 'panic|crypto provider|no process-level' "$TMP/stdout" "$TMP/stderr"; then + echo "provider emitted a panic/crypto-provider failure" >&2 + exit 1 +fi +printf 'PASS: release-shaped provider returned one in-band unreachable-cluster failure\n' diff --git a/scripts/test-k8s-sprig-image-live.sh b/scripts/test-k8s-sprig-image-live.sh new file mode 100755 index 0000000000..ec9f259053 --- /dev/null +++ b/scripts/test-k8s-sprig-image-live.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Prove that a cluster can resolve and start the exact digest-qualified Sprig +# image that will be passed to the Kubernetes provider. This is intentionally a +# separate preflight: a Docker image existing in the host daemon does not imply +# that the kubelet's container runtime can resolve the same name and digest. +set -euo pipefail + +: "${BUZZ_K8S_TEST_CONTEXT:?set the explicit disposable/local kubectl context}" +: "${BUZZ_SPRIG_IMAGE:?set an immutable image reference (name@sha256:<64 hex>)}" + +if [[ ! "$BUZZ_SPRIG_IMAGE" =~ ^[^[:space:]@]+@sha256:[0-9a-fA-F]{64}$ ]]; then + echo "error: BUZZ_SPRIG_IMAGE must be name@sha256:<64 hex>" >&2 + exit 2 +fi + +CONTEXT="$BUZZ_K8S_TEST_CONTEXT" +IMAGE="$BUZZ_SPRIG_IMAGE" +PULL_POLICY="${BUZZ_K8S_TEST_PULL_POLICY:-IfNotPresent}" +case "$PULL_POLICY" in + Always|IfNotPresent|Never) ;; + *) echo "error: invalid BUZZ_K8S_TEST_PULL_POLICY: $PULL_POLICY" >&2; exit 2 ;; +esac + +MANAGED_BY="buzz-backend-kubernetes" +BINDING_VERSION="v1" +NAMESPACE="buzz-k8s-sprig-$(date +%s)-$RANDOM" +CREATED=0 + +cleanup() { + (( CREATED == 1 )) || return 0 + local managed binding foreign + managed="$(kubectl --context "$CONTEXT" get namespace "$NAMESPACE" \ + -o jsonpath='{.metadata.labels.app\.kubernetes\.io/managed-by}' 2>/dev/null || true)" + binding="$(kubectl --context "$CONTEXT" get namespace "$NAMESPACE" \ + -o jsonpath='{.metadata.labels.buzz\.block\.xyz/binding-version}' 2>/dev/null || true)" + if [[ "$managed" != "$MANAGED_BY" || "$binding" != "$BINDING_VERSION" ]]; then + echo "REFUSING cleanup: namespace ownership markers changed: $NAMESPACE" >&2 + return 1 + fi + foreign="$(kubectl --context "$CONTEXT" --namespace "$NAMESPACE" get pods -o json \ + | jq '[.items[] | select(.metadata.labels["app.kubernetes.io/managed-by"] != "buzz-backend-kubernetes" or .metadata.labels["buzz.block.xyz/binding-version"] != "v1")] | length')" + if [[ "$foreign" != 0 ]]; then + echo "REFUSING cleanup: namespace contains an unowned pod: $NAMESPACE" >&2 + return 1 + fi + kubectl --context "$CONTEXT" delete namespace "$NAMESPACE" --wait=true +} +trap cleanup EXIT + +# Fail before mutation if the named context is absent or inaccessible. Print the +# cluster identity so evidence cannot be mistaken for a different kubeconfig. +kubectl config get-contexts "$CONTEXT" >/dev/null +SERVER="$(kubectl config view --minify --context "$CONTEXT" -o jsonpath='{.clusters[0].cluster.server}')" +kubectl --context "$CONTEXT" get nodes -o name >/dev/null +printf 'context=%s\nserver=%s\nimage=%s\npull_policy=%s\nnamespace=%s\n' \ + "$CONTEXT" "$SERVER" "$IMAGE" "$PULL_POLICY" "$NAMESPACE" + +kubectl --context "$CONTEXT" create namespace "$NAMESPACE" +CREATED=1 +kubectl --context "$CONTEXT" label namespace "$NAMESPACE" \ + "app.kubernetes.io/managed-by=$MANAGED_BY" \ + "buzz.block.xyz/binding-version=$BINDING_VERSION" + +cat <&2 || true + exit 1 +fi + +kubectl --context "$CONTEXT" --namespace "$NAMESPACE" logs digest-resolution-probe +IMAGE_ID="$(kubectl --context "$CONTEXT" --namespace "$NAMESPACE" get pod digest-resolution-probe \ + -o jsonpath='{.status.containerStatuses[0].imageID}')" +RESOLVED_SPEC="$(kubectl --context "$CONTEXT" --namespace "$NAMESPACE" get pod digest-resolution-probe \ + -o jsonpath='{.spec.containers[0].image}')" +[[ "$RESOLVED_SPEC" == "$IMAGE" ]] +[[ -n "$IMAGE_ID" ]] +printf 'resolved_spec=%s\nimage_id=%s\nPASS: exact digest-qualified Sprig reference started\n' \ + "$RESOLVED_SPEC" "$IMAGE_ID" diff --git a/scripts/test-sprig-image.sh b/scripts/test-sprig-image.sh new file mode 100755 index 0000000000..4ca42fdb4d --- /dev/null +++ b/scripts/test-sprig-image.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${1:-buzz-sprig:contract-test}" +if [[ "${SKIP_BUILD:-0}" != 1 ]]; then + docker build --file Dockerfile.sprig --tag "$IMAGE" . +fi + +assert_run() { + docker run --rm --entrypoint /bin/bash "$IMAGE" -ceu "$1" +} + +assert_run ' + command -v bash git update-ca-certificates >/dev/null + test "$(readlink /usr/local/bin/buzz-acp)" = sprig + for name in buzz-agent buzz-dev-mcp rg tree buzz git-credential-nostr git-sign-nostr; do + test "$(readlink "/usr/local/bin/$name")" = sprig + done + test "$(git config --system gpg.x509.program)" = /usr/local/bin/git-sign-nostr + ! git config --system --get-all credential.helper + test "$HOME" = /home/agent + test "$(pwd)" = /home/agent +' + +assert_run ' + grep -Eq "^[[:space:]]*exec buzz-acp" /usr/local/bin/sprig-entrypoint + ! grep -Eq "^[[:space:]]*(buzz-acp|bash -c .*buzz-acp)" /usr/local/bin/sprig-entrypoint +' + +docker run --rm --entrypoint /bin/bash \ + -e BUZZ_RELAY_URL=wss://relay.example.test/ "$IMAGE" -ceu ' + /usr/local/bin/sprig-entrypoint --help >/dev/null 2>&1 & pid=$! + for _ in 1 2 3 4 5; do + git config --global --get credential.https://relay.example.test/git.helper >/dev/null 2>&1 && break + sleep 0.1 + done + test "$(git config --global --get credential.https://relay.example.test/git.helper)" = /usr/local/bin/git-credential-nostr + test "$(git config --global --get credential.https://relay.example.test/git.useHttpPath)" = true + ! git config --global --get-all credential.helper + wait "$pid" || true + ' + +echo "PASS: Sprig image runtime contract ($IMAGE)"