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 ? (
+ void decide(allowOnce.optionId)}
+ size="xs"
+ type="button"
+ >
+ {submitting === allowOnce.optionId ? "Allowing…" : allowOnce.name}
+
+ ) : null}
+ {rejectOnce ? (
+ void decide(rejectOnce.optionId)}
+ size="xs"
+ type="button"
+ variant="outline"
+ >
+ {submitting === rejectOnce.optionId ? "Denying…" : rejectOnce.name}
+
+ ) : 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%" }}
>
-
+
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 (
- Open in Buzz
+ Open in {BUZZ_APP_NAME}
);
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;