diff --git a/.github/workflows/buzz-for-devin-signed-macos-canary.yml b/.github/workflows/buzz-for-devin-signed-macos-canary.yml new file mode 100644 index 00000000000..421a24d59bd --- /dev/null +++ b/.github/workflows/buzz-for-devin-signed-macos-canary.yml @@ -0,0 +1,247 @@ +name: Buzz for Devin Signed macOS Canary + +# Manual-only release gate. This workflow never creates a tag, GitHub Release, +# updater manifest, or public community. It produces a short-lived signed and +# notarized Apple Silicon DMG for clean-machine acceptance testing. +on: + workflow_dispatch: + inputs: + accept_unmaintained_rust_risk: + description: >- + Explicitly accept the documented no-safe-upgrade Rust maintenance + advisories for this short-lived canary only + required: true + default: false + type: boolean + +permissions: + contents: read + +jobs: + build: + name: Build signed Buzz for Devin canary + if: github.repository == 'fenner888/BuzzforDevin' + runs-on: macos-latest + timeout-minutes: 120 + environment: buzz-for-devin-release + env: + TARGET: aarch64-apple-darwin + BUZZ_BUILD_KEYRING_SERVICE: buzz-for-devin-desktop + BUZZ_BUILD_DEEP_LINK_SCHEME: buzz-for-devin + BUZZ_BUILD_NEST_DIR: .buzz-for-devin + BUZZ_BUILD_CLI_LINK_NAME: buzz-for-devin + VITE_BUZZ_APP_NAME: Buzz for Devin + VITE_BUZZ_DEEP_LINK_SCHEME: buzz-for-devin + VITE_BUZZ_RELEASES_URL: https://github.com/fenner888/BuzzforDevin/releases + VITE_BUZZ_RELEASES_API_URL: https://api.github.com/repos/fenner888/BuzzforDevin/releases?per_page=10 + MACOSX_DEPLOYMENT_TARGET: "11.0" + CMAKE_OSX_DEPLOYMENT_TARGET: "11.0" + TAURI_BUNDLER_DMG_IGNORE_CI: "true" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Require signing and notarization secrets + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + for name in \ + APPLE_CERTIFICATE \ + APPLE_CERTIFICATE_PASSWORD \ + APPLE_SIGNING_IDENTITY \ + APPLE_ID \ + APPLE_PASSWORD \ + APPLE_TEAM_ID \ + KEYCHAIN_PASSWORD + do + if [[ -z "${!name:-}" ]]; then + echo "::error::Required protected secret $name is not configured" + exit 1 + fi + done + + - name: Install pinned dependencies + run: pnpm install --frozen-lockfile + + - name: Run complete repository validation + run: just ci + + - name: Audit JavaScript dependencies + run: pnpm audit --audit-level=low + + - name: Audit Rust dependency policy + shell: bash + env: + ACCEPT_UNMAINTAINED_RUST_RISK: ${{ inputs.accept_unmaintained_rust_risk }} + run: | + set -euo pipefail + export GIT_CONFIG_GLOBAL=/dev/null + export GIT_CONFIG_SYSTEM=/dev/null + + cargo deny --locked check advisories + cargo deny --locked check bans licenses sources + cargo deny --locked \ + --manifest-path desktop/src-tauri/Cargo.toml \ + check bans licenses sources + + set +e + cargo deny --locked \ + --manifest-path desktop/src-tauri/Cargo.toml \ + --target "$TARGET" \ + check advisories + DESKTOP_ADVISORY_STATUS=$? + set -e + + if [[ "$DESKTOP_ADVISORY_STATUS" == "0" ]]; then + echo "Apple Silicon desktop advisory gate passed." + elif [[ "$ACCEPT_UNMAINTAINED_RUST_RISK" == "true" ]]; then + echo "::warning::Proceeding with the explicitly accepted, documented no-safe-upgrade maintenance advisories for this canary." + else + echo "::error::Apple Silicon desktop advisory gate failed. Review the documented findings or explicitly accept them for this canary." + exit "$DESKTOP_ADVISORY_STATUS" + fi + + - name: Derive isolated canary version + id: version + shell: bash + run: | + set -euo pipefail + BASE_VERSION=$(node -p "require('./desktop/package.json').version") + if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Desktop version '$BASE_VERSION' is not semver" + exit 1 + fi + VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-devin-test.${GITHUB_RUN_NUMBER}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building Buzz for Devin canary $VERSION from $GITHUB_SHA" + + - name: Patch canary version + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + cd desktop + node scripts/set-version-from-tag.mjs "$VERSION" + ruby - "$VERSION" <<'RUBY' + path = "src-tauri/Cargo.lock" + version = ARGV.fetch(0) + lock = File.read(path) + pattern = /(\[\[package\]\]\nname = "buzz-desktop"\nversion = ")[^"]+(")/ + matches = lock.scan(pattern).length + abort "expected exactly one buzz-desktop lock entry, found #{matches}" unless matches == 1 + File.write(path, lock.sub(pattern, "\\1#{version}\\2")) + RUBY + cd src-tauri + cargo metadata --locked --format-version 1 >/dev/null + + - name: Generate isolated non-updating config + run: cd desktop && node scripts/build-buzz-for-devin-config.mjs + + - name: Build and stage sidecars + shell: bash + run: | + set -euo pipefail + cargo build --release --target "$TARGET" \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr \ + -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" + for binary in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + test -x "desktop/src-tauri/binaries/${binary}-${TARGET}" + done + + - name: Import Apple Developer ID certificate + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + umask 077 + CERTIFICATE_PATH="$RUNNER_TEMP/buzz-for-devin-certificate.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/buzz-for-devin-signing.keychain-db" + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 3600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" \ + -k "$KEYCHAIN_PATH" \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_PATH" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" | + grep -F -- "$APPLE_SIGNING_IDENTITY" >/dev/null + security list-keychains -d user -s "$KEYCHAIN_PATH" + rm -f "$CERTIFICATE_PATH" + + - name: Build, sign, notarize, and staple DMG + shell: bash + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + cd desktop + pnpm tauri build \ + --verbose \ + --target "$TARGET" \ + --bundles dmg \ + --config src-tauri/tauri.buzz-for-devin.conf.json + + - name: Verify signed artifact + id: artifact + shell: bash + run: | + set -euo pipefail + BUNDLE_ROOT="desktop/src-tauri/target/$TARGET/release/bundle" + APP_PATH="$BUNDLE_ROOT/macos/Buzz for Devin.app" + DMG_COUNT=$(find "$BUNDLE_ROOT/dmg" -name '*.dmg' -type f | wc -l | tr -d ' ') + [[ "$DMG_COUNT" == "1" ]] || { + echo "::error::Expected exactly one DMG, found $DMG_COUNT" + exit 1 + } + DMG_PATH=$(find "$BUNDLE_ROOT/dmg" -name '*.dmg' -type f -print -quit) + ./scripts/verify-buzz-for-devin-macos-app.sh "$APP_PATH" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + spctl --assess --type execute --verbose=4 "$APP_PATH" + xcrun stapler validate "$DMG_PATH" + desktop/scripts/verify-macos-entitlements.sh "$APP_PATH" + echo "path=$DMG_PATH" >> "$GITHUB_OUTPUT" + + - name: Upload signed canary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: buzz-for-devin-macos-${{ steps.version.outputs.version }}-${{ github.sha }} + path: ${{ steps.artifact.outputs.path }} + if-no-files-found: error + retention-days: 7 + + - name: Remove temporary signing keychain + if: always() + shell: bash + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/buzz-for-devin-signing.keychain-db" + security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true + rm -f "$RUNNER_TEMP/buzz-for-devin-certificate.p12" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad7f77f6bfe..64f2b2e3c8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1010,14 +1010,14 @@ jobs: 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" - # Mesh rev is derived from Cargo.lock so a dependency bump needs no - # lockstep edit here; the cache key tracks it automatically. + # The desktop crate is excluded from the root workspace, so its own + # lockfile is authoritative for the mesh revision and cache key. - name: Resolve mesh-llm rev id: mesh_rev run: | set -euo pipefail - REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') - [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } + REV=$(python3 -c 'import tomllib; d=tomllib.load(open("desktop/src-tauri/Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') + [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from desktop/src-tauri/Cargo.lock"; exit 1; } echo "rev=$REV" >> "$GITHUB_OUTPUT" echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" - name: Restore mesh llama build cache diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 31080652eaf..0e38c4036f1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -73,6 +73,8 @@ env: # variable to override (e.g., for forks that want to push to their own # namespace without forking this file). IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || 'ghcr.io/block/buzz' }} + # Keep the separately published push gateway fork-configurable too. + GATEWAY_IMAGE: ${{ vars.GHCR_GATEWAY_IMAGE != '' && vars.GHCR_GATEWAY_IMAGE || 'ghcr.io/block/buzz-push-gateway' }} jobs: build: @@ -350,7 +352,7 @@ jobs: id: meta uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: - images: ghcr.io/block/buzz-push-gateway + images: ${{ env.GATEWAY_IMAGE }} labels: | org.opencontainers.image.title=Buzz Push Gateway org.opencontainers.image.description=Capability-gated APNs last hop for Buzz @@ -363,9 +365,9 @@ jobs: file: ./Dockerfile.push-gateway platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} - cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }} - cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} + outputs: type=image,name=${{ env.GATEWAY_IMAGE }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: type=registry,ref=${{ env.GATEWAY_IMAGE }}-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.GATEWAY_IMAGE, matrix.arch) || '' }} - name: Export digest if: github.event_name != 'pull_request' env: @@ -410,7 +412,7 @@ jobs: id: meta uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: - images: ghcr.io/block/buzz-push-gateway + images: ${{ env.GATEWAY_IMAGE }} tags: | type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} @@ -420,11 +422,12 @@ jobs: id: manifest working-directory: /tmp/gateway-digests env: + GATEWAY_IMAGE: ${{ env.GATEWAY_IMAGE }} META_TAGS: ${{ steps.meta.outputs.tags }} run: | set -euo pipefail tags=(); while IFS= read -r tag; do [ -n "$tag" ] && tags+=("-t" "$tag"); done <<< "$META_TAGS" - digests=(); for digest in *; do digests+=("ghcr.io/block/buzz-push-gateway@sha256:${digest}"); done + digests=(); for digest in *; do digests+=("${GATEWAY_IMAGE}@sha256:${digest}"); done docker buildx imagetools create "${tags[@]}" "${digests[@]}" first_tag=$(echo "$META_TAGS" | head -n1) digest=$(docker buildx imagetools inspect "$first_tag" --format '{{json .Manifest}}' | jq -r '.digest') @@ -432,17 +435,19 @@ jobs: - name: Attest gateway image provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-name: ghcr.io/block/buzz-push-gateway + subject-name: ${{ env.GATEWAY_IMAGE }} subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true - name: Gateway publication summary env: + GATEWAY_IMAGE: ${{ env.GATEWAY_IMAGE }} GATEWAY_DIGEST: ${{ steps.manifest.outputs.digest }} GATEWAY_TAGS: ${{ steps.meta.outputs.tags }} + GATEWAY_OWNER: ${{ github.repository_owner }} run: | set -euo pipefail { - echo "### Published \`ghcr.io/block/buzz-push-gateway\`" + echo "### Published \`${GATEWAY_IMAGE}\`" echo printf "**Digest:** \`%s\`\n" "$GATEWAY_DIGEST" echo @@ -453,6 +458,6 @@ jobs: echo echo 'Verify provenance before deployment:' echo "\`\`\`" - printf 'gh attestation verify oci://ghcr.io/block/buzz-push-gateway@%s --owner block\n' "$GATEWAY_DIGEST" + printf 'gh attestation verify oci://%s@%s --owner %s\n' "$GATEWAY_IMAGE" "$GATEWAY_DIGEST" "$GATEWAY_OWNER" echo "\`\`\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3e20c393d5..21f17d2923e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -137,13 +137,14 @@ jobs: cargo build --release -p buzz-acp -p buzz-agent -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. + # The desktop crate is excluded from the root workspace, so its own + # lockfile is authoritative for the mesh revision and cache key. - name: Resolve mesh-llm rev id: mesh_rev run: | set -euo pipefail - REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') - [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } + REV=$(python3 -c 'import tomllib; d=tomllib.load(open("desktop/src-tauri/Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') + [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from desktop/src-tauri/Cargo.lock"; exit 1; } echo "rev=$REV" >> "$GITHUB_OUTPUT" echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" - name: Restore mesh llama build cache diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index fb0656028af..c8b0488061a 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -96,13 +96,14 @@ jobs: cargo build --release -p buzz-acp -p buzz-agent -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. + # The desktop crate is excluded from the root workspace, so its own + # lockfile is authoritative for the mesh revision and cache key. - name: Resolve mesh-llm rev id: mesh_rev run: | set -euo pipefail - REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') - [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } + REV=$(python3 -c 'import tomllib; d=tomllib.load(open("desktop/src-tauri/Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') + [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from desktop/src-tauri/Cargo.lock"; exit 1; } echo "rev=$REV" >> "$GITHUB_OUTPUT" echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 65ddcaf1c42..a26fc3d4b10 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /target/ /dist/ /admin-web/dist/ +/desktop/src-tauri/tauri.buzz-for-devin.conf.json # lefthook-generated hook scripts (machine-specific) .hooks/ diff --git a/COMMUNITY_FORK.md b/COMMUNITY_FORK.md index 2bebcbeabff..7981a4a614a 100644 --- a/COMMUNITY_FORK.md +++ b/COMMUNITY_FORK.md @@ -9,8 +9,16 @@ their own authenticated Devin agents. - This is not an official Block release. - This is not an official Cognition release. -- The project is in foundation and integration development. -- No public release is currently represented as production ready. +- Native Devin runtime support is implemented and covered by focused and + regression tests. +- Local proof and the two-context authorization checks are complete. A second + macOS-user context proved its own isolated identity, Nest, authenticated + Devin runtime, reply path, and process cleanup. +- The immutable source alpha and cross-platform builder preview are available + from [the releases page](https://github.com/fenner888/BuzzforDevin/releases). + No public binary release is represented as production ready. +- The focused upstream preset is proposed in + [`block/buzz` PR #3225](https://github.com/block/buzz/pull/3225). ## Integration Boundary @@ -52,20 +60,52 @@ that can be proposed to `block/buzz`, including: Fork-only branding, installers, release workflows, and community defaults remain separate from upstream-facing changes. +The proposed generic patch boundaries and fork-only exclusions are recorded in +[docs/buzz-for-devin-upstream-patch-plan.md](docs/buzz-for-devin-upstream-patch-plan.md). +Reviewer-ready upstream descriptions are drafted in +[docs/buzz-for-devin-upstream-pr-drafts.md](docs/buzz-for-devin-upstream-pr-drafts.md). + If upstream Buzz ships equivalent native Devin support, duplicate integration logic should be removed from this fork. ## Distribution -The initial distribution target is a locally built macOS application installed -under `~/Applications`. It will use immutable source tags and clearly describe -what is built and installed. +The pre-merge testing path is source-first, matching the way other Buzz harness +presets can be exercised before upstream merge. Builders use an immutable source +tag and run the reviewed checkout directly; this fork does not redistribute an +unsigned application. + +The cross-platform source-preview instructions are in +[docs/buzz-for-devin-builders.md](docs/buzz-for-devin-builders.md). Apple +Silicon has completed live acceptance. Intel macOS, Linux, and Windows remain +experimental until a real builder completes the documented Devin ACP acceptance +on each host. + +The initial packaged distribution target remains a locally built macOS +application installed under `~/Applications`. It uses immutable source tags and +clearly describes what is built and installed. + +The source-build distribution uses `Buzz for Devin`, +`community.buzzfordevin.desktop`, `buzz-for-devin://`, and the +`buzz-for-devin-desktop` Keychain service. Its `~/.buzz-for-devin` Nest and +`~/.local/bin/buzz-for-devin` convenience link are also isolated so it can +coexist with upstream Buzz. +See [docs/buzz-for-devin-macos.md](docs/buzz-for-devin-macos.md). +The signed release, clean-machine, updater, rollback, and publication gates are +defined in +[docs/buzz-for-devin-release-checklist.md](docs/buzz-for-devin-release-checklist.md). +The candidate evidence schema and public release-note draft live in +[docs/buzz-for-devin-validation-record-template.md](docs/buzz-for-devin-validation-record-template.md) +and +[docs/buzz-for-devin-release-notes-draft.md](docs/buzz-for-devin-release-notes-draft.md). The Devin CLI must come from Cognition's official installation path. Buzz for Devin will not redistribute a privately built or modified Devin executable. -Windows and Linux support are later milestones, not implied by the first -macOS release. +Windows and Linux source previews do not require Apple signing. Supported +Windows or Linux binary releases remain later milestones and require +platform-specific packaging and live acceptance; Windows executable signing +is a separate trust decision from Apple notarization. ## Licensing and Names diff --git a/Cargo.lock b/Cargo.lock index 9d0190868de..672d30d5160 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2797,7 +2797,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin 0.9.9", ] [[package]] @@ -2808,7 +2808,7 @@ checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin 0.9.9", ] [[package]] @@ -2884,7 +2884,7 @@ dependencies = [ "diatomic-waker", "futures-core", "pin-project-lite", - "spin 0.10.0", + "spin 0.10.1", ] [[package]] @@ -5457,9 +5457,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.3" +version = "0.44.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" +checksum = "0112d3433a5550ba13481970d5b6844714510ddcdaca4d9d0aa6e7b83f270271" dependencies = [ "base64", "bech32", @@ -8295,18 +8295,18 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spki" diff --git a/DEVIN.md b/DEVIN.md index 4c8e4fab0e4..cc5fdbfd2fc 100644 --- a/DEVIN.md +++ b/DEVIN.md @@ -4,7 +4,7 @@ Buzz for Devin is a community-maintained distribution of [Buzz](https://github.com/block/buzz) that adds -[Devin for Terminal](https://docs.devin.ai/work-with-devin/devin-cli) as a +[Devin for Terminal](https://docs.devin.ai/cli) as a first-class Agent Client Protocol (ACP) runtime. It is not a shared Devin account, a model proxy, or a replacement for @@ -13,6 +13,113 @@ workspace, permissions, and usage plan they already control. This project is not an official Block or Cognition release. +Builders can use the immutable source preview before upstream merge by +following [the cross-platform builder guide](docs/buzz-for-devin-builders.md). +The focused merge proposal is +[`block/buzz` PR #3225](https://github.com/block/buzz/pull/3225). + +## Implementation Status + +Native Devin runtime support is implemented and validated in the development +build. Buzz discovers the official `devin` executable, launches it as +`devin acp`, checks authentication with `devin auth status`, and presents it +as **Devin** in the runtime catalog. The managed runtime keeps Devin's default +permission policy, defaults to one worker and owner-only invocation, and uses +the authenticated local Devin CLI account. + +The Agents view derives presentation from the Rust runtime catalog. Both a +persona-only Devin card before first launch and a managed Devin instance use +the white-background Devin mark. Runtimes for which Buzz cannot apply a model, +including Devin, are labeled **Runtime default** instead of incorrectly +claiming the workspace's Buzz Agent model. The same catalog policy prevents +Buzz's generic `BUZZ_ACP_MODEL` bootstrap value from being passed to Devin; +Devin's official ACP server owns model selection. Existing Claude, Codex, +Goose, Buzz Agent, and custom-runtime bootstrap behavior is unchanged. + +Devin's catalog entry disables the harness's historical permission +auto-approval and enforces `default` mode. When Devin requests permission, +Buzz presents the agent owner with exact per-request **Allow once** and +**Deny** actions when Devin offers the corresponding one-shot options. The +encrypted owner-signed decision must match the exact channel, turn, and ACP +request, and the selected one-shot option must belong to that request; stale, +unknown, missing, persistent, and timed-out decisions fail closed. Other +runtimes retain their existing behavior. Buzz does not automatically switch +Devin into a bypass mode, grant persistent approval, or edit Devin permission +configuration. + +The project publishes an immutable source-only technical alpha, not an unsigned +binary download or production-ready public release. The installed Apple Silicon +bundle proves that the packaged desktop launches its sibling `buzz-acp`, which +in turn launches the official `devin acp`. A same-name development-agent DM was +identified as a separate public identity; opening a DM from the installed +agent's own profile created the correct membership and delivered the prompt. +That turn initialized without Buzz's previous forced-model warning. A corrected +installed-app prompt permitted only the Buzz publication call; after an explicit +**Allow once** decision, Devin published the requested exact reply and Buzz +rendered it under the requested reply destination. That also proved the +packaged sibling `buzz` CLI is selected ahead of an unrelated Buzz installation. + +Installed-app inspection found that a normal top-level DM question could still +receive Devin's answer only inside the question's thread. Regular channel +mentions are intentionally threaded, and replies sent from an existing DM +thread stay in that thread. A normal top-level DM answer must instead remain in +the DM's main timeline. The generic ACP prompt now states that destination +explicitly rather than relying on the absence of a `--reply-to` instruction. + +The official Devin ACP process did not return `session/prompt` after its +successful publication. The source now has an opt-in compatibility recovery: +Devin waits 30 seconds for natural completion, then closes only the exact turn +that produced the visible self-authored result, drops the already-satisfied +batch, rotates that session, and records a successful end turn. The option +defaults off, so existing runtimes retain their behavior. Rebuilt installed-app +tests proved that recovery and two subsequent top-level DM replies: both +appeared in the main DM timeline, not a thread, with no duplicate publication +or lingering turn. + +One cold turn then exposed a separate startup failure mode. Desktop previously +requested lazy ACP subprocess startup for every runtime, so the first message +also paid for Devin process launch and initialization. After initialization, +that turn produced no ACP output and remained silent under the generic +15-minute idle allowance until a manual restart; the same queued request then +succeeded. Devin now opts out of deferred subprocess startup through +`KnownAcpRuntime`, so its single official ACP worker initializes when the +managed agent starts. The same catalog entry supplies a 120-second default +silence bound when no record, inherited environment, or merged user override is +present. A silent process is replaced through the existing timeout/requeue +path. Other runtimes retain lazy startup and the harness idle default. Safe +observer timing identifies pool initialization, prompt dispatch, first ACP +activity, and prompt completion without recording prompt content. In the +rebuilt installed app, approving the isolated Keychain item restored the saved +managed agent automatically: the packaged harness applied the 120-second +default, started the official `devin acp` immediately, and completed pool +initialization in 37 milliseconds before a new message was sent. A subsequent +managed-agent restart replaced both packaged processes, re-subscribed to the +relay, and initialized in 44 milliseconds. From the live agent profile's +top-level DM, a cold probe reached ACP in about 2.4 seconds and completed in +4.017 seconds; the next warm probe reached ACP in about 1.1 seconds and +completed in 4.047 seconds. Each produced exactly one requested reply in the +main DM timeline, with the white-background Devin avatar and no thread. + +No MCP configuration or credentials were inspected or changed. After upstream +merged the generic BYOH harness seam, the upstream contribution was reduced to +the focused Devin preset, official command, logo, metadata, and tests in +[`block/buzz` PR #3225](https://github.com/block/buzz/pull/3225). Fork-only +branding, compatibility behavior, distribution, and community policy remain +outside that merge proposal. A second macOS-user context proved its own isolated +Buzz identity, Nest, authenticated Devin runtime, reply path, process cleanup, +allowlisted invocation, and revocation boundary. Cognition's signed-in CLI usage +surface showed usage attributed to the locally authenticated user after the +official-CLI smoke path; no credentials were inspected. Windows and Linux +source previews are available for builders, while live Devin ACP acceptance on +those hosts remains pending. +Development agent names such as "Devin Phase 2" are local test fixtures; +released agents and the runtime catalog are not phase-numbered. + +Release builds isolate their application data, Keychain service, deep links, +agent workspace (`~/.buzz-for-devin`), and bundled CLI convenience link +(`~/.local/bin/buzz-for-devin`) from upstream Buzz. They do not import or fall +back to upstream `~/.buzz` workspace data. + ## The Product Buzz gives people and agents shared channels, identities, presence, messaging, @@ -135,6 +242,29 @@ The community operator must never collect or redistribute Devin credentials. Buzz for Devin must not silently change a user's Devin configuration or grant broader workspace access than the user selected. +Devin CLI authentication belongs to the local operating-system user, not to a +Buzz identity stored inside one running desktop profile. Multi-user proof must +therefore use separate machines or separate macOS user accounts. Changing only +the Buzz identity inside one OS login does not create a second Devin credential +store and must not be represented as account isolation. + +For local agents, Buzz's persistent Nest is the ACP workspace. A community's +validated **Repos Directory** is exposed inside that workspace as `REPOS`, so +the owner can point Devin at existing local checkouts without moving or +copying them. This is a canonicalized workspace mapping, not an OS sandbox. +Devin's normal permission mode remains in force: Buzz does not automatically +select bypass mode or edit the user's Devin permission files. ACP permission +requests require an explicit owner-signed selection from the options Devin +offered for that exact request; denial, an unknown option, timeout, or absence +of an active approval surface fails closed. + +Cognition also offers an optional +[OS-level `--sandbox` research preview](https://docs.devin.ai/cli/sandbox). +Buzz for Devin does not force that flag in the initial integration because +doing so would replace the CLI's normal permission experience and has +platform-specific requirements. A future opt-in must be designed and tested +separately rather than silently changing every user's launch policy. + ## MVP Boundary The first release is local Devin for Terminal running through `devin acp`. @@ -165,3 +295,18 @@ Generic Devin runtime support should be suitable for contribution to The detailed implementation and validation plan lives in [docs/plans/2026-07-24-native-devin-acp-community.md](docs/plans/2026-07-24-native-devin-acp-community.md). +The macOS source-build, upgrade, rollback, and uninstall path is documented in +[docs/buzz-for-devin-macos.md](docs/buzz-for-devin-macos.md). +The separate-account Phase 2 and Phase 3 checks are documented in +[docs/buzz-for-devin-multi-user-validation.md](docs/buzz-for-devin-multi-user-validation.md). +The current authorization matrix, process boundary, dependency review, and open +release gates are documented in +[docs/buzz-for-devin-security-review.md](docs/buzz-for-devin-security-review.md). +The proposed generic patch boundaries and fork-only exclusions are recorded in +[docs/buzz-for-devin-upstream-patch-plan.md](docs/buzz-for-devin-upstream-patch-plan.md), +with reviewer-ready descriptions in +[docs/buzz-for-devin-upstream-pr-drafts.md](docs/buzz-for-devin-upstream-pr-drafts.md). +The release evidence schema and public release-note draft live in +[docs/buzz-for-devin-validation-record-template.md](docs/buzz-for-devin-validation-record-template.md) +and +[docs/buzz-for-devin-release-notes-draft.md](docs/buzz-for-devin-release-notes-draft.md). diff --git a/README.md b/README.md index 7e1af683254..aea78c60b85 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ > Cognition release. Start with [the product brief](DEVIN.md) and > [community-fork policy](COMMUNITY_FORK.md). +

+ Run the source preview · + Source alpha · + Upstream proposal +

+

Vision · Sovereign · @@ -107,7 +113,7 @@ Agents are part of the room, not haunted cron jobs. |---|---|---| | Relay, channels, threads, DMs, canvases, media, search, audit log | Mobile clients (iOS + Android, Flutter) | Web-of-trust reputation across relays | | Desktop app (Tauri + React) | Workflow approval gates (infra exists, glue still drying) | Push notifications | -| `buzz-cli` (agent-first, JSON in / JSON out) + ACP harness (Goose, Codex, Claude Code) | Huddle lifecycle events | Culture features | +| `buzz-cli` (agent-first, JSON in / JSON out) + ACP harness (Devin, Goose, Codex, Claude Code) | Huddle lifecycle events | Culture features | | YAML workflows: message / reaction / schedule / webhook triggers | | | | Git events (NIP-34: patches, repo announcements, status) | | | | Git hosting backend | | | @@ -120,11 +126,27 @@ Agents are part of the room, not haunted cron jobs. New to Buzz? Pick the path that matches you. -### I just want to try the app +### I want to try Devin before the upstream proposal merges -Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest) — macOS (`.dmg`), Linux (`.AppImage` / `.deb`), or Windows (`.exe`). Install it like any other app. +Use the immutable +[source alpha](https://github.com/fenner888/BuzzforDevin/releases/tag/buzz-for-devin-v0.4.25-alpha.2) +and follow the +[builder preview guide](docs/buzz-for-devin-builders.md). There is no unsigned +application download to redistribute. Builders run the reviewed source on +macOS, Linux, or Windows and connect their own authenticated official Devin CLI. -By default the app connects to `ws://localhost:3000`. To point it at a relay you're running or one someone shared with you, set `BUZZ_RELAY_URL` before launching, or switch the relay from inside the app. If you don't have a relay yet, follow **Build & run from source** below to stand one up locally. +Apple Silicon has completed fork-specific live acceptance. Intel macOS, Linux, +and Windows are experimental until a builder completes the documented live +Devin ACP acceptance on each host. + +### I want the current upstream Buzz release + +Block publishes upstream Buzz for macOS (`.dmg`), Linux +(`.AppImage` / `.deb`), and Windows (`.exe`) on the +[Block Buzz releases page](https://github.com/block/buzz/releases/latest). +Those packages should not be described as including Devin until +[the focused Devin preset proposal](https://github.com/block/buzz/pull/3225) +is merged and appears in an upstream release. ### I work at Block @@ -140,11 +162,19 @@ See **Quick start** below — this is the developer / self-host path. ## Quick start -You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.88+, Node 24+, pnpm 10+, `just`). +For the fastest preview against an existing community, follow +[the source-preview instructions](docs/buzz-for-devin-builders.md); that path +does not require a local relay or Docker. + +To run a complete self-hosted development stack, you'll need +[Docker](https://docs.docker.com/get-docker/) and +[Hermit](https://cashapp.github.io/hermit/) (or the repository-compatible Rust, +Node, pnpm, and `just` toolchains). **Once:** ```bash -git clone https://github.com/block/buzz.git && cd buzz +git clone https://github.com/fenner888/BuzzforDevin.git && cd BuzzforDevin +git checkout buzz-for-devin-v0.4.25-alpha.2 . ./bin/activate-hermit # pinned toolchain (tools auto-download on first use) just setup && just build ``` diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 78db7ff718b..d2b5cb83789 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -132,6 +132,56 @@ fn build_initialize_params() -> serde_json::Value { }) } +#[cfg(test)] +fn permission_option_id( + options: &[serde_json::Value], + approve: bool, +) -> Result<(&str, bool), AcpError> { + if approve { + if let Some(option) = options + .iter() + .find(|option| option.get("kind").and_then(|kind| kind.as_str()) == Some("allow_once")) + { + let option_id = option["optionId"] + .as_str() + .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?; + return Ok((option_id, true)); + } + } + + let option = options + .iter() + .find(|option| option.get("kind").and_then(|kind| kind.as_str()) == Some("reject_once")) + .ok_or_else(|| { + AcpError::Protocol("no reject_once option available for permission response".into()) + })?; + let option_id = option["optionId"] + .as_str() + .ok_or_else(|| AcpError::Protocol("reject_once option missing optionId".into()))?; + Ok((option_id, false)) +} + +fn permission_option_for_selection<'a>( + options: &'a [serde_json::Value], + selection: &crate::pool::PermissionSelection, +) -> Option<(&'a str, &'a str)> { + options.iter().find_map(|option| { + let option_id = option.get("optionId")?.as_str()?; + let kind = option.get("kind")?.as_str()?; + // Interactive managed-runtime consent is deliberately one-shot. + // Never forward allow_always/reject_always, even if an owner-signed + // control names an exact persistent option offered by the runtime. + if !matches!(kind, "allow_once" | "reject_once") { + return None; + } + let matches = match selection { + crate::pool::PermissionSelection::OptionId(selected) => option_id == selected, + crate::pool::PermissionSelection::Kind(selected) => kind == selected, + }; + matches.then_some((option_id, kind)) + }) +} + /// ACP client that owns an agent subprocess and communicates over its stdio. /// /// One `AcpClient` per agent process. Multiple sessions can be created on the @@ -158,6 +208,16 @@ pub struct AcpClient { /// Guards against double-response if a timeout fires after the allow_once /// response was written but before `pending_permission_id` was cleared. permission_responded: bool, + /// Whether ACP permission requests may be approved with `allow_once`. + /// + /// Buzz historically auto-approves these requests. Managed runtimes may + /// disable that fallback so a headless community prompt cannot silently + /// stand in for user consent. + auto_approve_permissions: bool, + /// Whether owner-signed observer controls may resolve permission requests. + interactive_permissions: bool, + /// Per-turn channel for owner permission decisions. + permission_rx: Option>, /// The JSON-RPC id of the most recently sent `session/prompt` request. /// Used by [`cancel_with_cleanup`] to drain the correct response. /// Set in [`session_prompt_with_idle_timeout`]; consumed in [`cancel_with_cleanup`]. @@ -488,6 +548,9 @@ impl AcpClient { next_id: 0, pending_permission_id: None, permission_responded: false, + auto_approve_permissions: true, + interactive_permissions: false, + permission_rx: None, last_prompt_id: None, current_hard_deadline: None, observer: None, @@ -505,6 +568,38 @@ impl AcpClient { self.observer_agent_index = Some(agent_index); } + /// Control whether `session/request_permission` may select `allow_once`. + /// + /// When disabled, the client selects `reject_once` and never falls back to + /// an allow option. This is set from the managed runtime launch policy at + /// the start of every prompt. + pub fn set_auto_approve_permissions(&mut self, enabled: bool) { + self.auto_approve_permissions = enabled; + } + + /// Control whether owner-signed observer controls may select an exact option + /// offered by the current permission request. + pub fn set_interactive_permissions(&mut self, enabled: bool) { + self.interactive_permissions = enabled; + } + + /// Install the permission-decision channel for one prompt turn. + pub fn install_permission_rx( + &mut self, + rx: tokio::sync::mpsc::Receiver, + ) { + debug_assert!( + self.permission_rx.is_none(), + "install_permission_rx: previous turn receiver was not cleared" + ); + self.permission_rx = Some(rx); + } + + /// Clear any permission receiver before the agent returns to the pool. + pub fn clear_permission_rx(&mut self) { + self.permission_rx = None; + } + /// Update metadata that will be attached to subsequent raw wire events. pub fn set_observer_context(&mut self, context: ObserverContext) { self.observer_context = context; @@ -706,6 +801,19 @@ impl AcpClient { self.current_hard_deadline = None; return Err(e); } + let prompt_dispatched_at = tokio::time::Instant::now(); + self.observe( + "prompt_dispatched", + serde_json::json!({ + "idleTimeoutSeconds": idle_timeout.as_secs(), + "maxDurationSeconds": max_duration.as_secs(), + }), + ); + tracing::info!( + idle_timeout_secs = idle_timeout.as_secs(), + max_duration_secs = max_duration.as_secs(), + "ACP prompt dispatched" + ); let result = self .read_until_response_with_idle_timeout( @@ -716,6 +824,19 @@ impl AcpClient { max_duration, ) .await; + let elapsed_ms = prompt_dispatched_at.elapsed().as_millis() as u64; + self.observe( + "prompt_wait_finished", + serde_json::json!({ + "elapsedMs": elapsed_ms, + "success": result.is_ok(), + }), + ); + tracing::info!( + elapsed_ms, + success = result.is_ok(), + "ACP prompt wait finished" + ); // On timeout errors, leave current_hard_deadline set so cancel_with_cleanup // can inherit the remaining budget. Clear it on all other outcomes. @@ -1224,6 +1345,7 @@ impl AcpClient { let mut idle_deadline = now + idle_timeout; let mut hard_deadline = hard_deadline; let mut last_activity_at = now; + let mut first_activity_observed = false; loop { // Determine which deadline fires first BEFORE sleeping — this is @@ -1411,6 +1533,15 @@ impl AcpClient { continue; } }; + if !first_activity_observed { + first_activity_observed = true; + let elapsed_ms = now.elapsed().as_millis() as u64; + self.observe( + "prompt_first_activity", + serde_json::json!({ "elapsedMs": elapsed_ms }), + ); + tracing::info!(elapsed_ms, "ACP prompt produced first activity"); + } self.observe("acp_read", msg.clone()); let activity_now = Instant::now(); @@ -1668,10 +1799,12 @@ impl AcpClient { } } - /// Auto-approve a `session/request_permission` request from the agent. + /// Respond to a `session/request_permission` request from the agent. /// - /// Finds the option with `kind == "allow_once"` and responds with its `optionId`. - /// If no `allow_once` option exists, falls back to `reject_once`. + /// When auto-approval is enabled, finds the option with + /// `kind == "allow_once"` and responds with its `optionId`. Otherwise it + /// selects `reject_once`. A missing allow option also falls back to + /// `reject_once`; a missing reject option is a protocol error. /// /// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`. /// @@ -1699,39 +1832,79 @@ impl AcpClient { options.len() ); - // Find allow_once by kind — NEVER hardcode optionId. - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + // Auto-approval preserves the historical behavior for existing + // runtimes. Interactive mode waits only for an owner-signed control + // routed to this exact turn and request id. Any missing, stale, closed, + // or timed-out decision fails closed. + let selection = if self.auto_approve_permissions { + Some(crate::pool::PermissionSelection::Kind( + "allow_once".to_string(), + )) + } else if self.interactive_permissions { + self.wait_for_permission_decision(&id).await + } else { + None + }; - let response = if let Some(opt) = allow_once { - let option_id = opt["optionId"] - .as_str() - .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?; - tracing::info!( - target: "acp::permission", - "auto-approving permission id={id} with allow_once optionId={option_id:?}" - ); + // Owner controls carry an exact option ID. Validate it against this + // live request before returning it to the runtime. Any missing or + // unknown selection fails closed to reject_once. + let selected = selection + .as_ref() + .and_then(|selection| permission_option_for_selection(options, selection)); + let (option_id, kind) = match selected { + Some(selected) => selected, + None => { + if selection.is_some() { + tracing::warn!( + target: "acp::permission", + "permission selection did not match the current request — rejecting" + ); + } + permission_option_for_selection( + options, + &crate::pool::PermissionSelection::Kind("reject_once".to_string()), + ) + .ok_or_else(|| { + AcpError::Protocol( + "no reject_once option available for permission response".into(), + ) + })? + } + }; + let approved = !kind.starts_with("reject"); + + let response = if approved { + if self.auto_approve_permissions { + tracing::info!( + target: "acp::permission", + "auto-approving permission id={id} with {kind} optionId={option_id:?}" + ); + } else { + tracing::info!( + target: "acp::permission", + "owner selected permission id={id} with {kind} optionId={option_id:?}" + ); + } permission_response_selected(&id, option_id) } else { - // No allow_once — fall back to reject_once. - tracing::warn!( - target: "acp::permission", - "no allow_once option found in permission request id={id}, falling back to reject_once" - ); - let reject = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); - - if let Some(opt) = reject { - let option_id = opt["optionId"].as_str().unwrap_or("reject"); - permission_response_selected(&id, option_id) + if self.auto_approve_permissions && selection.is_some() { + tracing::warn!( + target: "acp::permission", + "no allow_once option found in permission request id={id}, falling back to reject_once" + ); + } else if self.interactive_permissions { + tracing::info!( + target: "acp::permission", + "owner rejected or did not resolve permission request id={id}" + ); } else { - return Err(AcpError::Protocol( - "no suitable permission option found (neither allow_once nor reject_once)" - .into(), - )); + tracing::info!( + target: "acp::permission", + "rejecting permission request id={id} because auto-approval is disabled" + ); } + permission_response_selected(&id, option_id) }; // Write the response first, then mark as responded. @@ -1754,6 +1927,58 @@ impl AcpClient { Ok(()) } + /// Wait for a decision that matches this ACP JSON-RPC request id. + /// + /// The receiver is scoped to one prompt turn. The hard turn deadline also + /// bounds this wait so an unattended prompt cannot stay parked forever. + async fn wait_for_permission_decision( + &mut self, + request_id: &serde_json::Value, + ) -> Option { + let Some(deadline) = self.current_hard_deadline else { + tracing::warn!( + target: "acp::permission", + "interactive permission request has no hard deadline — rejecting" + ); + return None; + }; + let Some(rx) = self.permission_rx.as_mut() else { + tracing::warn!( + target: "acp::permission", + "interactive permission request has no owner decision channel — rejecting" + ); + return None; + }; + + loop { + match tokio::time::timeout_at(deadline, rx.recv()).await { + Ok(Some(decision)) if decision.request_id == *request_id => { + return Some(decision.selection); + } + Ok(Some(_)) => { + tracing::debug!( + target: "acp::permission", + "ignoring permission decision for a different request id" + ); + } + Ok(None) => { + tracing::warn!( + target: "acp::permission", + "permission decision channel closed — rejecting" + ); + return None; + } + Err(_) => { + tracing::info!( + target: "acp::permission", + "interactive permission request timed out — rejecting" + ); + return None; + } + } + } + } + /// Parse `stopReason` from a `session/prompt` result value. fn parse_stop_reason(&self, result: &serde_json::Value) -> Result { let raw = result["stopReason"].as_str().ok_or_else(|| { @@ -2111,6 +2336,109 @@ mod tests { assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x")); } + #[test] + fn permission_policy_rejects_when_auto_approval_is_disabled() { + let options = serde_json::json!([ + {"optionId": "reject-this", "kind": "reject_once"}, + {"optionId": "allow-this", "kind": "allow_once"} + ]); + let options = options.as_array().unwrap(); + + let (option_id, approved) = permission_option_id(options, false).unwrap(); + + assert_eq!(option_id, "reject-this"); + assert!(!approved); + } + + #[test] + fn permission_policy_preserves_existing_allow_once_behavior_when_enabled() { + let options = serde_json::json!([ + {"optionId": "reject-this", "kind": "reject_once"}, + {"optionId": "allow-this", "kind": "allow_once"} + ]); + let options = options.as_array().unwrap(); + + let (option_id, approved) = permission_option_id(options, true).unwrap(); + + assert_eq!(option_id, "allow-this"); + assert!(approved); + } + + #[test] + fn permission_selection_resolves_exact_one_shot_option_id() { + let options = serde_json::json!([ + {"optionId": "allow-once", "kind": "allow_once"}, + {"optionId": "reject-this", "kind": "reject_once"} + ]); + let selection = crate::pool::PermissionSelection::OptionId("allow-once".to_string()); + + assert_eq!( + permission_option_for_selection(options.as_array().unwrap(), &selection), + Some(("allow-once", "allow_once")) + ); + } + + #[test] + fn permission_selection_rejects_persistent_options() { + let options = serde_json::json!([ + {"optionId": "allow-once", "kind": "allow_once"}, + { + "optionId": "allow-buzz-messages-in-workspace", + "kind": "allow_always" + }, + {"optionId": "reject-always", "kind": "reject_always"}, + {"optionId": "reject-this", "kind": "reject_once"} + ]); + + for option_id in ["allow-buzz-messages-in-workspace", "reject-always"] { + let selection = crate::pool::PermissionSelection::OptionId(option_id.to_string()); + assert_eq!( + permission_option_for_selection(options.as_array().unwrap(), &selection), + None, + "{option_id} must not be actionable" + ); + } + } + + #[test] + fn permission_selection_by_kind_rejects_persistent_options() { + let options = serde_json::json!([ + {"optionId": "allow-always", "kind": "allow_always"}, + {"optionId": "reject-this", "kind": "reject_once"} + ]); + let selection = crate::pool::PermissionSelection::Kind("allow_always".to_string()); + + assert_eq!( + permission_option_for_selection(options.as_array().unwrap(), &selection), + None + ); + } + + #[test] + fn permission_selection_rejects_unknown_option_id() { + let options = serde_json::json!([ + {"optionId": "allow-once", "kind": "allow_once"}, + {"optionId": "reject-this", "kind": "reject_once"} + ]); + let selection = + crate::pool::PermissionSelection::OptionId("not-offered-by-agent".to_string()); + + assert_eq!( + permission_option_for_selection(options.as_array().unwrap(), &selection), + None + ); + } + + #[test] + fn permission_policy_never_allows_when_reject_is_unavailable() { + let options = serde_json::json!([ + {"optionId": "allow-this", "kind": "allow_once"} + ]); + let options = options.as_array().unwrap(); + + assert!(permission_option_id(options, false).is_err()); + } + #[test] fn request_has_id_field() { let id: u64 = 42; @@ -2627,6 +2955,48 @@ mod tests { ); } + #[tokio::test] + async fn prompt_timing_observer_events_do_not_capture_prompt_content() { + let script = r#"IFS= read -r _line +printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"stopReason":"end_turn"}}'"#; + let mut client = spawn_script(script).await; + let observer = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + + let result = client + .session_prompt_with_idle_timeout( + "test-session", + "SENSITIVE_PROMPT_SENTINEL", + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(5), + ) + .await; + assert!(matches!(result, Ok(StopReason::EndTurn))); + + let events = observer.snapshot(); + for kind in [ + "prompt_dispatched", + "prompt_first_activity", + "prompt_wait_finished", + ] { + assert!( + events.iter().any(|event| event.kind == kind), + "missing {kind} timing event" + ); + } + let timing_payloads = events + .iter() + .filter(|event| event.kind.starts_with("prompt_")) + .map(|event| event.payload.to_string()) + .collect::>() + .join("\n"); + assert!( + !timing_payloads.contains("SENSITIVE_PROMPT_SENTINEL"), + "timing telemetry must never include prompt content" + ); + client.shutdown().await; + } + #[tokio::test] async fn hard_timeout_fires_when_deadline_is_immediate() { let mut client = spawn_script("while true; do echo 'noise'; sleep 0.01; done").await; diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14b..6395b9ef3a0 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -361,6 +361,21 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_NO_IGNORE_SELF")] pub no_ignore_self: bool, + /// Seconds to wait after observing a self-authored channel message before + /// closing the exact still-running turn that published it. 0 disables the + /// compatibility recovery. + /// + /// Some ACP adapters can successfully publish their externally visible + /// result and then fail to return `session/prompt`. The grace period lets + /// the adapter finish naturally first; an exact turn-id match prevents a + /// delayed timer from cancelling later work in the same channel. + #[arg( + long = "self-publish-completion-grace", + env = "BUZZ_ACP_SELF_PUBLISH_COMPLETION_GRACE", + default_value_t = 0 + )] + pub self_publish_completion_grace_secs: u64, + /// Maximum number of context messages to include for thread replies and DMs. /// Set to 0 to disable automatic context fetching. Max 100. #[arg(long, env = "BUZZ_ACP_CONTEXT_MESSAGE_LIMIT", default_value_t = 12, @@ -437,6 +452,33 @@ pub struct CliArgs { )] pub permission_mode: PermissionMode, + /// Whether Buzz may answer ACP permission requests with `allow_once`. + /// + /// This preserves the historical harness behavior by default. A managed + /// runtime may enforce `false` when a headless community turn must never + /// substitute for interactive user consent. + #[arg( + long, + env = "BUZZ_ACP_AUTO_APPROVE_PERMISSIONS", + default_value_t = true, + action = clap::ArgAction::Set + )] + pub auto_approve_permissions: bool, + + /// Whether owner-signed observer controls may resolve ACP permission + /// requests interactively with `allow_once` or `reject_once`. + /// + /// Disabled by default so existing runtimes preserve their historical + /// behavior. Managed runtimes that disable auto-approval may enable this + /// owner-consent path without selecting a bypass permission mode. + #[arg( + long, + env = "BUZZ_ACP_INTERACTIVE_PERMISSIONS", + default_value_t = false, + action = clap::ArgAction::Set + )] + pub interactive_permissions: bool, + /// Inbound author gate: which authors' events the harness forwards. /// Modes: owner-only (default), allowlist, anyone, nobody. #[arg( @@ -506,6 +548,9 @@ pub struct Config { pub dedup_mode: DedupMode, pub multiple_event_handling: MultipleEventHandling, pub ignore_self: bool, + /// Runtime compatibility recovery after a self-authored result publish. + /// 0 disables the recovery and preserves historical behavior. + pub self_publish_completion_grace_secs: u64, pub kinds_override: Option>, pub channels_override: Option>, pub no_mention_filter: bool, @@ -524,6 +569,10 @@ pub struct Config { pub model: Option, /// Permission mode to apply after session creation. `Default` = skip. pub permission_mode: PermissionMode, + /// Whether ACP permission requests may select `allow_once`. + pub auto_approve_permissions: bool, + /// Whether owner-signed observer controls may resolve permission requests. + pub interactive_permissions: bool, /// Inbound author gate mode. pub respond_to: RespondTo, /// Validated allowlist of pubkey hex strings (used when respond_to == Allowlist). @@ -616,7 +665,7 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { fn default_agent_args(command: &str) -> Option> { match normalize_agent_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + "goose" | "devin" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -982,6 +1031,7 @@ impl Config { dedup_mode: args.dedup, multiple_event_handling: args.multiple_event_handling, ignore_self: !args.no_ignore_self, + self_publish_completion_grace_secs: args.self_publish_completion_grace_secs, kinds_override: args.kinds, channels_override: args.channels, no_mention_filter: args.no_mention_filter, @@ -993,6 +1043,8 @@ impl Config { memory_enabled: args.memory && !args.no_memory, model, permission_mode: args.permission_mode, + auto_approve_permissions: args.auto_approve_permissions, + interactive_permissions: args.interactive_permissions, respond_to: args.respond_to, respond_to_allowlist, allowed_respond_to, @@ -1024,7 +1076,7 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} self_publish_completion_grace={}s context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} auto_approve_permissions={} interactive_permissions={} {}{}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, @@ -1038,6 +1090,7 @@ impl Config { self.dedup_mode, self.multiple_event_handling, self.ignore_self, + self.self_publish_completion_grace_secs, self.context_message_limit, self.max_turns_per_session, self.presence_enabled, @@ -1045,6 +1098,8 @@ impl Config { self.memory_enabled, self.model.as_deref().unwrap_or("(agent default)"), self.permission_mode, + self.auto_approve_permissions, + self.interactive_permissions, respond_to_detail, allowed_respond_to_detail, ) @@ -1351,6 +1406,7 @@ mod tests { dedup_mode: DedupMode::Queue, multiple_event_handling: MultipleEventHandling::Queue, ignore_self: true, + self_publish_completion_grace_secs: 0, kinds_override: None, channels_override: None, no_mention_filter: false, @@ -1362,6 +1418,8 @@ mod tests { memory_enabled: true, model: None, permission_mode: PermissionMode::BypassPermissions, + auto_approve_permissions: true, + interactive_permissions: false, respond_to: RespondTo::Anyone, respond_to_allowlist: HashSet::new(), allowed_respond_to: Vec::new(), @@ -1492,6 +1550,22 @@ mod tests { ); } + #[test] + fn normalizes_devin_args_to_native_acp_subcommand() { + assert_eq!(normalize_agent_args("devin", Vec::new()), vec!["acp"]); + assert_eq!( + normalize_agent_args("/usr/local/bin/devin", vec!["".into()]), + vec!["acp"] + ); + assert_eq!( + normalize_agent_args( + "devin", + vec!["acp".into(), "--agent-type".into(), "review".into()] + ), + vec!["acp", "--agent-type", "review"] + ); + } + #[test] fn normalize_agent_command_identity_variants() { assert_eq!(normalize_agent_command_identity("goose"), "goose"); @@ -2048,6 +2122,57 @@ channels = "ALL" assert!(CliArgs::parse_from(["buzz-acp", "--private-key", &key, "--lazy-pool"]).lazy_pool); } + #[test] + fn self_publish_completion_recovery_defaults_off_and_accepts_a_grace() { + let key = "0".repeat(64); + assert_eq!( + CliArgs::parse_from(["buzz-acp", "--private-key", &key]) + .self_publish_completion_grace_secs, + 0 + ); + assert_eq!( + CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &key, + "--self-publish-completion-grace", + "30", + ]) + .self_publish_completion_grace_secs, + 30 + ); + } + + #[test] + fn permission_request_auto_approval_defaults_on_and_can_be_disabled() { + let key = "0".repeat(64); + assert!(CliArgs::parse_from(["buzz-acp", "--private-key", &key]).auto_approve_permissions); + assert!( + !CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &key, + "--auto-approve-permissions=false", + ]) + .auto_approve_permissions + ); + } + + #[test] + fn interactive_permissions_default_off_and_can_be_enabled() { + let key = "0".repeat(64); + assert!(!CliArgs::parse_from(["buzz-acp", "--private-key", &key]).interactive_permissions); + assert!( + CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &key, + "--interactive-permissions=true", + ]) + .interactive_permissions + ); + } + #[test] fn test_summary_includes_agents_and_heartbeat() { let config = test_config(SubscribeMode::Mentions); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0230ea0875f..58369b98c16 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -885,12 +885,106 @@ fn handle_relay_observer_control_event( Some("switch_model") => { handle_switch_model_control(&payload, pool, observer); } + Some("permission_decision") => { + handle_permission_decision_control(&payload, pool, observer); + } _ => { tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); } } } +/// Resolve one ACP permission request from an owner-signed encrypted control. +/// +/// Only per-request `allow_once` and `reject_once` decisions are accepted. +/// Channel, turn, and JSON-RPC request ids must all match the live task. +fn handle_permission_decision_control( + payload: &serde_json::Value, + pool: &mut AgentPool, + observer: Option<&observer::ObserverHandle>, +) { + let Some(channel_id) = payload + .get("channelId") + .and_then(|value| value.as_str()) + .and_then(|value| value.parse::().ok()) + else { + tracing::warn!("permission decision control missing valid channelId"); + return; + }; + let Some(turn_id) = payload.get("turnId").and_then(|value| value.as_str()) else { + tracing::warn!("permission decision control missing turnId"); + return; + }; + let Some(request_id) = payload + .get("requestId") + .filter(|value| value.is_string() || value.is_number()) + .cloned() + else { + tracing::warn!("permission decision control missing valid requestId"); + return; + }; + let selection = match payload.get("optionId").and_then(|value| value.as_str()) { + Some(option_id) if !option_id.is_empty() && option_id.len() <= 512 => { + pool::PermissionSelection::OptionId(option_id.to_string()) + } + Some(_) => { + tracing::warn!("permission optionId must be a non-empty string of at most 512 bytes"); + return; + } + None => match payload.get("decision").and_then(|value| value.as_str()) { + Some("allow_once") => pool::PermissionSelection::Kind("allow_once".to_string()), + Some("reject_once") => pool::PermissionSelection::Kind("reject_once".to_string()), + _ => { + tracing::warn!( + "permission decision must include optionId or a legacy allow_once/reject_once decision" + ); + return; + } + }, + }; + let selected_option_id = match &selection { + pool::PermissionSelection::OptionId(option_id) => Some(option_id.clone()), + pool::PermissionSelection::Kind(_) => None, + }; + let selected_kind = match &selection { + pool::PermissionSelection::Kind(kind) => Some(kind.clone()), + pool::PermissionSelection::OptionId(_) => None, + }; + + let status = match pool.send_permission_decision( + channel_id, + turn_id, + pool::PermissionDecision { + request_id, + selection, + }, + ) { + Ok(()) => "sent", + Err(pool::PermissionDecisionError::NoActiveTurn) => "no_active_turn", + Err(pool::PermissionDecisionError::StaleTurn) => "stale_turn", + Err(pool::PermissionDecisionError::Unavailable) => "unavailable", + }; + + if let Some(observer) = observer { + observer.emit( + "control_result", + None, + &observer::ObserverContext { + channel_id: Some(channel_id.to_string()), + session_id: None, + turn_id: Some(turn_id.to_string()), + started_at: None, + }, + serde_json::json!({ + "type": "permission_decision", + "status": status, + "optionId": selected_option_id, + "decision": selected_kind, + }), + ); + } +} + /// Handle a `cancel_turn` control frame: signal the in-flight task to cancel. fn handle_cancel_turn_control( payload: &serde_json::Value, @@ -1553,6 +1647,8 @@ async fn tokio_main() -> Result<()> { context_message_limit: config.context_message_limit, max_turns_per_session: config.max_turns_per_session, permission_mode: config.permission_mode, + auto_approve_permissions: config.auto_approve_permissions, + interactive_permissions: config.interactive_permissions, agent_keys: config.keys.clone(), agent_owner_pubkey: startup_owner .as_deref() @@ -1627,6 +1723,13 @@ async fn tokio_main() -> Result<()> { // withheld event in `EventQueue::withheld_native_steer` until // `IN_FLIGHT_DEADLINE_SECS` expires. let (steer_ack_tx, mut steer_ack_rx) = mpsc::unbounded_channel::(); + // Runtime-scoped compatibility recovery for ACP adapters that publish a + // visible Buzz result but fail to finish `session/prompt`. One pending + // timer per turn bounds task/channel growth even if the agent publishes + // several messages during the grace window. + let (self_publish_completion_tx, mut self_publish_completion_rx) = + mpsc::unbounded_channel::<(Uuid, String, String)>(); + let mut pending_self_publish_completions = HashSet::::new(); // ── Step 7: Shutdown signal ─────────────────────────────────────────────── let (shutdown_tx, mut shutdown_rx) = watch::channel(()); @@ -1701,6 +1804,11 @@ async fn tokio_main() -> Result<()> { Result(Box), Panic(tokio::task::JoinError), SteerAck(SteerAckEvent), + SelfPublishCompletion { + channel_id: Uuid, + turn_id: String, + event_id: String, + }, Wake(u32, Result), } @@ -1844,6 +1952,15 @@ async fn tokio_main() -> Result<()> { Some(ack_event) = steer_ack_rx.recv() => { Some(PoolEvent::SteerAck(ack_event)) } + Some((channel_id, turn_id, event_id)) = self_publish_completion_rx.recv(), + if config.self_publish_completion_grace_secs > 0 => + { + Some(PoolEvent::SelfPublishCompletion { + channel_id, + turn_id, + event_id, + }) + } Some((attempt, result)) = wake_rx.recv(), if config.lazy_pool && !pool_ready => { Some(PoolEvent::Wake(attempt, result)) } @@ -2024,6 +2141,29 @@ async fn tokio_main() -> Result<()> { continue; } + if buzz_event.event.pubkey.to_hex() == pubkey_hex + && kind_u32 == KIND_STREAM_MESSAGE + && config.self_publish_completion_grace_secs > 0 + { + if let Some(turn_id) = + in_flight_turn_id(&pool, buzz_event.channel_id) + { + let turn_id = turn_id.to_owned(); + if pending_self_publish_completions.insert(turn_id.clone()) { + let tx = self_publish_completion_tx.clone(); + let channel_id = buzz_event.channel_id; + let event_id = buzz_event.event.id.to_hex(); + let grace = Duration::from_secs( + config.self_publish_completion_grace_secs, + ); + tokio::spawn(async move { + tokio::time::sleep(grace).await; + let _ = tx.send((channel_id, turn_id, event_id)); + }); + } + } + } + if config.ignore_self && buzz_event.event.pubkey.to_hex() == pubkey_hex { tracing::debug!(channel_id = %buzz_event.channel_id, "dropping self-authored event"); continue; @@ -2533,6 +2673,34 @@ async fn tokio_main() -> Result<()> { typing_channels.insert(channel_id, thread_tags); } } + Some(PoolEvent::SelfPublishCompletion { + channel_id, + turn_id, + event_id, + }) => { + pending_self_publish_completions.remove(&turn_id); + if signal_exact_in_flight_task( + &mut pool, + channel_id, + &turn_id, + ControlSignal::PublishedResult, + ) { + tracing::warn!( + channel = %channel_id, + turn_id, + event_id, + grace_secs = config.self_publish_completion_grace_secs, + "self-authored result was published but ACP turn remained open — completing exact turn" + ); + } else { + tracing::debug!( + channel = %channel_id, + turn_id, + event_id, + "self-publish completion grace elapsed after turn already finished" + ); + } + } Some(PoolEvent::Wake(attempt, result)) => { let completion = result.as_ref().map(|_| ()).map_err(|error| error.clone()); if let Err(error) = @@ -2728,6 +2896,14 @@ fn is_owner_control_command( // ── signal_in_flight_task ───────────────────────────────────────────────────── +/// Return the turn id currently checked out for `channel_id`. +fn in_flight_turn_id(pool: &AgentPool, channel_id: uuid::Uuid) -> Option<&str> { + pool.task_map() + .values() + .find(|meta| meta.channel_id == Some(channel_id)) + .map(|meta| meta.turn_id.as_str()) +} + /// Decide which [`ControlSignal`] (if any) to send to an in-flight turn when a /// new, already-author-gated event arrives for that channel. /// @@ -2776,6 +2952,36 @@ fn signal_in_flight_task( false } +/// Send a control signal only when both channel and turn id still match. +/// +/// Delayed compatibility timers use this stricter boundary so a stale timer +/// from a completed turn can never cancel later work in the same channel. +fn signal_exact_in_flight_task( + pool: &mut AgentPool, + channel_id: uuid::Uuid, + turn_id: &str, + mode: ControlSignal, +) -> bool { + let entry = pool + .task_map_mut() + .values_mut() + .find(|meta| meta.channel_id == Some(channel_id) && meta.turn_id == turn_id); + + if let Some(meta) = entry { + if let Some(tx) = meta.control_tx.take() { + tracing::info!( + channel = %channel_id, + turn_id, + ?mode, + "exact-turn control signal sent to in-flight task" + ); + let _ = tx.send(mode); + return true; + } + } + false +} + /// Attempt the non-cancelling (ACP) steer for a freshly-queued event. /// /// Caller invariants: @@ -2937,6 +3143,9 @@ fn dispatch_pending( let (tx, rx) = tokio::sync::mpsc::channel::(1); agent.acp.install_steer_rx(rx); let steer_tx = Some(tx); + let (permission_tx, permission_rx) = + tokio::sync::mpsc::channel::(4); + agent.acp.install_permission_rx(permission_rx); // Prompt text is now built inside run_prompt_task (needs async for // context fetching). Pass None for prompt_text; batch carries the data. @@ -2966,6 +3175,7 @@ fn dispatch_pending( recoverable_batch, control_tx: Some(control_tx), steer_tx, + permission_tx: Some(permission_tx), }, ); dispatched_channels.push((channel_id, typing_scope)); @@ -3579,6 +3789,7 @@ fn dispatch_heartbeat( recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); *heartbeat_in_flight = true; @@ -3742,6 +3953,11 @@ async fn initialize_agent_pool( startup: &PoolStartup, mut shutdown: Option>, ) -> Result { + let pool_started_at = std::time::Instant::now(); + tracing::info!( + agents = startup.agents, + "ACP agent pool initialization started" + ); // One agent failing to start must not kill the whole pool. // Attempt each spawn under a 60-second timeout; a partial pool is valid. let mut agent_slots: Vec> = Vec::with_capacity(startup.agents as usize); @@ -3836,7 +4052,11 @@ async fn initialize_agent_pool( startup.agents ); } - tracing::info!("agent_pool_ready agents={}", live_count); + tracing::info!( + agents = live_count, + elapsed_ms = pool_started_at.elapsed().as_millis() as u64, + "agent_pool_ready" + ); Ok(AgentPool::from_slots(agent_slots)) } @@ -4321,6 +4541,7 @@ mod owner_control_command_tests { recoverable_batch: None, control_tx: Some(control_tx), steer_tx: None, + permission_tx: None, }, ); @@ -4341,6 +4562,120 @@ mod owner_control_command_tests { ControlSignal::Rotate )); } + + #[tokio::test] + async fn self_publish_completion_signal_requires_the_exact_turn() { + let mut pool = AgentPool::from_slots(vec![]); + let channel_id = Uuid::new_v4(); + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "current-turn".to_string(), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + permission_tx: None, + }, + ); + + assert_eq!(in_flight_turn_id(&pool, channel_id), Some("current-turn")); + assert!(!signal_exact_in_flight_task( + &mut pool, + channel_id, + "stale-turn", + ControlSignal::PublishedResult, + )); + assert!(signal_exact_in_flight_task( + &mut pool, + channel_id, + "current-turn", + ControlSignal::PublishedResult, + )); + assert_eq!(control_rx.await.unwrap(), ControlSignal::PublishedResult); + } + + #[tokio::test] + async fn permission_control_forwards_exact_option_and_preserves_numeric_request_id() { + let mut pool = AgentPool::from_slots(vec![None]); + let channel_id = Uuid::new_v4(); + let (permission_tx, mut permission_rx) = + tokio::sync::mpsc::channel::(1); + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "permission-turn".to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + permission_tx: Some(permission_tx), + }, + ); + + handle_permission_decision_control( + &serde_json::json!({ + "type": "permission_decision", + "channelId": channel_id, + "turnId": "permission-turn", + "requestId": 42, + "optionId": "allow-buzz-messages-in-workspace", + }), + &mut pool, + None, + ); + let decision = permission_rx + .recv() + .await + .expect("exact permission selection should be delivered"); + assert_eq!(decision.request_id, serde_json::json!(42)); + assert_eq!( + decision.selection, + pool::PermissionSelection::OptionId("allow-buzz-messages-in-workspace".to_string()) + ); + } + + #[tokio::test] + async fn permission_control_rejects_invalid_option_ids() { + let mut pool = AgentPool::from_slots(vec![None]); + let channel_id = Uuid::new_v4(); + let (permission_tx, mut permission_rx) = + tokio::sync::mpsc::channel::(1); + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "permission-turn".to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + permission_tx: Some(permission_tx), + }, + ); + + handle_permission_decision_control( + &serde_json::json!({ + "type": "permission_decision", + "channelId": channel_id, + "turnId": "permission-turn", + "requestId": 42, + "optionId": "", + }), + &mut pool, + None, + ); + assert!(matches!( + permission_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + } } #[cfg(test)] @@ -4963,6 +5298,7 @@ mod build_mcp_servers_tests { dedup_mode: config::DedupMode::Queue, multiple_event_handling: config::MultipleEventHandling::Queue, ignore_self: true, + self_publish_completion_grace_secs: 0, kinds_override: None, channels_override: None, no_mention_filter: false, @@ -4974,6 +5310,8 @@ mod build_mcp_servers_tests { memory_enabled: false, model: None, permission_mode: config::PermissionMode::BypassPermissions, + auto_approve_permissions: true, + interactive_permissions: false, respond_to: config::RespondTo::Anyone, respond_to_allowlist: std::collections::HashSet::new(), allowed_respond_to: vec![], @@ -5129,6 +5467,7 @@ mod error_outcome_emission_tests { dedup_mode: config::DedupMode::Queue, multiple_event_handling: config::MultipleEventHandling::Queue, ignore_self: true, + self_publish_completion_grace_secs: 0, kinds_override: None, channels_override: None, no_mention_filter: false, @@ -5140,6 +5479,8 @@ mod error_outcome_emission_tests { memory_enabled: false, model: None, permission_mode: config::PermissionMode::BypassPermissions, + auto_approve_permissions: true, + interactive_permissions: false, respond_to: config::RespondTo::Anyone, respond_to_allowlist: HashSet::new(), allowed_respond_to: vec![], @@ -5210,6 +5551,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); @@ -5286,6 +5628,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); started_rx.await.unwrap(); @@ -5378,6 +5721,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -5469,6 +5813,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -5574,6 +5919,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -5650,6 +5996,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -5744,6 +6091,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); let config = test_config(); @@ -5860,6 +6208,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -5999,6 +6348,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -6187,6 +6537,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -6272,6 +6623,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + permission_tx: None, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index cc537f86830..b60c127dff6 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -65,6 +65,35 @@ pub struct TaskMeta { /// tasks only — all prompt tasks install a steer channel regardless /// of the agent's name. pub steer_tx: Option>, + /// Owner-approved permission decisions for the in-flight turn. + /// + /// Decisions are matched to both `turn_id` and the ACP JSON-RPC request id + /// before the read loop may select `allow_once`. + pub permission_tx: Option>, +} + +/// One owner selection for an ACP `session/request_permission` request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionDecision { + pub request_id: serde_json::Value, + pub selection: PermissionSelection, +} + +/// How an owner-selected ACP permission option is identified. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PermissionSelection { + /// Exact `optionId` copied from the current permission request. + OptionId(String), + /// Backward-compatible selection by ACP option kind. + Kind(String), +} + +/// Failure to deliver an interactive permission decision to a live turn. +#[derive(Debug, PartialEq, Eq)] +pub enum PermissionDecisionError { + NoActiveTurn, + StaleTurn, + Unavailable, } /// Agent-level model capabilities. Populated on first session creation. @@ -263,6 +292,11 @@ fn apply_completed_before_control_signal( pub enum ControlSignal { /// Stop the current turn and drop its triggering batch. Cancel, + /// The agent already published an externally visible channel result, but + /// its ACP adapter did not finish `session/prompt` within the configured + /// compatibility grace. Stop the exact publishing turn, drop its already + /// satisfied batch, and report successful completion. + PublishedResult, /// Stop the current turn and requeue its triggering batch for a merged /// re-prompt framed as a **supersede**: the new request replaces the old. Interrupt, @@ -510,6 +544,10 @@ pub struct PromptContext { pub max_turns_per_session: u32, /// Permission mode to apply after session creation. `Default` = skip. pub permission_mode: PermissionMode, + /// Whether ACP permission requests may select `allow_once`. + pub auto_approve_permissions: bool, + /// Whether owner-signed controls may resolve ACP permission requests. + pub interactive_permissions: bool, /// Agent identity — used to derive the NIP-AE conversation key at /// session creation for core injection. pub agent_keys: nostr::Keys, @@ -661,6 +699,32 @@ impl AgentPool { .map_err(|e| SteerError::Transport(e.to_string())) } + /// Deliver an owner-approved permission decision to one exact in-flight + /// turn. Matching the turn id prevents a delayed control frame from + /// authorizing a later turn in the same channel. + pub fn send_permission_decision( + &mut self, + channel_id: Uuid, + turn_id: &str, + decision: PermissionDecision, + ) -> Result<(), PermissionDecisionError> { + let Some(meta) = self + .task_map + .values_mut() + .find(|meta| meta.channel_id == Some(channel_id)) + else { + return Err(PermissionDecisionError::NoActiveTurn); + }; + if meta.turn_id != turn_id { + return Err(PermissionDecisionError::StaleTurn); + } + let Some(tx) = meta.permission_tx.as_ref() else { + return Err(PermissionDecisionError::Unavailable); + }; + tx.try_send(decision) + .map_err(|_| PermissionDecisionError::Unavailable) + } + pub fn result_tx(&self) -> mpsc::UnboundedSender { self.result_tx.clone() } @@ -824,7 +888,6 @@ async fn create_session_and_apply_model( ), agent_canvas, ); - let resp = agent .acp .session_new_full( @@ -1241,6 +1304,7 @@ fn send_prompt_result( batch: Option, ) { agent.acp.clear_steer_rx(); + agent.acp.clear_permission_rx(); let _ = result_tx.send(PromptResult { agent, source, @@ -1271,6 +1335,13 @@ pub async fn run_prompt_task( control_rx: Option>, turn_id: String, ) { + agent + .acp + .set_auto_approve_permissions(ctx.auto_approve_permissions); + agent + .acp + .set_interactive_permissions(ctx.interactive_permissions); + // Is this a channel prompt or a heartbeat? let source = match &batch { Some(b) => PromptSource::Channel(b.channel_id), @@ -1576,7 +1647,6 @@ pub async fn run_prompt_task( "isNewSession": is_new_session, }), ); - if is_new_session { if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message) { @@ -1819,7 +1889,6 @@ pub async fn run_prompt_task( .collect(), None => prompt_sections.iter().map(String::as_str).collect(), }; - // When control_rx is Some (channel tasks), wrap the prompt in select! so // the main loop can cancel, interrupt, or rotate it. Heartbeats // (control_rx=None) take the simple await path — they are not controllable. @@ -1848,6 +1917,8 @@ pub async fn run_prompt_task( ) => result, mode = rx => { let control_signal = mode.unwrap_or(ControlSignal::Cancel); + let published_result = + matches!(control_signal, ControlSignal::PublishedResult); // Land the model switch before any cancel/requeue work: setting // `desired_model` here means the fresh session created by the // requeued turn (busy) or the next turn (already-completed) @@ -1878,7 +1949,11 @@ pub async fn run_prompt_task( observer_channel_id, &session_id, &turn_id, - Some(buzz_core::agent_turn_metric::StopReason::Cancelled), + Some(if published_result { + buzz_core::agent_turn_metric::StopReason::EndTurn + } else { + buzz_core::agent_turn_metric::StopReason::Cancelled + }), ) .await; send_prompt_result( @@ -1886,7 +1961,11 @@ pub async fn run_prompt_task( &turn_id, agent, source, - PromptOutcome::Cancelled, + if published_result { + PromptOutcome::Ok(StopReason::EndTurn) + } else { + PromptOutcome::Cancelled + }, retry_batch, ); return; @@ -2991,8 +3070,12 @@ fn requeue_cancelled_batch( let reason = match signal { ControlSignal::Steer => CancelReason::Steer, ControlSignal::Interrupt | ControlSignal::SwitchModel(_) => CancelReason::Interrupt, - // Cancel/Rotate discard the batch — no merged re-prompt. - ControlSignal::Cancel | ControlSignal::Rotate => return None, + // Cancel/Rotate discard the batch — no merged re-prompt. PublishedResult + // also discards it because the agent's self-authored channel event is + // evidence that the triggering work already produced its visible result. + ControlSignal::Cancel | ControlSignal::Rotate | ControlSignal::PublishedResult => { + return None; + } }; requeue_batch_if_queue(ctx, batch).map(|mut b| { b.cancel_reason = Some(reason); @@ -3653,6 +3736,59 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + #[tokio::test] + async fn permission_decision_requires_exact_turn_and_delivers_once() { + let channel_id = Uuid::new_v4(); + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + let (permission_tx, mut permission_rx) = + tokio::sync::mpsc::channel::(1); + pool.task_map_mut().insert( + task_id, + TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "turn-current".to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + permission_tx: Some(permission_tx), + }, + ); + + assert_eq!( + pool.send_permission_decision( + channel_id, + "turn-stale", + PermissionDecision { + request_id: json!("request-1"), + selection: PermissionSelection::Kind("allow_once".to_string()), + }, + ), + Err(PermissionDecisionError::StaleTurn) + ); + + pool.send_permission_decision( + channel_id, + "turn-current", + PermissionDecision { + request_id: json!("request-1"), + selection: PermissionSelection::OptionId("allow-workspace".to_string()), + }, + ) + .expect("matching owner decision should be delivered"); + + let delivered = permission_rx + .recv() + .await + .expect("permission decision should arrive"); + assert_eq!(delivered.request_id, json!("request-1")); + assert_eq!( + delivered.selection, + PermissionSelection::OptionId("allow-workspace".to_string()) + ); + } + // These pin the initial_message dispatch path (run_prompt_task, ~line 855): // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. @@ -4466,6 +4602,7 @@ mod tests { ), (ControlSignal::Cancel, None), (ControlSignal::Rotate, None), + (ControlSignal::PublishedResult, None), ]; let mut ctx = make_prompt_context_no_owner(); ctx.dedup_mode = DedupMode::Queue; @@ -4556,6 +4693,15 @@ mod tests { expected_reason: None, invalidate_all: false, }, + Case { + name: "CancelDrainTimeout + PublishedResult drops the satisfied batch", + error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), + signal: ControlSignal::PublishedResult, + expected_outcome: "CancelDrainTimeout", + batch_preserved: false, + expected_reason: None, + invalidate_all: false, + }, Case { name: "CancelDrainTimeout + Interrupt preserves batch with Interrupt reason", error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), @@ -5302,6 +5448,8 @@ mod tests { context_message_limit: 0, max_turns_per_session: 0, permission_mode: PermissionMode::Default, + auto_approve_permissions: true, + interactive_permissions: false, agent_keys: agent_keys.clone(), agent_owner_pubkey: owner_pubkey, memory_enabled: false, diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf4..f2026002b32 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1171,6 +1171,21 @@ fn append_new_thread_reply_instruction(s: &mut String, event_id: &str) { )); } +/// Append the destination instruction for an ordinary top-level DM response. +/// +/// Omitting a reply anchor is not enough for every ACP runtime: an agent may +/// still choose to thread a conversational response. State the main-timeline +/// destination explicitly while preserving an explicit human request to open a +/// thread. +fn append_top_level_dm_reply_instruction(s: &mut String) { + s.push_str( + "\nIMPORTANT: This is a top-level DM message. For ordinary replies in \ + this turn, use `buzz messages send` without `--reply-to` so the answer \ + appears directly in the DM's main timeline. Only use `--reply-to` if \ + the human explicitly asks for a threaded response.", + ); +} + /// Decide whether a turn is human-facing for reply-anchor purposes. /// /// A turn is human-facing when the triggering sender is a human, OR a human @@ -1275,6 +1290,8 @@ fn format_context_hints( if let Some(event_id) = reply_anchor { append_reply_instruction(&mut s, event_id); } + } else { + append_top_level_dm_reply_instruction(&mut s); } s } else if let Some(ref root) = thread_tags.root_event_id { @@ -1463,7 +1480,8 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec + + + diff --git a/desktop/scripts/build-buzz-for-devin-config.mjs b/desktop/scripts/build-buzz-for-devin-config.mjs new file mode 100644 index 00000000000..d1d34126e75 --- /dev/null +++ b/desktop/scripts/build-buzz-for-devin-config.mjs @@ -0,0 +1,53 @@ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const PRODUCT_NAME = "Buzz for Devin"; +const IDENTIFIER = "community.buzzfordevin.desktop"; +const DEEP_LINK_SCHEME = "buzz-for-devin"; +const outputConfigPath = resolve( + process.cwd(), + "src-tauri/tauri.buzz-for-devin.conf.json", +); + +const updaterPublicKey = process.env.BUZZ_UPDATER_PUBLIC_KEY?.trim(); +const updaterEndpoint = process.env.BUZZ_UPDATER_ENDPOINT?.trim(); +if (Boolean(updaterPublicKey) !== Boolean(updaterEndpoint)) { + console.error( + "BUZZ_UPDATER_PUBLIC_KEY and BUZZ_UPDATER_ENDPOINT must be supplied together", + ); + process.exit(1); +} + +const releaseConfig = { + productName: PRODUCT_NAME, + identifier: IDENTIFIER, + bundle: { + createUpdaterArtifacts: Boolean(updaterPublicKey), + macOS: { + infoPlist: "Info.buzz-for-devin.plist", + minimumSystemVersion: "11.0", + }, + }, + plugins: { + "deep-link": { + desktop: { + schemes: [DEEP_LINK_SCHEME], + }, + }, + updater: updaterPublicKey + ? { + pubkey: updaterPublicKey, + endpoints: [updaterEndpoint], + } + : { + endpoints: [], + }, + }, +}; + +const formattedConfig = JSON.stringify(releaseConfig, null, 2).replace( + /\[\n\s+"([^"\n]+)"\n\s+\]/g, + '["$1"]', +); +writeFileSync(outputConfigPath, `${formattedConfig}\n`); +console.log(`Wrote isolated ${PRODUCT_NAME} config to ${outputConfigPath}`); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5835f8b39b2..0024951f42b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2779,7 +2779,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin 0.9.9", ] [[package]] @@ -2882,7 +2882,7 @@ dependencies = [ "diatomic-waker", "futures-core", "pin-project-lite", - "spin 0.10.0", + "spin 0.10.1", ] [[package]] @@ -5899,9 +5899,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.4" +version = "0.44.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98cf5d15d70d1f8f4059e5f79923ac15891eb691d2843d01191e0585fb064d70" +checksum = "0112d3433a5550ba13481970d5b6844714510ddcdaca4d9d0aa6e7b83f270271" dependencies = [ "base64 0.22.1", "bech32", @@ -9201,18 +9201,18 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spki" diff --git a/desktop/src-tauri/Info.buzz-for-devin.plist b/desktop/src-tauri/Info.buzz-for-devin.plist new file mode 100644 index 00000000000..c0426bf90eb --- /dev/null +++ b/desktop/src-tauri/Info.buzz-for-devin.plist @@ -0,0 +1,16 @@ + + + + + CFBundleDisplayName + Buzz for Devin + CFBundleName + Buzz for Devin + NSMicrophoneUsageDescription + Buzz for Devin needs microphone access for voice huddles. + NSCameraUsageDescription + Buzz for Devin needs camera access to record animated avatars. + NSLocalNetworkUsageDescription + Buzz for Devin uses your local network for optional Share Compute and local relay connections. Remote messaging does not require this access. + + diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 0fb3747718a..4ee27a2ec2e 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -9,6 +9,10 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP"); println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY"); println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_ENDPOINT"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_KEYRING_SERVICE"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DEEP_LINK_SCHEME"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_NEST_DIR"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_CLI_LINK_NAME"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_PROVIDER"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); @@ -26,6 +30,68 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_HTTP={relay_http}"); } + if let Ok(service) = std::env::var("BUZZ_BUILD_KEYRING_SERVICE") { + let service = service.trim(); + assert!( + !service.is_empty() + && service.len() <= 128 + && service + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')), + "BUZZ_BUILD_KEYRING_SERVICE must be 1-128 ASCII letters, digits, '.', '_', or '-'" + ); + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_KEYRING_SERVICE={service}"); + } + + if let Ok(scheme) = std::env::var("BUZZ_BUILD_DEEP_LINK_SCHEME") { + let scheme = scheme.trim(); + assert!( + !scheme.is_empty() + && scheme.len() <= 64 + && scheme + .bytes() + .enumerate() + .all(|(index, byte)| if index == 0 { + byte.is_ascii_lowercase() + } else { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'+' | b'-' | b'.') + }), + "BUZZ_BUILD_DEEP_LINK_SCHEME must be a lowercase RFC 3986 URI scheme" + ); + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DEEP_LINK_SCHEME={scheme}"); + } + + if let Ok(nest_dir) = std::env::var("BUZZ_BUILD_NEST_DIR") { + let nest_dir = nest_dir.trim(); + assert!( + nest_dir.starts_with('.') + && nest_dir.len() >= 2 + && nest_dir.len() <= 64 + && nest_dir.as_bytes()[1].is_ascii_alphanumeric() + && nest_dir + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')), + "BUZZ_BUILD_NEST_DIR must be a hidden directory basename of 2-64 ASCII letters, digits, '.', '_', or '-'" + ); + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_NEST_DIR={nest_dir}"); + } + + if let Ok(link_name) = std::env::var("BUZZ_BUILD_CLI_LINK_NAME") { + let link_name = link_name.trim(); + assert!( + !link_name.is_empty() + && link_name.len() <= 64 + && link_name.as_bytes()[0].is_ascii_alphanumeric() + && link_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')), + "BUZZ_BUILD_CLI_LINK_NAME must be 1-64 ASCII letters, digits, '.', '_', or '-'" + ); + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_CLI_LINK_NAME={link_name}"); + } + if let Ok(provider) = std::env::var("BUZZ_BUILD_BUZZ_AGENT_PROVIDER") { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUZZ_AGENT_PROVIDER={provider}"); } diff --git a/desktop/src-tauri/src/app_state_keyring.rs b/desktop/src-tauri/src/app_state_keyring.rs index 68d24e87f58..82e389c1d3a 100644 --- a/desktop/src-tauri/src/app_state_keyring.rs +++ b/desktop/src-tauri/src/app_state_keyring.rs @@ -6,6 +6,10 @@ fn dev_keyring_service(configured: Option) -> String { .unwrap_or_else(|| "buzz-desktop-dev".to_string()) } +fn release_keyring_service(configured: Option<&'static str>) -> &'static str { + configured.unwrap_or("buzz-desktop") +} + pub(crate) fn keyring_service() -> &'static str { if cfg!(debug_assertions) { static DEV_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -13,7 +17,7 @@ pub(crate) fn keyring_service() -> &'static str { .get_or_init(|| dev_keyring_service(std::env::var("BUZZ_DEV_KEYRING_SERVICE").ok())) .as_str() } else { - "buzz-desktop" + release_keyring_service(option_env!("BUZZ_DESKTOP_BUILD_KEYRING_SERVICE")) } } @@ -27,7 +31,7 @@ pub(super) fn migration_marker_name(service: &str, default_name: &str) -> String #[cfg(test)] mod tests { - use super::{dev_keyring_service, migration_marker_name}; + use super::{dev_keyring_service, migration_marker_name, release_keyring_service}; #[test] fn standalone_scope_must_remain_under_dev_service() { @@ -56,4 +60,13 @@ mod tests { "identity.buzz-desktop-dev.example.migrated" ); } + + #[test] + fn release_keyring_service_defaults_to_upstream_and_accepts_build_override() { + assert_eq!(release_keyring_service(None), "buzz-desktop"); + assert_eq!( + release_keyring_service(Some("buzz-for-devin-desktop")), + "buzz-for-devin-desktop" + ); + } } diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs index bba0f338602..388a28c7d41 100644 --- a/desktop/src-tauri/src/commands/agent_auth.rs +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -68,6 +68,12 @@ pub async fn connect_acp_runtime( } fn discover_acp_auth_methods_blocking(runtime_id: &str) -> Result { + if let Some(runtime) = known_acp_runtime_exact(runtime_id) { + if let Some(methods) = catalog_cli_auth_methods(runtime) { + return Ok(methods); + } + } + let output = run_buzz_acp_auth_command(runtime_id, ["auth-methods", "--json"])?; if !output.status.success() { return Err(command_error("buzz-acp auth-methods", &output)); @@ -77,6 +83,23 @@ fn discover_acp_auth_methods_blocking(runtime_id: &str) -> Result Option { + let command = runtime.auth_login_args?; + Some(AcpAuthMethodsResult { + methods: vec![AcpAuthMethod { + id: "cli-login".to_string(), + name: format!("Sign in to {}", runtime.label), + description: runtime.login_hint.map(str::to_string), + method_type: Some("terminal".to_string()), + args: Vec::new(), + command: command.iter().map(|arg| (*arg).to_string()).collect(), + meta: None, + }], + }) +} + fn connect_acp_runtime_blocking( request: &ConnectAcpRuntimeRequest, ) -> Result { @@ -256,7 +279,7 @@ fn launch_terminal_auth(runtime_id: &str, method: &AcpAuthMethod) -> Result<(), .ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?; let fallback_command = adapter_command.1.display().to_string(); let argv = adapter_terminal_argv(runtime.label, method, &fallback_command)?; - launch_visible_terminal(&argv) + launch_visible_terminal(&argv, runtime.scrub_env_vars) } fn adapter_terminal_argv( @@ -361,15 +384,16 @@ fn spawn_without_stdio(mut command: Command) -> Result<(), String> { } #[cfg(target_os = "macos")] -fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { +fn launch_visible_terminal(argv: &[String], scrub_env_vars: &[&str]) -> Result<(), String> { let mut script = tempfile::Builder::new() .prefix("buzz-auth-") .suffix(".command") .tempfile() .map_err(|error| format!("failed to create terminal login script: {error}"))?; + let unset_commands = shell_unset_commands(scrub_env_vars); writeln!( script, - "#!/bin/sh\ntrap 'rm -f -- \"$0\"' EXIT\n{}", + "#!/bin/sh\ntrap 'rm -f -- \"$0\"' EXIT\n{unset_commands}{}", shell_join(argv) ) .map_err(|error| format!("failed to write terminal login script: {error}"))?; @@ -395,8 +419,12 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { } #[cfg(target_os = "linux")] -fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { - let command = shell_join(argv); +fn launch_visible_terminal(argv: &[String], scrub_env_vars: &[&str]) -> Result<(), String> { + let command = format!( + "{}{}", + shell_unset_commands(scrub_env_vars), + shell_join(argv) + ); let candidates: [(&str, &[&str]); 4] = [ ("x-terminal-emulator", &["-e", "sh", "-lc"]), ("gnome-terminal", &["--", "sh", "-lc"]), @@ -406,6 +434,9 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { for (terminal, prefix) in candidates { let mut terminal_command = Command::new(terminal); terminal_command.args(prefix).arg(&command); + for key in scrub_env_vars { + terminal_command.env_remove(key); + } if spawn_without_stdio(terminal_command).is_ok() { return Ok(()); } @@ -414,7 +445,7 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { } #[cfg(target_os = "windows")] -fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { +fn launch_visible_terminal(argv: &[String], scrub_env_vars: &[&str]) -> Result<(), String> { use std::os::windows::process::CommandExt; const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; @@ -425,6 +456,9 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { command .args(windows_terminal_args(argv)) .creation_flags(CREATE_NEW_CONSOLE); + for key in scrub_env_vars { + command.env_remove(key); + } spawn_without_stdio(command) } @@ -436,10 +470,18 @@ fn windows_terminal_args(argv: &[String]) -> Vec { } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn launch_visible_terminal(_argv: &[String]) -> Result<(), String> { +fn launch_visible_terminal(_argv: &[String], _scrub_env_vars: &[&str]) -> Result<(), String> { Err("opening a terminal is not supported on this platform".to_string()) } +#[cfg(any(target_os = "macos", target_os = "linux", test))] +fn shell_unset_commands(scrub_env_vars: &[&str]) -> String { + scrub_env_vars + .iter() + .map(|key| format!("unset {}\n", shell_escape(key))) + .collect() +} + fn shell_join(argv: &[String]) -> String { argv.iter() .map(|arg| shell_escape(arg)) @@ -461,11 +503,35 @@ fn shell_escape(arg: &str) -> String { #[cfg(test)] mod tests { use super::{ - adapter_terminal_argv, append_inherited_path, is_claude_subscription_login, - run_buzz_acp_auth_command_with_paths, shell_escape, shell_join, uses_terminal_auth, - windows_terminal_args, AcpAuthMethod, + adapter_terminal_argv, append_inherited_path, catalog_cli_auth_methods, + is_claude_subscription_login, run_buzz_acp_auth_command_with_paths, shell_escape, + shell_join, shell_unset_commands, uses_terminal_auth, windows_terminal_args, AcpAuthMethod, }; + #[test] + fn devin_uses_catalog_declared_visible_terminal_login() { + let runtime = + crate::managed_agents::known_acp_runtime_exact("devin").expect("Devin runtime"); + let result = catalog_cli_auth_methods(runtime).expect("catalog CLI login method"); + + assert_eq!(result.methods.len(), 1); + assert_eq!(result.methods[0].id, "cli-login"); + assert_eq!(result.methods[0].method_type.as_deref(), Some("terminal")); + assert_eq!( + result.methods[0].command, + ["devin", "auth", "login"].map(str::to_string) + ); + } + + #[test] + fn terminal_login_scrubs_only_catalog_declared_identity_overrides() { + assert_eq!( + shell_unset_commands(&["WINDSURF_API_KEY"]), + "unset WINDSURF_API_KEY\n" + ); + assert!(shell_unset_commands(&[]).is_empty()); + } + /// Windows regression: the augmented PATH there holds only Buzz-managed /// dirs and the exe parent (no login-shell PATH, no managed Node), so the /// user's inherited PATH must be appended for npm `.cmd` adapters to find diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index f6094ed5298..717f3fe770b 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -599,40 +599,8 @@ mod tests { use super::*; use crate::managed_agents::{BackendKind, RespondTo}; - fn goose_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: "", - mcp_command: None, - mcp_hooks: false, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "", - adapter_install_instructions_url: "", - cli_install_hint: "", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[], - config_file_path: Some("~/.config/goose/config.yaml"), - config_file_format: Some("yaml"), - supports_acp_native_config: true, - thinking_env_var: Some("GOOSE_THINKING_EFFORT"), - max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), - context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - } - } + mod fixtures; + use fixtures::goose_runtime; fn agent_record() -> ManagedAgentRecord { ManagedAgentRecord { diff --git a/desktop/src-tauri/src/commands/agent_config/tests/fixtures.rs b/desktop/src-tauri/src/commands/agent_config/tests/fixtures.rs new file mode 100644 index 00000000000..1a4ec99c3fa --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_config/tests/fixtures.rs @@ -0,0 +1,50 @@ +use super::*; + +pub(super) fn goose_runtime() -> &'static KnownAcpRuntime { + &KnownAcpRuntime { + id: "goose", + label: "Goose", + display_label: "Goose", + sort_priority: 1, + onboarding_visible: false, + commands: &["goose"], + aliases: &[], + default_args: &["acp"], + default_parallelism: None, + defer_agent_start_until_work: true, + default_idle_timeout_secs: None, + icon_url: "", + icon_scale: 1.0, + avatar_url: "", + superseded_avatar_urls: &[], + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "", + adapter_install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + accepts_harness_model: true, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[], + enforced_env: &[], + scrub_env_vars: &[], + config_file_path: Some("~/.config/goose/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: true, + thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), + context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + auth_login_args: None, + } +} diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 7b1979d654d..0693d416a81 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1067,18 +1067,18 @@ pub async fn discover_managed_agent_prereqs( #[tauri::command] pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { - // Query kind:10100 agent profile events from the relay. + // Kind:30177 carries identity + inbound-author policy; 10100 is channel-add. let events = query_relay( &state, &[serde_json::json!({ - "kinds": [10100], + "kinds": [buzz_core_pkg::kind::KIND_MANAGED_AGENT], })], ) .await?; // The convert helper returns `{"agents": [...]}`. Extract and re-deserialize // into the strongly-typed `Vec` the frontend expects. - let value = nostr_convert::agents_from_events(&events); + let value = nostr_convert::managed_agents_from_events(&events); let agents = value .get("agents") .cloned() diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c65900b5dee..c65fcc728aa 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -7,11 +7,11 @@ use crate::{ build_managed_agent_summary, current_instance_id, discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, load_teams, managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, - provider_deploy, resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, stop_managed_agent_workspace_pair, + provider_deploy, resolve_agent_parallelism, resolve_provider_binary, save_managed_agents, + start_managed_agent_process, stop_managed_agent_process, stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, - ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, + ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, @@ -822,6 +822,7 @@ pub async fn create_managed_agent( input.parallelism, linked_persona.as_ref(), )?; + let resolved_parallelism = resolve_agent_parallelism(minted.parallelism, &agent_command); let record = crate::managed_agents::ManagedAgentRecord { pubkey: pubkey.clone(), @@ -850,7 +851,7 @@ pub async fn create_managed_agent( // 0 or None → harness uses its own default (320s idle, 3600s max), and the CLI also clamps 0 → minimum. idle_timeout_seconds: input.idle_timeout_seconds.filter(|s| *s > 0), max_turn_duration_seconds: input.max_turn_duration_seconds.filter(|s| *s > 0), - parallelism: minted.parallelism.unwrap_or(DEFAULT_AGENT_PARALLELISM), + parallelism: resolved_parallelism, system_prompt: snapshot_prompt.or_else(|| { input .system_prompt diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 1b2e0ed179a..8a7c3f5f38e 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -86,6 +86,27 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen } } +#[test] +fn created_devin_agents_default_to_one_worker() { + assert_eq!(resolve_agent_parallelism(None, "devin"), 1); +} + +#[test] +fn created_existing_runtime_agents_keep_the_global_default() { + for command in ["goose", "claude-agent-acp", "codex-acp", "buzz-agent"] { + assert_eq!( + resolve_agent_parallelism(None, command), + crate::managed_agents::DEFAULT_AGENT_PARALLELISM, + "{command}" + ); + } +} + +#[test] +fn explicit_or_persona_parallelism_overrides_runtime_default() { + assert_eq!(resolve_agent_parallelism(Some(3), "devin"), 3); +} + /// Auto-archive uses the same NIP-IA wire builder as the explicit GUI action, /// attaches owner consent, and marks a deliberate delete as `retired`. #[test] diff --git a/desktop/src-tauri/src/commands/project_repo_paths.rs b/desktop/src-tauri/src/commands/project_repo_paths.rs index 4193327c012..5acca6db607 100644 --- a/desktop/src-tauri/src/commands/project_repo_paths.rs +++ b/desktop/src-tauri/src/commands/project_repo_paths.rs @@ -147,12 +147,26 @@ pub(crate) fn find_local_repo_dir( pub(crate) fn default_repos_root_candidates() -> Vec { let mut candidates = Vec::new(); candidates.extend(nest_dir().map(|path| path.join("REPOS"))); + append_upstream_repos_fallback( + &mut candidates, + dirs::home_dir(), + crate::managed_agents::uses_upstream_nest_namespace(), + ); + candidates +} + +fn append_upstream_repos_fallback( + candidates: &mut Vec, + home: Option, + uses_upstream_namespace: bool, +) { + if !uses_upstream_namespace { + return; + } candidates.extend( - dirs::home_dir() - .map(|home| home.join(".buzz").join("REPOS")) + home.map(|home| home.join(".buzz").join("REPOS")) .filter(|path| !candidates.iter().any(|candidate| candidate == path)), ); - candidates } pub(crate) fn canonicalize_repos_root( @@ -190,3 +204,32 @@ pub(crate) fn canonical_repos_roots( } Ok(roots) } + +#[cfg(test)] +mod tests { + use super::append_upstream_repos_fallback; + + #[test] + fn isolated_build_does_not_fall_back_to_upstream_buzz_repos() { + let home = std::path::PathBuf::from("/Users/example"); + let mut candidates = vec![home.join(".buzz-for-devin/REPOS")]; + + append_upstream_repos_fallback(&mut candidates, Some(home), false); + + assert_eq!(candidates.len(), 1); + assert!(candidates[0].ends_with(".buzz-for-devin/REPOS")); + } + + #[test] + fn upstream_build_preserves_legacy_buzz_repos_fallback() { + let home = std::path::PathBuf::from("/Users/example"); + let mut candidates = vec![home.join(".buzz-dev/REPOS")]; + + append_upstream_repos_fallback(&mut candidates, Some(home.clone()), true); + + assert_eq!( + candidates, + vec![home.join(".buzz-dev/REPOS"), home.join(".buzz/REPOS")] + ); + } +} diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc367..8b83698d375 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -291,7 +291,17 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result &'static str { + option_env!("BUZZ_DESKTOP_BUILD_DEEP_LINK_SCHEME").unwrap_or(DEFAULT_DEEP_LINK_SCHEME) +} + +fn is_supported_deep_link_scheme(scheme: &str) -> bool { + scheme == configured_deep_link_scheme() || scheme == DEFAULT_DEEP_LINK_SCHEME +} + +/// Handle an incoming Buzz deep link URL. /// /// Currently supports: /// - `buzz://connect?relay=` — emits `deep-link-connect` to the frontend @@ -304,7 +314,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } }; - if url.scheme() != "buzz" { + if !is_supported_deep_link_scheme(url.scheme()) { eprintln!("buzz-desktop: ignoring unsupported deep link scheme: {url_str}"); return; } @@ -386,8 +396,16 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { #[cfg(test)] mod tests { + use super::{configured_deep_link_scheme, is_supported_deep_link_scheme}; use url::Url; + #[test] + fn configured_scheme_keeps_legacy_buzz_links_compatible() { + assert!(is_supported_deep_link_scheme(configured_deep_link_scheme())); + assert!(is_supported_deep_link_scheme("buzz")); + assert!(!is_supported_deep_link_scheme("https")); + } + use super::{ parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c5b71987cec..3fe1ea11096 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -395,6 +395,21 @@ pub fn run() { migration::run_boot_migrations(&app_handle); } + // Reclaim subprocesses left behind by an ungraceful prior exit + // before the webview can send messages. Agent restoration remains + // deferred until apply_workspace installs the correct relay, but + // cleanup needs neither relay nor identity and must not share that + // delay: a stale lazy harness can still subscribe as the same agent + // and consume the first post-relaunch prompt. + // + // The single-instance plugin has already admitted this process. + // Receipt, same-instance marker, and exact-bundle checks keep + // upstream Buzz plus other bundle identifiers out of scope. + let startup_instance_id = managed_agents::current_instance_id(&app_handle); + managed_agents::sweep_orphaned_agent_processes(&app_handle, &[]); + managed_agents::sweep_system_agent_processes(&startup_instance_id, &[]); + managed_agents::sweep_untracked_bundle_harnesses(&[]); + // Resolve persisted identity key (env var → file → generate+save). // This is fatal — the app should not start with an ephemeral identity // that will be lost on restart, as that silently breaks channel diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index ba4407d164d..90b6ae66a0e 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -102,7 +102,18 @@ pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventCont }, parallelism: record.parallelism, respond_to: record.respond_to, - respond_to_allowlist: record.respond_to_allowlist.clone(), + // The allowlist only means something in allowlist mode. The instance + // record deliberately retains it across mode toggles so an owner can + // flip away and back without retyping entries, but publishing it under + // another mode would advertise pubkeys the owner has already revoked — + // the same reason `apply_persona_behavior` clears it on the definition + // side. Spawn agrees: `build_respond_to_env` emits no allowlist + // variable unless the mode is Allowlist. + respond_to_allowlist: if record.respond_to == RespondTo::Allowlist { + record.respond_to_allowlist.clone() + } else { + Vec::new() + }, } } @@ -336,6 +347,44 @@ mod tests { assert_eq!(a, b); } + /// Revoking an allowlist by switching modes must not keep advertising the + /// revoked pubkeys. The record retains them on purpose (so the owner can + /// toggle back without retyping), so the projection is what has to drop + /// them — otherwise a revoked association stays publicly readable. + #[test] + fn projection_omits_allowlist_for_non_allowlist_modes() { + let mut agent = sample_agent(); + agent.respond_to = RespondTo::Allowlist; + agent.respond_to_allowlist = vec!["a".repeat(64)]; + assert_eq!( + agent_event_content(&agent).respond_to_allowlist, + vec!["a".repeat(64)], + "allowlist mode must still publish its entries" + ); + + // `nobody` is intentionally absent from this enum (harness-only). + for mode in [RespondTo::OwnerOnly, RespondTo::Anyone] { + let mut revoked = agent.clone(); + revoked.respond_to = mode; + let content = agent_event_content(&revoked); + assert!( + content.respond_to_allowlist.is_empty(), + "{mode:?} must not publish a retained allowlist" + ); + assert!( + !serde_json::to_string(&content) + .unwrap() + .contains(&"a".repeat(64)), + "{mode:?} projection must not carry the revoked pubkey on the wire" + ); + assert_eq!( + revoked.respond_to_allowlist, + vec!["a".repeat(64)], + "the local record keeps its entries for mode round-tripping" + ); + } + } + /// Mutating only runtime fields must NOT change the projection — the /// guarantee that operational start/stop never republishes. #[test] diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 372d2cfde1e..4539848bb74 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -205,6 +205,8 @@ pub(crate) fn read_config_surface( RuntimeConfigSurface { runtime_id: runtime_meta.map(|m| m.id.to_string()), runtime_label: runtime_meta.map(|m| m.label.to_string()), + supports_buzz_model_config: runtime_meta + .map(|m| m.model_env_var.is_some() || m.supports_acp_model_switching), is_pre_spawn, normalized, advanced, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 4c11cd6c49e..c52f6c804de 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -31,9 +31,19 @@ fn test_runtime() -> &'static KnownAcpRuntime { &KnownAcpRuntime { id: "goose", label: "Goose", + display_label: "Goose", + sort_priority: 1, + onboarding_visible: false, commands: &["goose"], aliases: &[], + default_args: &["acp"], + default_parallelism: None, + defer_agent_start_until_work: true, + default_idle_timeout_secs: None, + icon_url: "", + icon_scale: 1.0, avatar_url: "", + superseded_avatar_urls: &[], mcp_command: None, mcp_hooks: false, underlying_cli: None, @@ -46,10 +56,13 @@ fn test_runtime() -> &'static KnownAcpRuntime { adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: false, + accepts_harness_model: true, model_env_var: Some("GOOSE_MODEL"), provider_env_var: Some("GOOSE_PROVIDER"), provider_locked: false, default_env: &[], + enforced_env: &[], + scrub_env_vars: &[], config_file_path: Some("~/.config/goose/config.yaml"), config_file_format: Some("yaml"), supports_acp_native_config: true, @@ -59,6 +72,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + auth_login_args: None, } } @@ -131,6 +145,18 @@ fn pre_spawn_surface_reports_pending_acp_tiers() { ConfigTierStatus::Pending ); assert_eq!(surface.sources.env_vars, ConfigTierStatus::Available); + assert_eq!(surface.supports_buzz_model_config, Some(true)); +} + +#[test] +fn devin_surface_projects_runtime_owned_model_capability() { + let record = test_record(); + let runtime = + crate::managed_agents::known_acp_runtime("devin").expect("Devin must remain cataloged"); + let surface = read_config_surface(&record, Some(runtime), None, None); + + assert_eq!(surface.runtime_id.as_deref(), Some("devin")); + assert_eq!(surface.supports_buzz_model_config, Some(false)); } #[test] @@ -607,9 +633,19 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { &KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", + display_label: "Buzz", + sort_priority: 0, + onboarding_visible: false, commands: &["buzz-agent"], aliases: &[], + default_args: &[], + default_parallelism: None, + defer_agent_start_until_work: true, + default_idle_timeout_secs: None, + icon_url: "", + icon_scale: 1.0, avatar_url: "", + superseded_avatar_urls: &[], mcp_command: None, mcp_hooks: false, underlying_cli: None, @@ -622,10 +658,13 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: true, + accepts_harness_model: true, model_env_var: Some("BUZZ_AGENT_MODEL"), provider_env_var: Some("BUZZ_AGENT_PROVIDER"), provider_locked: false, default_env: &[], + enforced_env: &[], + scrub_env_vars: &[], config_file_path: None, config_file_format: None, supports_acp_native_config: false, @@ -635,6 +674,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + auth_login_args: None, } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 15ccb718e7f..33559aff383 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -132,6 +132,8 @@ pub struct ConfigSourceReport { pub struct RuntimeConfigSurface { pub runtime_id: Option, pub runtime_label: Option, + /// Catalog-projected model capability. `None` for unknown runtimes. + pub supports_buzz_model_config: Option, pub is_pre_spawn: bool, pub normalized: NormalizedConfig, pub advanced: Vec, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index ab769652595..7b846a588b1 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -7,18 +7,19 @@ use std::time::{Duration, Instant}; use crate::managed_agents::{ buzz_managed_command_path, buzz_managed_node_bin_dir, buzz_managed_npm_bin_dir, AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, + DEFAULT_AGENT_PARALLELISM, }; +mod runtime_catalog; mod runtime_metadata; +use runtime_catalog::KNOWN_ACP_RUNTIMES; +#[cfg(test)] +use runtime_catalog::{ + BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, +}; pub(crate) use runtime_metadata::KnownAcpRuntime; -const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; -const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; -const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; -const BUZZ_AGENT_AVATAR_URL: &str = - "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; - fn common_binary_paths() -> &'static [PathBuf] { static PATHS: OnceLock> = OnceLock::new(); PATHS.get_or_init(|| { @@ -62,140 +63,6 @@ fn common_binary_paths() -> &'static [PathBuf] { }) } -const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ - KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: GOOSE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("goose"), - cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], - // Goose's stable release currently publishes only the Unix installer; - // its official Windows instructions intentionally point at this main-branch script. - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex\""], - adapter_install_commands: &[], - cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", - adapter_install_instructions_url: "", - cli_install_hint: "Buzz requires the Goose CLI; the desktop app alone is not enough.", - adapter_install_hint: "", - skill_dir: Some(".goose/skills"), - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[("GOOSE_MODE", "auto")], - config_file_path: Some("~/.config/goose/config.yaml"), - config_file_format: Some("yaml"), - supports_acp_native_config: true, - thinking_env_var: Some("GOOSE_THINKING_EFFORT"), - max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), - context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - }, - KnownAcpRuntime { - id: "claude", - label: "Claude Code", - commands: &["claude-agent-acp", "claude-code-acp"], - aliases: &["claude-code", "claudecode"], - avatar_url: CLAUDE_CODE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("claude"), - cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""], - adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], - cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", - adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "Buzz requires the Claude Code CLI; the desktop app alone is not enough.", - adapter_install_hint: "Install the Claude Code ACP adapter via npm.", - skill_dir: Some(".claude/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: true, - default_env: &[], - config_file_path: Some("~/.claude/settings.json"), - config_file_format: Some("json"), - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - required_normalized_fields: &[], - login_hint: Some("Run the Claude CLI to complete authentication."), - auth_probe_args: Some(&["claude", "auth", "status"]), - }, - KnownAcpRuntime { - id: "codex", - label: "Codex", - commands: &["codex-acp"], - aliases: &[], - avatar_url: CODEX_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: false, - underlying_cli: Some("codex"), - cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""], - adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], - cli_install_instructions_url: "https://developers.openai.com/codex/cli/", - adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "Buzz requires the Codex CLI; the desktop app alone is not enough.", - adapter_install_hint: "Install the Codex ACP adapter via npm.", - skill_dir: Some(".codex/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: false, - default_env: &[], - config_file_path: Some("~/.codex/config.toml"), - config_file_format: Some("toml"), - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - required_normalized_fields: &[], - login_hint: Some("Run `codex login` to authenticate."), - // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. - auth_probe_args: Some(&["codex", "login", "status"]), - }, - KnownAcpRuntime { - id: "buzz-agent", - label: "Buzz Agent", - commands: &["buzz-agent"], - aliases: &[], - avatar_url: BUZZ_AGENT_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: true, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "https://github.com/block/buzz", - adapter_install_instructions_url: "https://github.com/block/buzz", - cli_install_hint: "Ships with the Buzz desktop app.", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: true, - model_env_var: Some("BUZZ_AGENT_MODEL"), - provider_env_var: Some("BUZZ_AGENT_PROVIDER"), - provider_locked: false, - default_env: &[], - config_file_path: None, - config_file_format: None, - supports_acp_native_config: false, - thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), - max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), - context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - }, -]; - /// Skill discovery directories declared by known runtimes. pub(crate) fn known_skill_dirs() -> impl Iterator { KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir) @@ -265,6 +132,12 @@ pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRunti KNOWN_ACP_RUNTIMES.iter().find(|p| p.id == id) } +pub(crate) fn resolve_agent_parallelism(requested: Option, command: &str) -> u32 { + requested + .or_else(|| known_acp_runtime(command).and_then(|runtime| runtime.default_parallelism)) + .unwrap_or(DEFAULT_AGENT_PARALLELISM) +} + /// The agent command a freshly-created agent defaults to when the create /// request supplies none. Resolves the bundled `buzz-agent` from the catalog so /// the default cannot drift from the provider definition. Falls back to the id @@ -347,12 +220,13 @@ mod overrides; pub use overrides::{apply_agent_command_update, create_time_agent_command_override}; fn default_agent_args(command: &str) -> Option> { - match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), - "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" - | "claudecode" | "buzz-agent" => Some(Vec::new()), - _ => None, - } + known_acp_runtime(command).map(|runtime| { + runtime + .default_args + .iter() + .map(|arg| (*arg).to_string()) + .collect() + }) } pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { @@ -378,8 +252,8 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec [PathBuf; 2] { - if cfg!(debug_assertions) { +fn profile_target_dirs(root: &Path, debug_build: bool) -> [PathBuf; 2] { + if debug_build { // `just dev` builds fresh debug sidecars; never prefer stale release output. [root.join("target/debug"), root.join("target/release")] } else { @@ -387,17 +261,31 @@ fn profile_target_dirs(root: &Path) -> [PathBuf; 2] { } } -fn command_search_dirs() -> Vec { - let mut dirs = profile_target_dirs(&workspace_root_dir()).to_vec(); - if let Ok(current_dir) = std::env::current_dir() { - dirs.extend(profile_target_dirs(¤t_dir)); +fn command_search_dirs_for( + workspace_root: &Path, + current_dir: Option<&Path>, + executable_dir: Option<&Path>, + debug_build: bool, +) -> Vec { + let mut dirs = Vec::new(); + + // A packaged release must run the sidecar that was signed and shipped + // beside the desktop executable. Build-machine checkout paths can still + // exist on a developer Mac; searching them first silently mixes an + // installed release with stale target/debug binaries. + if !debug_build { + dirs.extend(executable_dir.map(Path::to_path_buf)); + } + + dirs.extend(profile_target_dirs(workspace_root, debug_build)); + if let Some(current_dir) = current_dir { + dirs.extend(profile_target_dirs(current_dir, debug_build)); + } + + if debug_build { + dirs.extend(executable_dir.map(Path::to_path_buf)); } - dirs.extend( - std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(Path::to_path_buf)), - ); dirs.into_iter().fold(Vec::new(), |mut unique, dir| { if !unique.contains(&dir) { unique.push(dir); @@ -406,6 +294,19 @@ fn command_search_dirs() -> Vec { }) } +fn command_search_dirs() -> Vec { + let current_dir = std::env::current_dir().ok(); + let executable_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)); + command_search_dirs_for( + &workspace_root_dir(), + current_dir.as_deref(), + executable_dir.as_deref(), + cfg!(debug_assertions), + ) +} + fn is_executable_file(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; @@ -426,7 +327,7 @@ fn is_executable_file(path: &Path) -> bool { } } -fn resolve_workspace_command(command: &str) -> Option { +pub(crate) fn resolve_workspace_command(command: &str) -> Option { if command_looks_like_path(command) { let path = PathBuf::from(command); return is_executable_file(&path).then_some(path); @@ -911,16 +812,18 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool { /// background threads to prevent pipe-buffer deadlock. On timeout the child is /// killed and `Unknown` is returned; no orphaned threads or processes are left /// behind. Returns `Unknown` on timeout. -fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { +fn probe_auth_status( + binary_path: &Path, + probe_args: &[&str], + scrub_env_vars: &[&str], +) -> AuthStatus { use crate::managed_agents::readiness::cli_probe; let augmented_path = cli_probe::augmented_path(); let mut command = std::process::Command::new(binary_path); command.args(&probe_args[1..]); - if let Some(ref path) = augmented_path { - command.env("PATH", path); - } + cli_probe::configure_probe_environment(&mut command, augmented_path.as_deref(), scrub_env_vars); command .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) @@ -1201,10 +1104,11 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr .and_then(find_command) .map(|p| p.display().to_string()); - let default_args = command - .as_deref() - .map(|cmd| normalize_agent_args(cmd, Vec::new())) - .unwrap_or_default(); + let default_args = runtime + .default_args + .iter() + .map(|arg| (*arg).to_string()) + .collect(); let can_auto_install = !runtime.cli_install_commands_for_os().is_empty() || !runtime.adapter_install_commands.is_empty(); @@ -1251,7 +1155,19 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr entry: AcpRuntimeCatalogEntry { id: runtime.id.to_string(), label: runtime.label.to_string(), + display_label: runtime.display_label.to_string(), + sort_priority: runtime.sort_priority, + onboarding_visible: runtime.onboarding_visible, + icon_url: runtime.icon_url.to_string(), + icon_scale: runtime.icon_scale, avatar_url: runtime.avatar_url.to_string(), + superseded_avatar_urls: runtime + .superseded_avatar_urls + .iter() + .map(|url| (*url).to_string()) + .collect(), + supports_buzz_model_config: runtime.model_env_var.is_some() + || runtime.supports_acp_model_switching, availability, command, binary_path, @@ -1303,10 +1219,11 @@ pub fn discover_acp_runtimes() -> Vec { // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). let binary_path = resolve_command(probe_args[0])?; let probe_args_owned: Vec = probe_args.iter().map(|s| s.to_string()).collect(); + let scrub_env_vars = partial.runtime.scrub_env_vars; let handle = std::thread::spawn(move || { let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); - probe_auth_status(&binary_path, &refs) + probe_auth_status(&binary_path, &refs, scrub_env_vars) }); Some((idx, handle)) }) @@ -1347,5 +1264,25 @@ pub fn managed_agent_avatar_url(command: &str) -> Option { Some(runtime.avatar_url.to_string()) } +/// Replace a superseded catalog-default avatar without touching user-selected +/// images. This is intentionally a read-time normalization: existing records +/// render correctly immediately, their relay profiles reconcile to the new +/// default, and the normalized value is persisted on the next ordinary save. +pub fn normalize_managed_agent_avatar(command: &str, avatar_url: Option) -> Option { + let runtime = known_acp_runtime(command); + let should_replace = avatar_url.as_deref().is_some_and(|avatar| { + runtime.is_some_and(|runtime| runtime.superseded_avatar_urls.contains(&avatar)) + }); + + should_replace + .then(|| { + runtime + .expect("replacement requires a known runtime") + .avatar_url + .to_string() + }) + .or(avatar_url) +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_catalog.rs new file mode 100644 index 00000000000..8e87c882d71 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_catalog.rs @@ -0,0 +1,286 @@ +use super::KnownAcpRuntime; + +pub(super) const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; +pub(super) const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; +pub(super) const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; +pub(super) const LEGACY_DEVIN_AVATAR_URL: &str = "https://mintcdn.com/cognitionai/Hhrl_8XUBqA4VQ6v/logo/favicon.svg?fit=max&auto=format&n=Hhrl_8XUBqA4VQ6v&q=85&s=ab641f30c01bf5374b90b62209db569e"; +// The official Devin mark is transparent. Keep the profile avatar self-contained +// and add a white canvas so the black mark remains visible in dark themes. +pub(super) const DEVIN_AVATAR_URL: &str = concat!( + "data:image/svg+xml,%3Csvg%20width%3D%22425%22%20height%3D%22425%22%20viewBox%3D%220%200%20425%20425%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E", + "%3Crect%20width%3D%22425%22%20height%3D%22425%22%20fill%3D%22white%22%2F%3E", + "%3Cpath%20d%3D%22M70%20159.333V91.3471C70%2088.3592%2071.594%2085.5983%2074.1816%2084.1044L133.043%2050.1205C135.631%2048.6265%20138.819%2048.6265%20141.407%2050.1205L200.269%2084.1044C202.856%2085.5983%20204.45%2088.3592%20204.45%2091.3471V126.068C204.708%20137.606%20210.806%20148.734%20221.531%20154.926C232.256%20161.117%20244.942%20160.834%20255.063%20155.289L285.132%20137.929C287.719%20136.435%20290.907%20136.435%20293.495%20137.929L352.357%20171.913C354.944%20173.406%20356.538%20176.167%20356.538%20179.155V247.123C356.538%20250.111%20354.944%20252.872%20352.357%20254.366L293.495%20288.35C290.907%20289.844%20287.719%20289.844%20285.132%20288.35L255.306%20271.13C245.146%20265.456%20232.344%20265.117%20221.534%20271.358C210.809%20277.55%20204.711%20288.678%20204.453%20300.215V334.926C204.453%20337.914%20202.859%20340.675%20200.271%20342.169L141.41%20376.153C138.822%20377.647%20135.634%20377.647%20133.046%20376.153L74.1845%20342.169C71.5969%20340.675%2070.0028%20337.914%2070.0028%20334.926V266.959C70.0029%20263.971%2071.5969%20261.21%2074.1845%20259.716L133.046%20225.732C135.634%20224.238%20138.822%20224.238%20141.41%20225.732L171.547%20243.132C181.656%20248.638%20194.306%20248.906%20205.005%20242.729C215.815%20236.488%20221.922%20225.231%20222.088%20213.595C221.83%20202.057%20215.732%20189.737%20205.008%20183.545C194.283%20177.353%20181.597%20177.636%20171.476%20183.181L141.269%20200.72C138.67%20202.229%20135.461%20202.228%20132.864%20200.716L74.1576%20166.562C71.5835%20165.065%2070%20162.311%2070%20159.333Z%22%20fill%3D%22black%22%2F%3E", + "%3C%2Fsvg%3E" +); +pub(super) const BUZZ_AGENT_AVATAR_URL: &str = + "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; + +pub(super) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ + KnownAcpRuntime { + id: "goose", + label: "Goose", + display_label: "Goose", + sort_priority: 10, + onboarding_visible: false, + commands: &["goose"], + aliases: &[], + default_args: &["acp"], + default_parallelism: None, + defer_agent_start_until_work: true, + default_idle_timeout_secs: None, + icon_url: "/runtime-icons/goose.svg", + icon_scale: 1.25, + avatar_url: GOOSE_AVATAR_URL, + superseded_avatar_urls: &[], + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("goose"), + cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], + // Goose's stable release currently publishes only the Unix installer; + // its official Windows instructions intentionally point at this main-branch script. + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex\""], + adapter_install_commands: &[], + cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz requires the Goose CLI; the desktop app alone is not enough.", + adapter_install_hint: "", + skill_dir: Some(".goose/skills"), + supports_acp_model_switching: false, + accepts_harness_model: true, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[("GOOSE_MODE", "auto")], + enforced_env: &[], + scrub_env_vars: &[], + config_file_path: Some("~/.config/goose/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: true, + thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), + context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + auth_login_args: None, + }, + KnownAcpRuntime { + id: "claude", + label: "Claude Code", + display_label: "Claude Code", + sort_priority: 30, + onboarding_visible: true, + commands: &["claude-agent-acp", "claude-code-acp"], + aliases: &["claude-code", "claudecode"], + default_args: &[], + default_parallelism: None, + defer_agent_start_until_work: true, + default_idle_timeout_secs: None, + icon_url: "/runtime-icons/claude.png", + icon_scale: 1.1, + avatar_url: CLAUDE_CODE_AVATAR_URL, + superseded_avatar_urls: &[], + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("claude"), + cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""], + adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], + cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", + cli_install_hint: "Buzz requires the Claude Code CLI; the desktop app alone is not enough.", + adapter_install_hint: "Install the Claude Code ACP adapter via npm.", + skill_dir: Some(".claude/skills"), + supports_acp_model_switching: false, + accepts_harness_model: true, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + enforced_env: &[], + scrub_env_vars: &[], + config_file_path: Some("~/.claude/settings.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run the Claude CLI to complete authentication."), + auth_probe_args: Some(&["claude", "auth", "status"]), + auth_login_args: None, + }, + KnownAcpRuntime { + id: "codex", + label: "Codex", + display_label: "Codex", + sort_priority: 40, + onboarding_visible: true, + commands: &["codex-acp"], + aliases: &[], + default_args: &[], + default_parallelism: None, + defer_agent_start_until_work: true, + default_idle_timeout_secs: None, + icon_url: "/runtime-icons/codex.png", + icon_scale: 1.1, + avatar_url: CODEX_AVATAR_URL, + superseded_avatar_urls: &[], + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: false, + underlying_cli: Some("codex"), + cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""], + adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], + cli_install_instructions_url: "https://developers.openai.com/codex/cli/", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", + cli_install_hint: "Buzz requires the Codex CLI; the desktop app alone is not enough.", + adapter_install_hint: "Install the Codex ACP adapter via npm.", + skill_dir: Some(".codex/skills"), + supports_acp_model_switching: false, + accepts_harness_model: true, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + enforced_env: &[], + scrub_env_vars: &[], + config_file_path: Some("~/.codex/config.toml"), + config_file_format: Some("toml"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `codex login` to authenticate."), + // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. + auth_probe_args: Some(&["codex", "login", "status"]), + auth_login_args: None, + }, + KnownAcpRuntime { + id: "devin", + label: "Devin", + display_label: "Devin", + sort_priority: 20, + onboarding_visible: true, + commands: &["devin"], + aliases: &[], + default_args: &["acp"], + // One official CLI session is the safe local default. Explicit request + // or persona values still win. + default_parallelism: Some(1), + // The official CLI performs meaningful startup work before its first + // prompt. Pay that cost when the managed runtime starts so the first + // user message does not also become a process-health probe. + defer_agent_start_until_work: false, + // Devin normally emits ACP progress well inside this window. A fully + // silent native server is replaced and the queued batch retried rather + // than appearing to hang under the generic 15-minute tool allowance. + default_idle_timeout_secs: Some(120), + icon_url: "/runtime-icons/devin.svg", + icon_scale: 1.1, + avatar_url: DEVIN_AVATAR_URL, + superseded_avatar_urls: &[LEGACY_DEVIN_AVATAR_URL], + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("devin"), + cli_install_commands: &["curl -fsSL https://cli.devin.ai/install.sh | bash"], + cli_install_commands_windows: &[ + "powershell.exe -NoProfile -Command \"irm https://static.devin.ai/cli/setup.ps1 | iex\"", + ], + adapter_install_commands: &[], + cli_install_instructions_url: "https://docs.devin.ai/cli", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz requires the Devin CLI; the desktop app alone is not enough.", + adapter_install_hint: "", + skill_dir: Some(".devin/skills"), + supports_acp_model_switching: false, + // Devin's native ACP server owns model choice. Passing Buzz's global + // model would imply a capability we do not expose and causes the + // official CLI to reject unrelated Buzz model IDs before falling back. + accepts_harness_model: false, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + // Buzz's harness historically defaults to bypassing ACP permission + // requests. The native Devin runtime must always retain the official + // CLI's safe permission behavior. + enforced_env: &[ + ("BUZZ_ACP_PERMISSION_MODE", "default"), + ("BUZZ_ACP_AUTO_APPROVE_PERMISSIONS", "false"), + ("BUZZ_ACP_INTERACTIVE_PERMISSIONS", "true"), + ("BUZZ_ACP_SELF_PUBLISH_COMPLETION_GRACE", "30"), + ], + // WINDSURF_API_KEY: the official CLI gives this legacy ambient key + // precedence over the account established by `devin auth login`. Remove + // it without reading its value so readiness and usage attribution share + // one identity. + // + // ACP_BACKEND: set by the Devin IDE for its own ACP integration. When it + // leaks in — Buzz launched from a terminal inside that IDE, for example + // — `devin acp` switches to "ACP host is the sole source of + // credentials", refuses the stored CLI credentials, and fails every turn + // with "ACP host has not authenticated" even though `devin auth login` + // succeeded. Ambient state must not redefine the adapter's credential + // policy. + scrub_env_vars: &["WINDSURF_API_KEY", "ACP_BACKEND"], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `devin auth login` to authenticate."), + // Verified locally: the command exits 0 for an authenticated CLI. + auth_probe_args: Some(&["devin", "auth", "status"]), + auth_login_args: Some(&["devin", "auth", "login"]), + }, + KnownAcpRuntime { + id: "buzz-agent", + label: "Buzz Agent", + display_label: "Buzz", + sort_priority: 0, + onboarding_visible: false, + commands: &["buzz-agent"], + aliases: &[], + default_args: &[], + default_parallelism: None, + defer_agent_start_until_work: true, + default_idle_timeout_secs: None, + icon_url: "/app-icon@2x.png", + icon_scale: 1.1, + avatar_url: BUZZ_AGENT_AVATAR_URL, + superseded_avatar_urls: &[], + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: true, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://github.com/block/buzz", + adapter_install_instructions_url: "https://github.com/block/buzz", + cli_install_hint: "Ships with the Buzz desktop app.", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + accepts_harness_model: true, + model_env_var: Some("BUZZ_AGENT_MODEL"), + provider_env_var: Some("BUZZ_AGENT_PROVIDER"), + provider_locked: false, + default_env: &[], + enforced_env: &[], + scrub_env_vars: &[], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), + max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), + context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + auth_login_args: None, + }, +]; diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 2fb6a471d48..7d1d335759b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -2,9 +2,38 @@ pub(crate) struct KnownAcpRuntime { pub id: &'static str, pub label: &'static str, + /// Compact product label used by runtime pickers. + pub display_label: &'static str, + /// Stable catalog ordering before the label tie-breaker. + pub sort_priority: u16, + /// Whether first-run onboarding should offer this runtime. + pub onboarding_visible: bool, pub commands: &'static [&'static str], pub aliases: &'static [&'static str], + /// Arguments used when the runtime is launched without an explicit argv. + pub default_args: &'static [&'static str], + /// Runtime-specific worker default used when neither the request nor a + /// linked persona specifies parallelism. `None` preserves Buzz's global + /// default. + pub default_parallelism: Option, + /// Whether a desktop-requested lazy harness should defer spawning the ACP + /// subprocess until accepted work is queued. Runtimes with expensive or + /// failure-prone first handshakes can opt out while preserving the lazy + /// relay socket. + pub defer_agent_start_until_work: bool, + /// Runtime-specific idle timeout used only when the agent record, process + /// environment, and merged user environment do not supply an override. + /// `None` preserves the harness default. + pub default_idle_timeout_secs: Option, + /// App-local runtime mark used by catalog-driven frontend surfaces. + pub icon_url: &'static str, + /// Presentation scale for the runtime mark. Kept with the catalog entry so + /// React does not need a harness-ID lookup table. + pub icon_scale: f32, pub avatar_url: &'static str, + /// Catalog-default avatar URLs superseded by `avatar_url`. These are not + /// user-selected images and may be replaced during read-time migration. + pub superseded_avatar_urls: &'static [&'static str], /// Legacy MCP server binary field. Vestigial — all agents now use the bundled CLI /// directly. Will be removed when runtime discovery is simplified. pub mcp_command: Option<&'static str>, @@ -34,14 +63,28 @@ pub(crate) struct KnownAcpRuntime { /// runtime reads the canonical path directly or has no skill support. pub skill_dir: Option<&'static str>, /// Whether this runtime handles model switching via ACP protocol natively. - /// Currently unused — env var injection runs unconditionally regardless of - /// this value. Retained as scaffolding for when ACP model switching matures. - #[allow(dead_code)] + /// Env var injection still handles initial model selection separately. pub supports_acp_model_switching: bool, + /// Whether Buzz should pass its resolved model through the generic + /// `BUZZ_ACP_MODEL` harness setting at process launch. + /// + /// This is intentionally separate from `supports_acp_model_switching` and + /// `model_env_var`: existing adapters may consume the generic bootstrap + /// model without exposing Buzz-side model controls. Native runtimes whose + /// official ACP server owns model selection set this to `false`. + pub accepts_harness_model: bool, pub model_env_var: Option<&'static str>, pub provider_env_var: Option<&'static str>, pub provider_locked: bool, + /// Environment defaults applied only when neither the parent process nor + /// saved agent configuration supplies a value. pub default_env: &'static [(&'static str, &'static str)], + /// Environment values enforced at process launch after inherited and + /// user-configured values have been merged. + pub enforced_env: &'static [(&'static str, &'static str)], + /// Environment variables removed from runtime subprocesses. This prevents + /// ambient process state from overriding catalog-declared identity policy. + pub scrub_env_vars: &'static [&'static str], pub config_file_path: Option<&'static str>, #[allow(dead_code)] // reserved for format-based dispatch when readers are unified pub config_file_format: Option<&'static str>, @@ -62,6 +105,9 @@ pub(crate) struct KnownAcpRuntime { /// CLI args for probing authentication status. `args[0]` is the binary name; /// the remainder are the subcommand. `None` for runtimes with no login step. pub auth_probe_args: Option<&'static [&'static str]>, + /// CLI argv for an interactive login launched in a visible terminal. + /// `None` when authentication is adapter-owned or not applicable. + pub auth_login_args: Option<&'static [&'static str]>, } impl KnownAcpRuntime { @@ -120,5 +166,51 @@ mod tests { ); assert!(codex.adapter_install_instructions_url.contains("codex-acp")); assert!(codex.cli_install_hint.contains("desktop app alone")); + + let devin = known_acp_runtime_exact("devin").unwrap(); + assert_eq!(devin.commands, &["devin"]); + assert_eq!(devin.default_args, &["acp"]); + assert_eq!(devin.default_parallelism, Some(1)); + assert!(!devin.defer_agent_start_until_work); + assert_eq!(devin.default_idle_timeout_secs, Some(120)); + assert_eq!(devin.display_label, "Devin"); + assert_eq!(devin.sort_priority, 20); + assert!(devin.onboarding_visible); + assert_eq!(devin.icon_url, "/runtime-icons/devin.svg"); + assert_eq!(devin.icon_scale, 1.1); + assert_eq!(devin.underlying_cli, Some("devin")); + assert_eq!(devin.skill_dir, Some(".devin/skills")); + assert_eq!( + devin.auth_probe_args, + Some(&["devin", "auth", "status"][..]) + ); + assert_eq!(devin.auth_login_args, Some(&["devin", "auth", "login"][..])); + assert!(devin.default_env.is_empty()); + assert_eq!( + devin.enforced_env, + &[ + ("BUZZ_ACP_PERMISSION_MODE", "default"), + ("BUZZ_ACP_AUTO_APPROVE_PERMISSIONS", "false"), + ("BUZZ_ACP_INTERACTIVE_PERMISSIONS", "true"), + ("BUZZ_ACP_SELF_PUBLISH_COMPLETION_GRACE", "30"), + ] + ); + assert_eq!(devin.scrub_env_vars, &["WINDSURF_API_KEY", "ACP_BACKEND"]); + assert_eq!( + devin.cli_install_instructions_url, + "https://docs.devin.ai/cli" + ); + assert_eq!( + devin.cli_install_commands, + &["curl -fsSL https://cli.devin.ai/install.sh | bash"] + ); + assert_eq!( + devin.cli_install_commands_windows, + &[ + "powershell.exe -NoProfile -Command \"irm https://static.devin.ai/cli/setup.ps1 | iex\"" + ] + ); + assert!(devin.adapter_install_commands.is_empty()); + assert!(devin.cli_install_hint.contains("desktop app alone")); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 364caa452b7..57fb01c6860 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -6,43 +6,14 @@ use super::{ codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, effective_agent_command, find_nvm_default_bin, find_via_login_shell, is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, - parse_semver_tag, probe_codex_acp_major_version, record_agent_command, - refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, - GOOSE_AVATAR_URL, + normalize_managed_agent_avatar, parse_semver_tag, probe_codex_acp_major_version, + record_agent_command, refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, + CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; -#[test] -fn resolves_known_avatar_for_bare_command() { - let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve"); - - assert_eq!(avatar_url, GOOSE_AVATAR_URL); -} - -#[test] -fn resolves_known_avatar_for_command_paths_and_aliases() { - assert_eq!( - managed_agent_avatar_url("/usr/local/bin/codex-acp"), - Some(CODEX_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url("Claude Code"), - Some(CLAUDE_CODE_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"), - Some(CLAUDE_CODE_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url("/usr/local/bin/claude-code-acp"), - Some(CLAUDE_CODE_AVATAR_URL.to_string()) - ); -} - -#[test] -fn returns_none_for_unknown_commands() { - assert!(managed_agent_avatar_url("custom-agent").is_none()); -} +mod avatar; +mod devin; #[test] fn default_agent_command_resolves_bundled_buzz_agent() { diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/avatar.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/avatar.rs new file mode 100644 index 00000000000..af40349d017 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/avatar.rs @@ -0,0 +1,33 @@ +use super::*; + +#[test] +fn resolves_known_avatar_for_bare_command() { + let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve"); + + assert_eq!(avatar_url, GOOSE_AVATAR_URL); +} + +#[test] +fn resolves_known_avatar_for_command_paths_and_aliases() { + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/codex-acp"), + Some(CODEX_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("Claude Code"), + Some(CLAUDE_CODE_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"), + Some(CLAUDE_CODE_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/claude-code-acp"), + Some(CLAUDE_CODE_AVATAR_URL.to_string()) + ); +} + +#[test] +fn returns_none_for_unknown_commands() { + assert!(managed_agent_avatar_url("custom-agent").is_none()); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/devin.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/devin.rs new file mode 100644 index 00000000000..05fffd6ac78 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/devin.rs @@ -0,0 +1,118 @@ +use super::*; + +#[test] +fn resolves_devin_avatar() { + let avatar_url = + managed_agent_avatar_url("/usr/local/bin/devin").expect("Devin avatar should resolve"); + + assert_eq!(avatar_url, super::super::runtime_catalog::DEVIN_AVATAR_URL); + assert!(avatar_url.starts_with("data:image/svg+xml,")); + assert!(avatar_url.contains("fill%3D%22white%22")); +} + +#[test] +fn migrates_only_the_superseded_devin_default_avatar() { + let migrated = normalize_managed_agent_avatar( + "devin", + Some(super::super::runtime_catalog::LEGACY_DEVIN_AVATAR_URL.to_string()), + ); + assert_eq!( + migrated.as_deref(), + Some(super::super::runtime_catalog::DEVIN_AVATAR_URL) + ); + + let custom = Some("https://example.test/custom-devin.png".to_string()); + assert_eq!( + normalize_managed_agent_avatar("devin", custom.clone()), + custom + ); + + let other_runtime = Some(super::super::runtime_catalog::LEGACY_DEVIN_AVATAR_URL.to_string()); + assert_eq!( + normalize_managed_agent_avatar("goose", other_runtime.clone()), + other_runtime + ); +} + +#[test] +fn normalizes_devin_args_to_native_acp_subcommand() { + assert_eq!(normalize_agent_args("devin", Vec::new()), vec!["acp"]); + assert_eq!( + normalize_agent_args("/usr/local/bin/devin", vec!["".into()]), + vec!["acp"] + ); + assert_eq!( + normalize_agent_args( + "devin", + vec!["acp".into(), "--agent-type".into(), "review".into()] + ), + vec!["acp", "--agent-type", "review"] + ); +} + +#[test] +fn runtime_catalog_exposes_devin_once() { + let devin_entries = super::super::KNOWN_ACP_RUNTIMES + .iter() + .filter(|runtime| runtime.id == "devin") + .collect::>(); + + assert_eq!(devin_entries.len(), 1); + let devin = devin_entries[0]; + assert_eq!(devin.label, "Devin"); + assert_eq!(devin.display_label, "Devin"); + assert_eq!(devin.sort_priority, 20); + assert!(devin.onboarding_visible); + assert_eq!(devin.commands, &["devin"]); + assert_eq!(devin.default_args, &["acp"]); + assert_eq!(devin.icon_url, "/runtime-icons/devin.svg"); + assert_eq!(devin.icon_scale, 1.1); + assert_eq!(devin.underlying_cli, Some("devin")); + assert_eq!(devin.skill_dir, Some(".devin/skills")); + assert_eq!( + devin.auth_probe_args, + Some(&["devin", "auth", "status"][..]) + ); + assert!(devin.default_env.is_empty()); + assert_eq!( + devin.enforced_env, + &[ + ("BUZZ_ACP_PERMISSION_MODE", "default"), + ("BUZZ_ACP_AUTO_APPROVE_PERMISSIONS", "false"), + ("BUZZ_ACP_INTERACTIVE_PERMISSIONS", "true"), + ("BUZZ_ACP_SELF_PUBLISH_COMPLETION_GRACE", "30"), + ] + ); + // ACP_BACKEND must stay scrubbed: inherited from the Devin IDE it flips the + // adapter to host-supplied credentials only, and every turn then fails with + // "ACP host has not authenticated" despite a valid `devin auth login`. + assert_eq!( + devin.scrub_env_vars, + &["WINDSURF_API_KEY", "ACP_BACKEND"], + "ambient IDE state must not redefine Devin's credential policy" + ); + assert!(!devin.supports_acp_model_switching); + assert!(!devin.accepts_harness_model); +} + +#[test] +fn runtime_discovery_exposes_devin_entry() { + let runtimes = super::super::discover_acp_runtimes(); + let devin = runtimes + .iter() + .find(|runtime| runtime.id == "devin") + .expect("runtime discovery must project the Devin catalog entry"); + + assert_eq!(devin.label, "Devin"); + assert_eq!(devin.display_label, "Devin"); + assert_eq!(devin.sort_priority, 20); + assert!(devin.onboarding_visible); + assert_eq!(devin.icon_url, "/runtime-icons/devin.svg"); + assert_eq!(devin.icon_scale, 1.1); + assert_eq!( + devin.superseded_avatar_urls, + [super::super::runtime_catalog::LEGACY_DEVIN_AVATAR_URL] + ); + assert!(!devin.supports_buzz_model_config); + assert_eq!(devin.default_args, ["acp"]); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs index 2f6b038deb7..6995754ab5b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs @@ -1,4 +1,38 @@ use crate::managed_agents::discovery::{clear_resolve_cache, resolve_command}; +use std::path::{Path, PathBuf}; + +#[test] +fn packaged_release_prefers_its_bundled_sidecars_over_checkout_targets() { + let bundle = PathBuf::from("/Applications/Buzz.app/Contents/MacOS"); + let dirs = super::super::command_search_dirs_for( + Path::new("/build/buzz"), + Some(Path::new("/Users/developer/buzz")), + Some(&bundle), + false, + ); + + assert_eq!(dirs.first(), Some(&bundle)); + assert_eq!(dirs[1], PathBuf::from("/build/buzz/target/release")); + assert_eq!(dirs[2], PathBuf::from("/build/buzz/target/debug")); +} + +#[test] +fn debug_build_keeps_fresh_workspace_sidecars_ahead_of_executable_dir() { + let bundle = PathBuf::from("/build/buzz/target/debug"); + let dirs = super::super::command_search_dirs_for( + Path::new("/build/buzz"), + Some(Path::new("/build/buzz/desktop")), + Some(&bundle), + true, + ); + + assert_eq!(dirs.first(), Some(&bundle)); + assert_eq!(dirs[1], PathBuf::from("/build/buzz/target/release")); + assert_eq!( + dirs.last(), + Some(&PathBuf::from("/build/buzz/desktop/target/release")) + ); +} #[cfg(unix)] #[test] diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index e13ab2baad6..a351e055a89 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -1,4 +1,4 @@ -//! Buzz Nest — persistent agent workspace at `~/.buzz`. +//! Buzz Nest — persistent agent workspace in a build-scoped home directory. //! //! Creates a shared knowledge directory on first launch so every //! Buzz-spawned agent starts with orientation (AGENTS.md) and a @@ -21,6 +21,12 @@ use crate::managed_agents::discovery::known_skill_dirs; #[cfg(unix)] use crate::util::create_symlink; +mod release_identity; +pub use release_identity::{cli_link_name, uses_upstream_nest_namespace}; +use release_identity::{configured_release_nest_dir, NEST_DIR_DEV}; +#[cfg(test)] +use release_identity::{release_cli_link_name, release_nest_dir}; + /// Subdirectories created inside the nest. /// `REPOS` is intentionally absent: it is provisioned by /// [`super::repos::ensure_repos_symlink`], which makes it either a real directory (default) @@ -58,15 +64,6 @@ const END_MARKER: &str = ""; /// Canonical skill directory path relative to the nest root. const CANONICAL_SKILL_DIR: &str = ".agents/skills/buzz-cli"; -/// Nest directory name for production builds. -const NEST_DIR_PROD: &str = ".buzz"; - -/// Nest directory name for dev builds. Dev builds (those whose Tauri app-data -/// directory name starts with `"xyz.block.buzz.app.dev"`) use a separate nest -/// so that the DMG and dev-build instances don't clobber each other's -/// `.repos-dir` dotfile and `REPOS` symlink. -const NEST_DIR_DEV: &str = ".buzz-dev"; - /// Process-lifetime nest directory. Initialized once at startup via /// [`init_nest_dir`] before any call to [`nest_dir`]. /// @@ -86,7 +83,11 @@ static NEST_DIR: std::sync::OnceLock> = std::sync::OnceLock::new /// when the Tauri app-data directory name starts with `"xyz.block.buzz.app.dev"`. /// Pass `false` for production (signed DMG) builds. pub fn init_nest_dir(is_dev: bool) { - let suffix = if is_dev { NEST_DIR_DEV } else { NEST_DIR_PROD }; + let suffix = if is_dev { + NEST_DIR_DEV + } else { + configured_release_nest_dir() + }; let path = dirs::home_dir().map(|h| h.join(suffix)); // set() is a no-op when already initialized, which is correct: only the // first call (at boot, before any filesystem work) should win. @@ -102,7 +103,7 @@ pub fn nest_dir() -> Option { match NEST_DIR.get() { Some(path) => path.clone(), // Not yet initialized — fall back to prod path. Covers test code. - None => dirs::home_dir().map(|h| h.join(NEST_DIR_PROD)), + None => dirs::home_dir().map(|h| h.join(configured_release_nest_dir())), } } @@ -333,19 +334,6 @@ fn ensure_skill_symlinks(_root: &Path) -> Result<(), String> { Ok(()) } -/// Returns the `~/.local/bin` link name for the bundled CLI. -/// -/// Dev builds (`is_dev = true`) use `"buzz-dev"` so that a running DMG and a -/// concurrent dev build each own a separate link and never clobber each other — -/// the same isolation that separates `~/.buzz` (prod) from `~/.buzz-dev` (dev). -pub fn cli_link_name(is_dev: bool) -> &'static str { - if is_dev { - "buzz-dev" - } else { - "buzz" - } -} - /// Ensures `~/.local/bin/buzz` (prod) or `~/.local/bin/buzz-dev` (dev) is a /// symlink to the bundled CLI binary. /// diff --git a/desktop/src-tauri/src/managed_agents/nest/release_identity.rs b/desktop/src-tauri/src/managed_agents/nest/release_identity.rs new file mode 100644 index 00000000000..267aed50108 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest/release_identity.rs @@ -0,0 +1,40 @@ +//! Build-scoped release identity for the shared agent workspace and CLI link. + +/// Upstream Buzz's release Nest directory name. +const NEST_DIR_PROD: &str = ".buzz"; + +/// Nest directory name for dev builds. Dev builds intentionally remain shared +/// across distributions so their existing development workflow is unchanged. +pub(super) const NEST_DIR_DEV: &str = ".buzz-dev"; + +pub(super) fn release_nest_dir(configured: Option<&'static str>) -> &'static str { + configured.unwrap_or(NEST_DIR_PROD) +} + +pub(super) fn configured_release_nest_dir() -> &'static str { + release_nest_dir(option_env!("BUZZ_DESKTOP_BUILD_NEST_DIR")) +} + +/// Whether this build uses upstream Buzz's release Nest namespace. +/// +/// Downstream distributions with an isolated build-time Nest must not fall +/// back to or import upstream `~/.buzz` or legacy `~/.sprout` state. +pub fn uses_upstream_nest_namespace() -> bool { + configured_release_nest_dir() == NEST_DIR_PROD +} + +pub(super) fn release_cli_link_name(configured: Option<&'static str>) -> &'static str { + configured.unwrap_or("buzz") +} + +/// Returns the `~/.local/bin` link name for the bundled CLI. +/// +/// Dev builds use `buzz-dev`; release builds use their validated build-time +/// identity, falling back to upstream Buzz's `buzz` name. +pub fn cli_link_name(is_dev: bool) -> &'static str { + if is_dev { + "buzz-dev" + } else { + release_cli_link_name(option_env!("BUZZ_DESKTOP_BUILD_CLI_LINK_NAME")) + } +} diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index a9593816036..f7b7d74ed92 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -3,12 +3,12 @@ use super::*; #[test] fn nest_dir_is_under_home() { if let Some(dir) = nest_dir() { - // Accepts both .buzz (prod) and .buzz-dev (dev) depending on + // Accepts the configured release nest or .buzz-dev depending on // whether init_nest_dir was called before this test ran. let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or(""); assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, - "nest_dir must end with .buzz or .buzz-dev, got {dir:?}" + name == configured_release_nest_dir() || name == NEST_DIR_DEV, + "nest_dir must end with the release or dev nest name, got {dir:?}" ); } } @@ -23,12 +23,31 @@ fn init_nest_dir_prod_sets_buzz() { if let Some(d) = dir { let name = d.file_name().and_then(|n| n.to_str()).unwrap_or(""); assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, - "nest_dir suffix must be .buzz or .buzz-dev, got {d:?}" + name == configured_release_nest_dir() || name == NEST_DIR_DEV, + "nest_dir suffix must be the release or dev nest name, got {d:?}" ); } } +#[test] +fn release_nest_namespace_defaults_to_upstream_and_accepts_build_override() { + assert_eq!(release_nest_dir(None), ".buzz"); + assert_eq!(release_nest_dir(Some(".buzz-for-devin")), ".buzz-for-devin"); + assert_eq!( + uses_upstream_nest_namespace(), + configured_release_nest_dir() == ".buzz" + ); +} + +#[test] +fn release_cli_link_defaults_to_upstream_and_accepts_build_override() { + assert_eq!(release_cli_link_name(None), "buzz"); + assert_eq!( + release_cli_link_name(Some("buzz-for-devin")), + "buzz-for-devin" + ); +} + #[test] fn ensure_nest_creates_all_dirs_and_agents_md() { let tmp = tempfile::tempdir().unwrap(); @@ -330,8 +349,11 @@ fn ensure_skill_symlinks_skip_dangling_symlink() { } #[test] -fn cli_link_name_prod_is_buzz() { - assert_eq!(cli_link_name(false), "buzz"); +fn cli_link_name_prod_uses_configured_release_name() { + assert_eq!( + cli_link_name(false), + release_cli_link_name(option_env!("BUZZ_DESKTOP_BUILD_CLI_LINK_NAME")) + ); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c04dfa88a4f..bac2c6141dd 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -244,9 +244,9 @@ impl AgentReadiness { /// - `openai` → `OPENAI_COMPAT_API_KEY` /// - `databricks` / `databricks_v2` → `DATABRICKS_HOST` (token optional — /// OAuth PKCE is the fallback) -/// * **claude**: a successful `claude auth status` probe. -/// * **codex**: a successful `codex login status` probe (checks the codex -/// credential store — NOT `OPENAI_API_KEY`). +/// * **CLI-login runtimes**: a successful catalog-declared authentication +/// probe (for example `claude auth status`, `codex login status`, or +/// `devin auth status`). /// * **unknown / custom command**: always `Ready` (no requirements known). /// /// Databricks note: `DATABRICKS_TOKEN` is `.unwrap_or_default()` in @@ -275,7 +275,7 @@ fn collect_missing_requirements( return vec![]; }; - match rt.id { + let runtime_specific = match rt.id { "buzz-agent" => buzz_agent_requirements(effective), "goose" => { // Read the file config once at the call site so the inner fn is @@ -283,14 +283,21 @@ fn collect_missing_requirements( let file_cfg = read_goose_file_config(); goose_requirements(effective, file_cfg.as_ref()) } - "claude" => cli_login::requirements( - &["claude", "auth", "status"], - "complete Claude Code authentication by running the Claude CLI", - rt, - ), - "codex" => cli_login::requirements(&["codex", "login", "status"], "run `codex login`", rt), _ => vec![], + }; + if !runtime_specific.is_empty() || matches!(rt.id, "buzz-agent" | "goose") { + return runtime_specific; } + + let Some(probe_args) = rt.auth_probe_args else { + return vec![]; + }; + cli_login::requirements( + probe_args, + rt.login_hint + .unwrap_or("Complete authentication in the runtime CLI."), + rt, + ) } /// Requirements for buzz-agent (provider + model + provider-specific creds). @@ -493,6 +500,8 @@ mod tests { use super::*; use crate::managed_agents::discovery::known_acp_runtime_exact; + mod devin_tests; + /// Build a minimal `EffectiveAgentEnv` with the given env map and command. fn make_env(command: &str, env: BTreeMap) -> EffectiveAgentEnv { let runtime = known_acp_runtime_exact(command); @@ -860,9 +869,19 @@ mod tests { KnownAcpRuntime { id: "test-cli-runtime", label: "Test CLI", + display_label: "Test CLI", + sort_priority: 100, + onboarding_visible: false, commands, aliases: &[], + default_args: &[], + default_parallelism: None, + defer_agent_start_until_work: true, + default_idle_timeout_secs: None, + icon_url: "", + icon_scale: 1.0, avatar_url: "", + superseded_avatar_urls: &[], mcp_command: None, mcp_hooks: false, underlying_cli, @@ -875,12 +894,15 @@ mod tests { adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: false, + accepts_harness_model: true, config_file_path: None, config_file_format: None, model_env_var: None, provider_env_var: None, provider_locked: false, default_env: &[], + enforced_env: &[], + scrub_env_vars: &[], supports_acp_native_config: false, thinking_env_var: None, max_tokens_env_var: None, @@ -888,6 +910,7 @@ mod tests { required_normalized_fields: &[], login_hint: None, auth_probe_args: None, + auth_login_args: None, } } @@ -1055,9 +1078,19 @@ mod tests { KnownAcpRuntime { id: "codex", label: "Codex", + display_label: "Codex", + sort_priority: 100, + onboarding_visible: true, commands: adapter_commands, aliases: &[], + default_args: &[], + default_parallelism: None, + defer_agent_start_until_work: true, + default_idle_timeout_secs: None, + icon_url: "", + icon_scale: 1.0, avatar_url: "", + superseded_avatar_urls: &[], mcp_command: None, mcp_hooks: false, underlying_cli, @@ -1070,12 +1103,15 @@ mod tests { adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: false, + accepts_harness_model: true, config_file_path: None, config_file_format: None, model_env_var: None, provider_env_var: None, provider_locked: false, default_env: &[], + enforced_env: &[], + scrub_env_vars: &[], supports_acp_native_config: false, thinking_env_var: None, max_tokens_env_var: None, @@ -1083,6 +1119,7 @@ mod tests { required_normalized_fields: &[], login_hint: None, auth_probe_args: None, + auth_login_args: None, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs index 4036d9f2393..2c0bbb43124 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -10,7 +10,7 @@ use crate::managed_agents::{ use super::{cli_probe, Requirement}; -/// Requirements for CLI-login runtimes (claude, codex). +/// Requirements for runtimes with a catalog-declared CLI authentication probe. pub(super) fn requirements( probe_args: &[&str], setup_copy: &str, @@ -47,7 +47,12 @@ pub(super) fn requirements( )]; }; let augmented_path = cli_probe::augmented_path(); - match cli_probe::login_probe(&binary_path, probe_args, augmented_path.as_deref()) { + match cli_probe::login_probe( + &binary_path, + probe_args, + augmented_path.as_deref(), + runtime.scrub_env_vars, + ) { cli_probe::ProbeOutcome::LoggedIn => vec![], cli_probe::ProbeOutcome::LoggedOut => vec![missing_requirement( probe_args, diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 513da4e2a85..247ddb09a13 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -57,12 +57,11 @@ pub(crate) fn login_probe( binary_path: &Path, probe_args: &[&str], augmented_path: Option<&str>, + scrub_env_vars: &[&str], ) -> ProbeOutcome { let mut command = std::process::Command::new(binary_path); command.args(&probe_args[1..]); - if let Some(path) = augmented_path { - command.env("PATH", path); - } + configure_probe_environment(&mut command, augmented_path, scrub_env_vars); crate::util::configure_no_window(&mut command); match command.output() { @@ -72,6 +71,19 @@ pub(crate) fn login_probe( } } +pub(crate) fn configure_probe_environment( + command: &mut std::process::Command, + augmented_path: Option<&str>, + scrub_env_vars: &[&str], +) { + if let Some(path) = augmented_path { + command.env("PATH", path); + } + for key in scrub_env_vars { + command.env_remove(key); + } +} + /// Classify collected probe output into a `ProbeOutcome`. /// /// Shared between `login_probe` (which has the full `Output`) and the @@ -100,6 +112,18 @@ pub(crate) fn classify_probe_output(stderr_bytes: &[u8], exit_success: bool) -> mod tests { use super::{ProbeOutcome, CONFIG_PARSE_SIGNALS}; + #[test] + fn probe_environment_removes_catalog_declared_identity_overrides() { + let mut command = std::process::Command::new("devin"); + command.env("WINDSURF_API_KEY", "sentinel"); + + super::configure_probe_environment(&mut command, None, &["WINDSURF_API_KEY"]); + + assert!(command + .get_envs() + .any(|(key, value)| { key == "WINDSURF_API_KEY" && value.is_none() })); + } + #[cfg(unix)] #[test] fn login_probe_uses_augmented_path_for_env_shebang_interpreter() { @@ -154,6 +178,7 @@ mod tests { &script_path, &["fake-codex", "login", "status"], Some(&augmented_path), + &[], ), ProbeOutcome::LoggedIn, "the injected augmented PATH should allow /usr/bin/env to find the interpreter" @@ -187,6 +212,7 @@ mod tests { &script_path, &["fake-codex-bad-config", "login", "status"], None, + &[], ); assert!( matches!(outcome, ProbeOutcome::ConfigInvalid { .. }), @@ -225,6 +251,7 @@ mod tests { &script_path, &["fake-codex-logged-out", "login", "status"], None, + &[], ); assert_eq!( outcome, diff --git a/desktop/src-tauri/src/managed_agents/readiness/tests/devin_tests.rs b/desktop/src-tauri/src/managed_agents/readiness/tests/devin_tests.rs new file mode 100644 index 00000000000..28f13434994 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/tests/devin_tests.rs @@ -0,0 +1,88 @@ +use super::*; + +fn devin_runtime_for_test( + commands: &'static [&'static str], + underlying_cli: Option<&'static str>, + auth_probe_args: &'static [&'static str], +) -> KnownAcpRuntime { + KnownAcpRuntime { + id: "devin", + label: "Devin", + default_args: &["acp"], + login_hint: Some("Run `devin auth login` to authenticate."), + auth_probe_args: Some(auth_probe_args), + auth_login_args: Some(&["devin", "auth", "login"]), + ..make_cli_runtime(commands, underlying_cli) + } +} + +#[test] +fn devin_readiness_is_ready_when_auth_probe_succeeds() { + let exe = present_binary_str(); + let runtime = devin_runtime_for_test( + static_commands(vec![exe]), + Some(exe), + static_commands(vec![exe, "--list"]), + ); + let effective = EffectiveAgentEnv { + env: BTreeMap::new(), + config_file_path: None, + effective_command: "devin".to_string(), + }; + + assert!( + collect_missing_requirements(&effective, Some(&runtime)).is_empty(), + "a successful catalog-declared Devin auth probe must be ready" + ); +} + +#[test] +fn devin_readiness_requires_login_when_auth_probe_fails() { + let exe = present_binary_str(); + let runtime = devin_runtime_for_test( + static_commands(vec![exe]), + Some(exe), + static_commands(vec![exe, "--buzz-probe-fail-xyz"]), + ); + let effective = EffectiveAgentEnv { + env: BTreeMap::new(), + config_file_path: None, + effective_command: "devin".to_string(), + }; + + let requirements = collect_missing_requirements(&effective, Some(&runtime)); + assert_eq!(requirements.len(), 1); + assert!(matches!( + &requirements[0], + Requirement::CliLogin { + availability: AcpAvailabilityStatus::Available, + setup_copy, + .. + } if setup_copy.contains("devin auth login") + )); +} + +#[test] +fn devin_readiness_reports_missing_cli_before_authentication() { + let missing = "__buzz_nonexistent_devin_xyz789__"; + let runtime = devin_runtime_for_test( + static_commands(vec![missing]), + Some(missing), + static_commands(vec![missing, "auth", "status"]), + ); + let effective = EffectiveAgentEnv { + env: BTreeMap::new(), + config_file_path: None, + effective_command: "devin".to_string(), + }; + + let requirements = collect_missing_requirements(&effective, Some(&runtime)); + assert_eq!(requirements.len(), 1); + assert!(matches!( + requirements[0], + Requirement::CliLogin { + availability: AcpAvailabilityStatus::NotInstalled, + .. + } + )); +} diff --git a/desktop/src-tauri/src/managed_agents/repos.rs b/desktop/src-tauri/src/managed_agents/repos.rs index 1c77c5722e5..30d4e9f18b0 100644 --- a/desktop/src-tauri/src/managed_agents/repos.rs +++ b/desktop/src-tauri/src/managed_agents/repos.rs @@ -497,6 +497,53 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn validate_repos_dir_canonicalizes_parent_segments() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + let selected = tmp.path().join("selected"); + let decoy = tmp.path().join("decoy"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&selected).unwrap(); + fs::create_dir_all(&decoy).unwrap(); + + let candidate = decoy.join("..").join("selected"); + let resolved = validate_repos_dir(&root, candidate.to_str().unwrap()).unwrap(); + + assert_eq!( + resolved, + selected.canonicalize().unwrap(), + "parent segments must resolve before the selected workspace is used" + ); + } + + #[cfg(unix)] + #[test] + fn validate_repos_dir_canonicalizes_symlinks_and_rechecks_nest_ancestors() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + let root = home.join(".buzz"); + let selected = tmp.path().join("selected"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&selected).unwrap(); + + let selected_link = tmp.path().join("selected-link"); + std::os::unix::fs::symlink(&selected, &selected_link).unwrap(); + assert_eq!( + validate_repos_dir(&root, selected_link.to_str().unwrap()).unwrap(), + selected.canonicalize().unwrap(), + "a selected symlink must resolve to its real workspace target" + ); + + let ancestor_link = tmp.path().join("ancestor-link"); + std::os::unix::fs::symlink(&home, &ancestor_link).unwrap(); + assert!( + validate_repos_dir(&root, ancestor_link.to_str().unwrap()).is_err(), + "a symlink must not hide that its target is an ancestor of the nest" + ); + } + // ── persisted repos_dir dotfile ─────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 6687cbbcf2f..8de5f1d28a3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -8,8 +8,8 @@ use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + spawn_key_refusal, ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, + ManagedAgentSummary, }, util::now_iso, }; @@ -20,6 +20,16 @@ pub(crate) use path::compose_path_entries; pub(crate) use path::should_skip_claude_executable; pub(crate) use path::should_use_inherited; +mod cli_config; +pub(crate) use cli_config::configure_runtime_cli; +mod env_policy; +use env_policy::{ + apply_runtime_env_policy, effective_idle_timeout, harness_model_for_runtime, + should_defer_agent_start, +}; +mod presentation; +use presentation::runtime_presentation_for_summary; + mod stop; pub(crate) use stop::managed_agent_runtime_keys; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; @@ -45,6 +55,7 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ "claude_code_acp", "codex-acp", "codex_acp", + "devin", "goose", // buzz-dev-mcp's multicall personalities (rg, tree, buzz, // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call @@ -272,6 +283,10 @@ fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result #[cfg(unix)] pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { + // Reap independently-grouped ACP descendants while their ownership can + // still be proven through the live harness ancestry. + sweep::terminate_owned_descendant_groups(pid); + // Try graceful shutdown first (SIGTERM to the group). signal_process_group_or_leader(pid, libc::SIGTERM, "terminate")?; @@ -853,15 +868,6 @@ pub(crate) fn collect_same_instance_orphans( std::collections::HashSet::new() } -/// Binary names for the Buzz desktop/Tauri process. Used by dead-instance -/// detection to confirm the owning desktop is still alive. -const DESKTOP_BINARY_NAMES: &[&str] = &["Buzz", "buzz-desktop", "buzz_desktop"]; - -/// Check if a process name matches a known Buzz desktop binary. -fn is_desktop_binary(name: &str) -> bool { - DESKTOP_BINARY_NAMES.contains(&name) -} - /// Check whether `buf` contains `id` as a complete identifier — not as a /// prefix of a longer dotted name. The identifier appears in the Tauri config /// JSON as `"identifier":"xyz.block.buzz.app.dev"` and in environment entries @@ -987,7 +993,7 @@ fn desktop_is_alive_for_instance(instance_id: &str) -> bool { continue; } let name = String::from_utf8_lossy(&name_buf[..len as usize]); - if !is_desktop_binary(&name) { + if !sweep::is_desktop_binary(&name) { continue; } // Verify UID. @@ -1049,7 +1055,7 @@ fn desktop_is_alive_for_instance(instance_id: &str) -> bool { let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) else { continue; }; - if !is_desktop_binary(comm.trim()) { + if !sweep::is_desktop_binary(comm.trim()) { continue; } // Check cmdline for the identifier with boundary anchoring. @@ -1484,6 +1490,7 @@ pub fn build_managed_agent_summary( .and_then(|r| r.mcp_command) .unwrap_or("") .to_string(); + let runtime_presentation = runtime_presentation_for_summary(&effective_command); Ok(ManagedAgentSummary { pubkey: record.pubkey.clone(), @@ -1502,6 +1509,10 @@ pub fn build_managed_agent_summary( parallelism: record.parallelism, system_prompt: record.system_prompt.clone(), avatar_url: record.avatar_url.clone(), + runtime_icon_url: runtime_presentation.icon_url, + runtime_avatar_url: runtime_presentation.avatar_url, + runtime_superseded_avatar_urls: runtime_presentation.superseded_avatar_urls, + supports_buzz_model_config: runtime_presentation.supports_buzz_model_config, model: record.model.clone(), provider: record.provider.clone(), persona_out_of_date, @@ -1594,30 +1605,6 @@ pub(crate) fn build_respond_to_env( Ok((set, remove)) } -pub(crate) fn configure_runtime_cli( - command: &mut std::process::Command, - runtime: Option<&KnownAcpRuntime>, -) { - let Some(runtime) = runtime else { - return; - }; - if runtime.id != "claude" { - return; - } - if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) { - // On Windows, `.cmd` and `.bat` files are batch shims — they cannot be - // passed directly to `CreateProcess` and cause EINVAL when the Claude - // adapter tries to spawn them (issue #2397). Skip setting - // `CLAUDE_CODE_EXECUTABLE` for shim paths so the adapter falls back to - // its own PATH lookup and finds the real binary instead. - // Non-Windows: `.cmd`/`.bat` are valid executables and must be assigned. - if should_skip_claude_executable(&cli_path, cfg!(windows)) { - return; - } - command.env("CLAUDE_CODE_EXECUTABLE", cli_path); - } -} - /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. @@ -1661,6 +1648,7 @@ pub fn spawn_agent_child( // and for the env-var merge at spawn time. let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let effective_command = super::record_agent_command(record, &personas); + let runtime_meta = known_acp_runtime(&effective_command); let agent_args = normalize_agent_args(&effective_command, record.agent_args.clone()); let resolved_acp_command = resolve_command(&record.acp_command) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; @@ -1719,7 +1707,11 @@ pub fn spawn_agent_child( command.env("RUST_LOG", child_rust_log_filter()); command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec); command.env("BUZZ_RELAY_URL", &effective_relay_url); - command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); + let defer_agent_start = should_defer_agent_start(lazy, runtime_meta); + command.env( + "BUZZ_ACP_LAZY_POOL", + if defer_agent_start { "true" } else { "false" }, + ); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { @@ -1732,7 +1724,6 @@ pub fn spawn_agent_child( } // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them. // Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp". - let runtime_meta = known_acp_runtime(&effective_command); if runtime_meta.is_some_and(|r| r.mcp_hooks) { command.env("MCP_HOOK_SERVERS", "*"); } @@ -1844,13 +1835,18 @@ pub fn spawn_agent_child( ); } } - // Only emit BUZZ_ACP_IDLE_TIMEOUT when the user has explicitly set an - // override. When unset, the buzz-acp harness applies its own default - // (see `DEFAULT_IDLE_TIMEOUT_SECS` in crates/buzz-acp/src/config.rs), - // which is the single source of truth. The previously-emitted - // `BUZZ_ACP_TURN_TIMEOUT` is deprecated upstream and was pinning every - // agent to the desktop's stale default (320s), bypassing harness bumps. - if let Some(idle) = record.idle_timeout_seconds { + // Emit BUZZ_ACP_IDLE_TIMEOUT for an explicit agent override or a + // KnownAcpRuntime catalog default. Otherwise the buzz-acp harness applies + // its generic default (see `DEFAULT_IDLE_TIMEOUT_SECS` in + // crates/buzz-acp/src/config.rs). An inherited process value is preserved, + // and the merged global/persona/agent env layer below can still override + // the catalog default. The deprecated `BUZZ_ACP_TURN_TIMEOUT` remains + // intentionally absent. + if let Some(idle) = effective_idle_timeout( + record.idle_timeout_seconds, + std::env::var_os("BUZZ_ACP_IDLE_TIMEOUT").is_some(), + runtime_meta, + ) { command.env("BUZZ_ACP_IDLE_TIMEOUT", idle.to_string()); } @@ -1883,13 +1879,14 @@ pub fn spawn_agent_child( let effective_prompt = super::spawn_hash::effective_spawn_prompt(record); let (effective_model, effective_provider) = crate::managed_agents::resolve_effective_model_provider(record, &personas, &global); + let harness_model = harness_model_for_runtime(runtime_meta, effective_model); if let Some(prompt) = &effective_prompt { command.env("BUZZ_ACP_SYSTEM_PROMPT", prompt); } else { command.env_remove("BUZZ_ACP_SYSTEM_PROMPT"); } - if let Some(model) = effective_model { + if let Some(model) = harness_model { command.env("BUZZ_ACP_MODEL", model); } else { command.env_remove("BUZZ_ACP_MODEL"); @@ -2005,6 +2002,10 @@ pub fn spawn_agent_child( } } + // Runtime identity and safety policy goes last so ambient, global, + // persona, and per-agent values cannot silently override it. + apply_runtime_env_policy(&mut command, runtime_meta); + // Stamp desktop ownership and an unpredictable harness-generation identity. let start_nonce = uuid::Uuid::new_v4().simple().to_string(); command @@ -2208,5 +2209,7 @@ pub(crate) fn resolve_effective_prompt_model_provider( } } +#[cfg(all(test, unix))] +mod process_tree_tests; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/cli_config.rs b/desktop/src-tauri/src/managed_agents/runtime/cli_config.rs new file mode 100644 index 00000000000..79f16ca2538 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/cli_config.rs @@ -0,0 +1,71 @@ +use std::process::Command; + +use crate::managed_agents::{resolve_command, KnownAcpRuntime}; + +use super::should_skip_claude_executable; + +pub(crate) fn configure_runtime_cli(command: &mut Command, runtime: Option<&KnownAcpRuntime>) { + let Some(runtime) = runtime else { + return; + }; + if runtime.id != "claude" { + return; + } + if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) { + // On Windows, `.cmd` and `.bat` files are batch shims — they cannot be + // passed directly to `CreateProcess` and cause EINVAL when the Claude + // adapter tries to spawn them (issue #2397). Skip setting + // `CLAUDE_CODE_EXECUTABLE` for shim paths so the adapter falls back to + // its own PATH lookup and finds the real binary instead. + // Non-Windows: `.cmd`/`.bat` are valid executables and must be assigned. + if should_skip_claude_executable(&cli_path, cfg!(windows)) { + return; + } + command.env("CLAUDE_CODE_EXECUTABLE", cli_path); + } +} + +#[cfg(test)] +mod tests { + use super::configure_runtime_cli; + use crate::managed_agents::{known_acp_runtime, lock_path_mutex}; + + #[test] + fn claude_uses_the_probed_cli_executable() { + let _guard = lock_path_mutex(); + let temp = tempfile::tempdir().expect("temp dir"); + let cli = temp + .path() + .join(format!("claude{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&cli, "").expect("write fake cli"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)) + .expect("make fake cli executable"); + } + let original_path = std::env::var_os("PATH"); + std::env::set_var("PATH", temp.path()); + + let mut command = std::process::Command::new("buzz-acp"); + configure_runtime_cli(&mut command, known_acp_runtime("claude-agent-acp")); + + if let Some(path) = original_path { + std::env::set_var("PATH", path); + } else { + std::env::remove_var("PATH"); + } + assert!(command.get_envs().any(|(key, value)| { + key == "CLAUDE_CODE_EXECUTABLE" && value == Some(cli.as_os_str()) + })); + } + + #[test] + fn codex_does_not_set_a_claude_executable() { + let mut command = std::process::Command::new("buzz-acp"); + configure_runtime_cli(&mut command, known_acp_runtime("codex-acp")); + assert!(!command + .get_envs() + .any(|(key, _)| key == "CLAUDE_CODE_EXECUTABLE")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/env_policy.rs b/desktop/src-tauri/src/managed_agents/runtime/env_policy.rs new file mode 100644 index 00000000000..d5c2f3e3c15 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/env_policy.rs @@ -0,0 +1,183 @@ +use std::process::Command; + +use crate::managed_agents::KnownAcpRuntime; + +/// Honor the desktop caller's lazy request only when the runtime catalog says +/// deferring the ACP subprocess is appropriate. +pub(super) fn should_defer_agent_start( + requested_lazy: bool, + runtime: Option<&KnownAcpRuntime>, +) -> bool { + requested_lazy + && runtime + .map(|runtime| runtime.defer_agent_start_until_work) + .unwrap_or(true) +} + +/// Resolve the idle timeout that desktop should write into the harness +/// environment. Explicit record values win; an inherited process value remains +/// untouched; otherwise the runtime catalog may provide a safer default. +pub(super) fn effective_idle_timeout( + configured: Option, + inherited_is_set: bool, + runtime: Option<&KnownAcpRuntime>, +) -> Option { + configured.or_else(|| { + (!inherited_is_set) + .then(|| runtime.and_then(|runtime| runtime.default_idle_timeout_secs)) + .flatten() + }) +} + +/// Apply launch-only runtime environment policy after all user environment +/// layers have been merged. +pub(super) fn apply_runtime_env_policy(command: &mut Command, runtime: Option<&KnownAcpRuntime>) { + let Some(runtime) = runtime else { + return; + }; + for key in runtime.scrub_env_vars { + command.env_remove(key); + } + for (key, value) in runtime.enforced_env { + command.env(key, value); + } +} + +/// Resolve the generic harness bootstrap model for a known runtime. +/// +/// Unknown/custom runtimes preserve the historical behavior because Buzz +/// cannot infer their ACP capabilities. Known runtime policy comes only from +/// `KnownAcpRuntime`, so launch code never needs a runtime-ID branch. +pub(super) fn harness_model_for_runtime<'a>( + runtime: Option<&KnownAcpRuntime>, + effective_model: Option<&'a str>, +) -> Option<&'a str> { + if runtime.is_some_and(|runtime| !runtime.accepts_harness_model) { + None + } else { + effective_model + } +} + +#[cfg(test)] +mod tests { + use super::{ + apply_runtime_env_policy, effective_idle_timeout, harness_model_for_runtime, + should_defer_agent_start, + }; + use crate::managed_agents::known_acp_runtime; + + #[test] + fn devin_policy_enforces_safe_permissions_and_stored_login_identity() { + let mut command = std::process::Command::new("buzz-acp"); + command.env("BUZZ_ACP_PERMISSION_MODE", "bypassPermissions"); + command.env("BUZZ_ACP_AUTO_APPROVE_PERMISSIONS", "true"); + command.env("BUZZ_ACP_INTERACTIVE_PERMISSIONS", "false"); + command.env("WINDSURF_API_KEY", "sentinel"); + + apply_runtime_env_policy(&mut command, known_acp_runtime("devin")); + + assert!(command.get_envs().any(|(key, value)| { + key == "BUZZ_ACP_PERMISSION_MODE" && value.is_some_and(|value| value == "default") + })); + assert!(command.get_envs().any(|(key, value)| { + key == "BUZZ_ACP_AUTO_APPROVE_PERMISSIONS" + && value.is_some_and(|value| value == "false") + })); + assert!(command.get_envs().any(|(key, value)| { + key == "BUZZ_ACP_INTERACTIVE_PERMISSIONS" && value.is_some_and(|value| value == "true") + })); + assert!(command.get_envs().any(|(key, value)| { + key == "BUZZ_ACP_SELF_PUBLISH_COMPLETION_GRACE" + && value.is_some_and(|value| value == "30") + })); + assert!(command + .get_envs() + .any(|(key, value)| { key == "WINDSURF_API_KEY" && value.is_none() })); + } + + /// Regression: Buzz launched from a terminal inside the Devin IDE inherits + /// `ACP_BACKEND=windsurf`. Passing it through makes `devin acp` treat the + /// ACP host as the sole credential source, so it refuses the stored CLI + /// credentials and every turn fails with "ACP host has not authenticated" + /// even though `devin auth login` succeeded. + #[test] + fn devin_policy_scrubs_inherited_acp_backend() { + let mut command = std::process::Command::new("buzz-acp"); + command.env("ACP_BACKEND", "windsurf"); + + apply_runtime_env_policy(&mut command, known_acp_runtime("devin")); + + assert!( + command + .get_envs() + .any(|(key, value)| { key == "ACP_BACKEND" && value.is_none() }), + "ACP_BACKEND must be removed before spawning the Devin adapter" + ); + } + + #[test] + fn existing_runtime_policy_remains_unchanged() { + let mut command = std::process::Command::new("buzz-acp"); + command.env("GOOSE_MODE", "custom"); + + apply_runtime_env_policy(&mut command, known_acp_runtime("goose")); + + assert!(command.get_envs().any(|(key, value)| { + key == "GOOSE_MODE" && value.is_some_and(|value| value == "custom") + })); + } + + #[test] + fn devin_owns_its_model_selection_without_changing_existing_runtime_bootstrap() { + let requested = Some("swe-1-7-lightning"); + let devin = known_acp_runtime("devin").expect("Devin must remain cataloged"); + assert_eq!(harness_model_for_runtime(Some(devin), requested), None); + + for runtime_id in ["goose", "claude", "codex", "buzz-agent"] { + let runtime = + known_acp_runtime(runtime_id).expect("existing runtime must remain cataloged"); + assert_eq!( + harness_model_for_runtime(Some(runtime), requested), + requested, + "{runtime_id} bootstrap behavior must remain unchanged" + ); + } + + assert_eq!( + harness_model_for_runtime(None, requested), + requested, + "custom runtimes preserve historical bootstrap behavior" + ); + } + + #[test] + fn devin_starts_eagerly_without_changing_existing_runtime_startup() { + assert!(!should_defer_agent_start(true, known_acp_runtime("devin"))); + for runtime_id in ["goose", "claude", "codex", "buzz-agent"] { + assert!( + should_defer_agent_start(true, known_acp_runtime(runtime_id)), + "{runtime_id} must retain lazy startup" + ); + } + assert!(should_defer_agent_start(true, None)); + assert!(!should_defer_agent_start(false, known_acp_runtime("goose"))); + } + + #[test] + fn devin_idle_default_preserves_all_override_layers() { + let devin = known_acp_runtime("devin"); + assert_eq!(effective_idle_timeout(None, false, devin), Some(120)); + assert_eq!(effective_idle_timeout(Some(45), false, devin), Some(45)); + assert_eq!(effective_idle_timeout(None, true, devin), None); + + for runtime_id in ["goose", "claude", "codex", "buzz-agent"] { + assert_eq!( + effective_idle_timeout(None, false, known_acp_runtime(runtime_id)), + None, + "{runtime_id} must retain the harness idle default" + ); + } + assert_eq!(effective_idle_timeout(None, false, None), None); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/path.rs b/desktop/src-tauri/src/managed_agents/runtime/path.rs index cf6950f5773..b35519f08e6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/path.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/path.rs @@ -85,13 +85,16 @@ pub(crate) fn compose_path_entries( /// Assemble the augmented `PATH` for a launched managed-agent child process. /// /// Concatenates, in priority order: -/// 1. `/.local/bin` — bundled CLI symlink -/// 2. Buzz-managed npm prefix bin dir — app-private ACP adapter shims -/// 3. Buzz-managed Node.js bin dir — app-private Node/npm runtime -/// 4. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) -/// 5. exe parent dir — DMG sidecars under `Contents/MacOS/` -/// 6. user's login-shell `PATH` — runtimes like node/python from other managers -/// 7. Windows only: the current process `PATH` (appended when no login-shell +/// 1. exe parent dir when it contains the bundled `buzz` sidecar — this keeps +/// managed agents version-coupled to the app that launched them instead of +/// resolving another Buzz installation's `~/.local/bin/buzz` symlink +/// 2. `/.local/bin` — user-local CLIs and the compatibility Buzz symlink +/// 3. Buzz-managed npm prefix bin dir — app-private ACP adapter shims +/// 4. Buzz-managed Node.js bin dir — app-private Node/npm runtime +/// 5. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) +/// 6. exe parent dir when it does not contain the bundled `buzz` sidecar +/// 7. user's login-shell `PATH` — runtimes like node/python from other managers +/// 8. Windows only: the current process `PATH` (appended when no login-shell /// PATH exists, because callers use `Command::env("PATH", …)` which /// *replaces* the child's PATH — without this, the child loses node/npm/git /// and every npm `.cmd` shim fails with `'node' is not recognized`) @@ -111,9 +114,15 @@ pub(in crate::managed_agents) fn build_augmented_path( let home_added = home.is_some(); let exe_added = exe_parent.is_some(); let has_local_context = home_added || exe_added; + let prefer_exe_parent = exe_parent + .as_deref() + .is_some_and(|parent| parent.join(buzz_binary_name()).is_file()); // Build the managed/prefix entries (everything before login-shell PATH). let mut managed: Vec = Vec::new(); + if prefer_exe_parent { + managed.extend(exe_parent.iter().cloned()); + } if let Some(home) = home { managed.push(home.join(".local").join("bin")); } @@ -131,8 +140,8 @@ pub(in crate::managed_agents) fn build_augmented_path( if let Some(nvm_bin) = nvm_bin { managed.push(nvm_bin); } - if let Some(parent) = exe_parent { - managed.push(parent); + if !prefer_exe_parent { + managed.extend(exe_parent); } // Split the login-shell PATH into individual entries. @@ -157,6 +166,16 @@ pub(in crate::managed_agents) fn build_augmented_path( .map(|s| s.to_string_lossy().into_owned()) } +#[cfg(windows)] +fn buzz_binary_name() -> &'static str { + "buzz.exe" +} + +#[cfg(not(windows))] +fn buzz_binary_name() -> &'static str { + "buzz" +} + #[cfg(test)] mod tests { use super::build_augmented_path; @@ -165,22 +184,20 @@ mod tests { #[cfg(unix)] #[test] fn splits_colon_delimited_shell_path() { + let app_dir = tempfile::tempdir().expect("temp app directory"); // Regression: the shell PATH arrives as one colon-delimited string. It // must be split into segments before join_paths, or join_paths rejects // it and the whole augmented PATH collapses to None (managed agents then // lose `buzz`). let result = build_augmented_path( Some(PathBuf::from("/home/agent")), - Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")), + Some(app_dir.path().to_path_buf()), Some("/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin".to_string()), None, ); let result = result.expect("path"); assert!(result.starts_with("/home/agent/.local/bin:"), "{result}"); - assert!( - result.contains(":/Applications/Buzz.app/Contents/MacOS:"), - "{result}" - ); + assert!(result.contains(app_dir.path().to_str().expect("utf-8 path"))); assert!( result.ends_with(":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"), "{result}" @@ -202,9 +219,10 @@ mod tests { #[cfg(unix)] #[test] fn nvm_bin_inserted_after_local_bin_before_exe_parent() { + let app_dir = tempfile::tempdir().expect("temp app directory"); let result = build_augmented_path( Some(PathBuf::from("/home/user")), - Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")), + Some(app_dir.path().to_path_buf()), Some("/usr/bin:/bin".to_string()), Some(PathBuf::from("/home/user/.nvm/versions/node/v20.0.0/bin")), ); @@ -214,7 +232,7 @@ mod tests { .find("/home/user/.nvm/versions/node/v20.0.0/bin") .unwrap(); let exe = result - .find("/Applications/Buzz.app/Contents/MacOS") + .find(app_dir.path().to_str().expect("utf-8 path")) .unwrap(); assert!(local < nvm && nvm < exe, "{result}"); assert!(result.ends_with(":/usr/bin:/bin"), "{result}"); @@ -234,6 +252,47 @@ mod tests { assert!(result.ends_with(":/usr/local/bin"), "{result}"); } + #[cfg(unix)] + #[test] + fn bundled_buzz_precedes_another_installations_local_symlink() { + let app_dir = tempfile::tempdir().expect("temp app directory"); + std::fs::File::create(app_dir.path().join("buzz")).expect("bundled buzz sidecar"); + + let result = build_augmented_path( + Some(PathBuf::from("/home/user")), + Some(app_dir.path().to_path_buf()), + Some("/usr/bin:/bin".to_string()), + None, + ) + .expect("path"); + + let bundled = result + .find(app_dir.path().to_str().expect("utf-8 path")) + .unwrap(); + let local = result.find("/home/user/.local/bin").unwrap(); + assert!(bundled < local, "{result}"); + } + + #[cfg(unix)] + #[test] + fn exe_parent_without_bundled_buzz_keeps_existing_path_order() { + let app_dir = tempfile::tempdir().expect("temp app directory"); + + let result = build_augmented_path( + Some(PathBuf::from("/home/user")), + Some(app_dir.path().to_path_buf()), + Some("/usr/bin:/bin".to_string()), + None, + ) + .expect("path"); + + let bundled = result + .find(app_dir.path().to_str().expect("utf-8 path")) + .unwrap(); + let local = result.find("/home/user/.local/bin").unwrap(); + assert!(local < bundled, "{result}"); + } + /// On Unix, supplying a `shell_path` must NOT trigger the Windows process-PATH /// fallback — the output must be byte-identical to what it was before this /// fix. diff --git a/desktop/src-tauri/src/managed_agents/runtime/presentation.rs b/desktop/src-tauri/src/managed_agents/runtime/presentation.rs new file mode 100644 index 00000000000..6f4d76dfeb5 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/presentation.rs @@ -0,0 +1,53 @@ +use crate::managed_agents::known_acp_runtime; + +pub(super) struct RuntimePresentation { + pub(super) icon_url: Option, + pub(super) avatar_url: Option, + pub(super) superseded_avatar_urls: Vec, + pub(super) supports_buzz_model_config: Option, +} + +pub(super) fn runtime_presentation_for_summary(effective_command: &str) -> RuntimePresentation { + let runtime = known_acp_runtime(effective_command); + RuntimePresentation { + icon_url: runtime.map(|runtime| runtime.icon_url.to_string()), + avatar_url: runtime.map(|runtime| runtime.avatar_url.to_string()), + superseded_avatar_urls: runtime + .map(|runtime| { + runtime + .superseded_avatar_urls + .iter() + .map(|url| (*url).to_string()) + .collect() + }) + .unwrap_or_default(), + supports_buzz_model_config: runtime + .map(|runtime| runtime.model_env_var.is_some() || runtime.supports_acp_model_switching), + } +} + +#[cfg(test)] +mod tests { + use super::runtime_presentation_for_summary; + use crate::managed_agents::known_acp_runtime; + + #[test] + fn runtime_avatar_is_catalog_derived_without_process_state() { + let runtime = known_acp_runtime("devin").expect("Devin must remain a known runtime"); + let presentation = runtime_presentation_for_summary("devin"); + + assert_eq!(presentation.icon_url.as_deref(), Some(runtime.icon_url)); + assert_eq!(presentation.avatar_url.as_deref(), Some(runtime.avatar_url)); + assert_eq!( + presentation.superseded_avatar_urls, + runtime.superseded_avatar_urls + ); + assert_eq!(presentation.supports_buzz_model_config, Some(false)); + + let custom = runtime_presentation_for_summary("custom-agent"); + assert!(custom.icon_url.is_none()); + assert!(custom.avatar_url.is_none()); + assert!(custom.superseded_avatar_urls.is_empty()); + assert_eq!(custom.supports_buzz_model_config, None); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/process_tree_tests.rs b/desktop/src-tauri/src/managed_agents/runtime/process_tree_tests.rs new file mode 100644 index 00000000000..9f0f4b998be --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/process_tree_tests.rs @@ -0,0 +1,83 @@ +//! Process-tree regression tests that launch subprocesses. + +use std::os::unix::process::CommandExt; +use std::process::Command; + +/// Regression coverage for managed-agent restart teardown. The helper test +/// process acts as `buzz-acp` and starts a child in its own process group, +/// mirroring `AcpClient::spawn`. Terminating the helper must reap both groups. +#[test] +fn terminate_process_reaps_independent_descendant_group() { + let _path_guard = crate::managed_agents::lock_path_mutex(); + let marker_path = std::env::temp_dir().join(format!( + "buzz-process-tree-{}-{}.pid", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after Unix epoch") + .as_nanos() + )); + let _ = std::fs::remove_file(&marker_path); + + let mut helper = { + let mut command = Command::new(std::env::current_exe().expect("resolve test executable")); + command + .args([ + "--exact", + "managed_agents::runtime::process_tree_tests::process_tree_descendant_helper", + "--nocapture", + ]) + .env("BUZZ_PROCESS_TREE_TEST_MARKER", &marker_path) + .process_group(0); + command.spawn().expect("spawn process-tree helper") + }; + let helper_pid = helper.id(); + + let child_pid = (0..100) + .find_map(|_| { + let value = std::fs::read_to_string(&marker_path).ok(); + if value.is_none() { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + value.and_then(|pid| pid.trim().parse::().ok()) + }) + .expect("helper should report its independent child PID"); + + assert_eq!( + unsafe { libc::getpgid(child_pid as i32) }, + child_pid as i32, + "helper child should lead an independent process group" + ); + + super::terminate_process(helper_pid).expect("terminate complete helper process tree"); + let _ = helper.wait(); + + for _ in 0..50 { + if !super::process_is_running(child_pid) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!( + !super::process_is_running(child_pid), + "independently-grouped ACP child must not survive harness teardown" + ); + let _ = std::fs::remove_file(marker_path); +} + +/// Subprocess-only half of [`terminate_process_reaps_independent_descendant_group`]. +#[test] +fn process_tree_descendant_helper() { + let Some(marker_path) = std::env::var_os("BUZZ_PROCESS_TREE_TEST_MARKER") else { + return; + }; + let mut child = { + let mut command = Command::new("sh"); + command + .args(["-c", "while :; do sleep 60; done"]) + .process_group(0); + command.spawn().expect("spawn independent helper child") + }; + std::fs::write(marker_path, child.id().to_string()).expect("write helper child PID"); + let _ = child.wait(); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/sweep.rs b/desktop/src-tauri/src/managed_agents/runtime/sweep.rs index 3060ff6593a..586f231549d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/sweep.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/sweep.rs @@ -10,6 +10,11 @@ use std::path::{Path, PathBuf}; +/// Check if a process name belongs to a Buzz desktop/Tauri process. +pub(super) fn is_desktop_binary(name: &str) -> bool { + ["Buzz", "Buzz for Devin", "buzz-desktop", "buzz_desktop"].contains(&name) +} + // Re-declare the macOS process-info FFI so sweep.rs can call it independently. // Multiple extern "C" declarations of the same symbol are legal in Rust; the // linker sees one symbol regardless of how many translation units declare it. @@ -186,6 +191,82 @@ pub(super) fn ppid_of_linux(pid: u32) -> Option { proc_stat_ppid_pgid_linux(pid).map(|(ppid, _)| ppid) } +/// Snapshot every live, same-user descendant of `root_pid` while the root is +/// still running. +/// +/// Managed ACP runtimes may put their own subprocesses in independent process +/// groups. Signalling only the harness group therefore cannot guarantee that +/// the complete runtime tree exits. Callers use this snapshot immediately, +/// before terminating the root, so the ancestor relationship is still +/// available and unrelated same-user processes remain out of scope. +#[cfg(target_os = "macos")] +pub(super) fn collect_live_descendant_pids(root_pid: u32) -> Vec { + let my_uid = unsafe { libc::getuid() }; + collect_all_pids() + .into_iter() + .filter_map(|pid| { + if pid <= 0 || pid as u32 == root_pid { + return None; + } + let upid = pid as u32; + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + super::proc_pidinfo( + pid, + super::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + return None; + } + let info = unsafe { info.assume_init() }; + (info.pbi_uid == my_uid && walk_has_tracked_ancestor(upid, &[root_pid], ppid_of_macos)) + .then_some(upid) + }) + .collect() +} + +/// Terminate process groups led by a live, same-user descendant of `root_pid`. +/// +/// This must run while the root is still alive so the bounded ancestry walk +/// can prove ownership before any signal is sent. +#[cfg(unix)] +pub(super) fn terminate_owned_descendant_groups(root_pid: u32) { + let descendant_pids = collect_live_descendant_pids(root_pid) + .into_iter() + .map(|pid| pid as i32) + .collect::>(); + if !descendant_pids.is_empty() { + super::resolve_pgids_and_kill(&descendant_pids); + } +} + +/// Linux variant of [`collect_live_descendant_pids`]. +#[cfg(all(unix, not(target_os = "macos")))] +pub(super) fn collect_live_descendant_pids(root_pid: u32) -> Vec { + let my_uid = unsafe { libc::getuid() }; + let Ok(entries) = std::fs::read_dir("/proc") else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|entry| { + let pid = entry.file_name().to_str()?.parse::().ok()?; + if pid == 0 || pid == root_pid { + return None; + } + use std::os::unix::fs::MetadataExt; + if entry.metadata().ok()?.uid() != my_uid { + return None; + } + walk_has_tracked_ancestor(pid, &[root_pid], ppid_of_linux).then_some(pid) + }) + .collect() +} + /// True if `pid` is a live descendant of any tracked harness in `skip_pids`. /// /// Three complementary checks: @@ -433,7 +514,7 @@ fn collect_process_snapshots(harness_name: &str) -> Vec { snapshots } -// ── expected_harness_exe_path ───────────────────────────────────────────── +// ── expected_harness_exe_paths ──────────────────────────────────────────── /// Derive the expected path of the `buzz-acp` harness binary next to the /// current executable. Returns `None` if `current_exe()` fails or has no @@ -460,12 +541,29 @@ fn collect_process_snapshots(harness_name: &str) -> Vec { /// the same app (different bundle path, e.g. a prior DMG) will not match /// this path — that class is handled by `sweep_system_agent_processes`, which /// scopes by `BUZZ_MANAGED_AGENT` instance ID rather than exe path. -pub fn expected_harness_exe_path() -> Option { - let exe = std::env::current_exe().ok()?; - let dir = exe.parent()?; - let raw = dir.join("buzz-acp"); - // Canonicalize if possible; fall back to the raw path on failure. - Some(std::fs::canonicalize(&raw).unwrap_or(raw)) +pub fn expected_harness_exe_paths() -> Vec { + let mut paths = Vec::new(); + if let Some(raw) = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|dir| dir.join("buzz-acp"))) + { + paths.push(std::fs::canonicalize(&raw).unwrap_or(raw)); + } + + // `tauri dev` builds the desktop crate under `desktop/src-tauri/target` + // while Buzz's sidecar build lives under the repository-level `target`. + // The launch resolver intentionally uses that workspace sidecar, so the + // boot sweeper must recognize the same exact path. Release builds remain + // scoped to the sibling binary inside the app bundle. + #[cfg(debug_assertions)] + if let Some(raw) = crate::managed_agents::discovery::resolve_workspace_command("buzz-acp") { + let path = std::fs::canonicalize(&raw).unwrap_or(raw); + if !paths.contains(&path) { + paths.push(path); + } + } + + paths } /// The basename of the harness binary — used for the cheap name pre-filter in @@ -496,19 +594,28 @@ const HARNESS_BINARY_NAME: &str = "buzz-acp"; /// when `resolve_pgids_and_kill` signals the PGID. #[cfg(unix)] pub(crate) fn sweep_untracked_bundle_harnesses(skip_pids: &[u32]) { - let Some(harness_exe) = expected_harness_exe_path() else { + let harness_exes = expected_harness_exe_paths(); + if harness_exes.is_empty() { return; - }; + } let snapshots = collect_process_snapshots(HARNESS_BINARY_NAME); - let to_kill = select_untracked_bundle_harnesses(&snapshots, &harness_exe, skip_pids); + let mut to_kill = std::collections::BTreeSet::new(); + for harness_exe in &harness_exes { + to_kill.extend(select_untracked_bundle_harnesses( + &snapshots, + harness_exe, + skip_pids, + )); + } if to_kill.is_empty() { return; } + let to_kill = to_kill.into_iter().collect::>(); eprintln!( - "buzz-desktop: sweep_untracked_bundle_harnesses: reaping {} stale harness process(es) {:?} (exe: {})", + "buzz-desktop: sweep_untracked_bundle_harnesses: reaping {} stale harness process(es) {:?} (expected exe paths: {:?})", to_kill.len(), to_kill, - harness_exe.display(), + harness_exes, ); // Small snapshot→kill PID-reuse window: a PID in `to_kill` could be // recycled between the snapshot and the kill call. This matches the diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index fd758047c33..14c7dfc126e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -105,6 +105,61 @@ fn goose_has_no_mcp_hooks() { assert_eq!(p.mcp_command, None); } +#[test] +fn devin_uses_native_acp_without_mcp_hooks() { + let runtime = known_acp_runtime("/usr/local/bin/devin").expect("should resolve"); + assert_eq!(runtime.id, "devin"); + assert_eq!(runtime.default_args, &["acp"]); + assert!(!runtime.defer_agent_start_until_work); + assert_eq!(runtime.default_idle_timeout_secs, Some(120)); + assert!(runtime.default_env.is_empty()); + assert_eq!( + runtime.enforced_env, + &[ + ("BUZZ_ACP_PERMISSION_MODE", "default"), + ("BUZZ_ACP_AUTO_APPROVE_PERMISSIONS", "false"), + ("BUZZ_ACP_INTERACTIVE_PERMISSIONS", "true"), + ("BUZZ_ACP_SELF_PUBLISH_COMPLETION_GRACE", "30"), + ] + ); + assert_eq!(runtime.scrub_env_vars, &["WINDSURF_API_KEY", "ACP_BACKEND"]); + assert!(!runtime.mcp_hooks); + assert_eq!(runtime.mcp_command, None); +} + +#[test] +fn devin_permission_default_does_not_change_existing_runtimes() { + assert_eq!( + known_acp_runtime("goose") + .expect("Goose runtime") + .default_env, + &[("GOOSE_MODE", "auto")] + ); + for command in ["claude-agent-acp", "codex-acp", "buzz-agent"] { + let runtime = known_acp_runtime(command).expect("existing runtime"); + assert!( + runtime.default_env.is_empty(), + "{command} defaults must remain unchanged" + ); + assert!( + runtime.enforced_env.is_empty(), + "{command} enforced environment must remain unchanged" + ); + assert!( + runtime.scrub_env_vars.is_empty(), + "{command} environment scrubs must remain unchanged" + ); + assert!( + runtime.defer_agent_start_until_work, + "{command} lazy startup must remain unchanged" + ); + assert_eq!( + runtime.default_idle_timeout_secs, None, + "{command} idle timeout must remain unchanged" + ); + } +} + #[test] fn unknown_command_returns_none() { assert!(known_acp_runtime("custom-agent").is_none()); @@ -556,6 +611,13 @@ fn name_matches_known_binary_rejects_node() { assert!(!super::name_matches_known_binary("node")); } +#[test] +fn name_matches_known_binary_accepts_native_devin_child() { + // Devin starts in its own process group. The exact-instance environment + // marker keeps the sweep scoped to children launched by this app. + assert!(super::name_matches_known_binary("devin")); +} + #[test] fn name_matches_interpreter_accepts_node() { // `node` IS a known script interpreter and must be recognized. @@ -579,45 +641,6 @@ fn name_matches_interpreter_rejects_node_prefix() { assert!(!super::name_matches_interpreter("node-gyp")); } -#[test] -fn claude_spawn_uses_the_probed_cli_executable() { - let _guard = crate::managed_agents::lock_path_mutex(); - let temp = tempfile::tempdir().expect("temp dir"); - let cli = temp - .path() - .join(format!("claude{}", std::env::consts::EXE_SUFFIX)); - std::fs::write(&cli, "").expect("write fake cli"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)) - .expect("make fake cli executable"); - } - let original_path = std::env::var_os("PATH"); - std::env::set_var("PATH", temp.path()); - - let mut command = std::process::Command::new("buzz-acp"); - super::configure_runtime_cli(&mut command, super::known_acp_runtime("claude-agent-acp")); - - if let Some(path) = original_path { - std::env::set_var("PATH", path); - } else { - std::env::remove_var("PATH"); - } - assert!(command - .get_envs() - .any(|(key, value)| { key == "CLAUDE_CODE_EXECUTABLE" && value == Some(cli.as_os_str()) })); -} - -#[test] -fn codex_spawn_does_not_set_a_claude_executable() { - let mut command = std::process::Command::new("buzz-acp"); - super::configure_runtime_cli(&mut command, super::known_acp_runtime("codex-acp")); - assert!(!command - .get_envs() - .any(|(key, _)| key == "CLAUDE_CODE_EXECUTABLE")); -} - /// On Windows, `.cmd` and `.bat` batch shims must NOT be assigned to /// `CLAUDE_CODE_EXECUTABLE` — `CreateProcess` cannot exec them directly and /// returns EINVAL (issue #2397). The adapter must fall back to its own PATH @@ -688,6 +711,8 @@ fn grandchild_inherits_pgid_of_process_group_leader() { use std::os::unix::process::CommandExt; use std::process::Command; + let _path_guard = crate::managed_agents::lock_path_mutex(); + // Spawn a "harness" process in its own process group (mirrors // `command.process_group(0)` in the real spawn path). The harness // spawns an intermediate child which in turn spawns a grandchild. @@ -781,6 +806,8 @@ fn own_group_grandchild_detected_by_ancestor_walk() { use std::os::unix::process::CommandExt; use std::process::Command; + let _path_guard = crate::managed_agents::lock_path_mutex(); + // The test process is the "harness". Spawn an intermediate with its own // process group (mirrors the node shim). It backgrounds a grandchild // (sleep 30) and prints the grandchild PID so we can inspect it. diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index b9c6c7e6cde..0118f6af84c 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -11,7 +11,10 @@ use crate::app_state::keyring_service; use crate::managed_agents::{ ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentRuntimeReceipt, }; + +mod normalization; use crate::secret_store::{KeyringProbe, SecretStore}; +use normalization::normalize_runtime_avatars; /// Keyring key name for an agent's nsec, namespaced from the human identity /// key (`"identity"`) which shares the service. @@ -181,7 +184,7 @@ fn load_agent_store(app: &AppHandle) -> Result, String> let content = fs::read_to_string(&path) .map_err(|error| format!("failed to read agent store: {error}"))?; - serde_json::from_str(&content).map_err(|error| { + let mut records: Vec = serde_json::from_str(&content).map_err(|error| { // Fail loudly and preserve the evidence: a later in-app save rewrites // this file wholesale, which would silently destroy a malformed hand // edit. Best-effort file-authoring contract (see managed_agents:: @@ -190,7 +193,11 @@ fn load_agent_store(app: &AppHandle) -> Result, String> // swallowed into an empty store. backup_invalid_store(&path); format!("failed to parse agent store (preserved as .invalid): {error}") - }) + })?; + + normalize_runtime_avatars(&mut records); + + Ok(records) } /// Load the keyed agent *instances*. Key-less definitions (former personas, @@ -590,11 +597,34 @@ fn maybe_rotate_log(path: &Path) { pub(crate) fn open_log_file(path: &Path) -> Result { maybe_rotate_log(path); - OpenOptions::new() - .create(true) - .append(true) + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + + let file = options .open(path) - .map_err(|error| format!("failed to open log file {}: {error}", path.display())) + .map_err(|error| format!("failed to open log file {}: {error}", path.display()))?; + + // `mode()` applies only when the file is created. Tighten logs written by + // older builds as soon as they are reopened so upgrades do not leave agent + // activity readable by other local accounts. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| { + format!( + "failed to secure log file permissions for {}: {error}", + path.display() + ) + })?; + } + + Ok(file) } pub(crate) fn append_log_marker(path: &Path, message: &str) -> Result<(), String> { @@ -789,15 +819,17 @@ pub fn meaningful_agent_error_from_log(path: &Path) -> Option { mod tests { use std::cell::RefCell; use std::collections::HashMap; - use std::io::Write as _; - - use tempfile::NamedTempFile; use super::{ agent_keyring_name, hydrate_keys_with, migrate_inline_key, persist_agent_keys_with, KeyMigration, KeyStore, KeyringProbe, ManagedAgentRecord, }; + mod avatar; + mod log_errors; + #[cfg(unix)] + mod log_permissions; + /// In-memory [`KeyStore`] for testing the migrate decision without the OS /// keyring. `reachable=false` simulates a backend outage; `fail_verify` /// simulates a write whose read-back does not confirm. @@ -1095,12 +1127,6 @@ mod tests { assert!(records[1].private_key_nsec.is_empty()); } - fn write_log(content: &str) -> NamedTempFile { - let mut file = NamedTempFile::new().expect("temp log"); - file.write_all(content.as_bytes()).expect("write log"); - file - } - /// The keyringless fallback write must land `0o600` from the write itself — /// not a post-write `chmod` — so a crash in the umask window can never leave /// plaintext agent nsecs world-readable (Wes storage.rs:239, SECURITY.md:90). @@ -1127,58 +1153,6 @@ mod tests { ); } - #[test] - fn meaningful_agent_error_from_log_promotes_wrapped_llm_auth() { - let file = write_log( - "noise\nAgent reported error (code -32001): llm auth: 401 unauthorized: ...\n", - ); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert!(result.message.contains("llm auth")); - assert_eq!(result.code, Some(-32001)); - } - - #[test] - fn meaningful_agent_error_from_log_promotes_unwrapped_llm_auth() { - let file = write_log("noise\nllm auth: denied\n"); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert_eq!(result.message, "Agent reported error: llm auth: denied"); - assert_eq!(result.code, Some(-32001)); - } - - #[test] - fn meaningful_agent_error_from_log_promotes_bare_model_not_found() { - let file = write_log("noise\nllm model not found: (some-model) 404\n"); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert_eq!( - result.message, - "Agent reported error: llm model not found: (some-model) 404" - ); - assert_eq!(result.code, Some(-32002)); - } - - #[test] - fn meaningful_agent_error_from_log_promotes_legacy_format() { - let file = write_log("noise\nAgent reported error: llm: 500 internal\n"); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert_eq!(result.message, "Agent reported error: llm: 500 internal"); - assert_eq!(result.code, None); - } - - #[test] - fn meaningful_agent_error_from_log_does_not_promote_midline_auth_text() { - let file = write_log("noise before llm auth: denied\n"); - assert!(super::meaningful_agent_error_from_log(file.path()).is_none()); - } - - #[test] - fn strips_ansi_from_typical_tracing_line() { - let input = "\x1b[2m2026-05-27T15:16:32\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2mbuzz_acp\x1b[0m\x1b[2m:\x1b[0m starting"; - assert_eq!( - strip_ansi_escapes::strip_str(input), - "2026-05-27T15:16:32 INFO buzz_acp: starting" - ); - } - // ── keyring-dev-migration tests ──────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/managed_agents/storage/normalization.rs b/desktop/src-tauri/src/managed_agents/storage/normalization.rs new file mode 100644 index 00000000000..741276c7ecc --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/storage/normalization.rs @@ -0,0 +1,12 @@ +use crate::managed_agents::{normalize_managed_agent_avatar, ManagedAgentRecord}; + +pub(super) fn normalize_runtime_avatars(records: &mut [ManagedAgentRecord]) { + for record in records { + let command = record + .runtime + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&record.agent_command); + record.avatar_url = normalize_managed_agent_avatar(command, record.avatar_url.take()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/storage/tests/avatar.rs b/desktop/src-tauri/src/managed_agents/storage/tests/avatar.rs new file mode 100644 index 00000000000..a7179cd902a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/storage/tests/avatar.rs @@ -0,0 +1,28 @@ +use super::super::normalize_runtime_avatars; +use super::record_with_key; + +#[test] +fn loaded_devin_records_replace_only_the_superseded_default_avatar() { + let mut devin = record_with_key(""); + devin.runtime = Some("devin".to_string()); + devin.avatar_url = Some( + "https://mintcdn.com/cognitionai/Hhrl_8XUBqA4VQ6v/logo/favicon.svg?fit=max&auto=format&n=Hhrl_8XUBqA4VQ6v&q=85&s=ab641f30c01bf5374b90b62209db569e" + .to_string(), + ); + + let mut custom = record_with_key(""); + custom.runtime = Some("devin".to_string()); + custom.avatar_url = Some("https://example.test/custom.png".to_string()); + + normalize_runtime_avatars(std::slice::from_mut(&mut devin)); + normalize_runtime_avatars(std::slice::from_mut(&mut custom)); + + assert_eq!( + devin.avatar_url, + crate::managed_agents::managed_agent_avatar_url("devin") + ); + assert_eq!( + custom.avatar_url.as_deref(), + Some("https://example.test/custom.png") + ); +} diff --git a/desktop/src-tauri/src/managed_agents/storage/tests/log_errors.rs b/desktop/src-tauri/src/managed_agents/storage/tests/log_errors.rs new file mode 100644 index 00000000000..6bb1b2e2536 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/storage/tests/log_errors.rs @@ -0,0 +1,60 @@ +use std::io::Write as _; + +use tempfile::NamedTempFile; + +fn write_log(content: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().expect("temp log"); + file.write_all(content.as_bytes()).expect("write log"); + file +} + +#[test] +fn meaningful_agent_error_from_log_promotes_wrapped_llm_auth() { + let file = + write_log("noise\nAgent reported error (code -32001): llm auth: 401 unauthorized: ...\n"); + let result = super::super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert!(result.message.contains("llm auth")); + assert_eq!(result.code, Some(-32001)); +} + +#[test] +fn meaningful_agent_error_from_log_promotes_unwrapped_llm_auth() { + let file = write_log("noise\nllm auth: denied\n"); + let result = super::super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!(result.message, "Agent reported error: llm auth: denied"); + assert_eq!(result.code, Some(-32001)); +} + +#[test] +fn meaningful_agent_error_from_log_promotes_bare_model_not_found() { + let file = write_log("noise\nllm model not found: (some-model) 404\n"); + let result = super::super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!( + result.message, + "Agent reported error: llm model not found: (some-model) 404" + ); + assert_eq!(result.code, Some(-32002)); +} + +#[test] +fn meaningful_agent_error_from_log_promotes_legacy_format() { + let file = write_log("noise\nAgent reported error: llm: 500 internal\n"); + let result = super::super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!(result.message, "Agent reported error: llm: 500 internal"); + assert_eq!(result.code, None); +} + +#[test] +fn meaningful_agent_error_from_log_does_not_promote_midline_auth_text() { + let file = write_log("noise before llm auth: denied\n"); + assert!(super::super::meaningful_agent_error_from_log(file.path()).is_none()); +} + +#[test] +fn strips_ansi_from_typical_tracing_line() { + let input = "\x1b[2m2026-05-27T15:16:32\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2mbuzz_acp\x1b[0m\x1b[2m:\x1b[0m starting"; + assert_eq!( + strip_ansi_escapes::strip_str(input), + "2026-05-27T15:16:32 INFO buzz_acp: starting" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/storage/tests/log_permissions.rs b/desktop/src-tauri/src/managed_agents/storage/tests/log_permissions.rs new file mode 100644 index 00000000000..5a2e0fee9ce --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/storage/tests/log_permissions.rs @@ -0,0 +1,29 @@ +use std::os::unix::fs::PermissionsExt as _; + +#[test] +fn agent_logs_are_created_owner_only_and_tighten_legacy_permissions() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("agent.log"); + + drop(super::super::open_log_file(&path).expect("create log")); + assert_eq!( + std::fs::metadata(&path) + .expect("created log metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .expect("set legacy permissions"); + drop(super::super::open_log_file(&path).expect("reopen legacy log")); + assert_eq!( + std::fs::metadata(&path) + .expect("reopened log metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index af4908911b2..601e5625da5 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -476,7 +476,19 @@ pub struct ManagedAgentSummary { pub max_turn_duration_seconds: Option, pub parallelism: u32, pub system_prompt: Option, + /// User/persona avatar snapshot persisted on the agent record. pub avatar_url: Option, + /// App-local presentation mark derived from the effective runtime catalog. + pub runtime_icon_url: Option, + /// Presentation-only fallback derived from the effective runtime catalog. + /// This is never persisted as a user-selected avatar. + pub runtime_avatar_url: Option, + /// Superseded catalog defaults that the frontend must not prefer over the + /// current runtime avatar while a stopped agent's relay profile is stale. + pub runtime_superseded_avatar_urls: Vec, + /// Whether Buzz can apply its configured model to this runtime. `None` + /// preserves the existing display for unknown/custom runtimes. + pub supports_buzz_model_config: Option, pub model: Option, /// LLM inference provider, from the agent's pinned record snapshot. pub provider: Option, @@ -569,7 +581,14 @@ pub enum AuthStatus { pub struct AcpRuntimeCatalogEntry { pub id: String, pub label: String, + pub display_label: String, + pub sort_priority: u16, + pub onboarding_visible: bool, + pub icon_url: String, + pub icon_scale: f32, pub avatar_url: String, + pub superseded_avatar_urls: Vec, + pub supports_buzz_model_config: bool, pub availability: AcpAvailabilityStatus, pub command: Option, pub binary_path: Option, diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 0de8ea0a82b..552bf89466c 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -264,6 +264,9 @@ const LEGACY_NEST_KNOWLEDGE: &[&str] = &[ /// so the caller can emit a one-time hint inviting the user to delete it. The /// frontend dedupes the hint, so re-firing while `~/.sprout` lingers is benign. pub fn migrate_legacy_nest() -> bool { + if !should_import_legacy_nest(crate::managed_agents::uses_upstream_nest_namespace()) { + return false; + } let Some(home) = dirs::home_dir() else { eprintln!("buzz-desktop: nest-migration: cannot resolve home directory"); return false; @@ -276,6 +279,10 @@ pub fn migrate_legacy_nest() -> bool { migrate_legacy_nest_at(&home.join(".sprout"), ¤t_nest) } +fn should_import_legacy_nest(uses_upstream_namespace: bool) -> bool { + uses_upstream_namespace +} + /// Copy the [`LEGACY_NEST_KNOWLEDGE`] entries from `legacy` to `current`. /// /// Each entry is copied independently with its own log-and-continue, so a diff --git a/desktop/src-tauri/src/migration_tests.rs b/desktop/src-tauri/src/migration_tests.rs index 0d49bd02aab..b3db4fdc934 100644 --- a/desktop/src-tauri/src/migration_tests.rs +++ b/desktop/src-tauri/src/migration_tests.rs @@ -867,6 +867,12 @@ fn migrate_legacy_nest_carries_knowledge_and_skips_repos() { ); } +#[test] +fn isolated_release_nest_does_not_import_legacy_sprout_knowledge() { + assert!(!super::should_import_legacy_nest(false)); + assert!(super::should_import_legacy_nest(true)); +} + #[test] fn migrate_legacy_nest_does_not_clobber_existing_destination() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c92..fe941ca7085 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -22,6 +22,9 @@ pub use user_search::{ // ── Tag helpers ───────────────────────────────────────────────────────────── +mod managed_agents; +pub use managed_agents::managed_agents_from_events; + /// Find the first tag whose name matches `name` and return its first value. /// /// e.g. for tag `["name", "general"]` with `name="name"` returns `Some("general")`. diff --git a/desktop/src-tauri/src/nostr_convert/managed_agents.rs b/desktop/src-tauri/src/nostr_convert/managed_agents.rs new file mode 100644 index 00000000000..d9da5007e47 --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/managed_agents.rs @@ -0,0 +1,180 @@ +//! Managed-agent directory projection (kind:30177). +//! +//! Split out of `nostr_convert` so the generic event converters stay readable +//! and this authorization-facing projection has an obvious home. + +use nostr::{Event, ToBech32}; +use serde_json::{json, Value}; + +use super::first_tag_value; + +/// Convert public managed-agent projections into the relay-agent directory +/// shape consumed by the desktop. +/// +/// The event author is the human owner. The managed agent's public key is the +/// parameterized replaceable event's `d` tag, so a content-supplied key is +/// never authoritative. Build a narrow output object instead of forwarding +/// the complete content projection so future fields cannot accidentally cross +/// this frontend boundary. +pub fn managed_agents_from_events(events: &[Event]) -> Value { + let arr: Vec = events + .iter() + .filter_map(|event| { + let agent_pubkey = nostr::PublicKey::from_hex(first_tag_value(event, "d")?).ok()?; + let pubkey = agent_pubkey.to_hex(); + let npub = agent_pubkey.to_bech32().unwrap_or_else(|_| pubkey.clone()); + let content: Value = serde_json::from_str(&event.content).ok()?; + let object = content.as_object()?; + let name = object + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&npub); + // Only the modes `RespondTo` can represent may be projected. The + // harness also supports `nobody`, but the desktop enum deliberately + // omits it, so emitting it here would fail deserialization for the + // WHOLE directory response — one agent publishing an unsupported + // mode would hide every other agent. An unrepresentable mode + // becomes absent, which every eligibility check treats as + // not-invocable, so the projection degrades closed. + let respond_to = object + .get("respond_to") + .and_then(Value::as_str) + .filter(|mode| matches!(*mode, "owner-only" | "allowlist" | "anyone")); + + let mut respond_to_allowlist = Vec::new(); + if let Some(values) = object.get("respond_to_allowlist").and_then(Value::as_array) { + for value in values { + let Some(raw) = value.as_str() else { + continue; + }; + let Ok(key) = nostr::PublicKey::from_hex(raw.trim()) else { + continue; + }; + let normalized = key.to_hex(); + if !respond_to_allowlist.contains(&normalized) { + respond_to_allowlist.push(normalized); + } + } + } + + Some(json!({ + "pubkey": pubkey, + "name": name, + "agent_type": "agent", + "channels": [], + "channel_ids": [], + "capabilities": [], + "status": "offline", + "respond_to": respond_to, + "respond_to_allowlist": respond_to_allowlist, + })) + }) + .collect(); + + json!({ "agents": arr }) +} +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn ev(kind: u16, content: &str, tags: Vec>) -> Event { + let keys = Keys::generate(); + let tags: Vec = tags + .into_iter() + .map(|t| Tag::parse(t.iter().map(|s| s.to_string()).collect::>()).unwrap()) + .collect(); + EventBuilder::new(Kind::from(kind), content) + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + #[test] + fn managed_agents_use_d_tag_identity_and_preserve_allowlist_metadata() { + let agent_pubkey = "02".repeat(32); + let allowed_pubkey = "03".repeat(32); + let e = ev( + 30177, + &format!( + r#"{{"pubkey":"forged","name":"Scout","parallelism":1,"respond_to":"allowlist","respond_to_allowlist":["{allowed_pubkey}"],"env_vars":{{"SECRET":"do-not-project"}}}}"# + ), + vec![vec!["d", &agent_pubkey]], + ); + let v = managed_agents_from_events(std::slice::from_ref(&e)); + let agents = v.get("agents").cloned().unwrap(); + let parsed: Vec = + serde_json::from_value(agents).unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].pubkey, agent_pubkey); + assert_eq!(parsed[0].name, "Scout"); + assert_eq!( + parsed[0].respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(parsed[0].respond_to_allowlist, vec![allowed_pubkey]); + assert!( + !v.to_string().contains("SECRET"), + "the directory projection must remain an explicit public-field allowlist" + ); + } + + /// One agent publishing a mode the desktop enum cannot represent must not + /// take down the entire directory. `respond_to` deserializes into + /// `RespondTo`, which has no `nobody` variant, so projecting that string + /// would fail the whole `Vec` and hide every other agent. + #[test] + fn managed_agents_unsupported_mode_does_not_break_other_entries() { + let good_pubkey = "02".repeat(32); + let nobody_pubkey = "03".repeat(32); + let good = ev( + 30177, + r#"{"name":"Good","respond_to":"owner-only"}"#, + vec![vec!["d", &good_pubkey]], + ); + let nobody = ev( + 30177, + r#"{"name":"Nope","respond_to":"nobody"}"#, + vec![vec!["d", &nobody_pubkey]], + ); + let garbage = ev( + 30177, + r#"{"name":"Junk","respond_to":"not-a-mode"}"#, + vec![vec!["d", &"04".repeat(32)]], + ); + + let v = managed_agents_from_events(&[good, nobody, garbage]); + let parsed: Vec = + serde_json::from_value(v.get("agents").cloned().unwrap()) + .expect("an unsupported mode must not fail the whole directory"); + + assert_eq!(parsed.len(), 3, "every entry survives"); + assert_eq!( + parsed[0].respond_to, + Some(crate::managed_agents::RespondTo::OwnerOnly) + ); + // Unrepresentable modes degrade closed: absent, never invocable. + assert_eq!(parsed[1].respond_to, None); + assert_eq!(parsed[2].respond_to, None); + } + + #[test] + fn managed_agents_drop_events_without_a_valid_agent_d_tag() { + let missing = ev( + 30177, + r#"{"name":"Missing","parallelism":1,"respond_to":"owner-only"}"#, + vec![], + ); + let invalid = ev( + 30177, + r#"{"name":"Invalid","parallelism":1,"respond_to":"owner-only"}"#, + vec![vec!["d", "not-a-pubkey"]], + ); + let v = managed_agents_from_events(&[missing, invalid]); + + assert_eq!(v.get("agents").and_then(Value::as_array).unwrap().len(), 0); + } +} diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd681..56fb281c978 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -5,7 +5,7 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentIdentityInEligibleSet, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -136,27 +136,34 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); -test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => { - const managedAgentPubkeys = new Set([PUB_A]); +test("isAgentIdentityInEligibleSet: keeps people and only eligible agent identities", () => { + const eligibleAgentPubkeys = new Set([PUB_A, PUB_C]); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInEligibleSet( { isAgent: false, pubkey: PUB_B }, - managedAgentPubkeys, + eligibleAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInEligibleSet( { isAgent: true, pubkey: PUB_A.toUpperCase() }, - managedAgentPubkeys, + eligibleAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInEligibleSet( + { isAgent: true, pubkey: PUB_C }, + eligibleAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityInEligibleSet( { isAgent: true, pubkey: PUB_B }, - managedAgentPubkeys, + eligibleAgentPubkeys, ), false, ); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4a..efdd1ba7114 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -54,13 +54,13 @@ export function getMentionableAgentPubkeys({ return pubkeys; } -export function isAgentIdentityInManagedList( +export function isAgentIdentityInEligibleSet( candidate: { isAgent?: boolean; pubkey: string }, - managedAgentPubkeys: ReadonlySet, + eligibleAgentPubkeys: ReadonlySet, ) { return ( candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + eligibleAgentPubkeys.has(normalizePubkey(candidate.pubkey)) ); } diff --git a/desktop/src/features/agents/lib/agentRespondToUpdate.test.mjs b/desktop/src/features/agents/lib/agentRespondToUpdate.test.mjs new file mode 100644 index 00000000000..c320a2ef9e1 --- /dev/null +++ b/desktop/src/features/agents/lib/agentRespondToUpdate.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildAgentRespondToUpdate } from "./agentRespondToUpdate.ts"; + +const ALLOWED_PUBKEY = "c".repeat(64); + +test("instance edits always submit the form's respond-to mode", () => { + assert.deepEqual(buildAgentRespondToUpdate("owner-only", []), { + respondTo: "owner-only", + respondToAllowlist: undefined, + }); +}); + +test("allowlist edits always submit the complete form allowlist", () => { + const allowlist = [ALLOWED_PUBKEY]; + + const update = buildAgentRespondToUpdate("allowlist", allowlist); + + assert.deepEqual(update, { + respondTo: "allowlist", + respondToAllowlist: [ALLOWED_PUBKEY], + }); + assert.notEqual(update.respondToAllowlist, allowlist); +}); + +test("non-allowlist edits do not overwrite a preserved allowlist", () => { + assert.deepEqual(buildAgentRespondToUpdate("anyone", [ALLOWED_PUBKEY]), { + respondTo: "anyone", + respondToAllowlist: undefined, + }); +}); diff --git a/desktop/src/features/agents/lib/agentRespondToUpdate.ts b/desktop/src/features/agents/lib/agentRespondToUpdate.ts new file mode 100644 index 00000000000..fad21a76012 --- /dev/null +++ b/desktop/src/features/agents/lib/agentRespondToUpdate.ts @@ -0,0 +1,29 @@ +import type { + RespondToMode, + UpdateManagedAgentInput, +} from "@/shared/api/types"; + +type RespondToUpdate = Pick< + UpdateManagedAgentInput, + "respondTo" | "respondToAllowlist" +>; + +/** + * Build the authoritative inbound-author policy for an instance edit. + * + * The edit dialog stays mounted while managed-agent polling can replace its + * `agent` prop. Its local form state is therefore the only reliable snapshot + * of what the user is saving. Always send that state instead of diffing it + * against a possibly refreshed prop; Rust validates the merged policy and the + * retention layer suppresses unchanged relay publications. + */ +export function buildAgentRespondToUpdate( + respondTo: RespondToMode, + respondToAllowlist: string[], +): RespondToUpdate { + return { + respondTo, + respondToAllowlist: + respondTo === "allowlist" ? [...respondToAllowlist] : undefined, + }; +} diff --git a/desktop/src/features/agents/lib/formatAgentCardModelLabel.test.mjs b/desktop/src/features/agents/lib/formatAgentCardModelLabel.test.mjs new file mode 100644 index 00000000000..2faf0fa9ad2 --- /dev/null +++ b/desktop/src/features/agents/lib/formatAgentCardModelLabel.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatAgentCardModelLabel } from "./formatAgentCardModelLabel.ts"; + +test("runtime-owned models do not claim the Buzz global default", () => { + assert.equal( + formatAgentCardModelLabel(null, "swe-1-7-lightning", false), + "Runtime default", + ); + assert.equal( + formatAgentCardModelLabel("stored-but-not-applicable", "global", false), + "Runtime default", + ); +}); + +test("supported and unknown runtimes preserve configured model labels", () => { + assert.equal( + formatAgentCardModelLabel(null, "swe-1-7-lightning", true), + "Default model (swe-1-7-lightning)", + ); + assert.equal( + formatAgentCardModelLabel("explicit-model", "global", null), + "explicit-model", + ); +}); diff --git a/desktop/src/features/agents/lib/formatAgentCardModelLabel.ts b/desktop/src/features/agents/lib/formatAgentCardModelLabel.ts new file mode 100644 index 00000000000..92a59892a87 --- /dev/null +++ b/desktop/src/features/agents/lib/formatAgentCardModelLabel.ts @@ -0,0 +1,19 @@ +import { formatAgentModelLabel } from "./formatAgentModelLabel.ts"; + +/** + * Describe only model configuration that Buzz can actually apply. + * + * Unknown/custom runtimes preserve the historic card label. Known runtimes + * without a model-selection path use their own runtime default. + */ +export function formatAgentCardModelLabel( + explicitModel: string | null | undefined, + defaultModel: string, + supportsBuzzModelConfig: boolean | null, +) { + if (supportsBuzzModelConfig === false) return "Runtime default"; + const explicit = explicitModel?.trim(); + if (explicit) return formatAgentModelLabel(explicit); + const inherited = defaultModel.trim(); + return inherited ? `Default model (${inherited})` : "Default model"; +} diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs index 597b1b9323e..46b03ec6bb3 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs @@ -47,6 +47,15 @@ test("generic harness exit message → passthrough", () => { }); }); +test("Devin ACP startup failure remains distinct and actionable", () => { + const startupFailure = + "failed to start Devin ACP: process exited before initialization"; + assert.deepEqual(friendlyAgentLastError(startupFailure), { + severity: "generic", + copy: startupFailure, + }); +}); + test("trims whitespace before matching", () => { const result = friendlyAgentLastError( " Agent reported error: llm auth: nope\n", diff --git a/desktop/src/features/agents/lib/resolveAgentCardAvatarUrl.test.mjs b/desktop/src/features/agents/lib/resolveAgentCardAvatarUrl.test.mjs new file mode 100644 index 00000000000..124f0b18a30 --- /dev/null +++ b/desktop/src/features/agents/lib/resolveAgentCardAvatarUrl.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveAgentCardAvatarUrl } from "./resolveAgentCardAvatarUrl.ts"; + +test("stopped legacy agents use the catalog-projected runtime avatar", () => { + assert.equal( + resolveAgentCardAvatarUrl([null, null, "app-avatar://devin"]), + "app-avatar://devin", + ); +}); + +test("stored and custom profile avatars outrank the runtime fallback", () => { + assert.equal( + resolveAgentCardAvatarUrl([ + " https://relay.example/custom.png ", + "https://stored.example/custom.png", + "app-avatar://devin", + ]), + "https://relay.example/custom.png", + ); + assert.equal( + resolveAgentCardAvatarUrl([ + " ", + "https://stored.example/custom.png", + "app-avatar://devin", + ]), + "https://stored.example/custom.png", + ); +}); + +test("superseded relay defaults fall through to the current runtime avatar", () => { + const legacy = "https://runtime.example/old-default.svg"; + assert.equal( + resolveAgentCardAvatarUrl( + [null, legacy, "data:image/svg+xml,current"], + [legacy], + ), + "data:image/svg+xml,current", + ); +}); + +test("persona callers preserve custom instance avatars before the runtime fallback", () => { + assert.equal( + resolveAgentCardAvatarUrl([ + null, + "https://stored.example/custom.png", + "/runtime-icons/current.svg", + ]), + "https://stored.example/custom.png", + ); +}); + +test("persona-only cards use the catalog runtime icon before an instance exists", () => { + assert.equal( + resolveAgentCardAvatarUrl([ + null, + "/runtime-icons/devin.svg", + "data:image/svg+xml,runtime-avatar", + ]), + "/runtime-icons/devin.svg", + ); +}); + +test("missing custom and runtime avatars preserve the initials fallback", () => { + assert.equal(resolveAgentCardAvatarUrl([undefined, "", null]), null); +}); diff --git a/desktop/src/features/agents/lib/resolveAgentCardAvatarUrl.ts b/desktop/src/features/agents/lib/resolveAgentCardAvatarUrl.ts new file mode 100644 index 00000000000..6b31195a662 --- /dev/null +++ b/desktop/src/features/agents/lib/resolveAgentCardAvatarUrl.ts @@ -0,0 +1,21 @@ +/** + * Select the first non-empty avatar source for an agent-management card. + * + * Callers provide sources in presentation-priority order. The final source is + * normally the runtime-catalog fallback projected by the backend, so stopped + * legacy agents still render their runtime logo without overwriting stored or + * relay-published custom avatars. + */ +export function resolveAgentCardAvatarUrl( + candidates: Array, + supersededRuntimeAvatarUrls: readonly string[] = [], +): string | null { + const superseded = new Set( + supersededRuntimeAvatarUrls.map((candidate) => candidate.trim()), + ); + for (const candidate of candidates) { + const trimmed = candidate?.trim(); + if (trimmed && !superseded.has(trimmed)) return trimmed; + } + return null; +} diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 3236b4d8085..dcf5bf55694 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -168,6 +168,7 @@ function NormalizedRow({ field, isPreSpawn, configFilePath, + runtimeControlsModel = false, variant = "compact", }: { fieldKey: keyof NormalizedConfig; @@ -175,26 +176,31 @@ function NormalizedRow({ field: NormalizedField; isPreSpawn: boolean; configFilePath: string | null; + runtimeControlsModel?: boolean; variant?: RowVariant; }) { const Icon = NORMALIZED_ICONS[fieldKey]; // ACP-sourced origins only become meaningful post-spawn const isAcpOnly = field.origin === "acpNativeRead" || field.origin === "acpConfigOption"; - const rawDisplayValue = - isPreSpawn && isAcpOnly + const rawDisplayValue = runtimeControlsModel + ? "Runtime default" + : isPreSpawn && isAcpOnly ? "Available after agent starts" : (field.value ?? "—"); const displayValue = fieldKey === "provider" ? providerDisplayLabel(rawDisplayValue) : rawDisplayValue; - const provenance = field.value - ? provenanceSentence(field.origin, field.writeVia, configFilePath) - : null; - const locked = isReadOnlyField(field); + const provenance = runtimeControlsModel + ? "Controlled by runtime" + : field.value + ? provenanceSentence(field.origin, field.writeVia, configFilePath) + : null; + const locked = runtimeControlsModel || isReadOnlyField(field); const isCopyable = variant === "profile" && + !runtimeControlsModel && shouldOfferCopy({ fieldKey, origin: field.origin, @@ -216,7 +222,7 @@ function NormalizedRow({ )} {displayValue} {!(isPreSpawn && isAcpOnly) && field.overriddenValue ? ( @@ -417,6 +423,9 @@ export function AgentConfigPanel({ field={field} isPreSpawn={isPreSpawn} configFilePath={configFilePath} + runtimeControlsModel={ + key === "model" && data.supportsBuzzModelConfig === false + } variant={advancedMode === "flat" ? "profile" : "compact"} /> )) diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 5cd5c7a0167..b1bb347c877 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -12,6 +12,7 @@ import { useStartManagedAgentMutation, useUpdateManagedAgentMutation, } from "@/features/agents/hooks"; +import { buildAgentRespondToUpdate } from "@/features/agents/lib/agentRespondToUpdate"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import type { ManagedAgent, @@ -632,6 +633,10 @@ export function AgentInstanceEditDialog({ // all agree. See resolveInheritedRuntimeSubmission. const normalizedSubmitProvider = inheritedSubmission.provider; const submitEnvVars = inheritedSubmission.envVars; + const respondToUpdate = buildAgentRespondToUpdate( + respondTo, + respondToAllowlist, + ); const input: UpdateManagedAgentInput = { pubkey: agent.pubkey, name: name.trim() !== agent.name ? name.trim() : undefined, @@ -683,18 +688,7 @@ export function AgentInstanceEditDialog({ envVars: envVarsEqual(submitEnvVars, agent.envVars) ? undefined : submitEnvVars, - respondTo: respondTo !== agent.respondTo ? respondTo : undefined, - // The allowlist is preserved across mode toggles in local UI state - // (so a user can flip away from allowlist and back without losing - // their entries), but we only send it on the wire when (a) it - // actually changed, AND (b) the saved mode will need it. Sending - // an allowlist while switching to a non-allowlist mode would be - // harmless server-side, but it's noise in the persisted record. - respondToAllowlist: - respondTo === "allowlist" && - respondToAllowlist.join(",") !== agent.respondToAllowlist.join(",") - ? respondToAllowlist - : undefined, + ...respondToUpdate, }; const result = await updateMutation.mutateAsync(input); diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 77d9e820607..6be07e7a930 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -143,6 +143,7 @@ export function AgentsView() {

{groups.map((group) => { const profileAgent = pickProfileAgent(group.agents); + const personaRuntime = runtimes.find( + (runtime) => runtime.id === group.persona.runtime, + ); return ( ; onOpenAgentProfile: ( @@ -262,14 +275,28 @@ function AgentPersonaCard({ }) { const title = persona.displayName; const explicitModel = agent?.model ?? persona.model; - const modelLabel = explicitModel?.trim() - ? formatAgentModelLabel(explicitModel) - : formatDefaultModelLabel(defaultModel); + const modelLabel = formatAgentCardModelLabel( + explicitModel, + defaultModel, + runtime?.supportsBuzzModelConfig ?? agent?.supportsBuzzModelConfig ?? null, + ); const isActive = agent ? isManagedAgentActive(agent) : false; const profileQuery = useUserProfileQuery(agent?.pubkey); const avatarUrl = agent - ? firstAvatarUrl(persona.avatarUrl, profileQuery.data?.avatarUrl) - : persona.avatarUrl; + ? resolveAgentCardAvatarUrl( + [ + persona.avatarUrl, + agent.avatarUrl, + profileQuery.data?.avatarUrl, + runtime?.iconUrl ?? agent.runtimeIconUrl, + runtime?.avatarUrl ?? agent.runtimeAvatarUrl, + ], + runtime?.supersededAvatarUrls ?? agent.runtimeSupersededAvatarUrls, + ) + : resolveAgentCardAvatarUrl( + [persona.avatarUrl, runtime?.iconUrl, runtime?.avatarUrl], + runtime?.supersededAvatarUrls ?? [], + ); const friendlyError = agent ? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy : null; @@ -357,6 +384,10 @@ function StandaloneAgentCard({ )?.copy; const isActive = isManagedAgentActive(agent); const opensRuntimeTab = Boolean(friendlyError && !isActive); + const avatarUrl = resolveAgentCardAvatarUrl( + [agent.avatarUrl, profileQuery.data?.avatarUrl, agent.runtimeAvatarUrl], + agent.runtimeSupersededAvatarUrls, + ); return ( onStartAgent(agent.pubkey)} /> } - avatarUrl={profileQuery.data?.avatarUrl} + avatarUrl={avatarUrl} dataTestId={`managed-agent-${agent.pubkey}`} label={title} - modelLabel={ - agent.model?.trim() - ? formatAgentModelLabel(agent.model) - : formatDefaultModelLabel(defaultModel) - } + modelLabel={formatAgentCardModelLabel( + agent.model, + defaultModel, + agent.supportsBuzzModelConfig, + )} onClick={() => { onOpenAgentProfile( agent.pubkey, @@ -403,21 +434,6 @@ function StandaloneAgentCard({ ); } -function formatDefaultModelLabel(defaultModel: string) { - const model = defaultModel.trim(); - return model ? `Default model (${model})` : "Default model"; -} - -function firstAvatarUrl( - ...candidates: Array -): string | null { - for (const candidate of candidates) { - const trimmed = candidate?.trim(); - if (trimmed) return trimmed; - } - return null; -} - function NewAgentCard({ canChooseCatalog, isPersonasPending, diff --git a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx index 53a00e64638..b099bce2904 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx @@ -1,5 +1,11 @@ +import * as React from "react"; import { AlertCircle, CheckCircle2, ShieldCheck, XCircle } from "lucide-react"; +import { toast } from "sonner"; +import { resolveManagedAgentPermission } from "@/shared/api/agentControl"; +import { Button } from "@/shared/ui/button"; +import { oneShotPermissionOptions } from "../agentSessionPermission"; +import type { AgentPermissionOption } from "../agentSessionTypes"; import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; import { ActivityRow, ActivityRowLabel } from "./ActivityRow"; import { ToolActivity } from "./ToolActivity"; @@ -37,6 +43,82 @@ function permissionOutcomeTone(outcome: string): "approve" | "deny" | "cancel" { return "cancel"; } +function PermissionDecisionActions({ + agentPubkey, + channelId, + requestId, + turnId, + options, +}: { + agentPubkey: string; + channelId: string; + requestId: string | number; + turnId: string; + options: AgentPermissionOption[]; +}) { + const [submitting, setSubmitting] = React.useState(null); + + const decide = React.useCallback( + async (optionId: string) => { + setSubmitting(optionId); + try { + await resolveManagedAgentPermission( + agentPubkey, + channelId, + turnId, + requestId, + optionId, + ); + } catch { + setSubmitting(null); + toast.error("Couldn’t send the permission decision. Try again."); + } + }, + [agentPubkey, channelId, requestId, turnId], + ); + + const oneShotOptions = oneShotPermissionOptions(options); + const allowOnce = oneShotOptions.find( + (option) => option.kind === "allow_once", + ); + const rejectOnce = oneShotOptions.find( + (option) => option.kind === "reject_once", + ); + + return ( + <> +
+
+ Permission decision + {allowOnce ? ( + + ) : null} + {rejectOnce ? ( + + ) : null} +
+ + ); +} + export function LifecycleActivity(props: ActivityRenderClassItemProps) { if (props.item.type === "tool") { return ; @@ -97,6 +179,17 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) { {outcome}
+ ) : props.item.permissionRequestId != null && + props.item.channelId && + props.item.turnId && + props.item.permissionOptions?.length ? ( + ) : null}
); diff --git a/desktop/src/features/agents/ui/agentSessionPermission.ts b/desktop/src/features/agents/ui/agentSessionPermission.ts new file mode 100644 index 00000000000..5fc98c163c2 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionPermission.ts @@ -0,0 +1,114 @@ +import type { + AgentActivityDescriptor, + AgentPermissionOption, +} from "./agentSessionTypes"; +import { asRecord, asString } from "./agentSessionUtils"; + +export type PermissionRequestDescription = { + title: string; + text: string; + optionNames: Map; + permissionOptions: AgentPermissionOption[]; + descriptor: AgentActivityDescriptor; +}; + +/** + * Interactive managed-runtime consent is intentionally one-shot. Persistent + * runtime choices stay visible in the transcript for auditability, but they + * are never returned as actionable controls. + */ +export function oneShotPermissionOptions( + options: AgentPermissionOption[], +): AgentPermissionOption[] { + return options.filter( + (option) => option.kind === "allow_once" || option.kind === "reject_once", + ); +} + +export function describePermissionRequest( + payload: Record, +): PermissionRequestDescription { + const params = asRecord(payload.params); + const toolCall = asRecord(params.toolCall); + const title = + asString(toolCall.title) ?? + asString(params.title) ?? + asString(params.message) ?? + asString(params.reason) ?? + "Permission requested"; + const toolCallId = + asString(toolCall.toolCallId) ?? + asString(toolCall.tool_call_id) ?? + asString(params.toolCallId) ?? + asString(params.tool_call_id); + const permissionOptions: AgentPermissionOption[] = Array.isArray( + params.options, + ) + ? params.options + .map((option) => { + const record = asRecord(option); + const optionId = asString(record.optionId); + const kind = asString(record.kind); + if (!optionId || !kind) return null; + return { + optionId, + kind, + name: asString(record.name) ?? kind, + }; + }) + .filter((option): option is AgentPermissionOption => option !== null) + : []; + const detail: string[] = []; + if (title !== "Permission requested") detail.push(title); + if (toolCallId) detail.push(`Tool call: ${toolCallId}`); + if (permissionOptions.length > 0) { + detail.push( + `Options: ${permissionOptions.map((option) => option.name).join(", ")}`, + ); + } + + const optionNames = new Map(); + for (const option of permissionOptions) { + optionNames.set(option.optionId, option.kind); + } + + return { + title, + text: detail.join("\n"), + optionNames, + permissionOptions, + descriptor: { + renderClass: "permission", + label: "Permission requested", + preview: title, + action: { verb: "Requested", object: title }, + tone: "admin", + operation: "session/request_permission", + object: title, + source: "acp", + groupKey: "permission:request", + }, + }; +} + +/** + * Format a human-readable outcome label from a permission response. + * kind values from ACP: allow_once, allow_always, reject_once, reject_always. + * "reject_*" kinds are denials; anything else that is selected is an approval. + */ +export function describePermissionOutcome( + outcome: string, + optionId: string | null, + optionNames: Map, +): string { + if (outcome === "cancelled") { + return "Cancelled"; + } + if (outcome === "selected" && optionId) { + const kind = optionNames.get(optionId) ?? optionId; + const isDenial = kind.startsWith("reject"); + const verb = isDenial ? "Denied" : "Approved"; + return `${verb} (${kind})`; + } + return outcome; +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index cc6f0467d61..0d17629bc8f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -6,6 +6,7 @@ import { buildTranscriptDisplayBlocks, flattenDisplayBlocks, } from "./agentSessionTranscriptGrouping.ts"; +import { oneShotPermissionOptions } from "./agentSessionPermission.ts"; import { formatToolTitle } from "./agentSessionToolCatalog.ts"; const baseEvent = { @@ -572,12 +573,20 @@ test("buildTranscript surfaces session/request_permission as a permission lifecy turnId: "turn-1", payload: { jsonrpc: "2.0", + id: 41, method: "session/request_permission", params: { - toolCallId: "tool-1", - title: "Confirm force-with-lease push to block/buzz.", + toolCall: { + toolCallId: "tool-1", + title: "Confirm force-with-lease push to block/buzz.", + }, options: [ { optionId: "allow_once", kind: "allow_once", name: "Allow" }, + { + optionId: "allow_workspace", + kind: "allow_always", + name: "Always allow in this workspace", + }, { optionId: "reject_once", kind: "reject_once", name: "Reject" }, ], }, @@ -590,6 +599,41 @@ test("buildTranscript surfaces session/request_permission as a permission lifecy assert.equal(transcript[0].renderClass, "permission"); assert.equal(transcript[0].title, "Permission requested"); assert.match(transcript[0].text, /Confirm force-with-lease push/); + assert.equal(transcript[0].permissionRequestId, 41); + assert.deepEqual(transcript[0].permissionOptions, [ + { optionId: "allow_once", kind: "allow_once", name: "Allow" }, + { + optionId: "allow_workspace", + kind: "allow_always", + name: "Always allow in this workspace", + }, + { optionId: "reject_once", kind: "reject_once", name: "Reject" }, + ]); + assert.equal(transcript[0].channelId, "channel-1"); + assert.equal(transcript[0].turnId, "turn-1"); +}); + +test("interactive permission actions exclude persistent runtime choices", () => { + assert.deepEqual( + oneShotPermissionOptions([ + { optionId: "allow_once", kind: "allow_once", name: "Allow once" }, + { + optionId: "allow_workspace", + kind: "allow_always", + name: "Always allow in this workspace", + }, + { + optionId: "reject_always", + kind: "reject_always", + name: "Always reject", + }, + { optionId: "reject_once", kind: "reject_once", name: "Deny once" }, + ]), + [ + { optionId: "allow_once", kind: "allow_once", name: "Allow once" }, + { optionId: "reject_once", kind: "reject_once", name: "Deny once" }, + ], + ); }); test("buildTranscript stamps completedAt when a terminal tool update is inserted first", () => { @@ -821,6 +865,22 @@ test("buildTranscript no-ops on a permission response with an unmatched id", () assert.doesNotMatch(item.text ?? "", /Denied/); }); +test("buildTranscript keeps multiple permission requests in one turn separate", () => { + const transcript = buildTranscript([ + makePermissionRequest(1, "req-first"), + makePermissionResponse(2, "req-first", "selected", "allow_once"), + makePermissionRequest(3, "req-second"), + makePermissionResponse(4, "req-second", "selected", "reject_once"), + ]); + + assert.equal(transcript.length, 2); + assert.notEqual(transcript[0].id, transcript[1].id); + assert.equal(transcript[0].type, "lifecycle"); + assert.equal(transcript[0].outcome, "Approved (allow_once)"); + assert.equal(transcript[1].type, "lifecycle"); + assert.equal(transcript[1].outcome, "Denied (reject_once)"); +}); + test("buildTranscript appends Approved outcome for a numeric JSON-RPC id (selected allow_once)", () => { // JSON-RPC 2.0 allows numeric ids; the ACP runtime preserves them as // serde_json::Value. asString() drops numbers, so this exercises the diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 962290c6ca2..c24fe613792 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -1,6 +1,7 @@ import type { AgentActivityDescriptor, AgentActivityRenderClass, + AgentPermissionOption, ObserverEvent, PromptSection, ToolStatus, @@ -12,6 +13,10 @@ import { normalizeToolStatus, } from "./agentSessionToolCatalog"; import { classifyTool } from "./agentSessionToolClassifier"; +import { + describePermissionOutcome, + describePermissionRequest, +} from "./agentSessionPermission"; import { asRecord, asString, titleCase } from "./agentSessionUtils"; import { describeTurnStarted, @@ -171,85 +176,6 @@ function stringifyPayload(value: unknown) { } } -function describePermissionRequest(payload: Record) { - const params = asRecord(payload.params); - const title = - asString(params.title) ?? - asString(params.message) ?? - asString(params.reason) ?? - "Permission requested"; - const toolCallId = - asString(params.toolCallId) ?? asString(params.tool_call_id); - const options = Array.isArray(params.options) - ? params.options - .map((option) => { - const record = asRecord(option); - return ( - asString(record.name) ?? - asString(record.kind) ?? - asString(record.optionId) - ); - }) - .filter((option): option is string => Boolean(option)) - : []; - const detail: string[] = []; - if (title !== "Permission requested") detail.push(title); - if (toolCallId) detail.push(`Tool call: ${toolCallId}`); - if (options.length > 0) detail.push(`Options: ${options.join(", ")}`); - - // Build optionId → kind map for outcome labeling on the response. - const optionNames = new Map(); - if (Array.isArray(params.options)) { - for (const option of params.options) { - const record = asRecord(option); - const optionId = asString(record.optionId); - const kind = asString(record.kind); - if (optionId && kind) { - optionNames.set(optionId, kind); - } - } - } - - return { - title, - text: detail.join("\n"), - optionNames, - descriptor: { - renderClass: "permission" as const, - label: "Permission requested", - preview: title, - action: { verb: "Requested", object: title }, - tone: "admin" as const, - operation: "session/request_permission", - object: title, - source: "acp" as const, - groupKey: "permission:request", - }, - }; -} - -/** - * Format a human-readable outcome label from a permission response. - * kind values from ACP: allow_once, allow_always, reject_once, reject_always. - * "reject_*" kinds are denials; anything else that is selected is an approval. - */ -function describePermissionOutcome( - outcome: string, - optionId: string | null, - optionNames: Map, -): string { - if (outcome === "cancelled") { - return "Cancelled"; - } - if (outcome === "selected" && optionId) { - const kind = optionNames.get(optionId) ?? optionId; - const isDenial = kind.startsWith("reject"); - const verb = isDenial ? "Denied" : "Approved"; - return `${verb} (${kind})`; - } - return outcome; -} - /** * Stable map key for a JSON-RPC id, which may be a string or a finite number * per the spec. Using JSON.stringify avoids collisions between the number 1 and @@ -263,6 +189,12 @@ function jsonRpcId(value: unknown): string | null { return null; } +function jsonRpcIdValue(value: unknown): string | number | null { + if (typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + return null; +} + function describeFreeformStatus(payload: Record) { const statusType = asString(payload.type) ?? asString(payload.status); const title = @@ -408,6 +340,8 @@ function upsertLifecycleItem( ctx: TranscriptItemContext, acpSource?: string, descriptor?: AgentActivityDescriptor, + permissionRequestId?: string | number, + permissionOptions?: AgentPermissionOption[], ) { const existing = d.itemsById.get(id); if (existing?.type === "lifecycle") { @@ -417,6 +351,8 @@ function upsertLifecycleItem( title, text: joinLifecycleText(existing.text, text), descriptor: descriptor ?? existing.descriptor, + permissionRequestId: permissionRequestId ?? existing.permissionRequestId, + permissionOptions: permissionOptions ?? existing.permissionOptions, channelId: ctx.channelId, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, @@ -434,6 +370,8 @@ function upsertLifecycleItem( text, timestamp, descriptor, + permissionRequestId, + permissionOptions, channelId: ctx.channelId, turnId: ctx.turnId, sessionId: ctx.sessionId, @@ -792,7 +730,10 @@ export function processTranscriptEvent( if (method === "session/request_permission") { const request = describePermissionRequest(payload); - const itemId = `permission:${ch}:${event.turnId ?? event.seq}`; + const requestId = jsonRpcId(payload.id); + const itemId = `permission:${ch}:${event.turnId ?? event.seq}:${ + requestId ?? event.seq + }`; upsertLifecycleItem( d, itemId, @@ -803,10 +744,11 @@ export function processTranscriptEvent( ctx, "permission_request", request.descriptor, + jsonRpcIdValue(payload.id) ?? undefined, + request.permissionOptions, ); // Index by JSON-RPC id so the response (acp_write with result.outcome, // no method) can correlate by id rather than by turn/seq. - const requestId = jsonRpcId(payload.id); if (requestId) { d.pendingPermissions = new Map(d.pendingPermissions); d.pendingPermissions.set(requestId, { diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 578f98076cd..47bcb689edf 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -68,6 +68,12 @@ export type TranscriptItemIdentity = { channelId?: string | null; }; +export type AgentPermissionOption = { + optionId: string; + name: string; + kind: string; +}; + export type TranscriptItem = | ({ id: string; @@ -109,6 +115,10 @@ export type TranscriptItem = text: string; /** Resolved outcome for permission items (e.g. "Approved (allow_once)", "Denied (reject_once)", "Cancelled"). */ outcome?: string; + /** Raw ACP JSON-RPC id used for an exact interactive decision match. */ + permissionRequestId?: string | number; + /** Exact options offered by the runtime for this live request. */ + permissionOptions?: AgentPermissionOption[]; timestamp: string; descriptor?: AgentActivityDescriptor; acpSource?: TranscriptAcpSource; diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 21ab04f8d6b..cb22f40673c 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -57,6 +57,24 @@ export function BotActivityComposerAction({ Boolean(singleWorkingAgent), singleWorkingAgent?.pubkey, ); + const pendingPermission = React.useMemo(() => { + if (!singleWorkingAgent) return null; + const scoped = channelId + ? transcript.filter((item) => item.channelId === channelId) + : transcript; + for (let index = scoped.length - 1; index >= 0; index--) { + const item = scoped[index]; + if ( + item?.type === "lifecycle" && + item.renderClass === "permission" && + !item.outcome && + item.permissionRequestId != null + ) { + return item; + } + } + return null; + }, [channelId, singleWorkingAgent, transcript]); const activityHeadlines = React.useMemo(() => { if (!singleWorkingAgent) { return []; @@ -144,17 +162,21 @@ export function BotActivityComposerAction({ profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null; const selectedPubkey = openAgentSessionPubkey?.toLowerCase() ?? null; const triggerLabel = - workingAgents.length === 1 - ? `${workingAgents[0]?.name ?? "Agent"} is working` - : `${workingAgents.length} agents working`; + pendingPermission && singleWorkingAgent + ? `${singleWorkingAgent.name} needs permission` + : workingAgents.length === 1 + ? `${workingAgents[0]?.name ?? "Agent"} is working` + : `${workingAgents.length} agents working`; const isInline = variant === "inline"; const visibleStatusLabel = - workingAgents.length === 1 - ? `${workingAgents[0]?.name ?? "Agent"}: ${ - activityHeadlines[headlineIndex % activityHeadlines.length] ?? - "Working" - }` - : `${workingAgents[0]?.name ?? "Agent"} +${workingAgents.length - 1}`; + pendingPermission && singleWorkingAgent + ? `${singleWorkingAgent.name}: Approval required` + : workingAgents.length === 1 + ? `${workingAgents[0]?.name ?? "Agent"}: ${ + activityHeadlines[headlineIndex % activityHeadlines.length] ?? + "Working" + }` + : `${workingAgents[0]?.name ?? "Agent"} +${workingAgents.length - 1}`; return ( @@ -166,11 +188,17 @@ export function BotActivityComposerAction({ isInline ? "h-7 min-w-0 gap-2 overflow-visible border-transparent bg-transparent px-0 text-xs font-semibold leading-none shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent" : "h-9 min-w-9 gap-1.5 px-2 text-xs", + pendingPermission ? "text-amber-700 dark:text-amber-400" : null, )} data-testid="bot-activity-composer-trigger" onBlur={closeWithDelay} onClick={() => { clearHoverTimer(); + if (pendingPermission && singleWorkingAgent) { + setOpen(false); + onOpenAgentSession(singleWorkingAgent.pubkey, channelId); + return; + } setOpen((current) => !current); }} onFocus={() => setOpen(true)} diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a23..ef0ff0507d5 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -9,7 +9,7 @@ import { import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; import { coalesceAgentAutocompleteCandidates, - isAgentIdentityInManagedList, + isAgentIdentityInEligibleSet, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; @@ -282,7 +282,7 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentIdentityInEligibleSet(candidate, managedAgentPubkeys) ) { return; } diff --git a/desktop/src/features/messages/lib/messageLink.test.mjs b/desktop/src/features/messages/lib/messageLink.test.mjs index 68ccd385eeb..55775c63bf4 100644 --- a/desktop/src/features/messages/lib/messageLink.test.mjs +++ b/desktop/src/features/messages/lib/messageLink.test.mjs @@ -7,6 +7,7 @@ import { parseMessageLink, resolveMessageLinkRenderTarget, } from "./messageLink.ts"; +import { messageUrlPatternForScheme } from "./remarkMessageLinks.ts"; const CHANNEL = "f570339f-8f8a-4e08-a779-8d954aa44109"; const MESSAGE = @@ -120,6 +121,17 @@ test("isMessageLink matches buzz://message and legacy buzz://message", () => { assert.equal(isMessageLink(""), false); }); +test("bare message-link matcher supports the fork scheme and legacy Buzz", () => { + const pattern = messageUrlPatternForScheme("buzz-for-devin"); + const input = + "fork buzz-for-devin://message?channel=c&id=1 legacy buzz://message?channel=c&id=2"; + + assert.deepEqual(input.match(pattern), [ + "buzz-for-devin://message?channel=c&id=1", + "buzz://message?channel=c&id=2", + ]); +}); + test("resolveMessageLinkRenderTarget distinguishes autolinks from labeled links", () => { const href = `buzz://message?channel=${CHANNEL}&id=${MESSAGE}`; diff --git a/desktop/src/features/messages/lib/messageLink.ts b/desktop/src/features/messages/lib/messageLink.ts index 56f67b23af7..d9110b0c8b9 100644 --- a/desktop/src/features/messages/lib/messageLink.ts +++ b/desktop/src/features/messages/lib/messageLink.ts @@ -4,7 +4,13 @@ * Format: `buzz://message?channel=&id=[&thread=]` */ -const MESSAGE_LINK_SCHEME = "buzz:"; +import { + APP_DEEP_LINK_PROTOCOL, + APP_DEEP_LINK_SCHEME, + isSupportedAppDeepLinkProtocol, +} from "../../../shared/appIdentity.ts"; + +const MESSAGE_LINK_SCHEME = APP_DEEP_LINK_PROTOCOL; const MESSAGE_LINK_HOST = "message"; export type MessageLinkInput = { @@ -67,7 +73,7 @@ export function parseMessageLink(url: string): MessageLinkParseResult { return { ok: false, reason: "invalid-url" }; } - if (parsed.protocol !== MESSAGE_LINK_SCHEME) { + if (!isSupportedAppDeepLinkProtocol(parsed.protocol)) { return { ok: false, reason: "wrong-scheme" }; } // `new URL("buzz://message?…")` puts "message" in `hostname`. @@ -100,7 +106,12 @@ export function parseMessageLink(url: string): MessageLinkParseResult { */ export function isMessageLink(href: string | undefined | null): boolean { if (!href) return false; - return href.startsWith("buzz://message?") || href === "buzz://message"; + return ( + href.startsWith(`${APP_DEEP_LINK_SCHEME}://message?`) || + href === `${APP_DEEP_LINK_SCHEME}://message` || + href.startsWith("buzz://message?") || + href === "buzz://message" + ); } type MessageLinkRenderInput = { diff --git a/desktop/src/features/messages/lib/remarkMessageLinks.ts b/desktop/src/features/messages/lib/remarkMessageLinks.ts index cafd584c95e..864c24d572c 100644 --- a/desktop/src/features/messages/lib/remarkMessageLinks.ts +++ b/desktop/src/features/messages/lib/remarkMessageLinks.ts @@ -19,8 +19,18 @@ // `markdown.tsx` and by `markdown.test.mjs` running under `node --test // --experimental-strip-types`. `tsconfig.json` enables `allowImportingTsExtensions`. import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; +import { APP_DEEP_LINK_SCHEME } from "../../../shared/appIdentity.ts"; -const MESSAGE_URL_PATTERN = /(?:buzz|buzz):\/\/message\?[^\s<>"')\]]+/g; +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function messageUrlPatternForScheme(scheme: string): RegExp { + const schemes = [...new Set([scheme, "buzz"])].map(escapeRegExp).join("|"); + return new RegExp(`(?:${schemes}):\\/\\/message\\?[^\\s<>"')\\]]+`, "g"); +} + +const MESSAGE_URL_PATTERN = messageUrlPatternForScheme(APP_DEEP_LINK_SCHEME); const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; function trimMessageLinkMatch(matchText: string) { diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b753390..67eb7dbe208 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -16,7 +16,7 @@ import { coalesceAutocompleteCandidatesByKey, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentIdentityInEligibleSet, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { @@ -246,7 +246,7 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if (!isAgentIdentityInEligibleSet(candidate, mentionableAgentPubkeys)) { return; } if ( @@ -420,7 +420,6 @@ export function useMentions( managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, - managedAgentPubkeys, managedAgentsQuery.data, memberPubkeys, members, diff --git a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx index a4109143770..3395622a2f3 100644 --- a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx +++ b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx @@ -3,30 +3,11 @@ import { TerminalSquare } from "lucide-react"; import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { useTheme } from "@/shared/theme/ThemeProvider"; -import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; -import chatgptLogoUrl from "../assets/harness-logos/chatgpt.png?inline"; -import claudeLogoUrl from "../assets/harness-logos/claude.png?inline"; -import gooseLogoUrl from "../assets/harness-logos/goose.png?inline"; - -const RUNTIME_LOGOS: Record = { - claude: claudeLogoUrl, - codex: chatgptLogoUrl, - goose: gooseLogoUrl, -}; - -function isBuzzRuntime(runtime: AcpRuntimeCatalogEntry): boolean { - return runtime.id.trim().toLowerCase() === "buzz-agent"; -} export function getRuntimeDisplayLabel( runtime: AcpRuntimeCatalogEntry, ): string { - return isBuzzRuntime(runtime) ? "Buzz" : runtime.label; -} - -function getRuntimeLogoUrl(runtime: AcpRuntimeCatalogEntry): string | null { - return RUNTIME_LOGOS[runtime.id.trim().toLowerCase()] ?? null; + return runtime.displayLabel; } export function RuntimeIcon({ @@ -37,27 +18,16 @@ export function RuntimeIcon({ runtime: AcpRuntimeCatalogEntry; }) { const [imageFailed, setImageFailed] = React.useState(false); - const { isDark } = useTheme(); - const runtimeLogoUrl = getRuntimeLogoUrl(runtime); - const imageUrl = runtimeLogoUrl ?? runtime.avatarUrl; - const shouldForceForegroundColor = !runtimeLogoUrl && runtime.id === "goose"; - - if (isBuzzRuntime(runtime)) { - return ; - } + const imageUrl = runtime.iconUrl || runtime.avatarUrl; if (imageUrl && !imageFailed) { return ( setImageFailed(true)} src={imageUrl} + style={{ transform: `scale(${runtime.iconScale})` }} /> ); } diff --git a/desktop/src/features/onboarding/ui/agentReadiness.test.mjs b/desktop/src/features/onboarding/ui/agentReadiness.test.mjs index 9d6deafb09a..2ce228038da 100644 --- a/desktop/src/features/onboarding/ui/agentReadiness.test.mjs +++ b/desktop/src/features/onboarding/ui/agentReadiness.test.mjs @@ -15,6 +15,9 @@ function makeRuntime(overrides = {}) { binaryPath: "/usr/local/bin/goose", defaultArgs: [], mcpCommand: null, + modelEnvVar: "GOOSE_MODEL", + providerEnvVar: "GOOSE_PROVIDER", + thinkingEnvVar: "GOOSE_THINKING_EFFORT", installHint: "", installInstructionsUrl: "https://example.com", canAutoInstall: false, @@ -40,7 +43,15 @@ function makeConfig(overrides = {}) { // --------------------------------------------------------------------------- test("resolveAgentReadiness_cli_returns_ready_when_preferred_cli_runtime_is_logged_in", () => { - const runtimes = [makeRuntime({ id: "claude", label: "Claude" })]; + const runtimes = [ + makeRuntime({ + id: "claude", + label: "Claude", + modelEnvVar: null, + providerEnvVar: null, + thinkingEnvVar: null, + }), + ]; const result = resolveAgentReadiness( runtimes, makeConfig({ preferred_runtime: "claude" }), @@ -52,9 +63,37 @@ test("resolveAgentReadiness_cli_returns_ready_when_preferred_cli_runtime_is_logg }); }); +test("resolveAgentReadiness_devin_uses_catalog_capabilities_without_an_id_check", () => { + const runtimes = [ + makeRuntime({ + id: "devin", + label: "Devin", + modelEnvVar: null, + providerEnvVar: null, + thinkingEnvVar: null, + }), + ]; + const result = resolveAgentReadiness( + runtimes, + makeConfig({ preferred_runtime: "devin" }), + "preferred", + ); + assert.deepEqual(result, { + ready: true, + reason: "cli", + runtimeLabel: "Devin", + }); +}); + test("resolveAgentReadiness_uses_only_the_preferred_runtime", () => { const runtimes = [ - makeRuntime({ id: "claude", label: "Claude" }), + makeRuntime({ + id: "claude", + label: "Claude", + modelEnvVar: null, + providerEnvVar: null, + thinkingEnvVar: null, + }), makeRuntime({ id: "goose", label: "Goose" }), ]; const result = resolveAgentReadiness(runtimes, makeConfig(), "preferred"); @@ -197,7 +236,15 @@ test("resolveAgentReadiness_neither_returns_not_ready", () => { }); test("resolveAgentReadiness_welcome_readiness_uses_ready_cli_without_preference", () => { - const runtimes = [makeRuntime({ id: "claude", label: "Claude" })]; + const runtimes = [ + makeRuntime({ + id: "claude", + label: "Claude", + modelEnvVar: null, + providerEnvVar: null, + thinkingEnvVar: null, + }), + ]; const result = resolveAgentReadiness( runtimes, makeConfig({ preferred_runtime: null }), diff --git a/desktop/src/features/onboarding/ui/agentReadiness.ts b/desktop/src/features/onboarding/ui/agentReadiness.ts index 86b9721af35..d92d1467c89 100644 --- a/desktop/src/features/onboarding/ui/agentReadiness.ts +++ b/desktop/src/features/onboarding/ui/agentReadiness.ts @@ -12,7 +12,8 @@ export type AgentReadinessResult = /** * Determine whether the user has a working agent path configured. * - * CLI path: the preferred Claude or Codex runtime is available and logged in. + * CLI path: a catalog-declared runtime without provider configuration is + * available and logged in. * Provider path: the preferred Buzz Agent or Goose runtime has provider and * model set, plus all required credential env vars for that provider. * @@ -47,7 +48,7 @@ export function resolveAgentReadiness( } if ( - (preferredRuntime.id === "claude" || preferredRuntime.id === "codex") && + preferredRuntime.providerEnvVar == null && (preferredRuntime.authStatus.status === "logged_in" || preferredRuntime.authStatus.status === "not_applicable") ) { diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index b10aa191549..56f85f14b13 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -8,29 +8,66 @@ import { runtimeIsVisibleInOnboarding, } from "./onboardingRuntimeSelection.ts"; -function runtime(id, availability, status) { - return { id, availability, authStatus: { status } }; +function runtime( + id, + availability, + status, + { onboardingVisible = true, sortPriority = 100 } = {}, +) { + return { + id, + availability, + authStatus: { status }, + onboardingVisible, + sortPriority, + }; } -test("only Claude Code and Codex are visible in onboarding", () => { - assert.equal(runtimeIsVisibleInOnboarding("claude"), true); - assert.equal(runtimeIsVisibleInOnboarding("codex"), true); - assert.equal(runtimeIsVisibleInOnboarding("goose"), false); - assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), false); - assert.equal(runtimeIsVisibleInOnboarding("custom"), false); +test("onboarding visibility comes from catalog metadata", () => { + assert.equal( + runtimeIsVisibleInOnboarding(runtime("devin", "available", "logged_in")), + true, + ); + assert.equal( + runtimeIsVisibleInOnboarding( + runtime("future-runtime", "available", "logged_in", { + onboardingVisible: false, + }), + ), + false, + ); }); -test("visible onboarding runtimes use the product order", () => { +test("visible onboarding runtimes use catalog ordering", () => { const runtimes = [ - runtime("buzz-agent", "available", "not_applicable"), - runtime("codex", "available", "logged_in"), - runtime("goose", "available", "not_applicable"), - runtime("claude", "available", "logged_in"), + runtime("buzz-agent", "available", "not_applicable", { + onboardingVisible: false, + sortPriority: 0, + }), + runtime("codex", "available", "logged_in", { sortPriority: 40 }), + runtime("goose", "available", "not_applicable", { + onboardingVisible: false, + sortPriority: 10, + }), + runtime("devin", "available", "logged_in", { sortPriority: 20 }), + runtime("claude", "available", "logged_in", { sortPriority: 30 }), ]; assert.deepEqual( getVisibleOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude", "codex"], + ["devin", "claude", "codex"], + ); +}); + +test("catalog ordering falls back to labels for rolling-upgrade payloads", () => { + const alpha = runtime("alpha", "available", "logged_in"); + alpha.label = "Alpha"; + const beta = runtime("beta", "available", "logged_in"); + beta.label = "Beta"; + + assert.deepEqual( + getVisibleOnboardingRuntimes([beta, alpha]).map(({ id }) => id), + ["alpha", "beta"], ); }); @@ -39,6 +76,10 @@ test("readiness requires an available and authenticated runtime", () => { runtimeIsReadyForOnboarding(runtime("claude", "available", "logged_in")), true, ); + assert.equal( + runtimeIsReadyForOnboarding(runtime("devin", "available", "logged_in")), + true, + ); assert.equal( runtimeIsReadyForOnboarding( runtime("codex", "available", "not_applicable"), @@ -57,9 +98,14 @@ test("readiness requires an available and authenticated runtime", () => { test("ready onboarding runtimes exclude hidden ready harnesses", () => { const runtimes = [ - runtime("goose", "available", "not_applicable"), + runtime("goose", "available", "not_applicable", { + onboardingVisible: false, + }), runtime("codex", "available", "logged_out"), - runtime("buzz-agent", "available", "not_applicable"), + runtime("buzz-agent", "available", "not_applicable", { + onboardingVisible: false, + }), + runtime("devin", "available", "logged_out"), runtime("claude", "available", "logged_in"), ]; diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 51339e2afea..baef13ac34a 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,13 +1,7 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; - -const VISIBLE_ONBOARDING_RUNTIME_IDS = new Set( - ONBOARDING_RUNTIME_ORDER, -); - -export function runtimeIsVisibleInOnboarding(runtimeId: string) { - return VISIBLE_ONBOARDING_RUNTIME_IDS.has(runtimeId); +export function runtimeIsVisibleInOnboarding(runtime: AcpRuntimeCatalogEntry) { + return runtime.onboardingVisible; } export function runtimeIsReadyForOnboarding(runtime: AcpRuntimeCatalogEntry) { @@ -22,11 +16,13 @@ export function getVisibleOnboardingRuntimes( runtimes: readonly AcpRuntimeCatalogEntry[], ) { return runtimes - .filter((runtime) => runtimeIsVisibleInOnboarding(runtime.id)) + .filter(runtimeIsVisibleInOnboarding) .sort( (left, right) => - ONBOARDING_RUNTIME_ORDER.indexOf(left.id) - - ONBOARDING_RUNTIME_ORDER.indexOf(right.id), + left.sortPriority - right.sortPriority || + (left.displayLabel || left.label || left.id).localeCompare( + right.displayLabel || right.label || right.id, + ), ); } diff --git a/desktop/src/features/profile/ui/ProfileAvatar.tsx b/desktop/src/features/profile/ui/ProfileAvatar.tsx index 3153fb4be1c..41f70ff819d 100644 --- a/desktop/src/features/profile/ui/ProfileAvatar.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatar.tsx @@ -16,6 +16,7 @@ type ProfileAvatarProps = { className?: string; iconClassName?: string; imageClassName?: string; + imageStyle?: React.CSSProperties; plain?: boolean; testId?: string; }; @@ -27,6 +28,7 @@ export function ProfileAvatar({ className, iconClassName, imageClassName, + imageStyle, plain = false, testId, }: ProfileAvatarProps) { @@ -87,6 +89,7 @@ export function ProfileAvatar({ }} referrerPolicy="no-referrer" src={src} + style={imageStyle} /> ) : null} {shouldShowFallback ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index c1f713d2304..4015ef31804 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -80,6 +80,7 @@ import { resolveAgentInstruction, resolvePanelProfile, resolveProfileDisplayName, + resolveProfileEditTarget, truncatePubkey, type UserProfilePanelProps, useRetainedPersona, @@ -398,12 +399,18 @@ export function UserProfilePanel({ }); const handleEditAgent = React.useCallback(() => { - if (resolvedPersona) { + // See resolveProfileEditTarget: an instance-backed profile must edit the + // instance, whose respond-to pair is the one enforced at spawn. + const target = resolveProfileEditTarget({ + hasManagedInstance: managedAgent !== undefined, + hasDefinition: resolvedPersona !== undefined, + }); + if (target === "definition" && resolvedPersona) { setPersonaDialogState(editPersonaDialogState(resolvedPersona)); return; } setEditAgentOpen(true); - }, [resolvedPersona]); + }, [managedAgent, resolvedPersona]); const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } = useProfileAgentDeletion({ diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 89837f6017c..3cb9b8b0d7e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -7,6 +7,7 @@ import { personaManagedAgentUpdate, profilePanelTabFromSearch, profilePanelViewFromSearch, + resolveProfileEditTarget, } from "./UserProfilePanelUtils.ts"; function agent(overrides = {}) { @@ -189,3 +190,53 @@ test("profilePanelTabFromSearch falls back to info for invalid values", () => { assert.equal(profilePanelTabFromSearch("missing"), "info"); assert.equal(profilePanelTabFromSearch(null), "info"); }); + +// ── Profile Edit routing: displayed policy must be the enforced policy ─────── +// +// Regression: a persona-linked agent routed Edit to the DEFINITION editor, so +// the dialog showed the definition's inbound-author policy while the running +// agent still enforced the instance's own policy. A definition's behavior +// group is copied onto an instance only at mint time, so an owner who granted +// (or revoked) access there changed nothing about the live agent. + +test("resolveProfileEditTarget: an instance-backed profile edits the instance", () => { + assert.equal( + resolveProfileEditTarget({ + hasManagedInstance: true, + hasDefinition: true, + }), + "instance", + "a persona-linked instance must still edit the instance it displays", + ); + assert.equal( + resolveProfileEditTarget({ + hasManagedInstance: true, + hasDefinition: false, + }), + "instance", + ); +}); + +test("resolveProfileEditTarget: a definition-only profile edits the definition", () => { + assert.equal( + resolveProfileEditTarget({ + hasManagedInstance: false, + hasDefinition: true, + }), + "definition", + "with no minted instance the definition is the only editable record", + ); +}); + +test("resolveProfileEditTarget: no instance and no definition falls back to instance", () => { + // Preserves the pre-existing fallback: the caller renders the instance + // dialog only when a managed agent exists, so this is inert rather than a + // route into a dialog that cannot edit anything. + assert.equal( + resolveProfileEditTarget({ + hasManagedInstance: false, + hasDefinition: false, + }), + "instance", + ); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b4a..951a0905f78 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -259,6 +259,39 @@ export function resolveAgentInstruction( ); } +/** + * Decide which editor the profile panel's Edit action must open. + * + * The profile panel is an *instance* view: it shows this agent's own public + * key, runtime state, and Stop/Restart controls. So when a concrete managed + * instance exists, Edit has to open the instance editor — the instance + * record's `respond_to`/allowlist is the pair `build_respond_to_env` turns + * into `BUZZ_ACP_RESPOND_TO` at spawn, and it is therefore the only policy the + * running agent actually enforces. + * + * Routing an instance-backed profile to the definition editor instead lets the + * dialog display an inbound-author policy that the live agent does not apply: + * a definition's behavior group is copied onto an instance only when a *new* + * instance is minted from it, never onto instances that already exist. An + * owner who added someone to an allowlist there would believe they had granted + * access — and, worse, an owner who removed someone would believe they had + * revoked it — while the running agent kept its original policy. + * + * Definition editing stays reachable: the agent library's actions menu opens + * the definition editor directly, and the instance editor offers a hop to the + * linked definition. + */ +export function resolveProfileEditTarget({ + hasManagedInstance, + hasDefinition, +}: { + hasManagedInstance: boolean; + hasDefinition: boolean; +}): "instance" | "definition" { + if (hasManagedInstance) return "instance"; + return hasDefinition ? "definition" : "instance"; +} + export function personaManagedAgentUpdate( agent: ManagedAgent, persona: AgentPersona, diff --git a/desktop/src/features/settings/hooks/use-updater.ts b/desktop/src/features/settings/hooks/use-updater.ts index 70f2544a8c7..034312fd0fd 100644 --- a/desktop/src/features/settings/hooks/use-updater.ts +++ b/desktop/src/features/settings/hooks/use-updater.ts @@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from "react"; import { check, type Update } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import { isAutoUpdateSupported } from "@/shared/api/tauri"; +import { APP_RELEASES_URL } from "@/shared/appIdentity"; export type UpdateStatus = | { state: "idle" } @@ -30,8 +31,6 @@ const BACKGROUND_BLOCKED_STATES = new Set([ "manual-required", ]); -const GITHUB_RELEASES_URL = "https://github.com/block/buzz/releases/latest"; - function toErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -168,7 +167,7 @@ export function useUpdater() { setStatus({ state: "manual-required", version: update.version, - releaseUrl: GITHUB_RELEASES_URL, + releaseUrl: APP_RELEASES_URL, }); } } else if (shouldShowQuietResult) { diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 894d88bd227..415107d474f 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -34,25 +34,6 @@ import { SectionHeader } from "@/shared/ui/PageHeader"; import { Spinner } from "@/shared/ui/spinner"; import { Switch } from "@/shared/ui/switch"; -const RUNTIME_LOGO_URLS: Record = { - "buzz-agent": "/app-icon@2x.png", - claude: "/runtime-icons/claude.png", - codex: "/runtime-icons/codex.png", - goose: "/runtime-icons/goose.svg", -}; - -const RUNTIME_LOGO_SCALE: Record = { - "buzz-agent": "scale-110", - claude: "scale-110", - codex: "scale-110", - goose: "scale-125", -}; - -const RUNTIME_SORT_PRIORITY: Record = { - "buzz-agent": 0, - goose: 1, -}; - function runtimeInstallGuideLabel(runtime: AcpRuntimeCatalogEntry) { return runtime.availability === "adapter_missing" || runtime.availability === "adapter_outdated" @@ -61,13 +42,11 @@ function runtimeInstallGuideLabel(runtime: AcpRuntimeCatalogEntry) { } function RuntimeLogo({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { - const avatarUrl = RUNTIME_LOGO_URLS[runtime.id] ?? runtime.avatarUrl; - return ( @@ -536,8 +515,10 @@ export function DoctorSettingsPanel() { () => [...(runtimesQuery.data ?? [])].sort( (left, right) => - (RUNTIME_SORT_PRIORITY[left.id] ?? Number.MAX_SAFE_INTEGER) - - (RUNTIME_SORT_PRIORITY[right.id] ?? Number.MAX_SAFE_INTEGER), + left.sortPriority - right.sortPriority || + (left.displayLabel || left.label || left.id).localeCompare( + right.displayLabel || right.label || right.id, + ), ), [runtimesQuery.data], ); diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 677f0ffad49..2e0cce4a8e9 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -29,3 +29,24 @@ export async function switchManagedAgentModel( modelId, }); } + +/** + * Resolve one live ACP permission request. The harness accepts only + * owner-signed, encrypted controls that match the exact channel, turn, and + * JSON-RPC request id, then verifies that optionId belongs to that request. + */ +export async function resolveManagedAgentPermission( + pubkey: string, + channelId: string, + turnId: string, + requestId: string | number, + optionId: string, +): Promise { + await sendAgentObserverControl(pubkey, { + type: "permission_decision", + channelId, + turnId, + requestId, + optionId, + }); +} diff --git a/desktop/src/shared/api/inviteHelpers.ts b/desktop/src/shared/api/inviteHelpers.ts index 11ea60cc7e8..4729d2253c0 100644 --- a/desktop/src/shared/api/inviteHelpers.ts +++ b/desktop/src/shared/api/inviteHelpers.ts @@ -1,3 +1,5 @@ +import { isSupportedAppDeepLinkProtocol } from "@/shared/appIdentity"; + export const INVITE_EXPIRED_ERROR = "invite_expired"; /** @@ -32,7 +34,7 @@ export function parseInviteInput(input: string): ParsedInvite | null { // buzz://join?relay=...&code=... // Non-special schemes put the authority in `host`, not `pathname`. - if (url.protocol === "buzz:") { + if (isSupportedAppDeepLinkProtocol(url.protocol)) { if (url.host !== "join") return null; const relay = url.searchParams.get("relay"); const code = url.searchParams.get("code"); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 5406cf820d2..5bfbc9ca308 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -131,6 +131,10 @@ export type RawManagedAgent = { parallelism: number; system_prompt: string | null; avatar_url?: string | null; + runtime_icon_url?: string | null; + runtime_avatar_url?: string | null; + runtime_superseded_avatar_urls?: string[]; + supports_buzz_model_config?: boolean | null; model: string | null; provider: string | null; persona_out_of_date: boolean; @@ -172,7 +176,21 @@ type RawManagedAgentLog = { export type RawAcpRuntimeCatalogEntry = { id: string; label: string; + /** Optional only for older E2E fixtures; the Rust catalog always supplies it. */ + display_label?: string; + /** Optional only for older E2E fixtures; the Rust catalog always supplies it. */ + sort_priority?: number; + /** Optional only for older E2E fixtures; the Rust catalog always supplies it. */ + onboarding_visible?: boolean; + /** Optional only for older E2E fixtures; the Rust catalog always supplies it. */ + icon_url?: string; + /** Optional only for older E2E fixtures; the Rust catalog always supplies it. */ + icon_scale?: number; avatar_url: string; + /** Optional only for older E2E fixtures; the Rust catalog always supplies it. */ + superseded_avatar_urls?: string[]; + /** Optional only for older E2E fixtures; the Rust catalog always supplies it. */ + supports_buzz_model_config?: boolean; availability: AcpAvailabilityStatus; command: string | null; binary_path: string | null; @@ -700,6 +718,10 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { parallelism: agent.parallelism, systemPrompt: agent.system_prompt, avatarUrl: agent.avatar_url ?? null, + runtimeIconUrl: agent.runtime_icon_url ?? null, + runtimeAvatarUrl: agent.runtime_avatar_url ?? null, + runtimeSupersededAvatarUrls: agent.runtime_superseded_avatar_urls ?? [], + supportsBuzzModelConfig: agent.supports_buzz_model_config ?? null, model: agent.model, provider: agent.provider ?? null, personaOutOfDate: agent.persona_out_of_date ?? false, @@ -733,7 +755,14 @@ function fromRawAcpRuntimeCatalogEntry( return { id: entry.id, label: entry.label, + displayLabel: entry.display_label ?? entry.label, + sortPriority: entry.sort_priority ?? 100, + onboardingVisible: entry.onboarding_visible ?? false, + iconUrl: entry.icon_url ?? entry.avatar_url, + iconScale: entry.icon_scale ?? 1, avatarUrl: entry.avatar_url, + supersededAvatarUrls: entry.superseded_avatar_urls ?? [], + supportsBuzzModelConfig: entry.supports_buzz_model_config ?? true, availability: entry.availability, command: entry.command, binaryPath: entry.binary_path, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index a82f766b63e..8bea8ac68f5 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -362,6 +362,10 @@ export type ManagedAgent = { parallelism: number; systemPrompt: string | null; avatarUrl: string | null; + runtimeIconUrl: string | null; + runtimeAvatarUrl: string | null; + runtimeSupersededAvatarUrls: string[]; + supportsBuzzModelConfig: boolean | null; model: string | null; /** LLM inference provider, from the agent's pinned record snapshot. */ provider: string | null; @@ -488,7 +492,6 @@ export type ManagedAgentLog = { export type CancelManagedAgentTurnResult = { status: "sent" | "no_active_turn"; }; - /** * Outcome of a live `switch_model` control frame, surfaced asynchronously via * the agent's `control_result` observer frame. Busy path: `sent` (cancel + @@ -503,7 +506,7 @@ export type SwitchManagedAgentModelStatus = | "no_active_turn"; export type ControlResultFrame = { - type: "cancel_turn" | "switch_model"; + type: "cancel_turn" | "switch_model" | "permission_decision"; status: string; modelId?: string; }; @@ -533,7 +536,14 @@ export type AuthStatus = export type AcpRuntimeCatalogEntry = { id: string; label: string; + displayLabel: string; + sortPriority: number; + onboardingVisible: boolean; + iconUrl: string; + iconScale: number; avatarUrl: string; + supersededAvatarUrls: string[]; + supportsBuzzModelConfig: boolean; availability: AcpAvailabilityStatus; command: string | null; binaryPath: string | null; @@ -697,6 +707,7 @@ export type NormalizedConfig = { export type RuntimeConfigSurface = { runtimeId: string | null; runtimeLabel: string | null; + supportsBuzzModelConfig: boolean | null; isPreSpawn: boolean; normalized: NormalizedConfig; advanced: ConfigField[]; diff --git a/desktop/src/shared/appIdentity.test.mjs b/desktop/src/shared/appIdentity.test.mjs new file mode 100644 index 00000000000..fcdff066362 --- /dev/null +++ b/desktop/src/shared/appIdentity.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + APP_DEEP_LINK_SCHEME, + APP_RELEASES_URL, + isSupportedAppDeepLinkProtocol, + normalizeDeepLinkScheme, + normalizeReleasesUrl, +} from "./appIdentity.ts"; + +test("deep-link scheme defaults to upstream Buzz for ordinary builds", () => { + assert.equal(APP_DEEP_LINK_SCHEME, "buzz"); + assert.equal(normalizeDeepLinkScheme(undefined), "buzz"); +}); + +test("deep-link scheme accepts a valid fork release override", () => { + assert.equal(normalizeDeepLinkScheme("buzz-for-devin"), "buzz-for-devin"); +}); + +test("deep-link scheme rejects malformed build input", () => { + assert.equal(normalizeDeepLinkScheme("HTTPS://evil"), "buzz"); + assert.equal(normalizeDeepLinkScheme("1buzz"), "buzz"); + assert.equal(normalizeDeepLinkScheme("buzz for devin"), "buzz"); +}); + +test("configured and legacy Buzz protocols remain parse-compatible", () => { + assert.equal(isSupportedAppDeepLinkProtocol("buzz:"), true); + assert.equal(isSupportedAppDeepLinkProtocol("https:"), false); +}); + +test("release URL defaults to upstream Buzz for ordinary builds", () => { + assert.equal( + APP_RELEASES_URL, + "https://github.com/block/buzz/releases/latest", + ); + assert.equal( + normalizeReleasesUrl(undefined), + "https://github.com/block/buzz/releases/latest", + ); +}); + +test("release URL accepts only an HTTPS fork override", () => { + assert.equal( + normalizeReleasesUrl("https://github.com/fenner888/BuzzforDevin/releases"), + "https://github.com/fenner888/BuzzforDevin/releases", + ); + assert.equal( + normalizeReleasesUrl("http://downloads.example.test/release"), + "https://github.com/block/buzz/releases/latest", + ); + assert.equal( + normalizeReleasesUrl("not a URL"), + "https://github.com/block/buzz/releases/latest", + ); +}); diff --git a/desktop/src/shared/appIdentity.ts b/desktop/src/shared/appIdentity.ts new file mode 100644 index 00000000000..f3e7fd17994 --- /dev/null +++ b/desktop/src/shared/appIdentity.ts @@ -0,0 +1,44 @@ +const DEFAULT_DEEP_LINK_SCHEME = "buzz"; +const DEFAULT_RELEASES_URL = "https://github.com/block/buzz/releases/latest"; + +export function normalizeDeepLinkScheme(value: string | undefined): string { + const normalized = value?.trim().toLowerCase(); + if ( + normalized && + /^[a-z][a-z0-9+.-]*$/.test(normalized) && + normalized.length <= 64 + ) { + return normalized; + } + return DEFAULT_DEEP_LINK_SCHEME; +} + +export const APP_DEEP_LINK_SCHEME = normalizeDeepLinkScheme( + import.meta.env?.VITE_BUZZ_DEEP_LINK_SCHEME, +); + +export const APP_DEEP_LINK_PROTOCOL = `${APP_DEEP_LINK_SCHEME}:`; + +export function normalizeReleasesUrl(value: string | undefined): string { + const normalized = value?.trim(); + if (!normalized) return DEFAULT_RELEASES_URL; + try { + const parsed = new URL(normalized); + return parsed.protocol === "https:" + ? parsed.toString() + : DEFAULT_RELEASES_URL; + } catch { + return DEFAULT_RELEASES_URL; + } +} + +export const APP_RELEASES_URL = normalizeReleasesUrl( + import.meta.env?.VITE_BUZZ_RELEASES_URL, +); + +export function isSupportedAppDeepLinkProtocol(protocol: string): boolean { + return ( + protocol === APP_DEEP_LINK_PROTOCOL || + protocol === `${DEFAULT_DEEP_LINK_SCHEME}:` + ); +} diff --git a/desktop/src/shared/buildBuzzForDevinConfig.test.mjs b/desktop/src/shared/buildBuzzForDevinConfig.test.mjs new file mode 100644 index 00000000000..c7a8b5d5174 --- /dev/null +++ b/desktop/src/shared/buildBuzzForDevinConfig.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const generatorPath = fileURLToPath( + new URL("../../scripts/build-buzz-for-devin-config.mjs", import.meta.url), +); + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "buzz-for-devin-config-")); + mkdirSync(join(root, "src-tauri")); + t.after(() => rmSync(root, { force: true, recursive: true })); + return root; +} + +function runGenerator(root, overrides = {}) { + const env = { ...process.env, ...overrides }; + delete env.BUZZ_UPDATER_PUBLIC_KEY; + delete env.BUZZ_UPDATER_ENDPOINT; + Object.assign(env, overrides); + return spawnSync(process.execPath, [generatorPath], { + cwd: root, + encoding: "utf8", + env, + }); +} + +function readGeneratedConfig(root) { + return JSON.parse( + readFileSync( + join(root, "src-tauri", "tauri.buzz-for-devin.conf.json"), + "utf8", + ), + ); +} + +test("Buzz for Devin config is isolated and non-updating by default", (t) => { + const root = fixture(t); + const result = runGenerator(root); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(readGeneratedConfig(root), { + productName: "Buzz for Devin", + identifier: "community.buzzfordevin.desktop", + bundle: { + createUpdaterArtifacts: false, + macOS: { + infoPlist: "Info.buzz-for-devin.plist", + minimumSystemVersion: "11.0", + }, + }, + plugins: { + "deep-link": { + desktop: { + schemes: ["buzz-for-devin"], + }, + }, + updater: { + endpoints: [], + }, + }, + }); +}); + +test("Buzz for Devin updater configuration fails closed when incomplete", (t) => { + const root = fixture(t); + const publicKeyOnly = runGenerator(root, { + BUZZ_UPDATER_PUBLIC_KEY: "test-public-key", + }); + const endpointOnly = runGenerator(root, { + BUZZ_UPDATER_ENDPOINT: "https://updates.example.invalid/latest.json", + }); + + assert.equal(publicKeyOnly.status, 1); + assert.match(publicKeyOnly.stderr, /must be supplied together/); + assert.equal(endpointOnly.status, 1); + assert.match(endpointOnly.stderr, /must be supplied together/); +}); + +test("Buzz for Devin updater configuration enables only a paired endpoint and key", (t) => { + const root = fixture(t); + const result = runGenerator(root, { + BUZZ_UPDATER_ENDPOINT: "https://updates.example.invalid/latest.json", + BUZZ_UPDATER_PUBLIC_KEY: "test-public-key", + }); + + assert.equal(result.status, 0, result.stderr); + const config = readGeneratedConfig(root); + assert.equal(config.bundle.createUpdaterArtifacts, true); + assert.deepEqual(config.plugins.updater, { + endpoints: ["https://updates.example.invalid/latest.json"], + pubkey: "test-public-key", + }); +}); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index da398ca4ba1..8a2df1f5ad3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6963,6 +6963,11 @@ async function handleDiscoverAcpRuntimes( { id: "goose", label: "Goose", + display_label: "Goose", + sort_priority: 10, + onboarding_visible: false, + icon_url: "/runtime-icons/goose.svg", + icon_scale: 1.25, avatar_url: "", availability: "available", command: "goose", @@ -6981,6 +6986,11 @@ async function handleDiscoverAcpRuntimes( { id: "claude", label: "Claude Code", + display_label: "Claude Code", + sort_priority: 30, + onboarding_visible: true, + icon_url: "/runtime-icons/claude.png", + icon_scale: 1.1, avatar_url: "", availability: "adapter_missing", command: null, @@ -6997,9 +7007,38 @@ async function handleDiscoverAcpRuntimes( auth_status: { status: "unknown" }, login_hint: undefined, }, + { + id: "devin", + label: "Devin", + display_label: "Devin", + sort_priority: 20, + onboarding_visible: true, + icon_url: "/runtime-icons/devin.svg", + icon_scale: 1.1, + avatar_url: "", + availability: "not_installed", + command: null, + binary_path: null, + default_args: ["acp"], + mcp_command: null, + install_hint: + "Buzz requires the Devin CLI; the desktop app alone is not enough.", + install_instructions_url: "https://docs.devin.ai/cli", + can_auto_install: true, + requires_external_cli: true, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "unknown" }, + login_hint: "Run `devin auth login` to authenticate.", + }, { id: "codex", label: "Codex", + display_label: "Codex", + sort_priority: 40, + onboarding_visible: true, + icon_url: "/runtime-icons/codex.png", + icon_scale: 1.1, avatar_url: "", availability: "not_installed", command: null, @@ -7019,6 +7058,11 @@ async function handleDiscoverAcpRuntimes( { id: "buzz-agent", label: "Buzz Agent", + display_label: "Buzz", + sort_priority: 0, + onboarding_visible: false, + icon_url: "/app-icon@2x.png", + icon_scale: 1.1, avatar_url: "", availability: "available", command: "buzz-agent", @@ -9273,6 +9317,8 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload }); switch (command) { + case "is_shared_identity": + return false; case "get_builderlab_auth": return activeConfig?.mock?.builderlabAuth ?? null; case "start_builderlab_login": { diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 0f356fd9bae..39ed5b311b9 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -15,6 +15,7 @@ const SHOTS = "test-results/screenshots-doctor"; const GOOSE_AVAILABLE = { id: "goose", label: "Goose", + sort_priority: 10, avatar_url: "", availability: "available", command: "goose", @@ -34,6 +35,7 @@ const GOOSE_AVAILABLE = { const BUZZ_AGENT_AVAILABLE = { id: "buzz-agent", label: "Buzz Agent", + sort_priority: 0, avatar_url: "", availability: "available", command: "buzz-agent", @@ -55,6 +57,7 @@ const BUZZ_AGENT_AVAILABLE = { const CLAUDE_AVAILABLE_LOGGED_IN = { id: "claude", label: "Claude Code", + sort_priority: 30, avatar_url: "", availability: "available", command: "claude-agent-acp", @@ -77,6 +80,7 @@ const CLAUDE_AVAILABLE_LOGGED_IN = { const CODEX_NOT_INSTALLED = { id: "codex", label: "Codex", + sort_priority: 40, avatar_url: "", availability: "not_installed", command: null, diff --git a/desktop/tests/e2e/edit-agent.spec.ts b/desktop/tests/e2e/edit-agent.spec.ts index 95ef231556e..467f578be5d 100644 --- a/desktop/tests/e2e/edit-agent.spec.ts +++ b/desktop/tests/e2e/edit-agent.spec.ts @@ -278,16 +278,12 @@ test.describe("edit agent dialog", () => { ).toBeVisible(); }); - test("profile Edit routes persona-linked agents to the definition editor", async ({ + test("profile Edit routes persona-linked agents to the instance editor", async ({ page, }) => { - // Routing pin for handleEditAgent (UserProfilePanel): when the agent has - // a resolvable non-built-in persona, the Edit quick action opens the - // DEFINITION editor (persona dialog), not EditAgentDialog. The instance - // editor (and its inherit-runtime toggle) is reachable for persona-linked - // agents only via the requestOpenEditAgent event (ConfigNudgeCard) — no - // plain UI path — so its inherit-toggle behavior is covered by B3b's - // component-level pinning test, not e2e. + // Routing pin for handleEditAgent (UserProfilePanel): a profile represents + // one live managed instance, so Edit must open that instance's editor. + // Definition editing remains reachable through the linked-definition CTA. await installMockBridge(page, { managedAgents: [ { @@ -322,12 +318,20 @@ test.describe("edit agent dialog", () => { }); await page.getByTestId("user-profile-edit-agent").click(); - // Definition editor opens; the instance editor does not. - await expect(page.getByTestId("persona-dialog")).toBeVisible({ + // The instance editor opens, seeded from the concrete managed agent. + await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({ timeout: 10_000, }); + await expect(page.getByTestId("persona-dialog")).not.toBeVisible(); + await expect(page.locator("#edit-agent-name")).toHaveValue(AGENT_NAME); + + // The definition editor is still one explicit hop away for shared identity + // fields such as the avatar. + await page.getByRole("button", { name: "Edit avatar" }).click(); await expect(page.getByTestId("edit-agent-dialog")).not.toBeVisible(); - // And it is the persona's record that's being edited. + await expect(page.getByTestId("persona-dialog")).toBeVisible({ + timeout: 10_000, + }); await expect(page.locator("#persona-display-name")).toHaveValue( "Edit E2E Persona", ); diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index d9b3214e648..4bdd0684f28 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -809,9 +809,10 @@ test.describe("global agent config screenshots", () => { // Shot 10: the ORIGINAL defect — Ian's "Save button stays disabled after // editing an agent." This drives the real EDIT/Save path (not create): a // persona-linked Codex agent with an explicit custom model and no provider is - // opened via the Agents view → profile → Edit affordance, which mounts + // opened through the definition's Agents-library action, which mounts // AgentDefinitionDialog in edit mode (id present in initialValues, "Save - // changes" label). Before the provider-aware gate, the hidden Codex provider + // changes" label). The profile Edit affordance intentionally edits the live + // instance instead. Before the provider-aware gate, the hidden Codex provider // left Save permanently disabled on a value the user could never set. Now: // provider picker hidden, Save enabled, and no submit-block reason. Create // and Save share this rendering path, but the defect was Save-specific, so @@ -845,18 +846,15 @@ test.describe("global agent config screenshots", () => { ], }); - // Agents view → persona-grouped agent card → Edit quick action. + // Agents view → persona definition actions → Edit. await page.goto("/"); await page.getByTestId("open-agents-view").click(); - const agentButton = page.getByRole("button", { - name: "Codex Editor agent profile", + const actionsButton = page.getByRole("button", { + name: "Open actions for Codex Editor", }); - await expect(agentButton).toBeVisible({ timeout: 10_000 }); - await agentButton.click(); - await expect(page.getByTestId("user-profile-panel")).toBeVisible({ - timeout: 10_000, - }); - await page.getByTestId("user-profile-edit-agent").click(); + await expect(actionsButton).toBeVisible({ timeout: 10_000 }); + await actionsButton.click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); // The definition dialog opens in EDIT mode ("Save changes"), seeded from // the persona — confirm it's the edit path, not create. @@ -936,18 +934,15 @@ test.describe("global agent config screenshots", () => { ], }); - // Agents view → persona-grouped agent card → Edit quick action. + // Agents view → persona definition actions → Edit. await page.goto("/"); await page.getByTestId("open-agents-view").click(); - const agentButton = page.getByRole("button", { - name: "Legacy Editor agent profile", - }); - await expect(agentButton).toBeVisible({ timeout: 10_000 }); - await agentButton.click(); - await expect(page.getByTestId("user-profile-panel")).toBeVisible({ - timeout: 10_000, + const actionsButton = page.getByRole("button", { + name: "Open actions for Legacy Editor", }); - await page.getByTestId("user-profile-edit-agent").click(); + await expect(actionsButton).toBeVisible({ timeout: 10_000 }); + await actionsButton.click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); // Confirm the real EDIT dialog, seeded from the persona. await expect(page.getByTestId("persona-dialog")).toBeVisible({ diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 694b5abef50..7a393cc29fb 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -187,7 +187,7 @@ async function expectAgentProfileActionsHidden( ).toHaveCount(0); } -test("@ trigger prioritizes channel members before runnable personas and other managed agents", async ({ +test("@ trigger prioritizes invocable channel members before runnable personas and other managed agents", async ({ page, }) => { await installMockBridge(page, { @@ -209,7 +209,7 @@ test("@ trigger prioritizes channel members before runnable personas and other m const dropdown = autocomplete(page); await expect(dropdown).toBeVisible(); - await expect(dropdown.getByText("alice")).toHaveCount(0); + await expect(dropdown.getByText("alice")).toBeVisible(); await expect(dropdown.getByText("bob")).toBeVisible(); await expect(dropdown.getByText("Fizz")).toBeVisible(); await expect(dropdown.getByText("charlie")).toBeVisible(); @@ -225,6 +225,7 @@ test("@ trigger prioritizes channel members before runnable personas and other m const suggestions = dropdown.locator("button"); const suggestionText = await suggestions.allInnerTexts(); + const aliceIndex = suggestionText.findIndex((text) => text.includes("alice")); const fizzIndex = suggestionText.findIndex((text) => text.includes("Fizz")); const bobIndex = suggestionText.findIndex((text) => text.includes("bob")); const charlieIndex = suggestionText.findIndex((text) => @@ -233,10 +234,12 @@ test("@ trigger prioritizes channel members before runnable personas and other m const outsiderIndex = suggestionText.findIndex((text) => text.includes("outsider"), ); + expect(aliceIndex).toBeGreaterThanOrEqual(0); expect(fizzIndex).toBeGreaterThanOrEqual(0); expect(bobIndex).toBeGreaterThanOrEqual(0); expect(charlieIndex).toBeGreaterThanOrEqual(0); expect(outsiderIndex).toEqual(-1); + expect(aliceIndex).toBeLessThan(fizzIndex); expect(bobIndex).toBeLessThan(fizzIndex); expect(fizzIndex).toBeLessThan(charlieIndex); }); diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index d007bd6118b..fa8158c66d2 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -3,7 +3,7 @@ import { installMockBridge } from "../helpers/bridge"; import { passThroughBackupStep } from "../helpers/onboarding"; function runtime( - id: "buzz-agent" | "claude" | "codex" | "goose", + id: "buzz-agent" | "claude" | "codex" | "devin" | "goose", availability: string, authStatus: Record, overrides: Record = {}, @@ -17,12 +17,19 @@ function runtime( ? "Claude Code" : id === "codex" ? "Codex" - : "Goose", + : id === "devin" + ? "Devin" + : "Goose", + display_label: id === "buzz-agent" ? "Buzz" : undefined, avatar_url: "", + sort_priority: 100, + onboarding_visible: true, + icon_url: "", + icon_scale: 1, availability, command: availability === "available" ? id : null, binary_path: availability === "available" ? `/usr/local/bin/${id}` : null, - default_args: [], + default_args: id === "devin" || id === "goose" ? ["acp"] : [], mcp_command: null, install_hint: `Install ${id}`, install_instructions_url: "https://example.com", @@ -57,17 +64,47 @@ async function readSavedRuntime(page: Parameters[0]) { }); } -test("setup shows only Claude Code and Codex as detected harnesses", async ({ +test("setup projects catalog visibility and ordering, including Devin", async ({ page, }) => { await installMockBridge( page, { acpRuntimesCatalog: [ - runtime("buzz-agent", "available", { status: "not_applicable" }), - runtime("goose", "available", { status: "not_applicable" }), - runtime("codex", "available", { status: "logged_in" }), - runtime("claude", "available", { status: "logged_in" }), + runtime( + "buzz-agent", + "available", + { status: "not_applicable" }, + { onboarding_visible: false, sort_priority: 0 }, + ), + runtime( + "goose", + "available", + { status: "not_applicable" }, + { onboarding_visible: false, sort_priority: 10 }, + ), + runtime( + "codex", + "available", + { status: "logged_in" }, + { sort_priority: 40 }, + ), + runtime( + "devin", + "available", + { status: "logged_in" }, + { + sort_priority: 20, + icon_url: "/runtime-icons/devin.svg", + icon_scale: 1.1, + }, + ), + runtime( + "claude", + "available", + { status: "logged_in" }, + { sort_priority: 30 }, + ), ], }, { skipCommunitySeed: true, skipOnboardingSeed: true }, @@ -75,6 +112,7 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ await page.goto("/"); await navigateToSetupPage(page); + await expect(page.getByTestId("onboarding-runtime-devin")).toBeVisible(); await expect(page.getByTestId("onboarding-runtime-claude")).toBeVisible(); await expect(page.getByTestId("onboarding-runtime-codex")).toBeVisible(); await expect(page.getByTestId("onboarding-runtime-goose")).toHaveCount(0); @@ -82,6 +120,75 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ 0, ); await expect(page.getByRole("checkbox")).toHaveCount(0); + + const visibleRuntimeIds = await page + .locator("[data-testid^='onboarding-runtime-']") + .evaluateAll((elements) => + elements + .map((element) => element.getAttribute("data-testid")) + .filter( + (testId) => + testId != null && + !testId.includes("-ready-") && + !testId.includes("-checkmark-") && + !testId.includes("-instructions-") && + !testId.includes("-install-"), + ), + ); + expect(visibleRuntimeIds).toEqual([ + "onboarding-runtime-devin", + "onboarding-runtime-claude", + "onboarding-runtime-codex", + ]); + const devinIcon = page.getByTestId("onboarding-runtime-devin").locator("img"); + await expect(devinIcon).toHaveAttribute("src", "/runtime-icons/devin.svg"); + + const renderedIcon = await devinIcon.evaluate(async (element) => { + const image = element as HTMLImageElement; + await image.decode(); + + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("2D canvas context unavailable"); + context.drawImage(image, 0, 0); + + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let whitePixels = 0; + let darkPixels = 0; + let transparentPixels = 0; + for (let offset = 0; offset < pixels.length; offset += 4) { + const red = pixels[offset]; + const green = pixels[offset + 1]; + const blue = pixels[offset + 2]; + const alpha = pixels[offset + 3]; + if (alpha === 0) transparentPixels += 1; + if (alpha === 255 && red > 250 && green > 250 && blue > 250) { + whitePixels += 1; + } + if (alpha === 255 && red < 32 && green < 32 && blue < 32) { + darkPixels += 1; + } + } + + const pixelCount = canvas.width * canvas.height; + return { + naturalHeight: image.naturalHeight, + naturalWidth: image.naturalWidth, + transparentPixels, + whiteRatio: whitePixels / pixelCount, + darkRatio: darkPixels / pixelCount, + corner: Array.from(context.getImageData(0, 0, 1, 1).data), + }; + }); + + expect(renderedIcon.naturalWidth).toBe(425); + expect(renderedIcon.naturalHeight).toBe(425); + expect(renderedIcon.corner).toEqual([255, 255, 255, 255]); + expect(renderedIcon.transparentPixels).toBe(0); + expect(renderedIcon.whiteRatio).toBeGreaterThan(0.5); + expect(renderedIcon.darkRatio).toBeGreaterThan(0.05); }); test("setup distinguishes a missing CLI from an installed desktop app", async ({ @@ -559,8 +666,18 @@ test("defaults auto-selects the only ready visible harness", async ({ page, { acpRuntimesCatalog: [ - runtime("buzz-agent", "available", { status: "not_applicable" }), - runtime("goose", "available", { status: "not_applicable" }), + runtime( + "buzz-agent", + "available", + { status: "not_applicable" }, + { onboarding_visible: false }, + ), + runtime( + "goose", + "available", + { status: "not_applicable" }, + { onboarding_visible: false }, + ), runtime("claude", "available", { status: "logged_in" }), runtime("codex", "available", { status: "logged_out" }), ], @@ -629,8 +746,18 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy page, { acpRuntimesCatalog: [ - runtime("buzz-agent", "available", { status: "not_applicable" }), - runtime("goose", "available", { status: "not_applicable" }), + runtime( + "buzz-agent", + "available", + { status: "not_applicable" }, + { onboarding_visible: false }, + ), + runtime( + "goose", + "available", + { status: "not_applicable" }, + { onboarding_visible: false }, + ), runtime("claude", "available", { status: "logged_in" }), runtime("codex", "available", { status: "logged_in" }), ], diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index bfbfc6f0638..dac0a68e601 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1181,6 +1181,8 @@ test("first-community shows the scenario cards for localhost", async ({ { id: "claude", label: "Claude Code", + sort_priority: 30, + onboarding_visible: true, avatar_url: "", availability: "available", command: "claude", diff --git a/docs/buzz-for-devin-builders.md b/docs/buzz-for-devin-builders.md new file mode 100644 index 00000000000..76cef4ab3af --- /dev/null +++ b/docs/buzz-for-devin-builders.md @@ -0,0 +1,199 @@ +# Buzz for Devin builder preview + +Buzz for Devin can be run directly from GitHub while the focused upstream +integration is under review. This is the same source-first testing model used +for Buzz harness presets before they merge. There is no unsigned application +download to redistribute. + +This project is an unofficial community fork. It is not an official Block or +Cognition product. + +## Current checkpoint + +Use the immutable source prerelease: + +- Tag: `buzz-for-devin-v0.4.25-alpha.2` +- Release: + +- Focused upstream proposal: + + +The source preview connects to an existing Buzz community selected during +onboarding. It does not require a local relay or Docker. Builders who want to +host their own relay can still follow Buzz's normal self-host instructions. + +## Platform status + +| Host | Source preview | Fork-specific evidence | Current support statement | +|---|---|---|---| +| Apple Silicon macOS | Available | Full CI, packaged source build, and live Devin ACP acceptance passed | Verified technical alpha | +| Intel macOS | Available | Buzz and Devin provide host binaries; fork-specific live acceptance is pending | Experimental | +| Linux x86_64 / ARM64 | Available | Linux compilation and desktop CI pass; live Devin ACP acceptance is pending | Experimental | +| Windows x86_64 | Available through Git Bash with the MSVC toolchain | Windows Rust, Tauri, and shell gates pass; live Devin ACP acceptance is pending | Experimental | + +`Available` means a builder can compile and run the source preview. It does not +mean that this fork publishes a supported installer for that platform. + +## Security boundary + +- Install the official Devin CLI from + . +- Authenticate with your own Cognition account using `devin auth login`. +- Do not copy Devin configuration or authentication material between users or + machines. +- The source runner may execute `devin --version`. Buzz uses the documented + `devin auth status` readiness probe during onboarding. Neither path inspects + credential files. +- New agents default to one worker and owner-only invocation. +- Devin permission requests remain explicit and fail closed. The runner does + not enable a permission-bypass mode. +- The source preview does not enable the updater or use signing credentials. +- Debug source runs use Buzz's development-only Keychain and Nest namespaces. + The separately built macOS alpha application uses the full + `community.buzzfordevin.desktop` release isolation described in + [buzz-for-devin-macos.md](buzz-for-devin-macos.md). + +## Clone the reviewed source + +```sh +git clone https://github.com/fenner888/BuzzforDevin.git +cd BuzzforDevin +git checkout buzz-for-devin-v0.4.25-alpha.2 +``` + +Do not test an arbitrary moving branch when reporting a compatibility result. +Include the tag and commit in every report. + +## Install and authenticate Devin + +Follow Cognition's current instructions: + +```sh +curl -fsSL https://cli.devin.ai/install.sh | bash +devin auth login +devin --version +devin auth status +``` + +On Windows, run the installer from Git Bash. Cognition's installer delegates to +its PowerShell setup path for Windows. + +## macOS + +Install Xcode Command Line Tools if they are not already present. The repository +Hermit environment supplies the pinned Rust, Node, and pnpm toolchains. + +From the repository root: + +```sh +./scripts/run-buzz-for-devin-source.sh +``` + +Apple Silicon builders who need the isolated unsigned `.app` lifecycle instead +of a source preview should follow +[Buzz for Devin on macOS](buzz-for-devin-macos.md). Do not redistribute that +locally built unsigned application. + +## Linux + +Install the normal Tauri 2 Linux prerequisites for your distribution. On +Ubuntu 22.04 or compatible Debian-based systems, the CI-proven package set +includes: + +```sh +sudo apt-get update +sudo apt-get install -y \ + build-essential curl file git \ + libasound2-dev libayatana-appindicator3-dev libgtk-3-dev \ + librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev \ + patchelf pkg-config xdg-utils +``` + +Then run: + +```sh +./scripts/run-buzz-for-devin-source.sh +``` + +The repository Hermit environment supplies the pinned Rust, Node, and pnpm +toolchains on Linux. + +## Windows + +Windows source builders need: + +- Windows 11 x86_64 +- Git for Windows, including Git Bash +- Visual Studio Build Tools with the Desktop development with C++ workload +- Rust's `x86_64-pc-windows-msvc` toolchain +- Node.js 24 +- pnpm 11.4 +- WebView2 + +Hermit's repository bootstrap is macOS/Linux-only, so Windows uses the +host-installed tools above. Open Git Bash, confirm the commands resolve, and +run: + +```sh +cargo --version +rustc -vV +node --version +pnpm --version +./scripts/run-buzz-for-devin-source.sh +``` + +Do not use a GNU Rust host or WSL's `bash.exe` for this path. The supported +preview target is `x86_64-pc-windows-msvc`, and Buzz resolves Git Bash for +managed-agent shell activity. + +## What to test + +1. Complete Buzz onboarding and select or join a community. +2. Confirm Devin is shown with the white-background Devin mark. +3. Create a Devin-backed agent with the default model and owner-only policy. +4. Start the agent and verify it becomes ready without changing Devin + configuration. +5. Send a top-level DM and verify one reply appears in the DM timeline. +6. Mention the agent in a channel and verify the reply follows the channel's + thread behavior. +7. If Devin requests permission, choose only the action you intend and verify + there is no persistent auto-approval. +8. Restart the managed agent and send another message. +9. Stop the agent and close Buzz. + +For the first Windows and Linux acceptance, also record: + +- Operating system and architecture +- Source tag and exact commit +- `devin --version` +- Whether `devin auth status` reported ready +- Time to first response +- Whether an approval prompt appeared +- Whether the response landed in the expected DM or channel thread + +Do not include credentials, authentication output containing private material, +private repository contents, or unredacted logs in an issue. + +## Prepare without launching + +Maintainers can validate toolchain detection, sidecar compilation, and bundle +configuration without opening the desktop app: + +```sh +./scripts/run-buzz-for-devin-source.sh --prepare-only +``` + +Preparation is not a live Devin ACP acceptance test. + +## Reporting problems + +Open an issue at and include +the non-sensitive evidence above. Clearly distinguish: + +1. Devin CLI missing +2. Devin installed but unauthenticated +3. Devin authenticated and ready +4. Devin ACP startup failure + +The source preview does not claim model switching, Fusion, fan-out, Outposts, +cloud handoff, or cloud Devin parity. diff --git a/docs/buzz-for-devin-macos.md b/docs/buzz-for-devin-macos.md new file mode 100644 index 00000000000..4e384a16a57 --- /dev/null +++ b/docs/buzz-for-devin-macos.md @@ -0,0 +1,101 @@ +# Buzz for Devin on macOS + +Buzz for Devin's first distribution target is Apple Silicon running macOS 11.0 +or newer. The fork uses an isolated application identity: + +- Product name: `Buzz for Devin` +- Bundle identifier and application-support directory: + `community.buzzfordevin.desktop` +- Deep-link scheme: `buzz-for-devin://` +- Keychain service: `buzz-for-devin-desktop` +- Agent Nest and Repos Directory mapping: `~/.buzz-for-devin` +- Bundled CLI convenience link: `~/.local/bin/buzz-for-devin` + +These values keep the community build separate from upstream Buzz. Installing +or removing Buzz for Devin does not modify the official Devin CLI, Devin +configuration, Cognition credentials, upstream Buzz data or Nest, upstream +Buzz's `~/.local/bin/buzz` link, or upstream Buzz Keychain entries. + +If the relay-hosted web client is built for the community fork, configure it +with: + +```sh +VITE_BUZZ_APP_NAME="Buzz for Devin" +VITE_BUZZ_DEEP_LINK_SCHEME="buzz-for-devin" +VITE_BUZZ_RELEASES_URL="https://github.com/fenner888/BuzzforDevin/releases" +VITE_BUZZ_RELEASES_API_URL="https://api.github.com/repos/fenner888/BuzzforDevin/releases?per_page=10" +``` + +The defaults remain upstream Buzz values. A public fork deployment must set all +four values so invite and repository pages open Buzz for Devin and never send a +user to an upstream Buzz download by mistake. + +## Build from source + +Activate Hermit and run: + +```sh +. ./bin/activate-hermit +./scripts/build-buzz-for-devin-macos.sh +``` + +The script builds the pinned repository dependencies, all required Buzz +sidecars, and an unsigned `.app`. It does not sign, notarize, publish, launch, +or install anything. The public release pipeline will produce the distributable +DMG after signing and notarization are configured. + +An unsigned source build is for development validation. A public beta must use +an immutable source tag and a separately configured signing, notarization, and +updater pipeline owned by the community fork. + +Rehearse the complete lifecycle in a temporary directory without launching the +app or touching real application data and Keychain entries: + +```sh +./scripts/test-buzz-for-devin-macos-lifecycle.sh +``` + +The builder, installer, rollback tool, and signed-canary workflow all use the +same read-only bundle verifier. It checks the bundle identity, deep-link +scheme, executable sidecars, isolated Nest marker, and isolated Keychain +service marker: + +```sh +./scripts/verify-buzz-for-devin-macos-app.sh \ + "/path/to/Buzz for Devin.app" +``` + +## Install or upgrade + +Pass the built app bundle to the installer: + +```sh +./scripts/install-buzz-for-devin-macos.sh \ + "desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Buzz for Devin.app" +``` + +The default destination is `~/Applications/Buzz for Devin.app`. If an older +copy exists, the installer moves it to a timestamped backup before copying the +new app. It does not delete application data or Keychain entries. + +## Roll back + +Use the exact backup path printed by the installer: + +```sh +./scripts/rollback-buzz-for-devin-macos.sh \ + "$HOME/Applications/Buzz for Devin.app.backup-YYYYMMDDTHHMMSSZ" +``` + +The rollback moves the current app aside and restores the selected backup. + +## Uninstall + +```sh +./scripts/uninstall-buzz-for-devin-macos.sh +``` + +Uninstall moves only the app bundle to the user's Trash. Application data and +Keychain entries are intentionally preserved so an accidental uninstall is +recoverable. Credential or data removal is a separate, explicit manual action; +the project scripts never inspect or remove Devin authentication material. diff --git a/docs/buzz-for-devin-multi-user-validation.md b/docs/buzz-for-devin-multi-user-validation.md new file mode 100644 index 00000000000..d34012dd817 --- /dev/null +++ b/docs/buzz-for-devin-multi-user-validation.md @@ -0,0 +1,112 @@ +# Buzz for Devin multi-user validation + +This runbook proves the Phase 2 live reply and Phase 3 account-isolation +requirements without exposing authentication material. Run it only with +disposable repositories that contain no credentials, personal data, or +production configuration. + +## Required setup + +Use two separate Devin credential contexts: + +- Context A: machine A or macOS user A, Buzz identity A, Devin account A. +- Context B: machine B or macOS user B, Buzz identity B, Devin account B. + +Changing only the Buzz identity inside one macOS login is not a separate Devin +credential context. The official Devin CLI stores authentication for the +operating-system user, while Buzz invocation authorization is enforced by Buzz +identity. + +For each context: + +1. Install the same immutable Buzz for Devin build. +2. Install the official Devin CLI through Cognition's documented installation + path. +3. Authenticate interactively with `devin auth login` if needed. +4. Confirm readiness using only `devin auth status`. +5. Create a separate disposable repository with a unique, non-sensitive marker + file. +6. Select only that context's repository as its Buzz Repos Directory. + +Do not record command output containing tokens, cookies, Keychain values, +configuration contents, or environment variables. The test record needs only +the build identifier, anonymized account labels A/B, and pass/fail observations. + +## Test matrix + +| Test | Action | Expected result | +| --- | --- | --- | +| A owner invocation | Identity A mentions agent A under `owner-only` | Agent A replies and can work only in workspace A | +| B blocked by A | Identity B mentions agent A under `owner-only` | No Devin turn starts for agent A and no usage is attributed to account A | +| B own invocation | Identity B mentions agent B under `owner-only` | Agent B replies and can work only in workspace B | +| A blocked by B | Identity A mentions agent B under `owner-only` | No Devin turn starts for agent B and no usage is attributed to account B | +| Explicit allowlist | A changes agent A to `allowlist` and adds identity B | Identity B can invoke agent A; an unlisted identity remains blocked | +| Allowlist removal | A removes identity B | Identity B can no longer invoke agent A | +| Direct messages | An allowlisted external identity DMs agent A | The DM fails closed; channel allowlisting does not widen DM admission | +| Workspace A boundary | Agent A is asked for workspace B's unique marker | Agent A cannot access it through the configured `REPOS` mapping | +| Workspace B boundary | Agent B is asked for workspace A's unique marker | Agent B cannot access it through the configured `REPOS` mapping | +| Restart | Quit and reopen Buzz for Devin, then invoke each owned agent | Agent configuration is restored and each agent still uses its own OS-user Devin account | + +The workspace checks prove the selected mapping and the operating-system user +boundary used by the test. They do not claim that Buzz's `REPOS` mapping is an +OS sandbox. + +## Phase 2 live reply checkpoint + +In context A: + +1. Start the Devin-backed agent. +2. First ask it: `Use the Buzz CLI to publish exactly PHASE2_REPLY_OK to this + reply destination. Do not inspect files or use any other tools.` A visible + reply requires the one `buzz messages send` call; never use a blanket + "do not use tools" instruction for this checkpoint because raw ACP text is + not automatically reposted into Buzz. +3. Ask it to make a harmless, easily verified edit in workspace A. +4. Confirm that tool activity and the final response are visibly published in + the Buzz channel. +5. Restart Buzz for Devin. +6. Repeat the exact publication-aware reply check, then ask for another + harmless edit and confirm a second visible final response. +7. In Cognition's normal account dashboard, confirm that the activity is + attributed to account A. Record only yes/no; do not capture account secrets. + +Repeat the attribution check for context B during its own invocation. Usage +amounts may vary and are not an acceptance criterion; correct account +attribution is. + +## Failure-state checks + +Use a disposable test context where each state can be reached without altering +another user's credentials: + +1. With no `devin` executable on `PATH`, Doctor reports that the CLI is missing + and links to Cognition's official installation documentation. +2. With Devin installed but not authenticated, Doctor reports authentication + required and offers the official CLI login flow. +3. After successful authentication, Doctor reports Devin ready. +4. With a deliberately invalid ACP launch configuration in the disposable + agent record, startup reports an ACP startup failure rather than a missing or + unauthenticated CLI. + +Restore the disposable agent configuration after the fourth check. Never edit +or remove Devin authentication data to manufacture a failure. + +## Evidence record + +Copy +[the validation record template](buzz-for-devin-validation-record-template.md) +for the release candidate and record: + +- Immutable build tag or source commit. +- macOS and architecture for contexts A and B. +- Devin CLI version for each context. +- Buzz identity labels A and B, without private keys. +- Devin account labels A and B, without email addresses if the record will be + public. +- Pass/fail for every row in the matrix. +- Pass/fail for both Cognition usage-attribution checks. +- Any user-visible error text after confirming it contains no credential data. + +Stop and file a blocking issue if an invocation crosses an owner or allowlist +boundary, a workspace marker is visible from the other context, credentials +appear in logs or UI, or usage is attributed to the wrong Devin account. diff --git a/docs/buzz-for-devin-release-checklist.md b/docs/buzz-for-devin-release-checklist.md new file mode 100644 index 00000000000..0025dd7986b --- /dev/null +++ b/docs/buzz-for-devin-release-checklist.md @@ -0,0 +1,223 @@ +# Buzz for Devin release checklist + +This is the release gate for the initial community-supported macOS +distribution. It preserves Buzz's existing Tauri architecture and keeps +fork-owned signing, notarization, updater, and publication configuration out of +the generic Devin runtime patch. + +No step may print, copy into the repository, or attach signing, updater, +Keychain, Buzz identity, or Cognition credentials to test evidence. + +## Release scope + +The first supported artifact is a signed and notarized Apple Silicon DMG for +macOS 11.0 or newer, built from an immutable `buzz-for-devin-vX.Y.Z` tag. +Intel macOS, Windows, and Linux are not supported until separately built and +tested. + +The artifact must contain: + +- Product name `Buzz for Devin` +- Bundle identifier `community.buzzfordevin.desktop` +- Deep-link scheme `buzz-for-devin://` +- Keychain service `buzz-for-devin-desktop` +- Agent Nest `~/.buzz-for-devin` +- Bundled CLI convenience link `~/.local/bin/buzz-for-devin` +- Executable `buzz-desktop` +- Sidecars `buzz`, `buzz-acp`, `buzz-agent`, `buzz-dev-mcp`, and + `git-credential-nostr` + +The release must retain the unofficial community-project disclaimer and must +not claim cloud Devin, Fusion, fan-out, Outposts, or local-to-cloud handoff +capability. + +## Fork-owned release prerequisites + +Configure these only as protected repository or release-environment secrets: + +- Apple Developer ID Application certificate and its password +- Apple signing identity +- Apple account or App Store Connect API credentials accepted by Tauri +- Apple Team ID +- Tauri updater public key +- Tauri updater private key and password + +The upstream `block/buzz` workflow uses Block-only signing infrastructure and +must not be reused with its internal roles or buckets. The fork pipeline should +follow Tauri's official macOS signing/notarization and GitHub pipeline +documentation, pin every third-party action to an immutable commit, and require +manual approval through a protected release environment. + +The manual-only fork canary is defined in +`.github/workflows/buzz-for-devin-signed-macos-canary.yml`. It does not publish +or create a tag. Configure the `buzz-for-devin-release` environment with +required reviewers and the protected secrets above before dispatching it. The +workflow runs `just ci`, JavaScript audit, and Rust dependency policy checks +before importing the signing certificate. If the Apple Silicon dependency +graph still contains the documented no-safe-upgrade maintenance advisories, +the dispatcher must make an explicit, recorded canary-only risk decision; the +workflow never treats that decision as a passing advisory result. + +Authoritative references: + +- +- +- + +## Source and preflight gate + +Before creating a release tag: + +1. Review every changed line, with extra scrutiny on authorization, process + launch, environment policy, workspace mapping, storage, and packaging. + Copy + [the validation record template](buzz-for-devin-validation-record-template.md) + for the candidate; every required row must pass before publication. +2. Split generic upstream changes from fork branding/distribution and the + dependency-only override according to + [buzz-for-devin-upstream-patch-plan.md](buzz-for-devin-upstream-patch-plan.md). +3. Ensure the worktree is clean and every commit is understood. +4. Activate Hermit and run: + + ```sh + . ./bin/activate-hermit + just ci + cargo test -p buzz-acp + cargo test --manifest-path desktop/src-tauri/Cargo.toml + cd desktop + pnpm build:e2e + CI=true pnpm exec playwright test \ + tests/e2e/onboarding.spec.ts \ + tests/e2e/onboarding-agent-defaults.spec.ts + cd .. + pnpm audit --audit-level=low + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ + cargo deny --locked check advisories + cargo deny --locked check bans licenses sources + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ + cargo deny --locked --manifest-path desktop/src-tauri/Cargo.toml \ + check advisories + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ + cargo deny --locked --manifest-path desktop/src-tauri/Cargo.toml \ + --target aarch64-apple-darwin check advisories + cargo deny --locked --manifest-path desktop/src-tauri/Cargo.toml \ + check bans licenses sources + ./scripts/build-buzz-for-devin-macos.sh aarch64-apple-darwin + ./scripts/test-buzz-for-devin-macos-lifecycle.sh + ``` + +5. Inspect the packaged plist and confirm every bundled executable is + executable. +6. Confirm the changed-file secret scan is clean and retain only redacted + results. +7. Resolve or explicitly review the inherited desktop GTK3, `audiopus_sys`, + `mach`, `proc-macro-error`, and `rust-unic` maintenance advisories before + signing. The Apple Silicon-scoped graph excludes GTK3 and + `proc-macro-error`, but still fails on `audiopus_sys`, `mach`, and + `rust-unic`. Do not suppress the findings or call either failing advisory + gate green. +8. Record the exact source commit, Devin CLI version, Rust toolchain, Node + version, pnpm version, macOS version, and architecture. + +## Signed canary gate + +Before publishing a release: + +1. Build a signed and notarized canary from the exact candidate commit without + creating a release or updater manifest. +2. Verify: + + ```sh + codesign --verify --deep --strict --verbose=2 \ + "/Applications/Buzz for Devin.app" + spctl --assess --type execute --verbose=4 \ + "/Applications/Buzz for Devin.app" + xcrun stapler validate "/path/to/Buzz for Devin.dmg" + ``` + +3. Run `desktop/scripts/verify-macos-entitlements.sh` against the signed app. +4. Confirm the signed artifact still has the fork product name, identifier, + scheme, Keychain service behavior, and all executable sidecars. +5. Confirm Gatekeeper accepts a browser-downloaded copy, not only the local + build output. + +## Clean-machine acceptance + +Use a supported Mac that has never run this fork: + +1. Download the candidate DMG from the intended distribution surface. +2. Verify its checksum against the release record. +3. Install by dragging the app to Applications. +4. Confirm first launch produces no damaged-app or unidentified-developer + warning. +5. Confirm upstream Buzz can coexist and continues using its own data, + `~/.buzz` Nest, `~/.local/bin/buzz` link, Keychain service, and `buzz://` + links. +6. Confirm `buzz-for-devin://` opens only Buzz for Devin. +7. Verify all four readiness states: + - Devin CLI missing + - Devin installed but unauthenticated + - Devin authenticated and ready + - Devin ACP startup failure +8. Authenticate only through `devin auth login`; never copy credentials between + machines. +9. Create an owner-only Devin agent, select a disposable workspace, publish a + reply, perform safe tool activity, restart, and publish another reply. For + the reply-only checkpoint, instruct Devin to use the Buzz CLI publication + call and no other tools; a blanket "do not use tools" prompt makes the test + invalid because raw ACP text is not automatically posted to the channel. + After the reply appears, wait beyond the Devin compatibility grace and + confirm the exact turn closes once with no requeue or duplicate publication. +10. Confirm the white-background Devin avatar appears in onboarding and for a + newly created default Devin agent. +11. Confirm the Cognition dashboard attributes the test usage to the account + authenticated on that OS user. +12. Complete the separate two-context authorization and workspace-isolation + matrix. + +## Updater and rollback gate + +The updater stays disabled unless both the fork-owned public key and HTTPS +endpoint are embedded at build time. + +1. Publish a signed candidate `N-1` and install it on the clean machine. +2. Publish signed candidate `N` and a matching signed updater manifest. +3. Confirm `N-1` discovers only the fork artifact and never upstream Buzz. +4. Apply the update, relaunch, and verify the installed version and signature. +5. Confirm identities, managed-agent records, logs, and Devin authentication + remain available without migrating into upstream Buzz storage. +6. Test a deliberately unavailable update endpoint; the installed app must + remain usable. +7. Test rollback to the previous signed app and verify configuration remains + intact. +8. Rotate or revoke a test updater key only in a disposable pre-release lane; + never experiment with the production key. + +## Publication gate + +Publication requires explicit owner approval after every preceding gate passes. + +1. Create the immutable `buzz-for-devin-vX.Y.Z` tag at the reviewed commit. +2. Require the release workflow to verify that checkout `HEAD` exactly matches + that tag. +3. Publish the signed/notarized DMG, checksum, source archive, release notes, + and updater artifacts from the protected release environment. +4. Verify downloads, checksum, Gatekeeper acceptance, and updater URLs after + publication. +5. Publish known limitations and the unofficial-project disclaimer. +6. Preserve the previous signed release for rollback. +7. Do not create a public community or invite beta users until release + acceptance is recorded. + +## Upstream gate + +Upstream readiness is separate from fork publication: + +1. Rebase or merge the latest `block/buzz` into a review branch without + rewriting the user's working tree. +2. Re-run the focused and full gates on the resulting patch. +3. Open small generic pull requests in the documented patch order. +4. Exclude product branding, distribution secrets, community defaults, and + release credentials. +5. Do not represent upstream acceptance as complete until the pull requests are + reviewed and merged. diff --git a/docs/buzz-for-devin-release-notes-draft.md b/docs/buzz-for-devin-release-notes-draft.md new file mode 100644 index 00000000000..b0322a8424a --- /dev/null +++ b/docs/buzz-for-devin-release-notes-draft.md @@ -0,0 +1,111 @@ +# Buzz for Devin release notes draft + +> Draft only. Replace every placeholder and complete the release validation +> record before publication. Do not publish an unsigned source build. + +## Buzz for Devin `vX.Y.Z` + +Buzz for Devin is an unofficial community distribution of Buzz that adds the +official Devin CLI as a native local Agent Client Protocol runtime while +preserving Buzz's existing relay, identity, channel, and managed-agent +architecture. + +### What is included + +- First-class `Devin` runtime selection backed by `devin acp`. +- Readiness states that distinguish: + - Devin CLI missing; + - Devin installed but unauthenticated; + - Devin authenticated and ready; and + - Devin ACP startup failure. +- Interactive authentication through the official `devin auth login` flow. +- Cognition's official Devin CLI documentation as the installation source. +- White-background Devin runtime and default profile artwork. +- Owner-only invocation and one worker by default for newly created Devin + agents. +- Catalog-enforced default permission mode with automatic permission approval + disabled for Devin. Non-interactive permission requests fail closed. +- Exact-turn recovery when Devin publishes a visible result but its ACP prompt + remains open: Buzz waits 30 seconds, closes only that publishing turn, and + does not retry the already-satisfied request. Other runtimes keep their + historical default because the generic recovery is disabled unless selected + by runtime catalog policy. +- A selected Repos Directory mapped into the managed agent Nest after path and + symlink validation. +- Fork-specific app data, Keychain service, deep links, Agent Nest, and CLI link + so upstream Buzz and Buzz for Devin can coexist. +- Recoverable install, upgrade, rollback, and uninstall behavior. + +### Supported release + +The first supported binary release is planned as a signed and notarized Apple +Silicon DMG for macOS 11 or newer. Intel macOS, Windows, and Linux are not part +of this release unless separately built, tested, and documented. + +### Prerequisites + +1. Install the official Devin CLI using + [Cognition's Devin CLI documentation](https://docs.devin.ai/cli). +2. Authenticate in a visible terminal with `devin auth login`. +3. Install the signed and notarized Buzz for Devin DMG from this release. +4. Verify the downloaded DMG against the published SHA-256 checksum. + +Do not copy Devin credentials between users or machines. Buzz for Devin does +not read, store, migrate, or manage Cognition authentication material. + +### Security defaults + +- New Devin agents are `owner-only`. +- New Devin agents use one worker. +- Buzz does not add permission-bypass flags. +- Automatic permission approval is disabled for Devin. +- External identities require an explicit invocation-policy change. +- Channel allowlisting does not widen direct-message admission. +- Managed-agent logs are owner-readable only on Unix. + +The Repos Directory mapping is not an operating-system sandbox. Devin retains +the official CLI's behavior and the permissions of the macOS user running it. +Use separate macOS users or separate Macs when separate Devin credential and +workspace boundaries are required. + +### Intentional non-capabilities + +This release does not claim: + +- model switching; +- Fusion; +- multi-agent fan-out; +- Outposts; +- local-to-cloud handoff; or +- parity with cloud Devin. + +It provides the official local Devin ACP runtime inside Buzz. Future capability +claims require separate implementation and validation. + +### Upgrade, rollback, and uninstall + +- Quit Buzz for Devin before installing or upgrading. +- The installer validates product identity and bundled executables before + replacing an existing app. +- Upgrades retain a recoverable prior app bundle. +- Rollback restores a validated prior bundle without deleting app data or + Keychain state. +- Uninstall moves only the application to Trash and intentionally preserves + user data and Keychain entries for recovery. + +### Artifacts + +| Artifact | SHA-256 | +| --- | --- | +| `Buzz-for-Devin-vX.Y.Z-aarch64.dmg` | `REPLACE_BEFORE_RELEASE` | +| Source archive | `REPLACE_BEFORE_RELEASE` | + +The release must also link its completed validation record, source commit, +signed workflow run, notarization evidence, and known limitations. + +### Upstream relationship + +Buzz for Devin is a community project and is not an official Cognition or Block +product. Generic runtime-catalog and native-Devin changes are being prepared as +small upstream proposals for `block/buzz`; fork branding, release +infrastructure, credentials, and community policy remain fork-owned. diff --git a/docs/buzz-for-devin-security-review.md b/docs/buzz-for-devin-security-review.md new file mode 100644 index 00000000000..9b7b8582ec9 --- /dev/null +++ b/docs/buzz-for-devin-security-review.md @@ -0,0 +1,623 @@ +# Buzz for Devin security review + +**Review date:** 2026-07-25 + +> **Status update (2026-07-27):** The broad upstream draft PR #3072 is closed +> and superseded by the focused Devin preset +> [`block/buzz` PR #3225](https://github.com/block/buzz/pull/3225), built on +> Block's merged generic BYOH seam. Historical commit and validation references +> below describe the earlier review candidate. + +**Scope:** native Devin ACP runtime, managed-process launch policy, workspace +mapping, account isolation, invocation authorization, local packaging, and the +fork web deep-link boundary. + +This review does not treat a passing unit test as live multi-user proof. The +separate operating-system account matrix remains a release gate in +[buzz-for-devin-multi-user-validation.md](buzz-for-devin-multi-user-validation.md). + +## Authorization matrix + +Buzz signs and verifies community events independently of Devin. The +`buzz-acp` inbound author gate decides whether an event may start or steer a +turn before the prompt reaches `devin acp`. + +| Author | `owner-only` | `allowlist` | `anyone` | `nobody` | +| --- | --- | --- | --- | --- | +| Agent owner | Allow | Allow | Allow | Deny | +| Verified sibling agent owned by the same owner | Allow | Allow | Allow | Deny | +| Explicitly allowlisted external identity | Deny | Allow | Allow | Deny | +| Unlisted external identity | Deny | Deny | Allow | Deny | + +Direct messages are stricter: only the owner or a verified sibling is admitted +under `owner-only`, `allowlist`, or `anyone`; `nobody` denies all authors. +Allowlisting an external identity for community channels does not allow that +identity to invoke the agent through a direct message. + +The product default remains `owner-only`. `allowlist` and `anyone` require an +explicit owner change. The automated suite covers owner, sibling, allowlisted, +unlisted, stranger, direct-message, missing-metadata, and setup-listener +fail-closed cases. Live proof with two independent identities is still +required. + +### Which record the gate reads (2026-07-27 finding) + +The enforced policy is the **instance** record's `respond_to` / +`respond_to_allowlist` pair. `build_respond_to_env` converts that pair into +`BUZZ_ACP_RESPOND_TO` (and `BUZZ_ACP_RESPOND_TO_ALLOWLIST`) when the agent's +child process is spawned. A definition's behavior group is a *template*: it is +copied onto an instance only when a new instance is minted from it, and +`update_persona` propagates only `display_name` and `avatar_url` to instances +that already exist. + +An upstream routing defect made the two diverge silently. For a +definition-linked agent, the profile panel's Edit action opened the +**definition** editor, so the dialog displayed a definition-level allowlist +while the running agent continued to enforce its instance policy. Observed +live on `Devin Phase 2 Live`: the definition (kind `30175`, +`d=ea752a17-…`) carried `allowlist` with one entry, the instance (kind +`30177`, `d=1dcd8dfd…a506`) carried `owner-only` with none, and the running +harness had `BUZZ_ACP_RESPOND_TO=owner-only` with no allowlist variable set. + +The failure mode is bidirectional and matters most for revocation: an owner +who *removed* an identity from that dialog would believe access was revoked +while the live agent kept honouring the original policy. Fixed by +`resolveProfileEditTarget`, which routes an instance-backed profile to the +instance editor so the displayed policy is the enforced one; definition +editing remains available from the agent library's actions menu and from the +instance editor's linked-definition hop. This routing is upstream code +(`block/buzz` #1274, #1928) and is not fork-specific. + +### Revocation is not effective until the agent restarts (2026-07-27 finding) + +`build_respond_to_env` runs once, at spawn. A running harness therefore keeps +enforcing the policy it was started with, and an inbound-author change only +takes effect on the next start. Measured live while revoking one allowlisted +identity: + +| Time (UTC) | Persisted record | Live harness environment | +| --- | --- | --- | +| 18:39:55 | `owner-only` | `allowlist` + revoked pubkey | +| 18:42:35 | `owner-only` | `owner-only`, allowlist variable absent | + +For those two minutes and forty seconds the revoked identity retained full +invocation access. The agent record had `auto_restart_on_config_change: true`, +and no automatic restart occurred within that window; the UI surfaced a +`RESTART REQUIRED` badge and waited. That is the documented behaviour rather +than a malfunction — the setting restarts the agent "once it is idle and +connected" — but the security consequence is what matters: the revocation +window is bounded by agent idleness plus operator attention, not by the +revoking action, and nothing about the badge communicates that the old policy +is still being enforced meanwhile. + +Treat "removing an identity revokes access" as true only after a restart, and +do not record that row as passing on the basis of the persisted record alone. + +**Disposition.** This is left unfixed in this pass, deliberately. The narrow +change — treat an authorization *narrowing* as grounds for an immediate +restart rather than waiting for idleness — is the wrong shape of fix: it makes +policy correctness depend on process lifecycle, and it silently converts a +permission edit into cancelled work in progress. The right fix is for the +inbound-author gate to be evaluated per event against current policy instead +of against a snapshot captured in the spawn environment, so revocation is +effective the moment it is saved and no restart is implicated at all. That is +an architectural change to `buzz-acp`'s policy plumbing, it is upstream-generic, +and it warrants maintainer agreement rather than being bundled into a fix pass +for an unrelated defect. + +Until then the honest statement is the one above: revocation is effective on +restart. A release must not claim prompt revocation, and the +`RESTART REQUIRED` badge does not currently tell an owner that the previous +policy is still being enforced — which is the part most likely to mislead. + +### Revoking access is a two-place operation (2026-07-27 finding) + +Revoking on the instance does not revoke on its definition. Both records hold +an independent inbound-author policy, and each has its own editor, so an owner +who removes an identity from the agent they can see leaves the definition +untouched. + +Observed live. After the instance was set back to `owner-only` and the revoked +identity was confirmed denied, the definition still held +`respond_to: allowlist` with that identity, both in the local record and in its +published kind:30175. Because the definition's behavior group is copied onto +every newly minted instance, the next agent created from that definition would +have silently re-granted the revoked identity access. Clearing the definition +separately removed it from both the record and the published event, which also +confirms `apply_persona_behavior` clears the list correctly for non-allowlist +modes. + +This is the same two-record split that produced the routing defect above, seen +from the other side. Revocation should either propagate to the definition or +say plainly that it has not. Until then, treat revocation as incomplete until +both the instance and its definition have been checked. + +### Non-allowlist modes republished a stale allowlist (2026-07-27 — fixed) + +**Finding.** The definition write path clears the allowlist whenever the mode +is not `allowlist`, because "storing it for other modes would republish stale +pubkeys the author didn't choose" (`apply_persona_behavior`). The instance +write path did not. After revoking the one allowlisted identity, the instance +record and its public kind:30177 projection both still carried that pubkey +alongside `"respond_to":"owner-only"`. + +This was a disclosure and hygiene defect, not an access-control one. Spawn +drops `BUZZ_ACP_RESPOND_TO_ALLOWLIST` for non-allowlist modes, and +`relayAgentIsSharedWithUser` gates on `respondTo === "allowlist"` before it +consults membership, so the stale entry granted nothing and did not restore +the agent to the revoked identity's autocomplete. The harm was that a revoked +association stayed publicly readable on the relay. + +**Disposition — fixed.** `agent_event_content` now omits the allowlist unless +the mode is `Allowlist`. The instance record still retains entries across mode +toggles, deliberately, so an owner can switch away and back without retyping +them; the projection is what drops them. Verified live: after revoking, the +published kind:30177 for the instance no longer carried the revoked pubkey. +Pinned by `projection_omits_allowlist_for_non_allowlist_modes`. + +## Allowlisted-agent discovery diverges from upstream on purpose (2026-07-27) + +This fork shows an agent in mention autocomplete when the current identity is +on that agent's allowlist. Upstream deliberately does not: `block/buzz` +commit `a4dfead2`, "fix(desktop): prefer live agent mentions" (#2149), added +`isAgentIdentityInManagedList` and renamed its coverage from "allowlisted relay +agents are visible in channel mentions" to "relay-only agents stay hidden from +channel mentions even when allowlisted". Upstream's model is that autocomplete +lists agents you *manage*, and relay policy does not confer discovery. + +The fork needs the opposite for its two-account case: an identity that has been +granted access must be able to find the agent it can invoke, otherwise the +grant is unusable without out-of-band knowledge of the agent's name. That was +observed live — the allowlisted identity only saw the agent because of this +change. + +The upstream E2E test still passes here, because its fixture agent is not a +channel member and the divergence only affects channel-member agents the viewer +does not manage. Passing CI therefore does **not** mean upstream agrees with +this behaviour, and the change must not ride into an upstream PR unflagged. It +is excluded from the upstream series and kept fork-only until a maintainer +decides. If it is ever proposed upstream it needs its own PR, a rationale +referencing #2149, and coverage for the channel-member case that the current +test leaves open. + +## Inherited ACP host state must not reach the adapter (2026-07-27 finding) + +Cognition's own documentation frames Devin Desktop as an ACP *host* that +launches third-party agents and injects environment into them (see +, "Enabling custom agents" and the +`devin.acp.agentEnv.` setting). `ACP_BACKEND=windsurf` is part of +that host-side state: it tells a spawned `devin acp` that its host is Devin +Desktop, and therefore that the host supplies credentials over ACP. + +When Buzz is the host, that claim is false. If the variable is inherited — +for example because Buzz was launched from a terminal running inside Devin +Desktop — the adapter announces "ACP host is the sole source of credentials. +Local CLI credentials (env vars, on-disk REPL store) will NOT be used" and +every turn fails with `-32000 ACP host has not authenticated`, even though +`devin auth login` succeeded. With the variable absent the same binary reports +"`ACP_BACKEND` not set. Will accept host credentials if provided, otherwise +fall back to env vars and stored CLI credentials" and turns succeed. + +The same leak also corrupted readiness reporting: `devin auth status` returned +"Not logged in" under contamination and "Logged in (via Devin)" without it. + +`ACP_BACKEND` is therefore scrubbed alongside `WINDSURF_API_KEY` in Devin's +`scrub_env_vars`. That single list is consumed by process launch +(`apply_runtime_env_policy`), the login/readiness probe, and runtime discovery, +so one entry fixes invocation and readiness together. Verified in a packaged +build: the desktop process carried `ACP_BACKEND` while the Devin harness it +spawned did not. + +Buzz's own probing of `devin auth status` and remediation via `devin auth login` +remain correct, because an uncontaminated adapter falls back to exactly that +stored-credential path. + +## Devin authentication boundary + +- Buzz probes readiness with `devin auth status`. +- Interactive setup launches `devin auth login` in a visible terminal. +- Buzz does not read, serialize, log, copy, or remove Cognition credentials. +- Devin authentication belongs to the operating-system user. It is not scoped + to a Buzz identity inside one macOS login. +- The inherited legacy `WINDSURF_API_KEY` variable is removed from Devin's + catalog-discovery probe, readiness probe, visible login terminal, and managed + runtime without reading its value. This prevents an ambient key from silently + replacing the account selected by the official Devin login flow. +- Account-isolation acceptance therefore requires separate machines or + separate macOS users with independently authenticated Devin CLIs. + +## Process launch and permissions + +The runtime catalog is the capability authority. Its Devin entry declares only: + +```text +devin acp +``` + +No dangerous or bypass flag is added. Immediately before spawning the managed +harness, Buzz enforces `BUZZ_ACP_PERMISSION_MODE=default` and +`BUZZ_ACP_AUTO_APPROVE_PERMISSIONS=false` and +`BUZZ_ACP_INTERACTIVE_PERMISSIONS=true` for Devin after all ambient and +user-configured environment layers. A saved agent or parent process cannot +override those values. + +If Devin sends an ACP permission request, Buzz surfaces **Allow once** and +**Deny** to the agent owner. The encrypted owner-signed control must match the +exact channel, turn, and JSON-RPC request id. No `allow_always` or bypass +decision is accepted; stale, missing, unavailable, and timed-out decisions +fail closed. Existing non-Devin runtime behavior is unchanged. + +The managed harness receives the existing Buzz agent identity and relay +environment required by Buzz architecture. The Devin integration adds no +Cognition secret to that environment and does not log environment values. + +## Workspace boundary + +The configured Repos Directory must be an existing absolute directory. Buzz +canonicalizes parent segments and symlink targets before checking that the +selection is not the Nest or one of its ancestors. It exposes the validated +target as the Nest's `REPOS` mapping. + +This is path validation and mapping, not an operating-system sandbox. Devin +retains the official CLI's permission behavior and the local OS user's access. +The release does not claim that prompts cannot reach other paths available to +that OS user. Cross-account workspace isolation must be proven with separate +OS users or machines, and public documentation must keep this limitation +visible. + +## Storage and logs + +- The fork release uses its own bundle identifier, application-support + directory, deep-link scheme, Keychain service, `~/.buzz-for-devin` Nest, + and `~/.local/bin/buzz-for-devin` convenience link. +- The isolated release Nest does not fall back to upstream `~/.buzz/REPOS` or + import legacy `~/.sprout` knowledge. +- New Devin agents default to one worker. Existing records keep their stored + value because legacy storage cannot prove whether a value matching Buzz's + former global default was implicit or explicitly chosen. +- Managed-agent log files are created as owner-only (`0600`) on Unix. + Reopening a legacy log tightens it to `0600`. +- Source scans found no strong credential patterns in changed files. +- Logs can still contain tool and runtime output. A public beta must inspect + representative logs for accidental sensitive output without copying + credentials into the test record. + +## Distribution boundary + +- The source build is unsigned and updater-disabled. It is for development + validation only. +- Install and upgrade validate the product name, bundle identifier, deep-link + scheme, and every bundled executable before changing the installed app. +- Installation stages on the destination filesystem and switches only after + validation. Upgrade and rollback preserve recoverable prior app bundles. +- The repeatable lifecycle test covers malformed-bundle rejection, refusal + while the installed executable is running, install, upgrade, rollback, and + uninstall entirely inside a temporary directory. +- Packaged releases prefer executable-directory sidecars over source-checkout + `target` outputs. Debug builds keep workspace-first discovery. Focused tests + cover both orderings so an installed app cannot silently mix with a stale + developer `buzz-acp` when the checkout still exists. +- Devin's runtime-catalog policy suppresses Buzz's generic + `BUZZ_ACP_MODEL` bootstrap value because the native ACP server owns model + selection. The policy is applied in Rust launch code without a runtime-ID + branch; focused regression coverage proves existing known and custom runtime + bootstrap behavior is unchanged. +- Uninstall moves only the app to Trash. It intentionally preserves application + data and Keychain entries. +- The web client defaults to upstream Buzz but supports explicit fork app-name, + deep-link, release-page, and release-API build values. A fork deployment must + set all four. + +## Dependency review + +No Devin feature dependency was added. The release audit found advisories in +three existing transitive selections: + +- two high-severity quadratic-complexity advisories in `linkify-it` 5.0.0; +- a low-severity smartquotes quadratic-complexity advisory in `markdown-it` + 14.1.0; and +- a moderate-severity source-map arbitrary-file-read advisory in + `@babel/core` 7.28.5. + +The project now constrains those existing dependency ranges to patched, +same-major releases: `linkify-it` 5.0.2, `markdown-it` 14.3.0, and +`@babel/core` 7.29.7. Each package was verified before the override against +npm's official registry, its established upstream repository, maintainers, +integrity metadata, publication history, and substantial download history. +After the overrides, `pnpm audit --audit-level=low` reports no known +vulnerabilities, and `pnpm why` resolves exactly one copy of each patched +package. + +The root Rust workspace advisory, license, duplicate, and source policy checks +pass. The advisory fetch was run with Git's global and system configuration +disabled for that process because this machine's pre-existing GitHub routing +requires an interactive credential. That routing and its credentials were not +inspected or modified. + +The refreshed advisory index initially reported yanked transitive selections +for `spin` 0.9.8, `spin` 0.10.0, and `nostr` 0.44.3/0.44.4. Both lockfiles now +select the compatible unyanked releases `spin` 0.9.9, `spin` 0.10.1, and +`nostr` 0.44.5. The two `spin` patches retain their existing crate series and +feature surface. The `nostr` patch was additionally compared against 0.44.3: +its dependency and feature surface is unchanged, while its substantive source +changes erase the cached keypair on drop and reject authenticated undersized +NIP-44 payloads without panicking. Neither lockfile contains those yanked +selections. Existing permitted git-source and duplicate-version warnings remain +repository-wide maintenance concerns. + +The separately excluded desktop Tauri workspace does not currently pass +`cargo deny check advisories`. The exact locked graph reports unmaintained +dependencies in four inherited groups: + +- ten GTK3 binding advisories in Tauri's cross-platform Linux graph + (`RUSTSEC-2024-0411` through `RUSTSEC-2024-0420`); +- `audiopus_sys` through Buzz's existing `opus` audio dependency + (`RUSTSEC-2026-0150`); +- two locked `mach` selections and `proc-macro-error` + (`RUSTSEC-2020-0168` and `RUSTSEC-2024-0370`); and +- five crates from the unmaintained `rust-unic` project through + `tauri-utils 2.9.3 -> urlpattern 0.3.0`: `unic-char-property`, + `unic-char-range`, `unic-common`, `unic-ucd-ident`, and + `unic-ucd-version` (`RUSTSEC-2025-0081`, `RUSTSEC-2025-0075`, + `RUSTSEC-2025-0080`, `RUSTSEC-2025-0100`, and `RUSTSEC-2025-0098`). + +Cargo Deny reports no safe upgrade for these locked selections. None was +introduced by the Devin runtime work. + +For the first-release target, the narrower +`cargo deny --target aarch64-apple-darwin check advisories` excludes the GTK3 +and `proc-macro-error` findings because those crates are not in the Apple +Silicon graph. It still fails on `audiopus_sys`, the two `mach` selections, and +the five `rust-unic` crates. The target-scoped result is useful triage, not a +green gate. + +The desktop advisory failure is inherited from the upstream dependency graph, +not a Devin dependency or fork regression. Root and desktop bans, license, +and source checks still pass. The fork does not silently ignore these +advisories: a release must update the affected upstream dependencies where +safe fixes exist and explicitly review any no-fix maintenance risk before +signing. + +## Local validation snapshot + +This is development evidence, not release sign-off. The generic integration +series is submitted only as draft +[`block/buzz` PR #3072](https://github.com/block/buzz/pull/3072); it is not an +immutable release candidate. The prior draft head was `fff496e6`, based on +public upstream commit `63c62fcf3eb5`. + +On 2026-07-27 the series was refreshed locally onto public upstream +`7fc0cc82db4d9dced9c258bbe8b530164a832a77` as two generic commits, +`c4b94ebc` and `1387fbc4`. The refreshed patch preserves upstream's restored +Goose and Buzz Agent onboarding entries and keeps onboarding visibility, +ordering, model capability, runtime icons, launch defaults, and +authentication policy projected from Rust `KnownAcpRuntime` rather than a +duplicate TypeScript table. It is now the draft PR head. + +The following complete gates passed on 2026-07-25 against the then-current +upstream base; the 2026-07-26 refresh evidence is recorded below. After the +exact-turn completion recovery was added and the app was rebuilt, a fresh +development-tree `just ci` also passed in full on 2026-07-26: + +- `cargo test -p buzz-acp permission`: 16 tests passed. +- Focused Tauri Devin tests: 14 tests passed. +- Focused frontend readiness, catalog, link, and identity tests: 73 tests + passed through the repository test loader. +- Focused onboarding Playwright tests: 77 tests passed. +- The Devin onboarding Playwright check decodes and renders the actual SVG, + verifies an opaque white canvas and dark mark, and rejects transparent output. +- `just ci`: passed, including repository formatting, linting, unit tests, + desktop builds/tests, Tauri compilation/tests, web checks, and mobile checks. +- `pnpm audit --audit-level=low`: no known vulnerabilities. +- Root `cargo deny check advisories`: passed. +- Desktop `cargo deny check advisories`: failed on the inherited GTK3, + `audiopus_sys`, `mach`, `proc-macro-error`, and `rust-unic` maintenance + advisories documented above. +- Apple Silicon-scoped desktop `cargo deny check advisories`: failed on the + inherited `audiopus_sys`, `mach`, and `rust-unic` maintenance advisories; + GTK3 and `proc-macro-error` are outside that target graph. +- Root and desktop `cargo deny check bans licenses sources`: passed with only + the repository's existing permitted duplicate and git-source warnings. +- Redacted changed-file secret scan: no credential material found; candidates + were semantic test placeholders or nonliteral E2E mock expressions. +- The complete `buzz-acp` suite passed: 614 unit tests and 9 lifecycle tests. +- The complete desktop Tauri suite passed: 1,684 tests, with 14 intentionally + ignored tests that require real Keychain or external infrastructure. +- After the catalog model-launch policy fix, the installed bundle launched its + sibling `buzz-acp` and the official `devin acp` without the previous + forced-model warning. +- A corrected installed prompt permitted exactly the Buzz publication call. + After the owner selected **Allow once**, Devin published the requested exact + reply and Buzz rendered it at the requested reply destination. The packaged + sibling `buzz` CLI took precedence over an unrelated installed Buzz CLI. +- The official Devin ACP child remained open after successful publication + instead of returning `session/prompt`. The source now exposes a generic, + default-off post-publication completion grace. Devin opts into 30 seconds + through `KnownAcpRuntime`; the timer can signal only the exact publishing + turn, the already-satisfied batch is never retried, and the cancelled session + is invalidated before later work. The rebuilt installed app published the + requested exact reply after **Allow once**; after 45 seconds the turn showed + no working or permission state and the reply count remained one. +- The same live test found that a managed-agent restart could orphan the + official CLI because `buzz-acp` and its ACP child use independent process + groups. Generic Unix teardown now snapshots same-user live descendants before + stopping the tracked harness and terminates their owned process groups first. + A subprocess regression test proves the independent child is reaped. In the + rebuilt installed app, a real Restart replaced harness PID 32354 and Devin + PID 34505 with PIDs 37044 and 37045; both old processes were gone. +- A normal unlocked app relaunch restored the saved managed agent without a + Play action. A later cold prompt exposed two availability boundaries: the + first message paid for deferred Devin initialization, and one initialized + process then stayed silent under the generic 15-minute idle allowance. + Devin now opts out of deferred subprocess startup and receives a + catalog-provided 120-second silence default while preserving explicit + overrides. In the rebuilt installed app, Keychain unlock automatically + restored the saved agent, started the official `devin acp` before any new + message, and reported `agent_pool_ready` after 37 milliseconds. A clean + managed-agent restart reaped both old processes, re-subscribed, and reported + ready after 44 milliseconds. The top-level DM cold and warm probes completed + their ACP waits in 4.017 and 4.047 seconds respectively, and each exact reply + appeared once in the main timeline with no permission prompt, thread, or + duplicate. Buzz did not auto-approve a request or select a persistent or + bypass grant. +- On 2026-07-27, a second macOS-user context created a new Buzz identity and a + private `~/.buzz-for-devin` nest owned only by that user. The first context's + nest timestamp did not change. Onboarding reported the official Devin CLI + authenticated and ready, rendered the white-background Devin icon, retained + Devin as the default harness with `Default model`, and launched independent + `buzz-acp` and `devin acp` processes. A newly owned agent published the exact + DM reply `MFENNER_CONTEXT_OK`. Normal app quit removed the desktop process + and all observed harness and Devin children. This proves the second + context's own invocation and process isolation; cross-owner denial, + allowlisting, workspace-marker boundaries, restart, and Cognition + usage-attribution rows remain open. +- No MCP configuration or credential material was inspected or changed. + +The complete patch was applied to a detached worktree at public `block/buzz` +commit `07d0265cfc21` and passed every component of `just ci` using shared +build caches. After upstream advanced by six commits, it was refreshed again +on 2026-07-26 at `c2a4ee711e48`. Two conflicts were reviewed explicitly: +runtime process detection now retains upstream's Linux +`buzz-desktop.bi` entry plus the fork's `Buzz for Devin` entry, and sidecar +bundling retains upstream's executable-mode behavior plus the fork's named +destination. Upstream's removal of the old Agent directory UI remains intact. + +The refreshed tree passed locked metadata, formatting, Rust clippy for +`buzz-acp` and the complete Tauri crate, 603 `buzz-acp` unit plus 9 lifecycle +tests, 1,670 Tauri library tests plus 3 mixer diagnostics (14 external/real +Keychain tests intentionally ignored), 3,534 desktop frontend tests, +desktop/web TypeScript and production builds, and the touched web guards. +Root advisories and root/desktop bans, licenses, and sources still pass. The +desktop advisory gates retain the same documented no-safe-upgrade failures; +neither is represented as green. + +Public upstream later advanced again to `871a3b377234`. The patch was applied +to a fresh detached worktree and four conflicts were resolved without changing +the development branch: upstream's modular managed-runtime architecture was +preserved; packaged-sidecar PATH precedence remained conditional on the +sidecar's presence; upstream's `modelSource` card resolver was extended with +the catalog model-control capability; and Unix-only executable-mode repair was +kept in sidecar bundling. The resolved tree has no merge markers or whitespace +errors and passes complete root, desktop, Tauri, web, and mobile `just ci`. +That includes 3,543 desktop frontend tests, 1,734 Tauri library tests with 14 +intentional external/real-Keychain ignores, all 3 mixer diagnostics, and 685 +mobile tests with one intentional skip. + +Public upstream subsequently advanced to `63c62fcf3eb5`. The generic runtime +and permission changes were adapted to that architecture and committed locally +as `26ca733c` and `fff496e6`. The final series has a clean worktree and scope +audit and contains no fork branding, distribution, signing, dependency +manifest, or lockfile changes. All 614 `buzz-acp` unit tests, 9 lifecycle +integration tests, 1,811 Tauri library tests (14 intentional ignores), 3 mixer +diagnostics, and 3,642 desktop frontend tests pass. TypeScript, frontend +source guards, Rust formatting, and strict Clippy for both `buzz-acp` and the +complete Tauri crate also pass. + +Public upstream then advanced to `7fc0cc82db4d9dced9c258bbe8b530164a832a77`. +The locally refreshed two-commit series passes all 614 `buzz-acp` unit tests, +all 9 lifecycle tests, 1,812 Tauri library tests with 14 intentional +external/real-Keychain ignores, all 3,644 desktop frontend tests, and all 21 +tests in `onboarding-agent-defaults.spec.ts`. The frontend production build, +E2E build, Biome and source guards, Rust formatting, and strict Clippy for +`buzz-acp` and the full Tauri crate also pass. `cargo deny check` exits +successfully for the refreshed upstream dependency graph while still reporting +its inherited source and yanked-version warnings; the patch changes no +dependency manifest or lockfile. A redacted changed-file secret scan found no +credential-shaped material. Final review removed one production `expect()` from +avatar normalization; the safe branch and its focused regression test pass. + +On 2026-07-26, `pnpm audit --audit-level=low` was rerun against the current +lockfile and reported no known vulnerabilities. A high-confidence scan of +every staged changed file found no credential-shaped literals, and +`git diff --cached --check` remained clean. Cognition's current official CLI +quickstart and command reference were also rechecked: the catalog installation +endpoints, `devin auth status`, `devin auth login`, `devin acp`, and the +documented precedence of `WINDSURF_API_KEY` over stored login credentials still +match the implementation. Three dependency-free release-config tests also +prove the isolated non-updating default, fail-closed partial updater +configuration, and paired key-plus-endpoint enablement. + +A separate high-confidence scan covered every tracked and untracked changed +file and found no credential-shaped literals. Four representative production +log files under the fork's application-support directory were checked without +printing their contents; no high-confidence secret pattern was found. + +Final review also removed persistent ACP permission choices from the UI and +from the harness selection path. Only one-shot allow or reject choices can be +submitted; no bypass or persistent grant is selected. The native Devin process +name is included in same-user crash recovery, and the independent-process-group +regression remains covered so a desktop crash or restart does not leave the +official CLI running. + +The final local Apple Silicon source build also passed: + +- Bundle identifier: `community.buzzfordevin.desktop`. +- Deep-link scheme: `buzz-for-devin`. +- Architecture: `arm64`. +- Main executable SHA-256: + `23df661b2b2bae9933dc74439baedc8646e5c7886b541c56823c6fb8d9966d23`. +- Bundle-content manifest SHA-256: + `11204fd22c8493a20e8cf72b6656d89d056d0d251b2b3a0439ad53e94f19b370`. +- The packaged frontend embeds `/runtime-icons/devin.svg`; the source and + built SVGs are byte-identical and both contain the white background. +- Fork builds link manual updates and web downloads to + `fenner888/BuzzforDevin`; ordinary upstream builds retain Block's release + URLs. +- The bundle is unsigned as expected for a source build. +- A separate local Developer ID rehearsal signed every bundled executable, the + app, and `Buzz for Devin_0.4.25_aarch64.dmg` with + `Developer ID Application: Mark Fenner (Q7H78WYTAR)`. Strict code-signature, + application-identity, and entitlement verification passed. The signed DMG + SHA-256 was + `84d1a126d14c5fb0df64ee93492adf97d5d6dc298d78dc941573e23d3b7bb788`. + Gatekeeper then rejected it as `Unnotarized Developer ID`, and stapler + reported no ticket, as expected without protected Apple notarization + credentials. This artifact was not published. +- The isolated install, running-app refusal, upgrade, rollback, and recoverable + uninstall lifecycle suite passed. +- Installation at `~/Applications/Buzz for Devin.app` preserved application + data and Keychain entries, matched the verified binary hash, and relaunched + from the stable path. +- The manual signed-canary workflow parses successfully, has read-only + repository permissions, uses only `workflow_dispatch`, contains no + publication command, and pins all three external actions to full commit SHAs + verified against their official tags. +- The canary workflow runs the complete repository gate plus JavaScript and + Rust dependency policy checks before importing any signing certificate. A + failed Apple Silicon advisory check blocks by default. A protected + environment reviewer may explicitly record acceptance of the documented + no-safe-upgrade maintenance findings for that short-lived canary; the failed + audit remains visible and is never relabeled as green. +- The `buzz-for-devin-release` GitHub environment now exists with a required + reviewer. It contains no signing or notarization secrets, so the canary + remains fail-closed. + +The toolchain snapshot was Rust 1.95.0, Node 24.14.0, pnpm 11.4.0, macOS +26.5.1 on Apple Silicon, and Devin CLI 3000.2.17. `devin auth status` and +`devin acp --help` both returned success with their output suppressed where it +could contain account context. + +## Open release gates + +- Complete the live two-context authorization, workspace, restart, and usage + attribution matrix. Context B's own identity, Nest, authenticated runtime, + owned reply, and quit cleanup are proven; the cross-context rows remain open. +- Perform a clean-machine install and first-launch test. +- Export the validated Developer ID certificate to the protected GitHub + environment and configure Apple notarization credentials. Local Developer ID + signing is proven; notarization remains blocked. +- Configure the updater signing key and fork-owned HTTPS endpoint, then test + update and rollback from signed builds. +- Resolve or explicitly review the inherited desktop GTK3, `audiopus_sys`, + `mach`, `proc-macro-error`, and `rust-unic` maintenance advisories. Do not + mark the desktop advisory gate green while it still fails. +- Follow the documented + [upstream patch plan](buzz-for-devin-upstream-patch-plan.md) so generic + runtime changes remain separate from fork branding and distribution work. + The refreshed two-commit series satisfies this separation and is submitted + as a draft; upstream review and merge remain open. +- Complete every + [release checklist](buzz-for-devin-release-checklist.md) gate before + publication. +- Obtain explicit approval before pushing, tagging, opening a pull request, + signing, notarizing, publishing, or creating a public community. diff --git a/docs/buzz-for-devin-upstream-patch-plan.md b/docs/buzz-for-devin-upstream-patch-plan.md new file mode 100644 index 00000000000..c8a22c1275d --- /dev/null +++ b/docs/buzz-for-devin-upstream-patch-plan.md @@ -0,0 +1,303 @@ +# Buzz for Devin upstream patch plan + +> **Current upstream path (2026-07-27):** Block merged the generic BYOH harness +> seam in PR #2773. The earlier broad draft PR #3072 is closed and superseded +> by the focused Devin preset +> [`block/buzz` PR #3225](https://github.com/block/buzz/pull/3225). The patch +> boundaries below remain an architectural and historical review record. + +This document separates generally useful Buzz changes from community-fork +distribution work. The generic runtime series is prepared locally; it does not +authorize a push, tag, pull request, or release. + +## Patch 1: catalog-driven runtime capabilities + +Purpose: keep `KnownAcpRuntime` as the single source of runtime capability +facts and project those facts into discovery, readiness, and the frontend. + +Include: + +- `desktop/src-tauri/src/managed_agents/discovery.rs` +- `desktop/src-tauri/src/managed_agents/discovery/runtime_catalog.rs` +- `desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs` +- the associated discovery, metadata, avatar, and existing-runtime tests +- the catalog-driven frontend API types and onboarding/settings projections + +Review condition: product React code must not contain runtime-specific +capability checks or a second TypeScript runtime table. + +## Patch 2: native Devin ACP runtime + +Purpose: add the official Devin CLI as a first-class native ACP runtime. + +Include: + +- runtime ID `devin`, label `Devin`, executable `devin` +- default arguments `acp` +- underlying CLI `devin` +- skills directory `.devin/skills` +- readiness probe `devin auth status` +- interactive setup `devin auth login` +- official Cognition installation documentation +- the white-background Devin runtime icon and profile avatar mapping +- `devin` to `devin acp` argument normalization +- focused discovery, readiness, normalization, and regression tests + +Review condition: the implementation must not read or manage Cognition +credentials, add bypass flags, claim cloud Devin parity, or change existing +runtime behavior. + +## Patch 3: safe managed-runtime boundaries + +Purpose: make native ACP process behavior safe and deterministic for a +non-interactive community surface. + +Include as focused commits where independently reviewable: + +- owner-signed interactive allow-once/reject-once handling with exact + channel, turn, and request matching +- catalog-declared environment policy, Devin's enforced default permission + mode, disabled automatic approval, and enabled owner-consent bridge +- owner-only and one-worker Devin defaults +- selected-workspace canonicalization and Nest ancestor/symlink rejection +- stale managed-child cleanup +- owner-only managed-agent log permissions +- runtime avatar normalization + +Review condition: document that workspace mapping is not an operating-system +sandbox. Preserve the historical behavior of other runtimes unless a generic +security fix is intentionally proposed and covered by regression tests. + +## Patch 4: generic build-time app identity + +Purpose: let downstream distributions isolate product identity without +hardcoding fork checks in React. + +Possible upstream candidates: + +- build-time desktop app name and deep-link scheme +- build-time Keychain service name +- build-time Nest directory and bundled CLI convenience-link name +- build-time web app name, deep-link scheme, release page, and release API +- sidecar executable-bit preservation +- packaged-release sidecar precedence over source-checkout target directories + +Review condition: upstream defaults must remain exactly Buzz-compatible. Keep +the concrete `Buzz for Devin` plist, generated Tauri config, and distribution +scripts out of the generic patch. + +## Separate security patch + +The `linkify-it` 5.0.2, `markdown-it` 14.3.0, and `@babel/core` 7.29.7 +workspace overrides fix high-, low-, and moderate-severity advisories in the +existing JavaScript dependency graph. The lockfile-only updates to `spin` +0.9.9, `spin` 0.10.1, and `nostr` 0.44.5 replace yanked Rust selections with +compatible unyanked releases. None adds Devin capability. Propose this +maintenance independently so dependency review does not obscure the runtime +integration. + +Include: + +- `pnpm-workspace.yaml` +- `pnpm-lock.yaml` +- `Cargo.lock` +- `desktop/src-tauri/Cargo.lock` + +Review condition: retain the package-verification record, confirm that the +resolved versions remain within the dependency ranges already declared by +their consumers, and require `pnpm audit --audit-level=low` to report no known +vulnerabilities. + +## Fork-only changes + +Do not include these in a native-runtime upstream pull request: + +- `DEVIN.md`, `COMMUNITY_FORK.md`, and community delivery plans +- `Buzz for Devin` product names, bundle identifier, plist, and release URLs +- fork macOS build, install, upgrade, rollback, and uninstall scripts +- fork signing, notarization, updater, and release workflows +- public-community defaults, invitations, or release policy +- signing, notarization, updater keys, or publication configuration + +## Validation evidence + +Before proposing any patch, reproduce the smallest tests for that patch and +then the repository gate: + +```bash +. ./bin/activate-hermit +cargo test -p buzz-acp +cargo test --manifest-path desktop/src-tauri/Cargo.toml +cd desktop && pnpm test +cd .. +just ci +``` + +For the runtime patch, also run the focused onboarding Playwright tests and the +safe official-CLI smoke checks from the approved implementation plan. Do not +record credentials or Devin configuration in logs or PR evidence. + +## Current upstream drift audit + +The development worktree is based on local commit `a7ca0dd86b4f`. A complete +patch was first applied to public `block/buzz` commit `07d0265cfc21` on +2026-07-25 and passed every component of `just ci` using shared build caches. + +On 2026-07-26, public `block/buzz` `main` advanced to `871a3b377234`. The +complete staged patch was applied again to a detached worktree at that exact +head without modifying the development branch or its index. Four textual +conflicts were reviewed: + +- `desktop/src-tauri/src/managed_agents/runtime.rs`: the resolution preserves + upstream's `metadata`, `process`, `orphan_sweep`, `instance_reaper`, and + `lifecycle` module boundaries, then adds the generic catalog-driven launch, + presentation, and process-tree behavior. +- `desktop/src-tauri/src/managed_agents/runtime/path.rs`: the packaged sibling + `buzz` sidecar remains first only when it is actually present; development + and non-bundled ordering remains unchanged. +- `desktop/src/features/agents/ui/UnifiedAgentsSection.tsx`: upstream's + authoritative `modelSource` resolver is retained and extended with the + catalog-projected model-control capability. The older fork-only formatting + helper is omitted from the upstream patch. +- `scripts/bundle-sidecars.sh`: Unix sidecars are made executable while Windows + `.exe` files retain their native copied mode. + +The refreshed tree at `871a3b377234` passes the complete compatibility gate: + +- root and Tauri formatting, strict Clippy, source guards, and staged-diff + whitespace validation; +- all 3,543 desktop frontend tests and the production desktop build; +- all 1,734 Tauri library tests, with 14 infrastructure or real-Keychain tests + intentionally ignored, plus all 3 mixer diagnostics; +- web formatting, source guards, type checking, and production build; +- mobile formatting, analysis, source guards, and all 685 tests, with one + intentionally skipped test; and +- the public-upstream patch has no unresolved merge markers. + +The previous complete tree at `c2a4ee711e48` passed: + +- locked root and Tauri Cargo metadata, root and Tauri formatting, + `git diff --cached --check`, and shell syntax for the conflicted packaging + script; +- `cargo clippy -p buzz-acp --all-targets -- -D warnings` and complete Tauri + `cargo clippy --all-targets -- -D warnings`; +- all 608 `buzz-acp` unit tests and all 9 lifecycle tests after the exact-turn + recovery was mirrored byte-for-byte from the development tree; +- the complete Tauri suite after that mirror: 1,672 library tests passed, 14 + infrastructure or real-Keychain tests intentionally ignored, and all 3 + mixer diagnostics passed; +- desktop Biome over 420 files, all 3,531 frontend tests, TypeScript, and the + production Vite build; +- touched-web Biome, file-size and pubkey-truncation guards, TypeScript, and the + production Vite build; +- root advisories plus root and desktop bans/licenses/sources policy checks, + with only the repository's inherited warnings; and +- the expected failing desktop advisory gates. The all-target graph still + reports GTK3, `audiopus_sys`, `mach`, `proc-macro-error`, and `rust-unic`; + the Apple Silicon graph still reports `audiopus_sys`, `mach`, and + `rust-unic`. No failed advisory result is represented as green. + +This proves the reviewed change set integrates with public upstream as of +`871a3b377234` and passed the complete `just ci` gate there. A later refresh +and local commit series is recorded below. The gate must still be repeated on +the immutable candidate immediately before approval because upstream can +continue advancing. + +The later installed-bundle trace also showed Buzz passing its unrelated global +model to Devin even though the catalog reports no Buzz model-control +capability. The catalog now carries the launch policy as a runtime fact: +Devin does not receive generic `BUZZ_ACP_MODEL`, while Claude, Codex, Goose, +Buzz Agent, and custom-runtime behavior remains unchanged. The focused policy +and Devin catalog tests pass in both the development checkout and the detached +upstream integration tree. A restart of the installed bundle proved the +packaged process path and removed the forced-model warning. The test prompt +mistakenly prohibited tools, which also prohibited `buzz messages send`—the +upstream Buzz publication path. A direct official-CLI ACP handshake returned +the requested exact text with zero tools and zero file changes despite the same +user-configured MCP startup warnings, disproving those warnings as the cause. +No MCP configuration or credentials were inspected or changed. A corrected +installed prompt subsequently received **Allow once** and published the exact +requested reply through the packaged sibling Buzz CLI. The official Devin ACP +process did not return `session/prompt` afterward, so the generic harness now +offers a default-off self-publication completion grace. Devin opts into 30 +seconds through the Rust runtime catalog. The recovery targets the exact +publishing turn, drops its already-satisfied batch, invalidates that session, +and reports a successful end turn; existing runtimes remain unchanged. The +complete main-tree harness suite (614 unit and 9 lifecycle tests), Tauri suite +(1,684 passed and 14 intentionally ignored), formatting, strict Clippy, and the +full `just ci` gate pass. The rebuilt installed app published the requested +exact reply once after **Allow once** and cleared its working and permission +state after the 30-second grace. + +That live proof also found a generic Unix teardown gap: an ACP child in its own +process group survived a managed-agent restart after the desktop stopped only +the harness group. Teardown now snapshots same-user descendants while the +tracked harness is alive and terminates their owned process groups before the +harness. The independent-child regression test and strict Tauri Clippy pass in +both the main tree and refreshed upstream tree. The refreshed tree retains its +upstream-specific process detection while carrying the same cleanup patch. A +real Restart in the newest installed build replaced harness PID 32354 and +Devin PID 34505 with PIDs 37044 and 37045; both old processes were reaped. A +normal unlocked relaunch also restored the saved managed agent without a Play +action. + +The earlier cold first-turn delay was eliminated by catalog-driven eager Devin +startup and a 120-second idle default. In the rebuilt installed app, a clean +restart reached `agent_pool_ready` in 44 milliseconds. Cold and warm top-level +DM probes completed in 4.017 and 4.047 seconds, and each exact reply appeared +once in the main timeline with no permission prompt, thread, or duplicate. +Devin still uses its default permission mode; Buzz does not silently persist +approval or select bypass mode. + +## Prepared public-upstream series + +On 2026-07-27, the generic patch was refreshed onto public `block/buzz` +`7fc0cc82db4d9dced9c258bbe8b530164a832a77` on branch +`agent/upstream-native-devin-acp`: + +- `c4b94ebc` — `feat(desktop): add native Devin ACP runtime` +- `1387fbc4` — `feat(acp): mediate runtime permissions through owners` + +The series contains no fork product name, bundle identity, installer, signing +workflow, publication configuration, dependency manifest, or lockfile change. +It preserves upstream's modular runtime architecture and keeps +`KnownAcpRuntime` as the single capability authority. The white-background +Devin mark is the only new runtime asset. + +Validation on the committed series passed: + +- all 614 `buzz-acp` unit tests and all 9 pool-lifecycle integration tests; +- all 1,812 Tauri library tests, with 14 real-Keychain/infrastructure tests + intentionally ignored, plus all 3 mixer diagnostics; +- all 3,644 desktop frontend tests; +- all 21 onboarding runtime-default Playwright tests; +- desktop TypeScript, Biome, file-size, text-size, and pubkey guards; +- root and Tauri formatting; +- strict `buzz-acp` and complete Tauri Clippy with warnings denied; +- focused Devin catalog, normalization, readiness, launch-policy, and + process-tree tests; and +- clean diff, merge-marker, fork-branding, dependency-file, secret-pattern, + and Rust dependency-policy scope checks. + +The branch remains isolated from the fork development branch. The refreshed +series was pushed to the fork and is the head of draft +[`block/buzz` PR #3072](https://github.com/block/buzz/pull/3072). Its DCO check +plus Semgrep OSS and zizmor checks pass; review and upstream acceptance remain +open. + +## Remaining upstream prerequisites + +- Complete the live two-context authorization and isolation matrix. +- Repeat usage attribution in both independent operating-system/Devin + contexts. Installed exact-turn closure, responsive cold and warm messaging, + automatic relaunch, and real-process restart cleanup are proven locally. +- Re-run JavaScript and both Rust workspace dependency policy checks on the + immutable final candidate. Resolve or explicitly review the inherited desktop + GTK3, `audiopus_sys`, `mach`, `proc-macro-error`, and `rust-unic` + maintenance advisories. Also retain the Apple Silicon-scoped result, which + excludes GTK3 and `proc-macro-error` but still fails on `audiopus_sys`, + `mach`, and `rust-unic`. +- Re-fetch/rebase and review the two-commit series against `block/buzz` + immediately before submission, because upstream can advance after this + audit. +- Obtain explicit approval before pushing or opening a pull request. diff --git a/docs/buzz-for-devin-upstream-pr-drafts.md b/docs/buzz-for-devin-upstream-pr-drafts.md new file mode 100644 index 00000000000..cc51057b87f --- /dev/null +++ b/docs/buzz-for-devin-upstream-pr-drafts.md @@ -0,0 +1,177 @@ +# Buzz for Devin upstream pull-request drafts + +> **Current upstream path (2026-07-27):** The generic BYOH seam shipped through +> Block PR #2773. The broad draft PR #3072 is closed. The active, +> merge-focused contribution is +> [`block/buzz` PR #3225](https://github.com/block/buzz/pull/3225), containing +> only the Devin preset, official command, logo, metadata, and tests. + +The smaller boundaries below are retained as historical review drafts and as +fallback context if upstream reviewers request a different split. + +## PR 1: catalog-driven native runtime capabilities + +### Draft title + +`refactor(desktop): project ACP runtime capabilities from the Rust catalog` + +### Draft summary + +- Make `KnownAcpRuntime` the authority for runtime label, command, default + arguments, underlying CLI, skills directory, authentication probe, + installation guidance, icon, permission policy, and environment policy. +- Project catalog data through the existing Tauri discovery response. +- Remove duplicated runtime presentation decisions from React rendering. +- Preserve the behavior and metadata of every existing runtime. + +### Non-goals + +- No Devin runtime entry yet. +- No fork branding or distribution behavior. +- No second TypeScript runtime table. +- No runtime-specific checks in React rendering. + +### Required evidence + +- Catalog metadata tests for Claude, Codex, Goose, and Buzz Agent. +- Frontend projection and existing-runtime regression tests. +- Tauri tests and `just ci`. + +## PR 2: add the official Devin CLI as a native ACP runtime + +### Draft title + +`feat(desktop): add Devin as a native ACP runtime` + +### Draft summary + +- Add runtime ID `devin`, label `Devin`, executable `devin`, and default + argument `acp`. +- Add `.devin/skills`, `devin auth status`, and `devin auth login` metadata. +- Link installation help to Cognition's official Devin CLI documentation. +- Normalize a bare `devin` command to `devin acp`. +- Add the white-background Devin runtime icon and catalog-projected default + avatar. +- Distinguish missing CLI, unauthenticated CLI, ready CLI, and ACP startup + failure. +- Opt Devin into the generic, default-off exact-turn completion grace for the + official ACP behavior where a visible publication can outlive + `session/prompt`. + +### Safety and non-goals + +- Do not read, store, print, migrate, or modify Cognition credentials. +- Do not add dangerous permission or bypass flags. +- Do not claim model switching, Fusion, fan-out, Outposts, cloud handoff, or + cloud Devin parity. +- Do not change existing runtime behavior. +- Keep default invocation owner-only. +- Never retry the triggering batch after its visible result was published. + +### Required evidence + +- Devin catalog metadata tests. +- `devin` to `devin acp` normalization tests. +- Authentication-readiness tests. +- Discovery/catalog exposure tests. +- Existing-runtime regression tests. +- Exact-turn and no-requeue post-publication recovery tests. +- Safe local `devin --version`, `devin auth status`, and `devin acp --help` + smoke checks with authentication output suppressed. +- Focused frontend/Tauri tests and `just ci`. + +## PR 3: make managed ACP permission and environment policy catalog-driven + +### Draft title + +`fix(acp): enforce catalog-declared managed runtime policy` + +### Draft summary + +- Carry catalog-declared environment removals and enforced environment values + to runtime probes, visible login, and managed process launch. +- Let the ACP harness receive catalog-projected automatic and interactive + permission policy. +- Disable automatic permission approval for Devin and require an encrypted + owner-signed, exact-turn **Allow once** or **Deny** decision. +- Preserve the historical auto-approval behavior of existing managed runtimes. + +### Security review focus + +- Environment precedence after user-configured values. +- No environment-value logging. +- Fail-closed permission selection. +- Existing-runtime regression coverage. +- Clear documentation that workspace mapping is not an OS sandbox. + +### Required evidence + +- Permission-mode unit tests. +- Runtime environment-policy tests. +- Readiness and visible-login environment tests. +- Existing-runtime regression tests. +- Rust formatting, Clippy, Tauri tests, and `just ci`. + +## PR 4: support downstream desktop identity without changing Buzz defaults + +### Draft title + +`refactor(desktop): make downstream app identity build-configurable` + +### Draft summary + +- Allow build-time app name, deep-link scheme, Keychain service, Nest + directory, and bundled CLI-link name. +- Allow the web build to receive matching app name, deep-link, release-page, + and release-API values. +- Preserve executable bits when bundling sidecars. +- Keep every unset/default value identical to upstream Buzz. + +### Fork-only exclusions + +- No `Buzz for Devin` plist or product constants. +- No fork bundle identifier or release URL. +- No fork build/install/rollback/uninstall scripts. +- No signing, notarization, updater, or publication workflow. +- No public-community defaults or invitation policy. + +### Required evidence + +- Default Buzz identity regression tests. +- Alternate-identity build tests. +- Deep-link parsing tests for both the upstream default and alternate scheme. +- Frontend build, Tauri build/tests, and `just ci`. + +## Separate dependency-maintenance PR + +### Draft title + +`chore(deps): select patched JavaScript and Rust transitive releases` + +### Draft summary + +- Constrain existing transitive JavaScript selections to `linkify-it` 5.0.2, + `markdown-it` 14.3.0, and `@babel/core` 7.29.7. +- Replace yanked lockfile selections with compatible `spin` 0.9.9, `spin` + 0.10.1, and `nostr` 0.44.5. +- Keep this maintenance independent of Devin capability. + +### Required evidence + +- Official registry and repository verification for each selected package. +- `pnpm why` showing one resolved patched version of each JavaScript package. +- `pnpm audit --audit-level=low`. +- Root and desktop-workspace `cargo deny check advisories`. +- Root and desktop-workspace `cargo deny check bans licenses sources`. +- Locked Rust metadata and `just ci`. + +## Submission order + +1. Catalog-driven runtime capabilities. +2. Native Devin runtime. +3. Managed runtime policy, split further if reviewers prefer. +4. Generic downstream identity. +5. Independent dependency maintenance. + +Each proposal must be understandable and testable on its own. Do not stack fork +branding or distribution files onto the generic upstream series. diff --git a/docs/buzz-for-devin-validation-record-0.4.25-local.md b/docs/buzz-for-devin-validation-record-0.4.25-local.md new file mode 100644 index 00000000000..80fa0cc7d3f --- /dev/null +++ b/docs/buzz-for-devin-validation-record-0.4.25-local.md @@ -0,0 +1,143 @@ +# Buzz for Devin 0.4.25 local validation record + +This is a non-release local rehearsal record. It deliberately retains +`not run` and `fail` results for gates that require a notarized immutable +artifact, a clean machine, protected credentials, or additional live +multi-user actions. It is not release approval. + +No email addresses, authentication output, tokens, cookies, private keys, +Keychain contents, signing-secret values, or Devin configuration contents are +recorded here. + +## Candidate + +| Field | Value | +| --- | --- | +| Candidate version | `0.4.25-local-rehearsal` | +| Source commit | `c2cdc92a972fa4686344ccf64b9d46c262c7cf86` | +| Artifact SHA-256 | `84d1a126d14c5fb0df64ee93492adf97d5d6dc298d78dc941573e23d3b7bb788` | +| Artifact download source | Local Developer ID rehearsal; not published | +| Build workflow run | Local build; signed-canary workflow not run | +| Test date | 2026-07-25 through 2026-07-27 | +| Tester | Local maintainer | + +## Contexts + +The operating-system users are anonymized as A and B. Separate Devin account +attribution has not yet been confirmed in Cognition's dashboard. + +| Field | Context A | Context B | +| --- | --- | --- | +| Anonymized Buzz identity label | A | B | +| Anonymized Devin account label | Unverified A | Unverified B | +| macOS version | 26.5.1 | 26.5.1 | +| Architecture | Apple Silicon | Apple Silicon | +| Devin CLI version | 3000.2.17 | 3000.2.17 | +| Buzz for Devin version | 0.4.25 | 0.4.25 | +| Disposable workspace marker | Local disposable workspace | Not run | + +## Runtime readiness + +| Check | Result | Redacted observation | +| --- | --- | --- | +| CLI missing is distinguished | pass | Focused readiness tests passed | +| Installed but unauthenticated is distinguished | pass | Focused readiness tests passed | +| Authenticated and ready is distinguished | pass | Live contexts A and B reported ready | +| ACP startup failure is distinguished | pass | Focused startup-failure tests passed | +| Installation link opens Cognition's official CLI documentation | pass | Focused link tests passed | +| Login action uses `devin auth login` | pass | Command-policy tests passed | + +## Native ACP and restart + +| Check | Context A | Context B | Redacted observation | +| --- | --- | --- | --- | +| Runtime appears as `Devin` with the white-background icon | pass | pass | Source, built asset, automated render, and live onboarding checked | +| Default command resolves to `devin acp` | pass | pass | Live process trees used the official CLI | +| Default invocation is owner-only | pass | not run | Automated policy coverage passed; B live policy was not independently inspected | +| Default worker count is one | pass | not run | A checked; B not independently inspected | +| Initial visible channel reply | pass | not run | B completed a DM checkpoint, not a channel checkpoint | +| Published reply closes the exact turn after the compatibility grace | pass | not run | A exact-turn closure passed | +| Published reply is not retried or duplicated | pass | not run | A duplicate check passed | +| Safe tool activity in the disposable workspace | pass | not run | No B workspace was created | +| Visible reply after app restart | pass | not run | B normal quit cleanup passed; relaunch reply was not run | +| Cognition activity attributed to the local account | not run | not run | Dashboard confirmation requires an interactive account check | + +## Authorization and isolation + +| Check | Result | Redacted observation | +| --- | --- | --- | +| A can invoke agent A | pass | Prior live owner invocation passed; re-confirmed 2026-07-27 18:29 UTC | +| B cannot invoke agent A under owner-only | pass | 2026-07-27: established by the revocation test — with agent A back on `owner-only`, B attempted an invocation and was denied | +| B can invoke agent B | pass | Exact DM reply `MFENNER_CONTEXT_OK` appeared | +| A cannot invoke agent B under owner-only | not run | Live cross-owner denial remains open | +| Explicitly allowlisted B can invoke agent A | pass | 2026-07-27: B appeared in B's mention list via the kind:30177 directory, B's channel mention passed the inbound gate, and the turn published one reply in 6.4s | +| Unlisted identity remains blocked | not run | Only the one allowlisted identity was exercised | +| Removing B from the allowlist blocks B again | pass | 2026-07-27: after revoking and restarting, B attempted an invocation and was denied. Record, kind:30177 projection, and spawn env had all returned to `owner-only` with no allowlist variable. Two caveats: the denial only holds after a restart, and revoking on the instance left the definition still allowlisting B until that was cleared separately | +| Allowlisting does not admit external direct messages | not run | Live DM boundary test remains open | +| Agent A cannot read workspace B's marker | not run | B disposable workspace remains open | +| Agent B cannot read workspace A's marker | not run | B disposable workspace remains open | +| Both owned agents still work after restart | not run | A passed separately; the two-context row remains open | +| No usage is attributed to the wrong Devin account | not run | Cognition dashboard confirmation remains open | + +## Defects found during this validation pass + +Recorded so none of these is lost between passes. Details and evidence live in +[the security review](buzz-for-devin-security-review.md). + +| Defect | State | Effect if unfixed | +| --- | --- | --- | +| Profile Edit opened the definition editor for definition-linked agents, so an allowlist change never reached the instance the runtime enforces | fixed (`resolveProfileEditTarget`) | Owner believes access was granted or revoked when the live agent's policy never changed | +| Agent directory discovery queried kind:10100 instead of kind:30177 | fixed | An allowlisted identity never sees the agent in autocomplete | +| Unsupported `respond_to` modes broke the whole directory | fixed | One agent publishing `nobody` made every agent disappear from discovery | +| Allowlisted-agent autocomplete diverges from upstream #2149 | fork-only, deliberate | Excluded from the upstream series; needs a maintainer decision before it is proposed | +| Instance projection republished a retained allowlist under non-allowlist modes | fixed (`agent_event_content`) | Revoked pubkeys stay publicly readable on the relay | +| Inherited `ACP_BACKEND` reached the Devin adapter | fixed (`scrub_env_vars`) | Every Devin turn fails with "ACP host has not authenticated" despite a valid login | +| Revocation is not enforced until the agent restarts | open — product decision | A revoked identity keeps full access for an unbounded window | +| Revoking on an instance leaves its definition still allowlisting the identity | open | The next agent minted from that definition silently re-grants the revoked identity | +| ~~`auth_probe_args` probes a credential store ACP mode ignores~~ | withdrawn | Not a defect. `devin auth status` reported "Not logged in" only because the probe inherited `ACP_BACKEND`; the readiness and discovery probes both pass `runtime.scrub_env_vars`, so the same scrub fix makes the probe accurate. With `ACP_BACKEND` unset the adapter does fall back to stored CLI credentials, so that store is the correct thing to probe and `devin auth login` is the correct remediation | + +## macOS distribution + +| Check | Result | Redacted observation | +| --- | --- | --- | +| Developer ID signature verifies | pass | Strict local signature verification passed | +| Gatekeeper accepts a browser-downloaded copy | fail | Local rehearsal is unnotarized and was rejected | +| Notarization ticket is stapled and validates | fail | No protected notarization credentials or ticket are configured | +| Entitlements verify | pass | Local Developer ID rehearsal passed | +| DMG checksum matches the release record | pass | SHA-256 recorded above | +| Clean install succeeds on Apple Silicon macOS 11 or newer | not run | A separate clean machine is required | +| Upstream Buzz and Buzz for Devin coexist | pass | Isolated bundle identity and install path checked | +| `buzz://` remains owned by upstream Buzz | pass | Deep-link boundary verification passed | +| `buzz-for-devin://` opens only Buzz for Devin | pass | Deep-link boundary verification passed | +| Keychain prompts do not repeat during normal relaunch | not run | Must be repeated with a notarized immutable artifact | +| Upgrade preserves identity and managed-agent records | pass | Local installed-app lifecycle passed | +| Rollback restores the previous signed app | not run | Repeat with signed immutable N-1 and N artifacts | +| Uninstall removes the app but preserves user data and Keychain state | pass | Recoverable local uninstall lifecycle passed | +| Failed updater endpoint leaves the installed app usable | pass | Fail-closed updater policy tests passed | + +## Security and quality gates + +| Check | Result | Evidence reference | +| --- | --- | --- | +| Focused Rust tests | pass | Security review validation summary | +| Focused frontend tests | pass | Security review validation summary | +| Focused Playwright onboarding tests | pass | Security review validation summary | +| Full `just ci` | pass | Fork CI run `30237114011` | +| JavaScript dependency audit | pass | Security review dependency section | +| Root Rust advisory policy | pass | Security review dependency section | +| Desktop Tauri advisory policy | fail | Inherited maintenance advisories remain | +| Root and desktop Rust license, duplicate, and source policy | pass | Security review dependency section | +| Changed-file secret scan | pass | Security review validation summary | +| Packaging lifecycle test | pass | Security review packaging section | +| Final diff review | not run | Repeat against the immutable release commit | + +## Exceptions and sign-off + +Open gates are not converted into exceptions. No release role has signed off. + +| Role | Name | Date | Decision | +| --- | --- | --- | --- | +| Runtime reviewer | | | | +| Security reviewer | | | | +| Distribution reviewer | | | | +| Release owner | | | | diff --git a/docs/buzz-for-devin-validation-record-template.md b/docs/buzz-for-devin-validation-record-template.md new file mode 100644 index 00000000000..1b119370a4c --- /dev/null +++ b/docs/buzz-for-devin-validation-record-template.md @@ -0,0 +1,129 @@ +# Buzz for Devin validation record + +Copy this template for each release candidate. Store only non-secret, +publishable evidence. Do not record email addresses, authentication output, +tokens, cookies, private keys, Keychain contents, signing-secret values, or +Devin configuration contents. + +Use `pass`, `fail`, or `not run` for every result. A release candidate cannot +pass while any required row is `fail` or `not run`. + +## Candidate + +| Field | Value | +| --- | --- | +| Candidate version | | +| Source commit | | +| Artifact SHA-256 | | +| Artifact download source | | +| Build workflow run | | +| Test date | | +| Tester | | + +## Contexts + +Use anonymized labels. Contexts A and B must be separate macOS users or +separate Macs and must use separate Devin accounts. + +| Field | Context A | Context B | +| --- | --- | --- | +| Anonymized Buzz identity label | | | +| Anonymized Devin account label | | | +| macOS version | | | +| Architecture | | | +| Devin CLI version | | | +| Buzz for Devin version | | | +| Disposable workspace marker | | | + +## Runtime readiness + +Manufacture startup failures only through disposable agent configuration. +Never remove or edit Devin authentication data to reach a test state. + +| Check | Result | Redacted observation | +| --- | --- | --- | +| CLI missing is distinguished | | | +| Installed but unauthenticated is distinguished | | | +| Authenticated and ready is distinguished | | | +| ACP startup failure is distinguished | | | +| Installation link opens Cognition's official CLI documentation | | | +| Login action uses `devin auth login` | | | + +## Native ACP and restart + +| Check | Context A | Context B | Redacted observation | +| --- | --- | --- | --- | +| Runtime appears as `Devin` with the white-background icon | | | | +| Default command resolves to `devin acp` | | | | +| Default invocation is owner-only | | | | +| Default worker count is one | | | | +| Initial visible channel reply (prompt permits only the Buzz publication call) | | | | +| Published reply closes the exact turn after the compatibility grace | | | | +| Published reply is not retried or duplicated | | | | +| Safe tool activity in the disposable workspace | | | | +| Visible reply after app restart (prompt permits only the Buzz publication call) | | | | +| Cognition activity attributed to the local account | | | | + +## Authorization and isolation + +| Check | Result | Redacted observation | +| --- | --- | --- | +| A can invoke agent A | | | +| B cannot invoke agent A under owner-only | | | +| B can invoke agent B | | | +| A cannot invoke agent B under owner-only | | | +| Explicitly allowlisted B can invoke agent A | | | +| Unlisted identity remains blocked | | | +| Removing B from the allowlist blocks B again | | | +| Allowlisting does not admit external direct messages | | | +| Agent A cannot read workspace B's marker | | | +| Agent B cannot read workspace A's marker | | | +| Both owned agents still work after restart | | | +| No usage is attributed to the wrong Devin account | | | + +## macOS distribution + +| Check | Result | Redacted observation | +| --- | --- | --- | +| Developer ID signature verifies | | | +| Gatekeeper accepts a browser-downloaded copy | | | +| Notarization ticket is stapled and validates | | | +| Entitlements verify | | | +| DMG checksum matches the release record | | | +| Clean install succeeds on Apple Silicon macOS 11 or newer | | | +| Upstream Buzz and Buzz for Devin coexist | | | +| `buzz://` remains owned by upstream Buzz | | | +| `buzz-for-devin://` opens only Buzz for Devin | | | +| Keychain prompts do not repeat during normal relaunch | | | +| Upgrade preserves identity and managed-agent records | | | +| Rollback restores the previous signed app | | | +| Uninstall removes the app but preserves user data and Keychain state | | | +| Failed updater endpoint leaves the installed app usable | | | + +## Security and quality gates + +| Check | Result | Evidence reference | +| --- | --- | --- | +| Focused Rust tests | | | +| Focused frontend tests | | | +| Focused Playwright onboarding tests | | | +| Full `just ci` | | | +| JavaScript dependency audit | | | +| Root Rust advisory policy | | | +| Desktop Tauri advisory policy | | | +| Root and desktop Rust license, duplicate, and source policy | | | +| Changed-file secret scan | | | +| Packaging lifecycle test | | | +| Final diff review | | | + +## Exceptions and sign-off + +List every intentional limitation or pre-existing warning. Do not convert an +open release gate into an exception merely to ship. + +| Role | Name | Date | Decision | +| --- | --- | --- | --- | +| Runtime reviewer | | | | +| Security reviewer | | | | +| Distribution reviewer | | | | +| Release owner | | | | diff --git a/docs/plans/2026-07-24-native-devin-acp-community.md b/docs/plans/2026-07-24-native-devin-acp-community.md index 63ae6c2d210..c7003f7afe3 100644 --- a/docs/plans/2026-07-24-native-devin-acp-community.md +++ b/docs/plans/2026-07-24-native-devin-acp-community.md @@ -2,7 +2,13 @@ **Date:** 2026-07-24 -**Status:** Foundation approved for development +**Status:** Native runtime implementation, automated validation, documentation, +unsigned macOS packaging, automatic relaunch, restart-tree proof, two-context +authorization testing, and an immutable source alpha are complete. The earlier +broad upstream draft is superseded by focused +[`block/buzz` PR #3225](https://github.com/block/buzz/pull/3225). Windows and +Linux source previews await live ACP acceptance; signed clean-machine binary +release validation remains open. **Repository:** `fenner888/BuzzforDevin` @@ -61,6 +67,130 @@ devin models agent harness already launches ACP runtimes and connects them to community channels. +## Implementation Snapshot + +As of 2026-07-26, Phase 1 is implemented in the fork: + +- `KnownAcpRuntime` is the capability authority for the `devin` runtime. +- Bare `devin` commands normalize to `devin acp`. +- Readiness distinguishes missing CLI, unauthenticated CLI, authenticated + readiness, and ACP startup failure. +- Buzz projects the Rust catalog into the frontend without a duplicate + TypeScript runtime table or React runtime checks. +- The catalog and profile surfaces use the white-background Devin icon. +- Persona-only cards use the catalog icon before an agent instance exists, so + a stopped/unlaunched Devin persona does not fall back to initials. This was + verified in the installed unsigned macOS app without pressing Play. +- Agent cards project the catalog's model-selection capability. Devin displays + `Runtime default`; runtimes that support Buzz model configuration retain the + configured/default model label. +- The same catalog declares that Devin owns its bootstrap model selection, so + Buzz removes its generic `BUZZ_ACP_MODEL` value for Devin instead of passing + an unrelated workspace default. Focused tests lock existing Claude, Codex, + Goose, Buzz Agent, and custom-runtime launch behavior unchanged. +- Packaged release builds resolve bundled sidecars beside the running desktop + executable before consulting any source-checkout target directory. Debug + builds retain workspace-first resolution. This prevents an installed app on + a developer Mac from silently launching a stale `target/debug/buzz-acp`. +- Managed Devin processes retain permission mode `default`, remove the + legacy `WINDSURF_API_KEY` environment variable without reading it, and + default to owner-only invocation with one worker. +- Fork release builds isolate their Nest and bundled CLI convenience link as + `~/.buzz-for-devin` and `~/.local/bin/buzz-for-devin`, without importing or + falling back to upstream `~/.buzz`. +- Devin's catalog policy disables permission auto-approval, enables the + owner-consent bridge, and enforces `default` mode. ACP + `session/request_permission` requests surface exact per-request **Allow + once** and **Deny** actions when the runtime offers the corresponding + one-shot choices. Buzz returns only the exact one-shot option selected by + the owner; that option must belong to the current request, and the encrypted + owner-signed decision must match the exact channel, turn, and request id. + Stale, unknown, absent, persistent, or timed-out decisions fail closed. Buzz + never selects a persistent grant or bypass mode. +- Newly created Devin agents default to one worker. Existing saved agents keep + their stored worker count because the legacy record format cannot + distinguish an old default from an explicit user choice. + +Focused Rust, frontend, and desktop regression tests pass. Local ACP startup, +session creation, prompt completion, tool activity, file-write behavior, and +restart behavior have been exercised with the official CLI and a disposable +repository. + +The signed-in Cognition CLI usage surface now shows nonzero current-cycle CLI +usage attributed to the locally authenticated user after the official-CLI +smoke path. No authentication settings, tokens, or credentials were inspected, +and the private account identifier is not recorded. The installed app now +proves the packaged desktop launches its bundled `buzz-acp`, which launches +the official `devin acp`. The first post-install prompt was sent to a stale +same-name development-agent DM; the installed agent has a distinct public +identity by design. Opening the DM from the installed agent profile created +the correct membership, delivered a prompt, and initialized the official ACP +child without the earlier forced-model warning. A corrected installed prompt +then permitted exactly the Buzz publication call. After an explicit +per-request approval, Devin published the requested exact reply through the +packaged sibling CLI, and Buzz displayed it at the requested reply destination. +This proves installed message delivery and cross-install CLI selection. + +The official Devin ACP process remained open after the publication instead of +returning `session/prompt`. A catalog-enabled, generic harness compatibility +recovery now waits 30 seconds, targets only the exact publishing turn, drops +the already-satisfied batch, rotates the cancelled session, and records a +successful end turn. It defaults off for all runtimes; only Devin opts in. In +the rebuilt installed app, the owner selected **Allow once**, Devin published +the requested exact top-level reply, and a check after 45 seconds found one +publication with no working or permission state remaining. + +That proof exposed a separate generic restart defect: desktop teardown +signalled only the `buzz-acp` process group, while ACP runtimes intentionally +launch their CLI in an independent process group. The old Devin child +therefore survived one managed-agent restart. Teardown now snapshots +same-user descendants while the tracked harness is live and terminates their +owned process groups before the harness. A subprocess regression test proves +an independently grouped ACP child is reaped. The rebuilt installed app then +proved the same behavior with the official CLI: Restart replaced harness PID +32354 and Devin PID 34505 with PIDs 37044 and 37045, and both old processes +were gone. A normal unlocked app relaunch also restored the saved managed +agent without requiring Play. + +A separate cold-turn investigation found two latency boundaries. Desktop +requested deferred ACP subprocess startup for every managed runtime, so the +first accepted message also paid roughly 40 seconds for the official Devin +process and initialize handshake. After initialization, one turn produced no +ACP stdout or network-byte progress and remained silent under the generic +15-minute idle allowance until a manual restart; the same queued request then +succeeded in under a minute. + +The runtime catalog now declares whether a desktop-requested lazy harness may +defer its ACP subprocess and may supply a runtime-specific default idle +timeout. Devin initializes its single worker when the managed agent starts and +defaults to a 120-second silence bound only when the record, inherited +environment, and merged user environment provide no override. Existing +runtimes preserve deferred startup and the harness idle default. Content-free +timing events identify pool initialization, prompt dispatch, first ACP +activity, and prompt completion. Focused and full Rust suites, desktop +regressions, frontend catalog/readiness tests, formatting, clippy, typecheck, +and frontend lint guards pass. The rebuilt unsigned application bundle also +passes the fork package verifier. After the isolated Keychain item was +unlocked, the installed app automatically restored the saved managed agent, +applied the 120-second default, launched the packaged harness and official +`devin acp` without a Play action or incoming message, and reported +`agent_pool_ready` after 37 milliseconds. A clean managed-agent restart then +reaped both old processes, re-subscribed, and reported ready after 44 +milliseconds. In the live agent profile's top-level DM, the cold probe was +dispatched about 2.4 seconds after send and completed in 4.017 seconds; the +immediately following warm probe was dispatched after about 1.1 seconds and +completed in 4.047 seconds. Both exact replies appeared once in the main DM +timeline with the white-background Devin avatar and no thread. The installed +cold/warm policy check is complete. + +Buzz still leaves Devin in its safe default permission mode: the owner can +choose a one-time, session, or workspace-scoped grant offered by Devin, but +Buzz does not silently persist an approval or select bypass mode. No MCP +configuration or credential material was inspected or changed. The +selected-workspace mapping has been canonicalized and adversarially tested for +parent segments and symlink targets. This mapping is not represented as an OS +sandbox. + ## Proposed Architecture ```text @@ -125,8 +255,28 @@ environment values. ### Workspace -The agent must start with a visible, user-selected workspace. A community -message must not silently widen filesystem access beyond that workspace. +The ACP harness starts in Buzz's persistent Nest. The active community's +validated, user-selected Repos Directory is exposed there as `REPOS`; path +segments and symlink targets are canonicalized before the mapping is applied, +and a target that is the Nest or one of its ancestors is rejected. + +The Devin runtime retains the +[official CLI's normal permission behavior](https://docs.devin.ai/cli/reference/permissions). +Buzz does not automatically select bypass mode, add broad Read or Write grants, +or modify project or user Devin configuration. A community message must not +silently widen filesystem access beyond the workspace and permission scopes the +owner chose. Buzz projects ACP permission requests into its owner-only activity +surface. The owner can explicitly select an exact one-shot allow or reject +option offered by the runtime. Buzz validates the option against that live +request before returning it; unknown or persistent options, timeout, stale +controls, and unattended turns fail closed. Buzz does not choose persistent +approval or bypass. + +Cognition's optional +[`--sandbox` flag](https://docs.devin.ai/cli/sandbox) adds OS-level isolation +but is currently a research preview with platform-specific prerequisites. It +is not forced by this integration. Any future opt-in sandbox control requires +its own product, compatibility, and security review. The first functional test uses a disposable Git repository containing no credentials, personal data, or production configuration. @@ -185,6 +335,8 @@ participation enhances the workspace but does not replace human discussion. - Add icon and UI copy. - Add focused unit tests. +Status: implemented and validated in the development build. + ### Phase 2: Local proof - Build the desktop app locally. @@ -195,14 +347,59 @@ participation enhances the workspace but does not replace human discussion. - Confirm restart and session behavior. - Confirm usage is charged to the authenticated user's account. +Status: complete. CLI discovery, authentication readiness, one-worker ACP +startup, session creation, safe tool activity, disposable-repository writes, +restart behavior, canonicalized selected-workspace mapping, and signed-in +Cognition CLI usage attribution are proven. Packaged sidecar selection, +current-agent DM membership, and prompt delivery to the official CLI are also +proven. A corrected installed test received explicit one-time permission and +published the exact requested reply through the packaged sibling Buzz CLI. +After 45 seconds the exact turn was closed and the reply remained single. The +newest rebuilt app also automatically restored the saved managed agent after a +normal unlocked relaunch, and a real Restart reaped the old independently +grouped Devin CLI before starting the replacement process tree. Installed +cold/warm verification proved the catalog-driven eager-start and +bounded-silence policy: the restored and restarted pools initialized before a +message was sent, and consecutive top-level DM probes completed in about four +seconds with one reply each in the main timeline. A later Welcome-thread test +showed that Buzz displayed Devin's session/workspace approval choices but +offered only **Allow once** and **Deny** actions, so ordinary +`buzz messages send` publication prompted on every turn. Exact one-shot option +selection with live-request validation is now implemented and covered by the +focused and broader test suites; no option is selected automatically. + +The installed-app message-location check also established two distinct rules. +A top-level human message in a regular channel intentionally receives the +agent's ordinary response in a thread rooted at that message. A top-level DM +response belongs in the DM's main timeline. The previous generic prompt only +omitted `--reply-to` for a top-level DM; it did not explicitly tell the ACP +agent to publish without that flag. Devin therefore chose a threaded reply. +The runtime-neutral prompt now supplies the explicit top-level DM publication +instruction while preserving existing channel-thread and DM-thread behavior. +Two rebuilt installed-app turns confirmed that top-level DM answers remain in +the main timeline. + ### Phase 3: Multi-user proof -- Test with two distinct Buzz identities. -- Verify each identity connects its own Devin account. +- Test with two distinct Buzz identities in separate OS-user credential + contexts (separate machines or separate macOS user accounts). +- Verify each OS-user context connects its own Devin account. Switching only a + Buzz identity within one OS login is not a separate Devin authentication + context. - Verify owner-only isolation. - Verify allowlist behavior. - Confirm one member cannot access another member's workspace or credentials. +Status: automated author-gate coverage passes for owner-only, allowlist, +sibling, stranger, and fail-closed direct-message cases. A second macOS-user +context has created its own Buzz identity and private Nest, detected its own +authenticated Devin CLI, launched independent runtime processes, published an +owned DM reply, and exited without leaving those processes behind. This is +partial live evidence, not completion of the matrix. Cross-owner denial, +allowlist and revocation, direct-message admission, workspace-marker +boundaries, restart, and account attribution remain open and are defined in +[the multi-user validation runbook](../buzz-for-devin-multi-user-validation.md). + ### Phase 4: Community beta - Create the Devin Builders community. @@ -217,6 +414,17 @@ participation enhances the workspace but does not replace human discussion. - Tag an immutable community release. - Publish a clear source-build installer and uninstall path. +Status: source-build identity isolation plus recoverable macOS build, install, +upgrade, rollback, and uninstall scripts are implemented. The generic native +runtime and owner-consent work is separated from fork branding in two commits +and submitted as draft +[`block/buzz` PR #3072](https://github.com/block/buzz/pull/3072). A reviewed +refresh based on public upstream `7fc0cc82` is now the draft head; upstream +review remains open. Clean-machine proof, immutable tagging, notarization, +updater setup, and publication remain open. The current security and release +audit is recorded in +[the security review](../buzz-for-devin-security-review.md). + ### Phase 6: Optional cloud capabilities Evaluate separately: @@ -230,6 +438,15 @@ Evaluate separately: None of these are part of the native local ACP MVP. +## Later Bonus Track: Hermes + +After the Phase 1–5 release and upstream-readiness gates are complete, evaluate +Hermes as a separate optional agent runtime or bridge. That work must begin +with an architecture and credential-boundary review; it must not reuse, +inspect, migrate, or modify existing Hermes authentication or configuration. +Hermes support is not a release blocker for native Devin ACP support and is not +included in current completion percentages. + ## Acceptance Criteria The MVP is complete only when: @@ -284,8 +501,8 @@ Mitigation: persistent community-project disclaimer and precise onboarding. ### Runtime drift The Devin CLI may change ACP behavior or authentication commands. -Mitigation: minimum-version tests, current documentation, and release -validation against the official CLI. +Mitigation: catalog and argument-normalization tests, safe release-time CLI +smoke checks, current documentation, and validation against the official CLI. ### Fork maintenance diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8ed8ec3160..2a3f63a467b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: overrides: '@radix-ui/react-dismissable-layer': 1.1.15 + linkify-it: 5.0.2 + markdown-it: 14.3.0 + '@babel/core': 7.29.7 patchedDependencies: isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f @@ -412,35 +415,43 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.3': - resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} @@ -450,6 +461,10 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} @@ -458,12 +473,12 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.3': @@ -471,17 +486,22 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6': resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} @@ -491,14 +511,26 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@biomejs/biome@2.4.16': resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==} engines: {node: '>=14.21.3'} @@ -2729,8 +2761,8 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} @@ -2771,8 +2803,8 @@ packages: markdown-it-task-lists@2.1.1: resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true markdown-table@3.0.4: @@ -3720,19 +3752,19 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.3': {} + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -3750,29 +3782,39 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.3 - '@babel/helper-validator-option': 7.27.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -3780,29 +3822,35 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} - '@babel/helpers@7.29.2': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/runtime@7.29.7': {} @@ -3813,6 +3861,12 @@ snapshots: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -3825,11 +3879,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@biomejs/biome@2.4.16': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.16 @@ -4906,9 +4977,9 @@ snapshots: '@tanstack/router-plugin@1.168.10(@tanstack/react-router@1.170.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -4927,7 +4998,7 @@ snapshots: '@tanstack/router-utils@1.162.1': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.1 '@babel/parser': 7.29.3 '@babel/types': 7.29.0 @@ -5371,7 +5442,7 @@ snapshots: babel-dead-code-elimination@1.0.12: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/parser': 7.29.3 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -5892,7 +5963,7 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - linkify-it@5.0.0: + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -5928,11 +5999,11 @@ snapshots: markdown-it-task-lists@2.1.1: {} - markdown-it@14.1.1: + markdown-it@14.3.0: dependencies: argparse: 2.0.1 entities: 4.5.0 - linkify-it: 5.0.0 + linkify-it: 5.0.2 mdurl: 2.0.0 punycode.js: 2.3.1 uc.micro: 2.1.0 @@ -6465,7 +6536,7 @@ snapshots: prosemirror-markdown@1.13.4: dependencies: '@types/markdown-it': 14.1.2 - markdown-it: 14.1.1 + markdown-it: 14.3.0 prosemirror-model: 1.25.4 prosemirror-model@1.25.4: @@ -6802,7 +6873,7 @@ snapshots: dependencies: '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) '@types/markdown-it': 13.0.9 - markdown-it: 14.1.1 + markdown-it: 14.3.0 markdown-it-task-lists: 2.1.1 prosemirror-markdown: 1.13.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 44e39738e0c..cd1d7e3bc71 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,16 @@ overrides: # when the dialog closed, freezing the app (#1482, reminder dialog). # Removable once every @radix-ui dep converges on one version naturally. "@radix-ui/react-dismissable-layer": 1.1.15 + # markdown-it permits linkify-it 5.x, but the existing lock selected 5.0.0, + # which has two high-severity quadratic-complexity advisories. Keep the + # compatible major and require the release containing both fixes. + "linkify-it": 5.0.2 + # tiptap-markdown and prosemirror-markdown both allow markdown-it 14.x. + # Require a patched release for the smartquotes quadratic-complexity issue. + "markdown-it": 14.3.0 + # TanStack's router build plugins allow Babel 7.x. Keep the established major + # while requiring the sourceMappingURL arbitrary-file-read fix. + "@babel/core": 7.29.7 patchedDependencies: isomorphic-git: patches/isomorphic-git.patch virtua@0.49.3: patches/virtua@0.49.3.patch diff --git a/scripts/build-buzz-for-devin-macos.sh b/scripts/build-buzz-for-devin-macos.sh new file mode 100755 index 00000000000..1843be2b5a3 --- /dev/null +++ b/scripts/build-buzz-for-devin-macos.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) +. "$REPO_ROOT/bin/activate-hermit" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: the initial Buzz for Devin source build supports macOS only." >&2 + exit 1 +fi + +TARGET=${1:-aarch64-apple-darwin} +if [[ "$TARGET" != "aarch64-apple-darwin" ]]; then + echo "Error: the initial release target must be aarch64-apple-darwin, got '$TARGET'." >&2 + exit 1 +fi + +export BUZZ_BUILD_KEYRING_SERVICE="buzz-for-devin-desktop" +export BUZZ_BUILD_DEEP_LINK_SCHEME="buzz-for-devin" +export BUZZ_BUILD_NEST_DIR=".buzz-for-devin" +export BUZZ_BUILD_CLI_LINK_NAME="buzz-for-devin" +export VITE_BUZZ_APP_NAME="Buzz for Devin" +export VITE_BUZZ_DEEP_LINK_SCHEME="buzz-for-devin" +export VITE_BUZZ_RELEASES_URL="https://github.com/fenner888/BuzzforDevin/releases" +export VITE_BUZZ_RELEASES_API_URL="https://api.github.com/repos/fenner888/BuzzforDevin/releases?per_page=10" +export MACOSX_DEPLOYMENT_TARGET="11.0" +export CMAKE_OSX_DEPLOYMENT_TARGET="11.0" +unset BUZZ_UPDATER_PUBLIC_KEY BUZZ_UPDATER_ENDPOINT +unset TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD + +cd "$REPO_ROOT" +pnpm install --frozen-lockfile +cargo build --release --target "$TARGET" \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr \ + -p buzz-cli +./scripts/bundle-sidecars.sh "$TARGET" +for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + test -x "desktop/src-tauri/binaries/${bin}-${TARGET}" +done + +cd "$REPO_ROOT/desktop" +node scripts/build-buzz-for-devin-config.mjs +pnpm tauri build \ + --verbose \ + --no-sign \ + --target "$TARGET" \ + --bundles app \ + --config src-tauri/tauri.buzz-for-devin.conf.json + +BUNDLE_ROOT="$REPO_ROOT/desktop/src-tauri/target/$TARGET/release/bundle" +APP_PATH="$BUNDLE_ROOT/macos/Buzz for Devin.app" +"$REPO_ROOT/scripts/verify-buzz-for-devin-macos-app.sh" "$APP_PATH" + +echo "Unsigned source-build artifacts:" +echo "$APP_PATH" diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index be37cbce0dd..07de477405a 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -35,6 +35,14 @@ fi mkdir -p "$BINARIES_DIR" for bin in "${SIDECARS[@]}"; do - cp "$SRC_DIR/${bin}${EXE}" "$BINARIES_DIR/${bin}-${TARGET}${EXE}" + destination="$BINARIES_DIR/${bin}-${TARGET}${EXE}" + cp "$SRC_DIR/${bin}${EXE}" "$destination" + + # cp preserves the mode of an existing destination on macOS. Generated + # sidecar placeholders may not be executable, so make the bundled Unix + # binaries executable explicitly. + if [[ -z "$EXE" ]]; then + chmod 755 "$destination" + fi done echo "Sidecars bundled for $TARGET" diff --git a/scripts/install-buzz-for-devin-macos.sh b/scripts/install-buzz-for-devin-macos.sh new file mode 100755 index 00000000000..95e42ef7140 --- /dev/null +++ b/scripts/install-buzz-for-devin-macos.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: the initial Buzz for Devin installer supports macOS only." >&2 + exit 1 +fi + +SOURCE_APP=${1:-} +if [[ -z "$SOURCE_APP" || ! -d "$SOURCE_APP" || "$SOURCE_APP" != *".app" ]]; then + echo "Usage: $0 '/path/to/Buzz for Devin.app'" >&2 + exit 1 +fi + +INSTALL_ROOT=${BUZZ_FOR_DEVIN_INSTALL_ROOT:-"$HOME/Applications"} +DESTINATION="$INSTALL_ROOT/Buzz for Devin.app" +TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ) +BACKUP="$INSTALL_ROOT/Buzz for Devin.app.backup-$TIMESTAMP" + +validate_app() { + "$SCRIPT_DIR/verify-buzz-for-devin-macos-app.sh" "$1" >/dev/null +} + +app_is_running() { + local executable=$1 + local command + local process_list + if ! process_list=$(ps -axo command=); then + echo "Error: could not inspect running processes; installation was not changed." >&2 + exit 1 + fi + while IFS= read -r command; do + if [[ "$command" == "$executable" || "$command" == "$executable "* ]]; then + return 0 + fi + done <<<"$process_list" + return 1 +} + +if ! validate_app "$SOURCE_APP"; then + echo "Error: source is not a complete Buzz for Devin application bundle." >&2 + exit 1 +fi + +mkdir -p "$INSTALL_ROOT" +if app_is_running "$DESTINATION/Contents/MacOS/buzz-desktop"; then + echo "Error: quit the installed Buzz for Devin app before upgrading it." >&2 + exit 1 +fi +if [[ -e "$BACKUP" ]]; then + echo "Error: backup path already exists: $BACKUP" >&2 + exit 1 +fi + +STAGING_ROOT=$(mktemp -d "$INSTALL_ROOT/.buzz-for-devin-install.XXXXXX") +STAGED_APP="$STAGING_ROOT/Buzz for Devin.app" +cleanup_staging() { + if [[ -n "${STAGING_ROOT:-}" && -d "$STAGING_ROOT" ]]; then + rm -rf -- "$STAGING_ROOT" + fi +} +trap cleanup_staging EXIT + +ditto "$SOURCE_APP" "$STAGED_APP" +if ! validate_app "$STAGED_APP"; then + echo "Error: staged application failed validation; the installed app was not changed." >&2 + exit 1 +fi + +if [[ -e "$DESTINATION" ]]; then + mv "$DESTINATION" "$BACKUP" + echo "Previous installation moved to: $BACKUP" +fi + +if ! mv "$STAGED_APP" "$DESTINATION"; then + if [[ -e "$BACKUP" && ! -e "$DESTINATION" ]]; then + mv "$BACKUP" "$DESTINATION" + fi + echo "Error: installation failed; the previous app was restored when possible." >&2 + exit 1 +fi + +echo "Installed: $DESTINATION" +echo "Application data and Keychain entries were not modified." diff --git a/scripts/rollback-buzz-for-devin-macos.sh b/scripts/rollback-buzz-for-devin-macos.sh new file mode 100755 index 00000000000..fb9c08e83d5 --- /dev/null +++ b/scripts/rollback-buzz-for-devin-macos.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: the Buzz for Devin rollback tool supports macOS only." >&2 + exit 1 +fi + +BACKUP_APP=${1:-} +if [[ -z "$BACKUP_APP" || ! -d "$BACKUP_APP" || "$BACKUP_APP" != *".app.backup-"* ]]; then + echo "Usage: $0 '/path/to/Buzz for Devin.app.backup-TIMESTAMP'" >&2 + exit 1 +fi +if [[ -L "$BACKUP_APP" ]]; then + echo "Error: rollback backup must be an application bundle, not a symbolic link." >&2 + exit 1 +fi + +INSTALL_ROOT=${BUZZ_FOR_DEVIN_INSTALL_ROOT:-"$HOME/Applications"} +DESTINATION="$INSTALL_ROOT/Buzz for Devin.app" +mkdir -p "$INSTALL_ROOT" +BACKUP_PARENT=$(cd "$(dirname "$BACKUP_APP")" && pwd) +EXPECTED_PARENT=$(cd "$INSTALL_ROOT" && pwd) +if [[ "$BACKUP_PARENT" != "$EXPECTED_PARENT" ]]; then + echo "Error: backup must be inside $EXPECTED_PARENT." >&2 + exit 1 +fi + +if ! "$SCRIPT_DIR/verify-buzz-for-devin-macos-app.sh" \ + --allow-backup-name "$BACKUP_APP" >/dev/null; then + echo "Error: rollback backup is not a complete Buzz for Devin application bundle." >&2 + exit 1 +fi + +app_is_running() { + local executable=$1 + local command + local process_list + if ! process_list=$(ps -axo command=); then + echo "Error: could not inspect running processes; rollback was not started." >&2 + exit 1 + fi + while IFS= read -r command; do + if [[ "$command" == "$executable" || "$command" == "$executable "* ]]; then + return 0 + fi + done <<<"$process_list" + return 1 +} + +if app_is_running "$DESTINATION/Contents/MacOS/buzz-desktop"; then + echo "Error: quit the installed Buzz for Devin app before rolling it back." >&2 + exit 1 +fi + +TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ) +DISPLACED="$INSTALL_ROOT/Buzz for Devin.app.replaced-$TIMESTAMP" +if [[ -e "$DISPLACED" ]]; then + echo "Error: rollback displacement path already exists: $DISPLACED" >&2 + exit 1 +fi +if [[ -e "$DESTINATION" ]]; then + mv "$DESTINATION" "$DISPLACED" + echo "Current installation moved to: $DISPLACED" +fi + +if ! mv "$BACKUP_APP" "$DESTINATION"; then + if [[ -e "$DISPLACED" && ! -e "$DESTINATION" ]]; then + mv "$DISPLACED" "$DESTINATION" + fi + echo "Error: rollback failed; the current app was restored when possible." >&2 + exit 1 +fi +echo "Restored: $DESTINATION" +echo "Application data and Keychain entries were not modified." diff --git a/scripts/run-buzz-for-devin-source.sh b/scripts/run-buzz-for-devin-source.sh new file mode 100755 index 00000000000..58311c63a08 --- /dev/null +++ b/scripts/run-buzz-for-devin-source.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) + +usage() { + cat <<'EOF' +Usage: ./scripts/run-buzz-for-devin-source.sh [--prepare-only] + +Build the Buzz agent sidecars from this checkout and run the desktop app from +source. The app connects to a community selected during onboarding; it does not +start a relay or Docker services. + +Options: + --prepare-only Build sidecars and generate the isolated Tauri config, then exit. + -h, --help Show this help. +EOF +} + +MODE=run +case "${1:-}" in + "") + ;; + --prepare-only) + MODE=prepare + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "Error: unknown argument '$1'." >&2 + usage >&2 + exit 2 + ;; +esac + +HOST_OS=$(uname -s) +case "$HOST_OS" in + Darwin | Linux) + # Hermit supplies the repository-pinned Rust, Node, and pnpm toolchains. + # shellcheck disable=SC1091 + . "$REPO_ROOT/bin/activate-hermit" + ;; + MINGW64_NT* | MINGW32_NT* | MSYS_NT* | CYGWIN*) + # Hermit's bootstrapper is macOS/Linux-only. Windows builders bring the + # pinned-compatible tools documented in docs/buzz-for-devin-builders.md. + ;; + *) + echo "Error: unsupported source-preview host: $HOST_OS." >&2 + exit 1 + ;; +esac + +for required in cargo node pnpm rustc; do + if ! command -v "$required" >/dev/null 2>&1; then + echo "Error: required build tool '$required' is not on PATH." >&2 + echo "See docs/buzz-for-devin-builders.md for host prerequisites." >&2 + exit 1 + fi +done + +TARGET=$(rustc -vV | sed -n 's|host: ||p') +case "$TARGET" in + aarch64-apple-darwin | x86_64-apple-darwin | \ + x86_64-unknown-linux-gnu | aarch64-unknown-linux-gnu | \ + x86_64-pc-windows-msvc) + ;; + *) + echo "Error: unsupported Rust host target '$TARGET'." >&2 + exit 1 + ;; +esac + +export BUZZ_BUILD_KEYRING_SERVICE="buzz-for-devin-desktop" +export BUZZ_BUILD_DEEP_LINK_SCHEME="buzz-for-devin" +export BUZZ_BUILD_NEST_DIR=".buzz-for-devin" +export BUZZ_BUILD_CLI_LINK_NAME="buzz-for-devin" +export VITE_BUZZ_APP_NAME="Buzz for Devin" +export VITE_BUZZ_DEEP_LINK_SCHEME="buzz-for-devin" +export VITE_BUZZ_RELEASES_URL="https://github.com/fenner888/BuzzforDevin/releases" +export VITE_BUZZ_RELEASES_API_URL="https://api.github.com/repos/fenner888/BuzzforDevin/releases?per_page=10" +export CMAKE_POLICY_VERSION_MINIMUM="3.5" +unset BUZZ_UPDATER_PUBLIC_KEY BUZZ_UPDATER_ENDPOINT +unset TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD + +if command -v devin >/dev/null 2>&1; then + devin --version + echo "Buzz will check Devin authentication readiness during onboarding." +else + echo "Devin CLI not found; Buzz will show installation guidance from https://docs.devin.ai/cli." +fi + +cd "$REPO_ROOT" +pnpm install --frozen-lockfile +cargo build --release --target "$TARGET" \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr \ + -p buzz-cli +./scripts/bundle-sidecars.sh "$TARGET" + +EXE="" +if [[ "$TARGET" == *windows* ]]; then + EXE=".exe" +fi +for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + test -f "desktop/src-tauri/binaries/${bin}-${TARGET}${EXE}" +done + +cd "$REPO_ROOT/desktop" +node scripts/build-buzz-for-devin-config.mjs + +if [[ "$MODE" == "prepare" ]]; then + echo "Buzz for Devin source preview prepared for $TARGET." + exit 0 +fi + +# A source preview uses Buzz's debug-only keyring and Nest namespaces. Never +# inherit a private Buzz identity from the invoking shell. +unset BUZZ_PRIVATE_KEY BUZZ_SHARE_IDENTITY + +echo "Starting Buzz for Devin source preview for $TARGET." +echo "Select or join a community during onboarding." +pnpm exec tauri dev --config src-tauri/tauri.buzz-for-devin.conf.json diff --git a/scripts/test-buzz-for-devin-macos-lifecycle.sh b/scripts/test-buzz-for-devin-macos-lifecycle.sh new file mode 100755 index 00000000000..ac6b44c921b --- /dev/null +++ b/scripts/test-buzz-for-devin-macos-lifecycle.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) +SOURCE_APP=${1:-"$REPO_ROOT/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Buzz for Devin.app"} + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: the Buzz for Devin lifecycle test supports macOS only." >&2 + exit 1 +fi +if [[ ! -d "$SOURCE_APP" ]]; then + echo "Usage: $0 '/path/to/Buzz for Devin.app'" >&2 + exit 1 +fi + +TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/buzz-for-devin-lifecycle.XXXXXX") +INSTALL_ROOT="$TEST_ROOT/Applications" +TRASH_ROOT="$TEST_ROOT/Trash" +FIXTURES_ROOT="$TEST_ROOT/fixtures" +RUNNING_SENTINEL_PID="" + +cleanup() { + if [[ -n "$RUNNING_SENTINEL_PID" ]]; then + kill "$RUNNING_SENTINEL_PID" >/dev/null 2>&1 || true + wait "$RUNNING_SENTINEL_PID" 2>/dev/null || true + fi + if [[ -d "$TEST_ROOT" && "$TEST_ROOT" == "${TMPDIR:-/tmp}/buzz-for-devin-lifecycle."* ]]; then + rm -rf -- "$TEST_ROOT" + fi +} +trap cleanup EXIT + +mkdir -p "$INSTALL_ROOT" "$TRASH_ROOT" "$FIXTURES_ROOT" +V1_APP="$FIXTURES_ROOT/Buzz for Devin v1.app" +V2_APP="$FIXTURES_ROOT/Buzz for Devin v2.app" +INVALID_APP="$FIXTURES_ROOT/Buzz for Devin invalid.app" + +ditto "$SOURCE_APP" "$V1_APP" +ditto "$SOURCE_APP" "$V2_APP" +ditto "$SOURCE_APP" "$INVALID_APP" +touch "$V1_APP/Contents/lifecycle-v1" +touch "$V2_APP/Contents/lifecycle-v2" +chmod -x "$INVALID_APP/Contents/MacOS/buzz-acp" + +run_installer() { + BUZZ_FOR_DEVIN_INSTALL_ROOT="$INSTALL_ROOT" \ + "$REPO_ROOT/scripts/install-buzz-for-devin-macos.sh" "$1" +} + +run_rollback() { + BUZZ_FOR_DEVIN_INSTALL_ROOT="$INSTALL_ROOT" \ + "$REPO_ROOT/scripts/rollback-buzz-for-devin-macos.sh" "$1" +} + +run_uninstaller() { + BUZZ_FOR_DEVIN_INSTALL_ROOT="$INSTALL_ROOT" \ + BUZZ_FOR_DEVIN_TRASH_ROOT="$TRASH_ROOT" \ + "$REPO_ROOT/scripts/uninstall-buzz-for-devin-macos.sh" +} + +# A malformed source must be rejected before an installed app can be changed. +if run_installer "$INVALID_APP" >/dev/null 2>&1; then + echo "Error: installer accepted a bundle with a non-executable sidecar." >&2 + exit 1 +fi +[[ ! -e "$INSTALL_ROOT/Buzz for Devin.app" ]] + +run_installer "$V1_APP" +[[ -f "$INSTALL_ROOT/Buzz for Devin.app/Contents/lifecycle-v1" ]] + +# Upgrades must fail closed while the installed app's executable is represented +# by a live process. The sentinel's argv[0] is the exact installed executable +# path, but it runs only `sleep`; the application bundle is never launched. +/bin/bash -c 'exec -a "$1" sleep 30' \ + _ "$INSTALL_ROOT/Buzz for Devin.app/Contents/MacOS/buzz-desktop" & +RUNNING_SENTINEL_PID=$! +sleep 0.1 +if run_installer "$V2_APP" >/dev/null 2>&1; then + echo "Error: installer upgraded while the installed app was running." >&2 + exit 1 +fi +[[ -f "$INSTALL_ROOT/Buzz for Devin.app/Contents/lifecycle-v1" ]] +kill "$RUNNING_SENTINEL_PID" >/dev/null 2>&1 || true +wait "$RUNNING_SENTINEL_PID" 2>/dev/null || true +RUNNING_SENTINEL_PID="" + +run_installer "$V2_APP" +[[ -f "$INSTALL_ROOT/Buzz for Devin.app/Contents/lifecycle-v2" ]] +BACKUPS=("$INSTALL_ROOT"/Buzz\ for\ Devin.app.backup-*) +[[ ${#BACKUPS[@]} -eq 1 && -d "${BACKUPS[0]}" ]] +[[ -f "${BACKUPS[0]}/Contents/lifecycle-v1" ]] + +# A malformed rollback candidate must be rejected before the current +# installation is displaced. +INVALID_BACKUP="$INSTALL_ROOT/Buzz for Devin.app.backup-invalid" +ditto "$INVALID_APP" "$INVALID_BACKUP" +if run_rollback "$INVALID_BACKUP" >/dev/null 2>&1; then + echo "Error: rollback accepted a backup with a non-executable sidecar." >&2 + exit 1 +fi +[[ -f "$INSTALL_ROOT/Buzz for Devin.app/Contents/lifecycle-v2" ]] +rm -rf -- "$INVALID_BACKUP" + +run_rollback "${BACKUPS[0]}" +[[ -f "$INSTALL_ROOT/Buzz for Devin.app/Contents/lifecycle-v1" ]] +REPLACED=("$INSTALL_ROOT"/Buzz\ for\ Devin.app.replaced-*) +[[ ${#REPLACED[@]} -eq 1 && -d "${REPLACED[0]}" ]] +[[ -f "${REPLACED[0]}/Contents/lifecycle-v2" ]] + +run_uninstaller +[[ ! -e "$INSTALL_ROOT/Buzz for Devin.app" ]] +TRASHED=("$TRASH_ROOT"/Buzz\ for\ Devin-*.app) +[[ ${#TRASHED[@]} -eq 1 && -d "${TRASHED[0]}" ]] +[[ -f "${TRASHED[0]}/Contents/lifecycle-v1" ]] + +echo "Buzz for Devin macOS lifecycle test passed." +echo "Temporary Applications, Trash, fixtures, app data, and Keychain were not retained." diff --git a/scripts/uninstall-buzz-for-devin-macos.sh b/scripts/uninstall-buzz-for-devin-macos.sh new file mode 100755 index 00000000000..0b6f186fc35 --- /dev/null +++ b/scripts/uninstall-buzz-for-devin-macos.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: the Buzz for Devin uninstaller supports macOS only." >&2 + exit 1 +fi + +INSTALL_ROOT=${BUZZ_FOR_DEVIN_INSTALL_ROOT:-"$HOME/Applications"} +TRASH_ROOT=${BUZZ_FOR_DEVIN_TRASH_ROOT:-"$HOME/.Trash"} +SOURCE="$INSTALL_ROOT/Buzz for Devin.app" + +if [[ ! -e "$SOURCE" ]]; then + echo "Buzz for Devin is not installed at: $SOURCE" + exit 0 +fi + +app_is_running() { + local executable=$1 + local command + local process_list + if ! process_list=$(ps -axo command=); then + echo "Error: could not inspect running processes; uninstall was not started." >&2 + exit 1 + fi + while IFS= read -r command; do + if [[ "$command" == "$executable" || "$command" == "$executable "* ]]; then + return 0 + fi + done <<<"$process_list" + return 1 +} + +if app_is_running "$SOURCE/Contents/MacOS/buzz-desktop"; then + echo "Error: quit Buzz for Devin before uninstalling it." >&2 + exit 1 +fi + +mkdir -p "$TRASH_ROOT" +TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ) +DESTINATION="$TRASH_ROOT/Buzz for Devin-$TIMESTAMP.app" +if [[ -e "$DESTINATION" ]]; then + echo "Error: Trash destination already exists: $DESTINATION" >&2 + exit 1 +fi +mv "$SOURCE" "$DESTINATION" + +echo "Moved the app to: $DESTINATION" +echo "Application data and Keychain entries were preserved for recovery." diff --git a/scripts/verify-buzz-for-devin-macos-app.sh b/scripts/verify-buzz-for-devin-macos-app.sh new file mode 100755 index 00000000000..14b14087c5a --- /dev/null +++ b/scripts/verify-buzz-for-devin-macos-app.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +ALLOW_BACKUP_NAME=false +if [[ "${1:-}" == "--allow-backup-name" ]]; then + ALLOW_BACKUP_NAME=true + shift +fi +APP_PATH=${1:-} +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: the Buzz for Devin application verifier supports macOS only." >&2 + exit 1 +fi +if [[ -z "$APP_PATH" || ! -d "$APP_PATH" ]]; then + echo "Usage: $0 [--allow-backup-name] '/path/to/Buzz for Devin.app'" >&2 + exit 1 +fi +if [[ "$ALLOW_BACKUP_NAME" == true ]]; then + if [[ "$APP_PATH" != *".app" && "$APP_PATH" != *".app.backup-"* ]]; then + echo "Error: application path must end in .app or .app.backup-TIMESTAMP." >&2 + exit 1 + fi +elif [[ "$APP_PATH" != *".app" ]]; then + echo "Error: application path must end in .app." >&2 + exit 1 +fi + +PLIST="$APP_PATH/Contents/Info.plist" +if [[ ! -f "$PLIST" ]] || ! plutil -lint "$PLIST" >/dev/null; then + echo "Error: application has no valid Info.plist." >&2 + exit 1 +fi + +read_plist() { + /usr/libexec/PlistBuddy -c "Print :$1" "$PLIST" 2>/dev/null +} + +require_plist_value() { + local key=$1 + local expected=$2 + local actual + actual=$(read_plist "$key") || { + echo "Error: application plist is missing $key." >&2 + exit 1 + } + if [[ "$actual" != "$expected" ]]; then + echo "Error: application plist $key is '$actual'; expected '$expected'." >&2 + exit 1 + fi +} + +require_plist_value "CFBundleDisplayName" "Buzz for Devin" +require_plist_value "CFBundleName" "Buzz for Devin" +require_plist_value "CFBundleIdentifier" "community.buzzfordevin.desktop" +require_plist_value "CFBundleExecutable" "buzz-desktop" +require_plist_value "CFBundlePackageType" "APPL" +require_plist_value "CFBundleURLTypes:0:CFBundleURLSchemes:0" "buzz-for-devin" +require_plist_value "LSMinimumSystemVersion" "11.0" + +SHORT_VERSION=$(read_plist "CFBundleShortVersionString") || { + echo "Error: application plist is missing CFBundleShortVersionString." >&2 + exit 1 +} +if ! [[ "$SHORT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Error: application version '$SHORT_VERSION' is not semver." >&2 + exit 1 +fi +require_plist_value "CFBundleVersion" "$SHORT_VERSION" + +for binary in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz buzz-desktop; do + binary_path="$APP_PATH/Contents/MacOS/$binary" + if [[ ! -f "$binary_path" || ! -x "$binary_path" ]]; then + echo "Error: application is missing executable Contents/MacOS/$binary." >&2 + exit 1 + fi + if [[ "$(lipo -archs "$binary_path")" != "arm64" ]]; then + echo "Error: Contents/MacOS/$binary is not an Apple Silicon-only executable." >&2 + exit 1 + fi +done + +DESKTOP_BINARY="$APP_PATH/Contents/MacOS/buzz-desktop" +MINIMUM_OS=$( + otool -l "$DESKTOP_BINARY" | + awk '/cmd LC_BUILD_VERSION/{found=1; next} found && /minos/{print $2; exit}' +) +if [[ "$MINIMUM_OS" != "11.0" ]]; then + echo "Error: desktop binary minimum macOS is '$MINIMUM_OS'; expected '11.0'." >&2 + exit 1 +fi + +for marker in ".buzz-for-devin" "buzz-for-devin-desktop"; do + if ! strings "$DESKTOP_BINARY" | grep -F "$marker" >/dev/null; then + echo "Error: desktop binary is missing isolated build marker '$marker'." >&2 + exit 1 + fi +done + +echo "Verified Buzz for Devin macOS application: $APP_PATH" diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx index 6de8d038759..e6a52bae44c 100644 --- a/web/src/features/invite/ui/InvitePage.tsx +++ b/web/src/features/invite/ui/InvitePage.tsx @@ -1,5 +1,6 @@ import buzzAppIcon from "@/assets/app-icon@3x.png"; import { claimInviteInBrowser } from "@/features/invite/invite-api"; +import { BUZZ_APP_NAME, buzzAppDeepLink } from "@/shared/lib/app-identity"; import { BUZZ_RELEASES_URL, type BuzzDownloadPlatform, @@ -96,7 +97,7 @@ export function InvitePage({ code }: { code: string }) { const receipt = await acceptPolicy(); const query = new URLSearchParams({ relay, code }); if (receipt) query.set("policy_receipt", receipt); - window.location.href = `buzz://join?${query.toString()}`; + window.location.href = buzzAppDeepLink(`join?${query.toString()}`); } finally { setOpening(false); } @@ -183,7 +184,11 @@ export function InvitePage({ code }: { code: string }) { className="h-12 w-12 overflow-hidden bg-black" style={{ borderRadius: "22.37%" }} > - Buzz + {BUZZ_APP_NAME}

You're invited to @@ -231,9 +236,11 @@ export function InvitePage({ code }: { code: string }) { }`} > - Accept invite in Buzz + Accept invite in {BUZZ_APP_NAME} ) : ( @@ -246,7 +253,7 @@ export function InvitePage({ code }: { code: string }) { disabled={disabled} onClick={openInvite} > - Accept invite in Buzz + Accept invite in {BUZZ_APP_NAME} )} {browserJoinError ? ( diff --git a/web/src/features/repos/ui/ConnectButton.tsx b/web/src/features/repos/ui/ConnectButton.tsx index 312955b9c02..442c9c43944 100644 --- a/web/src/features/repos/ui/ConnectButton.tsx +++ b/web/src/features/repos/ui/ConnectButton.tsx @@ -1,10 +1,13 @@ import { ExternalLink } from "lucide-react"; +import { BUZZ_APP_NAME, buzzAppDeepLink } from "@/shared/lib/app-identity"; import { relayWsUrl } from "@/shared/lib/relay-url"; import { Button } from "@/shared/ui/button"; export function ConnectButton({ className }: { className?: string }) { - const deepLink = `buzz://connect?relay=${encodeURIComponent(relayWsUrl())}`; + const deepLink = buzzAppDeepLink( + `connect?relay=${encodeURIComponent(relayWsUrl())}`, + ); return ( ); diff --git a/web/src/shared/lib/app-identity.ts b/web/src/shared/lib/app-identity.ts new file mode 100644 index 00000000000..2f6aa8bb89c --- /dev/null +++ b/web/src/shared/lib/app-identity.ts @@ -0,0 +1,55 @@ +const DEFAULT_APP_NAME = "Buzz"; +const DEFAULT_DEEP_LINK_SCHEME = "buzz"; +const DEFAULT_RELEASES_URL = "https://github.com/block/buzz/releases"; +const DEFAULT_RELEASES_API_URL = + "https://api.github.com/repos/block/buzz/releases?per_page=10"; +const DEEP_LINK_SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*$/; + +function configuredString(value: string | undefined, fallback: string): string { + const configured = value?.trim(); + return configured || fallback; +} + +function configuredDeepLinkScheme(value: string | undefined): string { + const configured = value?.trim().toLowerCase(); + return configured && DEEP_LINK_SCHEME_PATTERN.test(configured) + ? configured + : DEFAULT_DEEP_LINK_SCHEME; +} + +function configuredHttpsUrl( + value: string | undefined, + fallback: string, +): string { + const configured = value?.trim(); + if (!configured) return fallback; + try { + const parsed = new URL(configured); + return parsed.protocol === "https:" ? parsed.toString() : fallback; + } catch { + return fallback; + } +} + +export const BUZZ_APP_NAME = configuredString( + import.meta.env.VITE_BUZZ_APP_NAME, + DEFAULT_APP_NAME, +); + +export const BUZZ_DEEP_LINK_SCHEME = configuredDeepLinkScheme( + import.meta.env.VITE_BUZZ_DEEP_LINK_SCHEME, +); + +export const BUZZ_RELEASES_URL = configuredHttpsUrl( + import.meta.env.VITE_BUZZ_RELEASES_URL, + DEFAULT_RELEASES_URL, +); + +export const BUZZ_RELEASES_API_URL = configuredHttpsUrl( + import.meta.env.VITE_BUZZ_RELEASES_API_URL, + DEFAULT_RELEASES_API_URL, +); + +export function buzzAppDeepLink(destination: string): string { + return `${BUZZ_DEEP_LINK_SCHEME}://${destination}`; +} diff --git a/web/src/shared/lib/buzz-download.ts b/web/src/shared/lib/buzz-download.ts index 3c198382b44..54ffbbb2611 100644 --- a/web/src/shared/lib/buzz-download.ts +++ b/web/src/shared/lib/buzz-download.ts @@ -1,6 +1,9 @@ -export const BUZZ_RELEASES_URL = "https://github.com/block/buzz/releases"; -const BUZZ_RELEASES_API_URL = - "https://api.github.com/repos/block/buzz/releases?per_page=10"; +import { + BUZZ_RELEASES_API_URL, + BUZZ_RELEASES_URL, +} from "@/shared/lib/app-identity"; + +export { BUZZ_RELEASES_URL }; const CACHE_KEY = "buzz.latestDownload.v1"; const CACHE_TTL_MS = 60 * 60 * 1000;