diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d01d335f3..3640a2023 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: entitlements-plist-path: src-tauri/entitlements.plist artifact-name: buzz-app-${{ github.run_id }}-${{ github.run_attempt }}-arm64 - - name: Verify release DMG and bundled runtime + - name: Verify release DMG env: SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }} run: | @@ -116,23 +116,6 @@ jobs: xcrun stapler validate "$app" codesign --display --entitlements :- "$app" > "$RUNNER_TEMP/signed-entitlements.plist" test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$RUNNER_TEMP/signed-entitlements.plist")" = true || { echo '::error::Signed app is missing microphone entitlement'; exit 1; } - node --input-type=module - "$app/Contents/Resources/agent-runtime" <<'JS' - import { readFileSync, lstatSync } from 'node:fs'; - import assert from 'node:assert/strict'; - const directory = process.argv[2]; - const source = JSON.parse(readFileSync('runtime/agent-runtime.json', 'utf8')); - const manifest = JSON.parse(readFileSync(`${directory}/manifest.json`, 'utf8')); - assert.equal(manifest.version, 2); - assert.deepEqual(manifest.goose, source.goose); - assert.equal(manifest.revision, source.revision); - assert.equal(manifest.target, 'aarch64-apple-darwin'); - assert.deepEqual(Object.keys(manifest.files).sort(), [...source.tools].sort()); - for (const name of source.tools) { - const path = `${directory}/${name}`; - assert.ok(lstatSync(path).isFile(), `${name} must be a regular file`); - assert.ok(lstatSync(path).mode & 0o111, `${name} must be executable`); - } - JS - name: Stage release assets and checksums env: @@ -321,20 +304,9 @@ jobs: - run: pnpm install --frozen-lockfile - name: Set candidate version run: node scripts/candidate-version.mjs - # Vendored OpenSSL (Goose runtime) runs Configure with `perl`. In this - # job's bash shell that is Git's MSYS perl, which lacks modules the - # Configure script needs. Use the image's Strawberry Perl instead. - - name: Select Strawberry Perl for vendored OpenSSL - shell: pwsh - run: | - $perl = 'C:\Strawberry\perl\bin\perl.exe' - if (!(Test-Path $perl)) { throw "Strawberry Perl not found at $perl" } - & $perl -MLocale::Maketext::Simple -MIPC::Cmd -e 1 - if ($LASTEXITCODE -ne 0) { throw 'Strawberry Perl lacks OpenSSL Configure modules' } - "OPENSSL_SRC_PERL=$perl" >> $env:GITHUB_ENV - name: Build NSIS installer run: pnpm tauri build --ci --no-sign --bundles nsis --config "$RUNNER_TEMP/candidate.json" -- --locked - - name: Install into disposable runner and verify resource payload + - name: Install into disposable runner and verify app executable shell: pwsh run: | $installers = @(Get-ChildItem target/release/bundle/nsis/*.exe) @@ -344,8 +316,6 @@ jobs: $process = Start-Process $installers[0].FullName -ArgumentList "/S /D=$destination" -Wait -PassThru if ($process.ExitCode -ne 0) { throw "Installer failed: $($process.ExitCode)" } if (!(Test-Path "$destination\buzz-foundation.exe")) { throw 'App executable missing' } - node scripts/verify-runtime-bundle.mjs "$destination\agent-runtime" x86_64-pc-windows-msvc - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Stage candidate and checksum run: | mkdir release-assets @@ -403,7 +373,7 @@ jobs: run: node scripts/candidate-version.mjs - name: Build deb and AppImage run: pnpm tauri build --ci --no-sign --bundles deb,appimage --config "$RUNNER_TEMP/candidate.json" -- --locked - - name: Repair AppImage and verify both packaged resource payloads + - name: Repair AppImage and verify packaged app executables run: | shopt -s nullglob debs=(target/release/bundle/deb/*.deb) @@ -411,11 +381,12 @@ jobs: test "${#debs[@]}" -eq 1 && test "${#images[@]}" -eq 1 bash scripts/fix-appimage.sh "${images[0]}" dpkg-deb -x "${debs[0]}" "$RUNNER_TEMP/deb" - node scripts/verify-runtime-bundle.mjs "$RUNNER_TEMP/deb/usr/lib/Buzz Foundation/agent-runtime" x86_64-unknown-linux-gnu + test -x "$RUNNER_TEMP/deb/usr/bin/buzz-foundation" image=$(realpath "${images[0]}") mkdir "$RUNNER_TEMP/appimage" (cd "$RUNNER_TEMP/appimage" && "$image" --appimage-extract >/dev/null) - node scripts/verify-runtime-bundle.mjs "$RUNNER_TEMP/appimage/squashfs-root/usr/lib/Buzz Foundation/agent-runtime" x86_64-unknown-linux-gnu + test -x "$RUNNER_TEMP/appimage/squashfs-root/usr/bin/buzz-foundation" + test -x "$RUNNER_TEMP/appimage/squashfs-root/usr/bin/buzz-foundation.bin" mkdir release-assets cp "${debs[0]}" "release-assets/Buzz_${VERSION}_amd64.deb" cp "${images[0]}" "release-assets/Buzz_${VERSION}_x86_64.AppImage" diff --git a/.gitignore b/.gitignore index 5767db2ba..8f21f7480 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,6 @@ coverage/ dist-author/ dist-plugins/ -# Generated immutable agent runtime resources. +# Leftover generated v1 runtime resources from older checkouts. src-tauri/resources/agent-runtime/* -!src-tauri/resources/agent-runtime/README.md dist-buzz-mcp/ diff --git a/Cargo.lock b/Cargo.lock index a3ac30037..e4bdc075a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "generic-array", ] @@ -125,15 +125,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - [[package]] name = "arrayvec" version = "0.7.8" @@ -329,73 +320,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "axum-macros", - "base64 0.22.1", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "base64" version = "0.21.7" @@ -531,15 +455,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - [[package]] name = "block-padding" version = "0.3.3" @@ -607,34 +522,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "buzz-agent" -version = "0.1.0" -source = "git+https://github.com/block/buzz.git?rev=4f51b9e1010e086a16c099cd8d8218ca974a5e18#4f51b9e1010e086a16c099cd8d8218ca974a5e18" -dependencies = [ - "arc-swap", - "async-trait", - "axum", - "base64 0.22.1", - "dirs", - "fs2", - "getrandom 0.4.3", - "hex", - "nix 0.31.3", - "reqwest", - "rmcp", - "serde", - "serde_json", - "serde_yaml", - "sha2 0.11.0", - "tokio", - "tracing", - "tracing-subscriber", - "url", - "urlencoding", - "webbrowser", -] - [[package]] name = "buzz-agent-controller" version = "0.1.0" @@ -642,7 +529,6 @@ dependencies = [ "base64 0.22.1", "bech32 0.11.1", "buzz-credential-store", - "dotenvy", "getrandom 0.3.4", "hmac", "libc", @@ -653,12 +539,9 @@ dependencies = [ "security-framework", "serde", "serde_json", - "sha2 0.10.9", - "strip-ansi-escapes", + "sha2", "tempfile", - "tungstenite", "url", - "windows-sys 0.61.2", "zeroize", ] @@ -670,7 +553,7 @@ dependencies = [ "keyring", "nix 0.31.3", "security-framework", - "sha2 0.10.9", + "sha2", "tempfile", "zeroize", ] @@ -683,7 +566,6 @@ dependencies = [ "base64 0.22.1", "bech32 0.11.1", "block2", - "buzz-agent", "buzz-agent-controller", "buzz-credential-store", "buzz-pairing", @@ -718,7 +600,7 @@ dependencies = [ "security-framework", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "tauri", "tauri-build", "tauri-plugin-deep-link", @@ -774,7 +656,7 @@ dependencies = [ "nostr 0.45.5", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "tempfile", "url", ] @@ -1022,7 +904,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "inout", "zeroize", ] @@ -1070,12 +952,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "const-random" version = "0.1.18" @@ -1215,15 +1091,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - [[package]] name = "cssparser" version = "0.36.0" @@ -1339,7 +1206,7 @@ dependencies = [ "num", "once_cell", "openssl", - "sha2 0.10.9", + "sha2", "zeroize", ] @@ -1430,22 +1297,11 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", + "block-buffer", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", -] - [[package]] name = "dirs" version = "6.0.0" @@ -1895,21 +1751,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.34" @@ -1917,7 +1758,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1985,7 +1825,6 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2444,7 +2283,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -2496,21 +2335,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "1.11.1" @@ -2524,7 +2348,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3209,21 +3032,6 @@ dependencies = [ "web_atoms", ] -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "maybe-owned" version = "0.3.4" @@ -3334,12 +3142,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -3450,15 +3252,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "num" version = "0.4.3" @@ -3973,7 +3766,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest 0.10.7", + "digest", "hmac", ] @@ -4262,20 +4055,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "process-wrap" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" -dependencies = [ - "futures", - "indexmap 2.14.2", - "nix 0.31.3", - "tokio", - "tracing", - "windows 0.62.2", -] - [[package]] name = "pxfm" version = "0.1.30" @@ -4576,7 +4355,6 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -4629,26 +4407,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rmcp" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" -dependencies = [ - "async-trait", - "chrono", - "futures", - "pin-project-lite", - "process-wrap", - "serde", - "serde_json", - "thiserror 2.0.20", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", -] - [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -4804,12 +4562,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "salsa20" version = "0.10.2" @@ -4903,7 +4655,7 @@ dependencies = [ "password-hash", "pbkdf2", "salsa20", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -4972,7 +4724,7 @@ dependencies = [ "once_cell", "rand 0.8.8", "serde", - "sha2 0.10.9", + "sha2", "zbus 4.4.0", ] @@ -5094,17 +4846,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_repr" version = "0.1.21" @@ -5134,18 +4875,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "serde_with" version = "3.22.0" @@ -5179,19 +4908,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.2", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serial2" version = "0.2.38" @@ -5242,7 +4958,7 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", + "digest", ] [[package]] @@ -5253,27 +4969,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", + "digest", ] [[package]] @@ -5454,15 +5150,6 @@ dependencies = [ "quote", ] -[[package]] -name = "strip-ansi-escapes" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" -dependencies = [ - "vte", -] - [[package]] name = "strsim" version = "0.11.1" @@ -5729,7 +5416,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "syn 2.0.119", "tauri-utils", "thiserror 2.0.20", @@ -6092,15 +5779,6 @@ dependencies = [ "syn 3.0.5", ] -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - [[package]] name = "tiff" version = "0.11.3" @@ -6216,17 +5894,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -6252,7 +5919,6 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "libc", "pin-project-lite", "tokio", ] @@ -6390,7 +6056,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -6429,7 +6094,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -6453,49 +6117,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", ] [[package]] @@ -6646,7 +6267,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "subtle", ] @@ -6656,12 +6277,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -6681,12 +6296,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "urlpattern" version = "0.3.0" @@ -6730,12 +6339,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "vcpkg" version = "0.2.15" @@ -6774,15 +6377,6 @@ dependencies = [ "libc", ] -[[package]] -name = "vte" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" -dependencies = [ - "memchr", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -6987,22 +6581,6 @@ dependencies = [ "string_cache_codegen", ] -[[package]] -name = "webbrowser" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" -dependencies = [ - "jni 0.22.4", - "log", - "ndk-context", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "url", - "web-sys", -] - [[package]] name = "webkit2gtk" version = "2.0.2" @@ -7791,7 +7369,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2 0.10.9", + "sha2", "soup3", "tao-macros", "thiserror 2.0.20", diff --git a/browser-host/channel-kit.test.mjs b/browser-host/channel-kit.test.mjs index 1a76b62b0..f61237cfe 100644 --- a/browser-host/channel-kit.test.mjs +++ b/browser-host/channel-kit.test.mjs @@ -18,7 +18,7 @@ import { validCanvas, } from "./channel-kit.mjs"; -import { encodeTeamPayload } from "../src/features/channel-templates/team-payload.ts"; +import { TEAM_CHUNK_BYTES } from "../src/features/channel-templates/team-payload.ts"; const community = "https://relay.example.test"; const owner = keypair(); const record = { @@ -182,12 +182,36 @@ it("encrypts manifest and payload records with exact owner/revision admission", }, ], }; - const { manifest, payloads } = await encodeTeamPayload( - snapshot, + // Chunked as old Buzz saved portable teams. + const bytes = new TextEncoder().encode(JSON.stringify(snapshot)); + const revision = crypto.randomUUID(); + const manifest = { + version: 1, + owner: owner.pubkey, + revision, + digest: Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""), + bytes: bytes.length, + chunks: Math.ceil(bytes.length / TEAM_CHUNK_BYTES), + }; + const payloads = Array.from({ length: manifest.chunks }, (_, index) => ({ + version: 1, community, - owner.pubkey, - "portable", - ); + owner: owner.pubkey, + teamId: "portable", + revision, + index, + data: btoa( + String.fromCharCode( + ...bytes.subarray( + index * TEAM_CHUNK_BYTES, + (index + 1) * TEAM_CHUNK_BYTES, + ), + ), + ), + })); const records = [ { version: 2, diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 2d97a1be3..762151fb3 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -1921,7 +1921,6 @@ export function relayBrokerPlugin({ "/api/relay/read-state-publish", "/api/relay/profile", "/api/relay/direct-message", - "/api/relay/authorize-agent", "/api/relay/agent-log-proof", "/api/relay/git-authorization", "/api/relay/resolve-agent-community", @@ -2196,26 +2195,6 @@ export function relayBrokerPlugin({ ); return json(res, 200, { identities }); } - if (route === "/api/relay/authorize-agent") { - if ( - !scoped || - filters?.owner !== viewer || - !/^[0-9a-f]{64}$/.test(filters?.pubkey ?? "") || - filters.pubkey === viewer || - Object.keys(filters).length !== 2 - ) - return json(res, 400, { - error: "Invalid agent owner authorization", - }); - cancel.signal.throwIfAborted(); - const digest = createHash("sha256") - .update(`nostr:agent-auth:${filters.pubkey}:`) - .digest(); - const signature = Buffer.from(schnorr.sign(digest, key)).toString( - "hex", - ); - return json(res, 200, { auth: ["auth", viewer, "", signature] }); - } if (presence && !presenceFilter(filters)) return json(res, 400, { error: "Invalid presence filter" }); let workflowPath; diff --git a/crates/agent-controller/Cargo.toml b/crates/agent-controller/Cargo.toml index c80325ec3..c2c0f63b6 100644 --- a/crates/agent-controller/Cargo.toml +++ b/crates/agent-controller/Cargo.toml @@ -4,12 +4,8 @@ version = "0.1.0" edition = "2021" rust-version = "1.89" -[build-dependencies] -dotenvy = "0.15" -url = "2" - [dev-dependencies] -dotenvy = "0.15" +tempfile = "3" [dependencies] serde = { version = "1", features = ["derive"] } @@ -19,9 +15,7 @@ sha2 = "0.10" hmac = "0.12" base64 = "0.22" nostr = { version = "=0.45.5", default-features = false, features = ["std", "nip44", "os-rng"] } -strip-ansi-escapes = "0.2" getrandom = "0.3" -tempfile = "3" url = "2" secp256k1 = { version = "0.31", default-features = false, features = ["std"] } bech32 = "0.11" @@ -34,11 +28,5 @@ libc = "0.2" security-framework = "3.7" nix = { version = "0.31", features = ["user"] } -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading"] } - [target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] buzz-credential-store = { path = "../credential-store" } - -[target.'cfg(target_os = "macos")'.dev-dependencies] -tungstenite = { version = "0.29", default-features = false, features = ["handshake"] } diff --git a/crates/agent-controller/README.md b/crates/agent-controller/README.md index 2e2118fba..16cd15f54 100644 --- a/crates/agent-controller/README.md +++ b/crates/agent-controller/README.md @@ -1,74 +1,28 @@ -# Local agent controller +# Native agent identities -This crate backs the [normal desktop agent workflow](../../docs/agent-control.md). -It owns native configuration/import and listener process lifetime without Tauri, -React, or the old desktop manager. The `Controller` must be called by one serialized -native host owner; dropping a page is not dropping this controller. Source and -fixture validation do not establish live readiness; the attended handover remains -required before replacing old Buzz. +The desktop host uses this crate for Agents2's `app_agent_*` commands: identity +custody, signing, credentials and bringing over identities from earlier stores. +Plugin runtimes own agent execution; this crate no longer contains a controller, +supervisor, harness launcher, teams, provider defaults or bundled runtime loader. +The crate name is retained to keep native consumers stable. -Implemented with isolated filesystem/credential/subprocess fixtures: +- `AppAgents` owns the saved identity list, with file-locked atomic updates, + verified owner attestations and create-only credential custody. +- `OldStores` reads v1 and Buzz 0.X identities without changing their records. + The selected source binds the exact library and credential service. Imports + verify the public key and preserve shared credentials on failed saves. +- `PlatformCredentials` stores keys in the OS credential store. Its macOS bundle + retains compatibility with individual entries; Windows/Linux use the sibling + credential-store adapter. No plaintext key-file fallback is supplied. +- `Secret` signs bounded events and owner-addressed encrypted memories. + `CreationProfile` retains the verified profile merge/publication helpers. +- `delete_local_agent_keys` reads the retired v1 registry for sign-out. It holds + the old controller lock, validates every identity before deleting any key, + refuses linked/unbounded files, and leaves records unchanged for retries. + It does not validate or execute retired harness settings. -- Bounded, versioned, profile-locked settings with atomic replacement, previous - revision backup, stale-save refusal, immutable exact agent/community IDs. -- Explicit chosen-source import preview; source drift and malformed config refusal; - the source enum binds the library directory and selected credential service - (`buzz-desktop` installed / `buzz-desktop-dev` development), with no fallback; - linked prompt/model/provider, runtime and environment resolution; native-only - opaque preservation; disabled import and create-only verified credential custody. -- Write-only environment patch semantics and explicit public DTO projection. - Explicit model/provider environment overrides win over typed selectors; a blank - selector does not erase an override. ACP receives the same effective model. -- Idempotent start, save-versus-running revision, explicit restart, durable disabled - Stop, enabled restore, exit/failure state, and teardown of separate process groups - inside the listener's isolated Unix session. -- Native attestation verification before spawn (unrestricted NIP-OA only); no parent - managed-agent identity/replay/marker/provider credential inheritance. -- Isolated macOS credential adapter: selected legacy service's `secrets` account, - strict bounded map parsing and exact agent-key validation; separate destination - service `dev.local.buzz.foundation.agents`, account `agent:`. - Destination adds are create-only; import reads back the resulting key. Missing, - denied and malformed credentials are distinct fixed errors, never source fallback. - Unit tests use an injected backend and compile out all live Keychain calls. - -## Known incomplete boundaries — do not claim live readiness - -- Normal native startup enables Start/Restart when the app's manifest-verified - runtime resources are staged. Credential import/create is macOS-only. Real - Keychain consent, signing/ACL behavior and packaged custody remain unverified; other credential platforms - report unavailable rather than storing agent keys in files. -- `scripts/build-agent-runtime.mjs` stages the immutable tools and hash manifest; - native startup and spawn validate them without an old-bundle/PATH fallback. - Synthetic bundled tests are separate opt-in checks, not evidence of a signed - package or live inference. -- Cooperating new-app profiles hold an exact-key/canonical-community OS lock in - addition to the profile lock. Native refuses detected legacy owners, but old - Buzz does not honor these locks and can be relaunched later. Before a live Start, - agree on handover and stop old Buzz AND its listeners; there is no coexistence - guarantee. -- The Unix-session cleanup handles ACP's ordinary new process groups. A descendant - that explicitly starts another session can escape this containment. Host crash - recovery and such detached tools require further ownership work. Windows is not - supported by this process adapter. The fixture uses macOS `/usr/bin/python3`. -- Status is process-alive, **not relay readiness, accepted mention, working or idle**. - Worker wake and idle sleep are configured on ACP; no live ACP test has run here. -- Owner-less legacy agents, conditional attestations, remote deployment, - relay mesh and unrecognized legacy catalog harnesses fail closed. Local team - imports snapshot deployment-team instructions; older imports require explicit - team repair through the chosen source library before Start. Custom harness - definitions can be imported but provider mappings outside buzz-agent/goose require - explicit environment configuration. Compiled-in legacy provider defaults cannot - be recovered from JSON. Existing instance runtime wins over linked runtime, - matching `readiness.rs`'s actual `try_record_agent_command` call (old `b9392d9`). -- Import currently snapshots definitions per exact keyed instance, not editable - shared definition relationships. Unknown fields are retained, not all executed. -- Environment overrides are stored in mode-0600 local config, not in the OS - credential store. Secret agent keys never intentionally enter that config. -- Default MCP/provider behavior, ACP argument normalization, execution policy and - workspace preservation need a real compatibility acceptance pass. -- Child stdout/stderr are discarded; user diagnostics are fixed host messages. - A safe live readiness/diagnostic adapter is still needed. - -Tests are engineering evidence only. Test-only keys are public secp256k1 vectors; -no live credential storage, current agent restart, old-library mutation, relay -publication or enrollment is performed. Do not run this against real data yet. +Tests use temporary directories and isolated credentials; they do not touch a +real Keychain or run agents. Profile tests verify signatures, merge preservation +and malformed-profile refusal. See [agents](../../docs/agents.md) for the app's +current agent behavior. Native import still requires an attended check of OS +credential consent and relay behavior; unit tests do not establish that result. diff --git a/crates/agent-controller/build.rs b/crates/agent-controller/build.rs deleted file mode 100644 index 24609d290..000000000 --- a/crates/agent-controller/build.rs +++ /dev/null @@ -1,38 +0,0 @@ -mod build_config; - -fn main() { - println!( - "cargo:rustc-env=BUZZ_RUNTIME_TARGET={}", - std::env::var("TARGET").unwrap() - ); - let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); - let local = root.join("../../.env.local"); - println!("cargo:rerun-if-changed={}", local.display()); - println!("cargo:rerun-if-changed=build_config.rs"); - println!("cargo:rerun-if-changed=../build_env.rs"); - for key in build_config::KEYS { - println!("cargo:rerun-if-env-changed={key}"); - } - let values = build_config::load(&local, |key| match std::env::var(key) { - Ok(value) => Some(value), - Err(std::env::VarError::NotPresent) => None, - Err(std::env::VarError::NotUnicode(_)) => panic!("Native build inputs must be UTF-8"), - }) - .expect("Invalid native build configuration"); - let raw = values - .get("BUZZ_BUILD_AGENT_ENV") - .map(String::as_str) - .unwrap_or(""); - let (host, filter, model) = build_config::parse(raw).expect("Invalid agent build defaults"); - let provider = values - .get("BUZZ_BUILD_BUZZ_AGENT_PROVIDER") - .map(String::as_str) - .unwrap_or(""); - build_config::selector(provider, 128).expect("Invalid agent provider default"); - let owner_only = values.contains_key("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"); - let output = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); - // Always overwrite: removing local/process inputs clears a warm build too. - std::fs::write(output.join("agent_defaults.rs"), format!( - "pub fn build_defaults() -> BuildDefaults {{ BuildDefaults {{ host: {host:?}.into(), filter: {filter:?}.into(), model: {model:?}.into(), provider: {provider:?}.into(), owner_only: {owner_only} }} }}" - )).expect("Could not write native build defaults"); -} diff --git a/crates/agent-controller/build_config.rs b/crates/agent-controller/build_config.rs deleted file mode 100644 index aca01d82c..000000000 --- a/crates/agent-controller/build_config.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::{collections::BTreeMap, path::Path}; - -pub const KEYS: [&str; 3] = [ - "BUZZ_BUILD_AGENT_ENV", - "BUZZ_BUILD_BUZZ_AGENT_PROVIDER", - "BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY", -]; - -// Both native builds select complete dotenv records before decoding them. -#[path = "../build_env.rs"] -mod build_env; - -pub fn load( - path: &Path, - process: impl Fn(&str) -> Option, -) -> Result, &'static str> { - build_env::load(path, &KEYS, process) -} - -pub fn selector(value: &str, limit: usize) -> Result<(), &'static str> { - if value.len() > limit || value.chars().any(char::is_control) { - Err("Invalid native build provider/model default") - } else { - Ok(()) - } -} - -// Only nonsecret connection defaults may enter an internal binary. Never forward -// the arbitrary agent environment wholesale or emit its raw contents in errors. -pub fn parse(raw: &str) -> Result<(String, String, String), &'static str> { - if raw.len() > 16 * 1024 { - return Err("Agent build configuration is too large"); - } - let mut host = None; - let mut filter = None; - let mut model = None; - for line in raw.lines().map(str::trim).filter(|s| !s.is_empty()) { - let (key, value) = line - .split_once('=') - .ok_or("Invalid agent build configuration")?; - let target = - match key { - "DATABRICKS_HOST" => &mut host, - "DATABRICKS_MODEL_FILTER" => &mut filter, - "DATABRICKS_MODEL" => &mut model, - _ => return Err( - "Unsupported agent build key; only nonsecret Databricks defaults are permitted", - ), - }; - if target.is_some() || value.len() > 4096 || value.chars().any(char::is_control) { - return Err("Invalid or duplicate agent build default"); - } - *target = Some(value.to_owned()); - } - let host = host.unwrap_or_default(); - if !host.is_empty() { - let rest = host - .strip_prefix("https://") - .ok_or("Invalid build workspace origin")?; - let url = url::Url::parse(&host).map_err(|_| "Invalid build workspace origin")?; - if rest.strip_suffix('/').unwrap_or(rest).contains(['/', '@']) - || host.contains('\\') - || url.host_str().is_none() - || url.query().is_some() - || url.fragment().is_some() - || url.path() != "/" - { - return Err("Invalid build workspace origin"); - } - } - let model = model.unwrap_or_default(); - selector(&model, 512)?; - Ok((host, filter.unwrap_or_default(), model)) -} diff --git a/crates/agent-controller/examples/team-preview.rs b/crates/agent-controller/examples/team-preview.rs deleted file mode 100644 index 5b5386a0a..000000000 --- a/crates/agent-controller/examples/team-preview.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Test adapter for production team validation and Serde serialization; no credentials or IPC. -use std::io::{self, Read}; - -fn preview() -> Result<(), Box> { - let mut bytes = Vec::new(); - io::stdin().read_to_end(&mut bytes)?; - let snapshot = buzz_agent_controller::TeamSnapshot::decode(&bytes)?; - serde_json::to_writer(io::stdout(), &snapshot)?; - Ok(()) -} - -fn main() { - if let Err(error) = preview() { - eprintln!("{error}"); - std::process::exit(1); - } -} diff --git a/crates/agent-controller/src/agent_defaults.rs b/crates/agent-controller/src/agent_defaults.rs deleted file mode 100644 index 31e967deb..000000000 --- a/crates/agent-controller/src/agent_defaults.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! Device-wide agent defaults, editable in Settings → Agents. Native-only file; -//! environment values are write-only and never cross IPC. -use crate::config::{Agent, HarnessEdit, SessionPolicy}; -use crate::Result; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; -use std::path::Path; - -/// Native-only key on an effective (never saved) clone: effort inherited from -/// defaults when the agent has no imported effort of its own. -const INHERITED_EFFORT: &str = "inheritedEffort"; - -#[derive(Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct AgentDefaults { - #[serde(default = "buzz_agent")] - pub harness: String, - #[serde(default)] - pub provider: String, - #[serde(default)] - pub model: String, - #[serde(default)] - pub effort: String, - #[serde(default)] - pub session_policy: SessionPolicy, - #[serde(default)] - pub environment: BTreeMap, -} -fn buzz_agent() -> String { - "buzz-agent".into() -} -impl Default for AgentDefaults { - fn default() -> Self { - Self { - harness: buzz_agent(), - provider: String::new(), - model: String::new(), - effort: String::new(), - session_policy: SessionPolicy::Thread, - environment: BTreeMap::new(), - } - } -} - -/// IPC projection: environment keys only. -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentDefaultsView { - pub harness: String, - pub provider: String, - pub model: String, - pub effort: String, - pub session_policy: SessionPolicy, - pub environment_keys: Vec, -} - -#[derive(Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AgentDefaultsEdit { - pub harness: String, - pub provider: String, - pub model: String, - pub effort: String, - /// Missing preserves the saved default; only an explicit choice replaces it. - #[serde(default)] - pub session_policy: Option, - /// Absence preserves; null deletes; a value replaces. Never a read API. - pub environment: BTreeMap>, -} - -impl AgentDefaults { - pub(crate) fn view(&self) -> AgentDefaultsView { - AgentDefaultsView { - harness: self.harness.clone(), - provider: self.provider.clone(), - model: self.model.clone(), - effort: self.effort.clone(), - session_policy: self.session_policy, - environment_keys: self.environment.keys().cloned().collect(), - } - } - pub(crate) fn apply(&mut self, edit: AgentDefaultsEdit) -> Result<()> { - // Submitted values are explicit. The card clears model and effort when - // the harness changes, so a value sent with a new harness was re-entered. - let mut environment = self.environment.clone(); - for (key, value) in edit.environment { - match value { - Some(value) => environment.insert(key, value), - None => environment.remove(&key), - }; - } - let next = Self { - harness: edit.harness, - provider: edit.provider, - model: edit.model, - effort: edit.effort, - session_policy: edit.session_policy.unwrap_or(self.session_policy), - environment, - }; - next.validate()?; - // Pi rejects a provider without a model only after Restart has stopped - // the old process, so refuse that pair before it is saved or applied. - if next.harness == "pi" { - crate::pi::validate_selection(&next.provider, &next.model)?; - } - *self = next; - Ok(()) - } - pub(crate) fn validate(&self) -> Result<()> { - if !matches!(self.harness.as_str(), "buzz-agent" | "goose" | "pi") { - return Err("Choose Buzz Agent, Goose or Pi as the default harness".into()); - } - for (value, limit, label) in [ - (&self.provider, 128, "Default provider"), - (&self.model, 512, "Default model"), - (&self.effort, 64, "Default effort"), - ] { - if value.len() > limit || value.chars().any(char::is_control) { - return Err(format!("{label} is too long or invalid")); - } - } - let command = if self.harness == "pi" { - "buzz-pi-acp" - } else { - &self.harness - }; - crate::config::validate_environment(&self.environment, command) - } -} - -/// Harness kind of a saved command; device defaults support a subset. -pub(crate) fn harness_kind(command: &str) -> Option<&'static str> { - match Path::new(command).file_name().and_then(|s| s.to_str()) { - Some("buzz-agent" | "buzz-agent.exe") => Some("buzz-agent"), - Some("goose" | "goose.exe" | "goose-acp" | "goose-acp.exe") => Some("goose"), - Some("buzz-pi-acp") => Some("pi"), - _ => crate::harness_preset(command).map(|preset| preset.id.as_str()), - } -} - -/// Temporary launch copy: blank provider/model/effort inherit defaults for the -/// same harness; ACP model overrides also stay within that harness. Other -/// environment merges per key with the agent's key winning. The -/// build floor (`BuildDefaults::resolve`) still applies afterwards. -pub(crate) fn effective_settings( - harness: &mut HarnessEdit, - environment: &mut BTreeMap, - defaults: &AgentDefaults, -) -> bool { - // Native Codex Default delegates to Codex itself. Neither device-wide - // selectors nor another harness's environment may enter that context. - if harness.integration == Some(crate::HarnessIntegration::Codex) { - return false; - } - let same_harness = harness_kind(&harness.command) == Some(defaults.harness.as_str()); - if same_harness { - if harness.provider.is_empty() { - harness.provider.clone_from(&defaults.provider); - } - if harness.model.is_empty() { - harness.model.clone_from(&defaults.model); - } - } - // A saved Databricks workspace/filter is an agent-owned override, even - // when the corresponding environment key was not set on that agent. - let own_databricks = - harness_kind(&harness.command) == Some("buzz-agent") && harness.databricks.is_some(); - for (key, value) in &defaults.environment { - // Pi and Goose share the listener key but use different model catalogs. - if !same_harness && key.eq_ignore_ascii_case("BUZZ_ACP_MODEL") { - continue; - } - // A Pi/Goose behavior override must not expand another harness's - // accepted configuration when device-wide defaults are inherited. - if crate::config::validate_env_key(key, &harness.command).is_err() { - continue; - } - if own_databricks && matches!(key.as_str(), "DATABRICKS_HOST" | "DATABRICKS_MODEL_FILTER") { - continue; - } - environment - .entry(key.clone()) - .or_insert_with(|| value.clone()); - } - same_harness -} - -pub(crate) fn effective(agent: &Agent, defaults: &AgentDefaults) -> Agent { - let mut out = agent.clone(); - out.session_policy = Some( - agent - .selected_session_policy() - .unwrap_or(defaults.session_policy), - ); - if effective_settings(&mut out.harness, &mut out.environment, defaults) - && !defaults.effort.is_empty() - { - out.extra.insert( - INHERITED_EFFORT.into(), - Value::String(defaults.effort.clone()), - ); - } - out -} - -/// The agent's own effort (from a portable import, else an old Buzz import) -/// wins over an inherited default. A saved null (cleared by a harness change) -/// hides the old Buzz value too. -pub(crate) fn effort(agent: &Agent) -> Option<&str> { - if agent.harness.integration == Some(crate::HarnessIntegration::Codex) { - return agent.harness.codex_effort(); - } - match agent.extra.get(crate::config::OWN_EFFORT) { - Some(own) => own.as_str(), - None => agent.imported["record"]["effort_level"].as_str(), - } - .or_else(|| agent.extra.get(INHERITED_EFFORT)?.as_str()) -} - -/// Whether a Pi or Goose behavior override picks effort at launch; an empty -/// value still replaces the saved effort. Its value never leaves native. -/// Matches names as the launched process does: case-insensitively on Windows. -pub(crate) fn effort_from_env(agent: &Agent) -> bool { - matches!(harness_kind(&agent.harness.command), Some("pi" | "goose")) - && agent.environment.keys().any(|key| { - if cfg!(windows) { - key.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL") - } else { - key == "BUZZ_ACP_EFFORT_LEVEL" - } - }) -} - -/// Effort the next start applies on an effective clone, unless an -/// environment override decides it. -pub(crate) fn launch_effort(agent: &Agent) -> Option<&str> { - if effort_from_env(agent) { - return None; - } - effort(agent).filter(|effort| !effort.is_empty()) -} - -#[cfg(test)] -#[path = "agent_defaults/tests.rs"] -mod tests; diff --git a/crates/agent-controller/src/agent_defaults/tests.rs b/crates/agent-controller/src/agent_defaults/tests.rs deleted file mode 100644 index e1f1bcae8..000000000 --- a/crates/agent-controller/src/agent_defaults/tests.rs +++ /dev/null @@ -1,638 +0,0 @@ -use super::*; -use crate::store::tests::fixture; - -fn defaults(harness: &str) -> AgentDefaults { - AgentDefaults { - harness: harness.into(), - provider: "global-provider".into(), - model: "global-model".into(), - effort: "high".into(), - session_policy: SessionPolicy::Channel, - environment: BTreeMap::from([ - ("SHARED".into(), "global".into()), - ("GLOBAL_ONLY".into(), "global".into()), - ]), - } -} -fn edit(harness: &str, model: &str, effort: &str) -> AgentDefaultsEdit { - AgentDefaultsEdit { - harness: harness.into(), - provider: "p".into(), - model: model.into(), - effort: effort.into(), - session_policy: Some(SessionPolicy::Channel), - environment: BTreeMap::new(), - } -} - -#[test] -fn saved_worker_count_projects_to_portable_next_launch_count() { - let mut agent = fixture(); - agent.harness.command = "buzz-agent".into(); - agent.imported = Value::Null; - let defaults = AgentDefaults::default(); - assert_eq!(agent.view(&defaults).launch_parallelism, Some(1)); - agent.harness.command = "/opt/buzz-agent".into(); - assert_eq!(agent.view(&defaults).launch_parallelism, Some(1)); - agent.harness.command = "buzz-agent".into(); - let mut other_device = defaults.clone(); - other_device - .environment - .insert("BUZZ_ACP_AGENTS".into(), "4".into()); - assert_eq!(agent.view(&other_device).launch_parallelism, Some(4)); - // The source's fallback must export explicitly: otherwise importing on - // other_device would silently change its listener from 1 to 4. - assert_eq!(agent.view(&defaults).launch_parallelism, Some(1)); - for count in [1, 4] { - agent - .environment - .insert("BUZZ_ACP_AGENTS".into(), count.to_string()); - let saved: crate::config::Agent = - serde_json::from_value(serde_json::to_value(&agent).unwrap()).unwrap(); - saved.validate().unwrap(); - assert_eq!(saved.view(&defaults).launch_parallelism, Some(count)); - } - agent.environment.remove("BUZZ_ACP_AGENTS"); - agent.imported = serde_json::json!({"record":{"parallelism":4}}); - assert_eq!(agent.view(&defaults).launch_parallelism, Some(4)); - let mut inherited = defaults; - inherited - .environment - .insert("BUZZ_ACP_AGENTS".into(), "1".into()); - assert_eq!(agent.view(&inherited).launch_parallelism, Some(1)); -} - -#[test] -fn blank_fields_inherit_for_the_same_harness_and_agent_values_win() { - let mut agent = fixture(); - agent.harness.command = "buzz-agent".into(); - agent.harness.provider.clear(); - agent.harness.model.clear(); - agent.imported = Value::Null; - agent.environment = BTreeMap::from([("SHARED".into(), "agent".into())]); - let saved = serde_json::to_value(&agent).unwrap(); - let out = effective(&agent, &defaults("buzz-agent")); - assert_eq!(out.harness.provider, "global-provider"); - assert_eq!(out.harness.model, "global-model"); - assert_eq!(effort(&out), Some("high")); - // Per-key merge: the agent's key wins; other default keys are added. - assert_eq!(out.environment["SHARED"], "agent"); - assert_eq!(out.environment["GLOBAL_ONLY"], "global"); - // A launch copy only; the saved agent is untouched. - assert_eq!(serde_json::to_value(&agent).unwrap(), saved); - - agent.harness.model = "agent-model".into(); - agent.imported = serde_json::json!({"record":{"effort_level":"low"}}); - let out = effective(&agent, &defaults("buzz-agent")); - assert_eq!(out.harness.model, "agent-model"); - assert_eq!(effort(&out), Some("low")); -} - -#[test] -fn imported_portable_effort_is_saved_and_wins_over_older_sources() { - let mut agent = fixture(); - agent.imported = serde_json::json!({"record":{"effort_level":"low"}}); - let edit = |effort: &str| crate::config::AgentEdit { - effort: Some(effort.into()), - name: agent.name.clone(), - picture: None, - system_prompt: agent.system_prompt.clone(), - session_policy: None, - workspace: agent.workspace.clone(), - harness: agent.harness.clone(), - environment: BTreeMap::new(), - }; - for invalid in ["", "high\n", "high\u{200b}low", "\u{d15}\u{d4d}\u{200d}$"] { - assert!(agent.clone().apply(edit(invalid)).is_err()); - } - let next = edit("high"); - agent.apply(next).unwrap(); - let out = effective(&agent, &defaults("buzz-agent")); - assert_eq!(effort(&out), Some("high")); - assert_eq!( - out.view(&defaults("buzz-agent")).launch_effort.as_deref(), - Some("high") - ); -} - -#[test] -fn harness_change_without_effort_drops_the_agents_own_effort() { - for imported_only in [false, true] { - let mut agent = fixture(); - agent.harness.command = "buzz-pi-acp".into(); - agent.imported = serde_json::json!({"record":{"effort_level":"xhigh"}}); - if !imported_only { - agent.extra.insert("effort".into(), "xhigh".into()); - } - let edit = |agent: &Agent, command: &str| crate::config::AgentEdit { - effort: None, - name: agent.name.clone(), - picture: None, - system_prompt: agent.system_prompt.clone(), - session_policy: None, - workspace: agent.workspace.clone(), - harness: crate::config::HarnessEdit { - command: command.into(), - ..agent.harness.clone() - }, - environment: BTreeMap::new(), - }; - // Same harness keeps it; switching to Goose drops it. - agent.apply(edit(&agent, "buzz-pi-acp")).unwrap(); - assert_eq!(effort(&agent), Some("xhigh")); - agent.apply(edit(&agent, "goose")).unwrap(); - assert_eq!(effort(&agent), None); - let out = effective(&agent, &defaults("goose")); - assert_eq!(effort(&out), Some("high"), "goose default applies"); - } -} - -#[test] -fn lowercase_effort_override_matches_launch_name_semantics() { - let mut agent = fixture(); - agent.harness.command = "pi".into(); - agent - .extra - .insert(crate::config::OWN_EFFORT.into(), "high".into()); - for value in ["low", ""] { - agent.environment = BTreeMap::from([("buzz_acp_effort_level".into(), value.into())]); - // Windows process environment names are case-insensitive, so the - // lowercase name replaces the saved effort at launch there only. - assert_eq!(effort_from_env(&agent), cfg!(windows)); - assert_eq!( - launch_effort(&agent), - if cfg!(windows) { None } else { Some("high") } - ); - } -} - -#[test] -fn selectors_do_not_cross_harnesses_but_environment_does() { - let mut agent = fixture(); - agent.harness.command = "/opt/tools/goose".into(); - agent.harness.provider.clear(); - agent.harness.model.clear(); - agent.imported = Value::Null; - let out = effective(&agent, &defaults("buzz-agent")); - assert_eq!(out.harness.provider, ""); - assert_eq!(out.harness.model, ""); - assert_eq!(effort(&out), None); - assert_eq!(out.environment["GLOBAL_ONLY"], "global"); - let out = effective(&agent, &defaults("goose")); - assert_eq!(out.harness.model, "global-model"); - agent.harness.command = "/opt/tools/buzz-pi-acp".into(); - assert_eq!( - effective(&agent, &defaults("pi")).harness.provider, - "global-provider" - ); -} - -#[test] -fn model_environment_overrides_inherit_only_within_their_harness() { - for (source, destination) in [("pi", "goose-acp"), ("goose", "/opt/tools/buzz-pi-acp")] { - let mut defaults = defaults(source); - defaults.environment.extend([ - ( - "BUZZ_ACP_MODEL".into(), - "default-provider/default-model".into(), - ), - ("BUZZ_ACP_AGENTS".into(), "10".into()), - ("BUZZ_ACP_SYSTEM_PROMPT".into(), "shared prompt".into()), - ]); - let mut agent = fixture(); - agent.harness.command = destination.into(); - let out = effective(&agent, &defaults); - let launch = crate::build_defaults().launch_view(&out.harness, &out.environment); - assert_eq!(launch.model.as_deref(), Some("test-model")); - assert_eq!(launch.model_env, None); - assert!(!out.environment.contains_key("BUZZ_ACP_MODEL")); - assert_eq!(out.environment["BUZZ_ACP_AGENTS"], "10"); - assert_eq!(out.environment["BUZZ_ACP_SYSTEM_PROMPT"], "shared prompt"); - assert_eq!(out.environment["GLOBAL_ONLY"], "global"); - - agent - .environment - .insert("BUZZ_ACP_MODEL".into(), "own-model".into()); - assert_eq!( - effective(&agent, &defaults).environment["BUZZ_ACP_MODEL"], - "own-model" - ); - agent.environment.clear(); - defaults.harness = harness_kind(destination).unwrap().into(); - assert_eq!( - effective(&agent, &defaults).environment["BUZZ_ACP_MODEL"], - "default-provider/default-model" - ); - } -} - -#[test] -fn conversation_context_inherits_defaults_unless_agent_or_imported_definition_selects_it() { - let mut agent = fixture(); - let mut defaults = defaults("buzz-agent"); - defaults.session_policy = SessionPolicy::Thread; - assert_eq!(agent.view(&defaults).session_policy, None); - assert_eq!( - effective(&agent, &defaults).session_policy, - Some(SessionPolicy::Thread) - ); - let channel_defaults = AgentDefaults { - session_policy: SessionPolicy::Channel, - ..AgentDefaults::default() - }; - let before = crate::restart::spawn_config(&effective(&agent, &channel_defaults)); - let after = crate::restart::spawn_config(&effective(&agent, &defaults)); - assert!(crate::restart::diff(&before, &after) - .iter() - .any(|entry| entry.field == "session_policy")); - - agent.session_policy = Some(SessionPolicy::Channel); - assert_eq!( - effective(&agent, &defaults).session_policy, - Some(SessionPolicy::Channel) - ); - agent.session_policy = None; - agent.imported = serde_json::json!({ - "record": {"session_policy": "channel"}, - "definition": {"session_policy": "thread"} - }); - assert_eq!( - agent.view(&defaults).session_policy, - Some(SessionPolicy::Thread) - ); - assert_eq!( - effective(&agent, &defaults).session_policy, - Some(SessionPolicy::Thread) - ); - agent.imported = serde_json::json!({ - "record": {"session_policy": "thread"}, - "definition": {} - }); - assert_eq!( - effective(&agent, &defaults).session_policy, - Some(SessionPolicy::Channel), - "a linked definition's omitted field is legacy Channel, not the record's stale Thread" - ); - agent.imported = serde_json::json!({"record": {}}); - assert_eq!( - effective(&agent, &defaults).session_policy, - Some(SessionPolicy::Channel), - "an unlinked import's omitted field is legacy Channel, not the device default" - ); - agent.imported = serde_json::Value::Null; - assert_eq!( - effective(&agent, &defaults).session_policy, - Some(SessionPolicy::Thread), - "a new agent still inherits the device default" - ); - agent.imported = serde_json::json!({"record": {}}); - agent.session_policy_inherit = true; - assert_eq!(agent.view(&defaults).session_policy, None); - assert_eq!( - effective(&agent, &defaults).session_policy, - Some(SessionPolicy::Thread), - "explicit inheritance overrides an imported legacy Channel" - ); - assert_eq!( - AgentDefaults::default().session_policy, - SessionPolicy::Thread - ); - let unset: AgentDefaults = serde_json::from_str(r#"{"harness":"buzz-agent"}"#).unwrap(); - assert_eq!( - unset.session_policy, - SessionPolicy::Thread, - "saved defaults that never chose a policy pick up the thread default" - ); -} - -#[test] -fn native_codex_never_inherits_buzz_model_effort_or_environment_defaults() { - let mut agent = fixture(); - agent.harness.integration = Some(crate::HarnessIntegration::Codex); - agent.harness.command = "/tools/codex-acp".into(); - agent.harness.model.clear(); - agent.harness.provider.clear(); - agent.harness.configuration = Some(crate::AiConfiguration::Default); - agent.environment.clear(); - agent.imported = serde_json::json!({"record": {"effort_level": "legacy-high"}}); - let defaults = AgentDefaults { - harness: "buzz-agent".into(), - provider: "default-provider".into(), - model: "default-model".into(), - effort: "default-effort".into(), - session_policy: SessionPolicy::Channel, - environment: BTreeMap::from([ - ("BUZZ_AGENT_MODEL".into(), "inherited-model".into()), - ("OPENAI_API_KEY".into(), "must-not-enter-codex".into()), - ]), - }; - - let effective = effective(&agent, &defaults); - assert!(effective.harness.model.is_empty()); - assert!(effective.environment.is_empty()); - assert_eq!(effort(&effective), None); - - // Native identity wins over a renamed adapter's basename in every build - // default projection. A Codex adapter named `buzz-agent` remains Codex. - agent.harness.command = "/tools/buzz-agent".into(); - let build = crate::BuildDefaults { - provider: "databricks_v2".into(), - model: "build-model".into(), - host: "https://build.example".into(), - filter: "build-*".into(), - owner_only: false, - }; - let resolved = build.resolve(&agent.harness, &agent.environment); - assert!(resolved.model.is_empty()); - assert!(resolved.provider.is_empty()); - assert!(resolved.databricks.is_none()); - let launch = build.launch_view(&agent.harness, &agent.environment); - assert_eq!(launch.model, None); - assert_eq!(launch.provider, None); - assert_eq!(launch.model_env, None); - assert_eq!(launch.provider_env, None); -} - -#[test] -fn omitted_policy_in_defaults_edit_preserves_the_saved_default() { - let mut saved = defaults("buzz-agent"); - saved.session_policy = SessionPolicy::Thread; - let mut payload = serde_json::json!({ - "harness": "buzz-agent", - "provider": "changed-provider", - "model": "global-model", - "effort": "high", - "environment": {} - }); - let omitted: AgentDefaultsEdit = serde_json::from_value(payload.clone()).unwrap(); - saved.apply(omitted).unwrap(); - assert_eq!(saved.provider, "changed-provider"); - assert_eq!(saved.session_policy, SessionPolicy::Thread); - - payload["sessionPolicy"] = Value::String("channel".into()); - let explicit: AgentDefaultsEdit = serde_json::from_value(payload).unwrap(); - saved.apply(explicit).unwrap(); - assert_eq!(saved.session_policy, SessionPolicy::Channel); -} - -#[test] -fn saved_databricks_workspace_wins_over_global_environment_and_blank_inherits() { - let mut agent = fixture(); - agent.harness.command = "buzz-agent".into(); - agent.environment.clear(); - let mut defaults = defaults("buzz-agent"); - defaults - .environment - .insert("DATABRICKS_HOST".into(), "https://global.example".into()); - defaults - .environment - .insert("DATABRICKS_MODEL_FILTER".into(), "global-*".into()); - agent.harness.databricks = Some(crate::connection::DatabricksSettings { - host: "https://agent.example".into(), - filter: "agent-*".into(), - }); - let own = effective(&agent, &defaults); - assert!(!own.environment.contains_key("DATABRICKS_HOST")); - assert!(!own.environment.contains_key("DATABRICKS_MODEL_FILTER")); - assert_eq!( - own.harness.databricks.as_ref().unwrap().host, - "https://agent.example" - ); - assert_eq!(own.environment["GLOBAL_ONLY"], "global"); - // An explicit empty Databricks object also blocks the inherited pair. - agent.harness.databricks = Some(Default::default()); - assert!(!effective(&agent, &defaults) - .environment - .contains_key("DATABRICKS_HOST")); - - agent.harness.databricks = None; - let blank = effective(&agent, &defaults); - assert_eq!( - blank.environment["DATABRICKS_HOST"], - "https://global.example" - ); - assert_eq!(blank.environment["DATABRICKS_MODEL_FILTER"], "global-*"); - // An agent-owned environment value still wins by key when it has no - // explicit workspace object. - agent - .environment - .insert("DATABRICKS_HOST".into(), "https://env.example".into()); - let mixed = effective(&agent, &defaults); - assert_eq!(mixed.environment["DATABRICKS_HOST"], "https://env.example"); - assert_eq!(mixed.environment["DATABRICKS_MODEL_FILTER"], "global-*"); -} - -#[test] -fn submitted_model_and_effort_are_explicit_across_a_harness_change() { - let mut saved = defaults("buzz-agent"); - // The card clears both on a harness change; re-entering the same values - // for the new harness must survive Save. - saved.apply(edit("goose", "global-model", "high")).unwrap(); - assert_eq!( - (saved.model.as_str(), saved.effort.as_str()), - ("global-model", "high") - ); - saved.apply(edit("goose", "", "")).unwrap(); - assert_eq!((saved.model.as_str(), saved.effort.as_str()), ("", "")); - // Values chosen together with the new harness are kept. - saved.apply(edit("pi", "pi-model", "medium")).unwrap(); - assert_eq!( - (saved.model.as_str(), saved.effort.as_str()), - ("pi-model", "medium") - ); - // Same harness: nothing is cleared. - saved.apply(edit("pi", "pi-model", "medium")).unwrap(); - assert_eq!(saved.model, "pi-model"); - assert!(saved.clone().apply(edit("claude", "", "")).is_err()); -} - -#[test] -fn environment_patch_is_write_only_and_validated() { - let mut saved = defaults("buzz-agent"); - let mut patch = edit("buzz-agent", "", ""); - patch.environment = BTreeMap::from([ - ("SHARED".into(), None), - ("API_TOKEN".into(), Some("secret-value".into())), - ]); - saved.apply(patch).unwrap(); - assert!(!saved.environment.contains_key("SHARED")); - assert_eq!(saved.environment["GLOBAL_ONLY"], "global"); - let view = serde_json::to_string(&saved.view()).unwrap(); - assert!(!view.contains("secret-value") && !view.contains("\"global\"")); - assert_eq!(saved.view().environment_keys, ["API_TOKEN", "GLOBAL_ONLY"]); - let mut reserved = edit("buzz-agent", "", ""); - reserved.environment = BTreeMap::from([("BUZZ_PRIVATE_KEY".into(), Some("x".into()))]); - assert!(saved.apply(reserved).is_err()); - - for harness in ["pi", "goose"] { - let mut patch = edit(harness, "model", ""); - patch.environment = BTreeMap::from([ - ("BUZZ_ACP_MODEL".into(), Some("private-model".into())), - ( - "BUZZ_ACP_SYSTEM_PROMPT".into(), - Some("private-prompt".into()), - ), - ("BUZZ_ACP_AGENTS".into(), Some("10".into())), - ]); - saved.apply(patch).unwrap(); - let wire = serde_json::to_string(&saved.view()).unwrap(); - assert!(!wire.contains("private-model") && !wire.contains("private-prompt")); - let mut agent = fixture(); - agent.harness.command = "buzz-agent".into(); - let out = effective(&agent, &saved); - out.validate().unwrap(); - assert!(!out.environment.contains_key("BUZZ_ACP_MODEL")); - assert!(!out.environment.contains_key("BUZZ_ACP_SYSTEM_PROMPT")); - assert_eq!(out.environment["BUZZ_ACP_AGENTS"], "10"); - agent.harness.command = if harness == "pi" { - "/opt/tools/buzz-pi-acp" - } else { - "goose-acp" - } - .into(); - let inherited = effective(&agent, &saved); - assert_eq!(inherited.environment["BUZZ_ACP_MODEL"], "private-model"); - agent - .environment - .insert("BUZZ_ACP_MODEL".into(), "agent-model".into()); - assert_eq!( - effective(&agent, &saved).environment["BUZZ_ACP_MODEL"], - "agent-model" - ); - } -} - -#[test] -fn an_invalid_pi_default_pair_is_refused_before_it_is_saved() { - // Buzz Agent defaults with a provider, then a harness-only switch to Pi: - // the card clears model/effort but keeps the provider. - let mut saved = defaults("buzz-agent"); - let before = saved.clone(); - let mut switch = edit("pi", "", ""); - switch.provider = "global-provider".into(); - let error = saved.apply(switch).unwrap_err(); - assert!(error.contains("Choose a Pi model"), "{error}"); - assert!(saved == before); - // Clearing the provider, or choosing a model with it, is accepted. - let mut cleared = edit("pi", "", ""); - cleared.provider.clear(); - saved.apply(cleared).unwrap(); - let mut chosen = edit("pi", "pi-model", ""); - chosen.provider = "global-provider".into(); - saved.apply(chosen).unwrap(); -} - -#[test] -fn snapshot_export_rejects_unrepresentable_native_behavior_without_exposing_records() { - let mut agent = fixture(); - agent.harness.command = "buzz-agent".into(); - agent.imported = Value::Null; - assert!(agent - .view(&AgentDefaults::default()) - .snapshot_export_limitations - .is_empty()); - for imported in [ - serde_json::json!({"teamInstructions":"legacy team rules"}), - serde_json::json!({"record":{"idle_timeout_seconds":30}}), - serde_json::json!({"record":{"max_turn_duration_seconds":60}}), - ] { - agent.imported = imported; - let view = serde_json::to_value(agent.view(&AgentDefaults::default())).unwrap(); - assert!(!view["snapshotExportLimitations"] - .as_array() - .unwrap() - .is_empty()); - assert!(view.get("imported").is_none()); - } - agent.imported = Value::Null; - assert_eq!( - agent - .view(&defaults("buzz-agent")) - .snapshot_export_limitations, - Vec::<&str>::new() - ); -} - -#[test] -fn snapshot_export_limits_effective_behavior_overrides_without_exposing_values() { - for placement in ["inherited", "agent"] { - for (harness, key) in [ - ("goose-acp", "BUZZ_ACP_SYSTEM_PROMPT"), - ("buzz-agent", "BUZZ_AGENT_SYSTEM_PROMPT"), - ("buzz-agent", "BUZZ_AGENT_SYSTEM_PROMPT_FILE"), - ("buzz-agent", "BUZZ_AGENT_MAX_CONTEXT_TOKENS"), - ("buzz-agent", "BUZZ_AGENT_MAX_HISTORY_BYTES"), - ("buzz-agent", "BUZZ_AGENT_NO_HINTS"), - ("buzz-agent", "BUZZ_AGENT_THINKING_EFFORT"), - ] { - let mut agent = fixture(); - agent.imported = Value::Null; - agent.harness.command = harness.into(); - agent.environment.clear(); - let mut defaults = AgentDefaults { - harness: if harness == "goose-acp" { - "goose" - } else { - "buzz-agent" - } - .into(), - ..AgentDefaults::default() - }; - // Credentials remain local; only effective behavioral overrides limit portability. - defaults - .environment - .insert("PROVIDER_TOKEN".into(), "private".into()); - let key = key.to_string(); - if placement == "inherited" { - defaults - .environment - .insert(key.clone(), "private override".into()); - } else { - agent - .environment - .insert(key.clone(), "private override".into()); - } - let view = serde_json::to_value(agent.view(&defaults)).unwrap(); - assert_eq!( - view["snapshotExportLimitations"], - serde_json::json!(["behavioral environment overrides"]) - ); - assert!(!view.to_string().contains("private override")); - assert!(!view.to_string().contains("PROVIDER_TOKEN")); - if placement == "inherited" { - agent.environment.insert(key.clone(), "own override".into()); - assert_eq!( - agent.view(&defaults).snapshot_export_limitations, - vec!["behavioral environment overrides"] - ); - } - defaults.environment.remove(&key); - agent.environment.remove(&key); - assert!(agent.view(&defaults).snapshot_export_limitations.is_empty()); - } - } -} - -#[test] -fn snapshot_export_ignores_unapplied_defaults_and_local_credentials() { - let mut agent = fixture(); - agent.imported = Value::Null; - agent.environment.clear(); - let mut defaults = AgentDefaults { - harness: "pi".into(), - ..AgentDefaults::default() - }; - defaults - .environment - .insert("BUZZ_ACP_SYSTEM_PROMPT".into(), "different harness".into()); - defaults - .environment - .insert("PROVIDER_TOKEN".into(), "local credential".into()); - assert!(agent.view(&defaults).snapshot_export_limitations.is_empty()); - defaults.environment.remove("BUZZ_ACP_SYSTEM_PROMPT"); - defaults - .environment - .insert("BUZZ_ACP_AGENTS".into(), "4".into()); - assert!(agent.view(&defaults).snapshot_export_limitations.is_empty()); - assert_eq!(agent.view(&defaults).launch_parallelism, Some(4)); -} diff --git a/crates/agent-controller/src/bundle.rs b/crates/agent-controller/src/bundle.rs deleted file mode 100644 index 1c487ace4..000000000 --- a/crates/agent-controller/src/bundle.rs +++ /dev/null @@ -1,176 +0,0 @@ -use crate::Result; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::io::Read; -use std::path::{Path, PathBuf}; - -#[derive(Deserialize)] -struct Source { - revision: String, - tools: Vec, - goose: GooseSource, - #[serde(rename = "gooseDevProfile")] - goose_dev_profile: String, -} -#[derive(Deserialize, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] -struct GooseSource { - repository: String, - revision: String, - profile: String, - features: String, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Manifest { - version: u32, - revision: String, - target: String, - files: BTreeMap, - goose: GooseSource, -} -/// App resource provenance/integrity check, not a defense against a same-user -/// attacker able to replace the application itself. No PATH/old-app fallback. -pub struct RuntimeBundle { - pub(crate) directory: PathBuf, - files: BTreeMap, -} -impl RuntimeBundle { - pub fn new(directory: PathBuf) -> Result { - Self::load(directory, verify_signed_resources) - } - - fn load( - directory: PathBuf, - verify_signature: impl FnOnce(&Path) -> Result<()>, - ) -> Result { - if !directory.is_absolute() { - return Err("Runtime bundle path must be absolute".into()); - } - regular_directory(&directory)?; - let path = directory.join("manifest.json"); - let meta = std::fs::symlink_metadata(&path) - .map_err(|_| "Agent runtime is not packaged; build its resources first")?; - if !meta.is_file() || meta.len() > 16384 { - return Err("Runtime manifest is invalid".into()); - } - let manifest: Manifest = serde_json::from_slice( - &std::fs::read(path).map_err(|_| "Could not read runtime manifest")?, - ) - .map_err(|_| "Runtime manifest is invalid")?; - let source: Source = - serde_json::from_str(include_str!("../../../runtime/agent-runtime.json")) - .map_err(|_| "Runtime source specification is invalid")?; - // `just desktop` stages Goose's fast dev profile; packaging stages the pin's. - let goose = manifest.goose == source.goose - || manifest.goose - == GooseSource { - profile: source.goose_dev_profile, - ..source.goose - }; - if manifest.version != 2 - || manifest.revision != source.revision - || !goose - || manifest.target != env!("BUZZ_RUNTIME_TARGET") - || manifest.files.len() != source.tools.len() - { - return Err("Runtime target/revision does not match this app".into()); - } - let mut bundle = Self { - directory, - files: manifest.files, - }; - let mut observed = BTreeMap::new(); - for name in source.tools { - let filename = filename(&name); - if !bundle.files.contains_key(&filename) { - return Err("Required runtime tool is absent from the manifest".into()); - } - observed.insert( - filename.clone(), - executable_hash(&bundle.directory.join(filename))?, - ); - } - if observed != bundle.files { - verify_signature(&bundle.directory)?; - } - // Keep final bytes in memory so every launch still detects later changes. - bundle.files = observed; - Ok(bundle) - } - /// Configured path for display only. Does not inspect files or authorize launch. - pub(crate) fn display_path(&self, name: &str) -> Option { - let filename = filename(name); - self.files - .contains_key(&filename) - .then(|| self.directory.join(filename)) - } - pub(crate) fn executable(&self, name: &str) -> Result { - let filename = filename(name); - let expected = self - .files - .get(&filename) - .ok_or("Required runtime tool is absent from the manifest")?; - let path = self.directory.join(filename); - if executable_hash(&path)? != *expected { - return Err(INTEGRITY_ERROR.into()); - } - Ok(path) - } -} -const INTEGRITY_ERROR: &str = - "Runtime executable failed its integrity check; rebuild the app resources"; - -fn filename(name: &str) -> String { - if cfg!(windows) { - format!("{name}.exe") - } else { - name.into() - } -} - -fn executable_hash(path: &Path) -> Result { - let meta = - std::fs::symlink_metadata(path).map_err(|_| "Required runtime executable is missing")?; - if !meta.is_file() { - return Err("Runtime executable must be a regular file, not a link".into()); - } - crate::runtime::executable(path)?; - let mut file = std::fs::File::open(path).map_err(|_| "Could not read runtime executable")?; - let mut digest = Sha256::new(); - let mut bytes = [0u8; 65536]; - loop { - let n = file - .read(&mut bytes) - .map_err(|_| "Could not verify runtime executable")?; - if n == 0 { - break; - } - digest.update(&bytes[..n]); - } - Ok(format!("{:x}", digest.finalize())) -} - -#[cfg(target_os = "macos")] -mod macos; -#[cfg(target_os = "macos")] -fn verify_signed_resources(directory: &Path) -> Result<()> { - let executable = std::env::current_exe().map_err(|_| INTEGRITY_ERROR)?; - macos::verify(directory, &executable, macos::REQUIREMENT) -} - -fn regular_directory(path: &Path) -> Result<()> { - let meta = std::fs::symlink_metadata(path) - .map_err(|_| "Agent runtime resource directory is missing")?; - if meta.is_dir() && !meta.file_type().is_symlink() { - Ok(()) - } else { - Err("Agent runtime resources cannot be a link".into()) - } -} - -#[cfg(not(target_os = "macos"))] -fn verify_signed_resources(_: &Path) -> Result<()> { - Err(INTEGRITY_ERROR.into()) -} diff --git a/crates/agent-controller/src/bundle/macos.rs b/crates/agent-controller/src/bundle/macos.rs deleted file mode 100644 index 0921c5918..000000000 --- a/crates/agent-controller/src/bundle/macos.rs +++ /dev/null @@ -1,153 +0,0 @@ -use super::{Result, INTEGRITY_ERROR}; -use std::path::Path; -use std::process::Command; - -// The Block Developer ID used by the shared signing service. -pub(super) const REQUIREMENT: &str = "anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = EYF346PHUG"; - -pub(super) fn verify(directory: &Path, executable: &Path, requirement: &str) -> Result<()> { - let executable = executable.canonicalize().map_err(|_| INTEGRITY_ERROR)?; - let macos = executable.parent().ok_or(INTEGRITY_ERROR)?; - let contents = macos.parent().ok_or(INTEGRITY_ERROR)?; - let app = contents.parent().ok_or(INTEGRITY_ERROR)?; - if macos.file_name() != Some("MacOS".as_ref()) - || contents.file_name() != Some("Contents".as_ref()) - || app.extension() != Some("app".as_ref()) - || directory.canonicalize().map_err(|_| INTEGRITY_ERROR)? - != contents.join("Resources/agent-runtime") - { - return Err(INTEGRITY_ERROR.into()); - } - // Resources (including the manifest and tools) must match the enclosing seal. - let output = Command::new("/usr/bin/codesign") - .args(["--verify", "--deep", "--strict", "-R"]) - .arg(format!("={requirement}")) - .arg(app) - .output() - .map_err(|_| INTEGRITY_ERROR)?; - if !output.status.success() { - return Err(INTEGRITY_ERROR.into()); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::bundle::{executable_hash, RuntimeBundle, Source}; - use serde_json::json; - use std::collections::BTreeMap; - use std::fs; - use std::path::PathBuf; - - struct Fixture { - _temp: tempfile::TempDir, - app: PathBuf, - executable: PathBuf, - directory: PathBuf, - } - impl Fixture { - fn new() -> Self { - let temp = tempfile::tempdir().unwrap(); - let app = temp.path().join("Fixture.app"); - let executable = app.join("Contents/MacOS/Fixture"); - let directory = app.join("Contents/Resources/agent-runtime"); - fs::create_dir_all(executable.parent().unwrap()).unwrap(); - fs::create_dir_all(&directory).unwrap(); - fs::copy("/usr/bin/true", &executable).unwrap(); - fs::write(app.join("Contents/Info.plist"), r#"CFBundleExecutableFixtureCFBundleIdentifierdev.buzz.runtime-fixtureCFBundlePackageTypeAPPL"#).unwrap(); - let source: Source = - serde_json::from_str(include_str!("../../../../runtime/agent-runtime.json")) - .unwrap(); - let mut files = BTreeMap::new(); - for name in source.tools { - let path = directory.join(&name); - fs::copy("/usr/bin/true", &path).unwrap(); - files.insert(name, executable_hash(&path).unwrap()); - sign(&path); - } - fs::write( - directory.join("manifest.json"), - serde_json::to_vec(&json!({ - "version": 2, "revision": source.revision, "goose": source.goose, - "target": env!("BUZZ_RUNTIME_TARGET"), "files": files, - })) - .unwrap(), - ) - .unwrap(); - Self { - _temp: temp, - app, - executable, - directory, - } - } - fn load(&self) -> Result { - // Exercise the real resource seal without needing a Developer ID key. - RuntimeBundle::load(self.directory.clone(), |directory| { - verify(directory, &self.executable, "true") - }) - } - } - fn sign(path: &Path) { - let output = Command::new("/usr/bin/codesign") - .args([ - "--force", - "--sign", - "-", - "--identifier", - "dev.buzz.runtime-fixture", - ]) - .arg(path) - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } - - #[test] - fn signed_resources_replace_pre_sign_hashes_but_detect_later_changes() { - let fixture = Fixture::new(); - assert!(fixture.load().is_err(), "unsigned app must fail"); - sign(&fixture.app); - let bundle = fixture.load().unwrap(); - assert!(bundle.executable("buzz-agent").is_ok()); - assert!( - verify(&fixture.directory, &fixture.executable, REQUIREMENT).is_err(), - "production must reject ad-hoc signatures" - ); - fs::write(fixture.directory.join("buzz-agent"), "tampered").unwrap(); - assert!(bundle.executable("buzz-agent").is_err()); - assert!(fixture.load().is_err(), "tampered seal must fail"); - } - - #[test] - fn signature_cannot_authorize_another_runtime_or_wrong_manifest() { - let fixture = Fixture::new(); - sign(&fixture.app); - let other = Fixture::new(); - assert!(verify(&other.directory, &fixture.executable, "true").is_err()); - assert!(verify(&fixture.directory, Path::new("/usr/bin/true"), "true").is_err()); - let manifest_path = fixture.directory.join("manifest.json"); - let original: serde_json::Value = - serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap(); - for field in ["revision", "target", "files"] { - let mut manifest = original.clone(); - manifest[field] = if field == "files" { - let mut files = original["files"].clone(); - let hash = files.as_object_mut().unwrap().remove("buzz-agent").unwrap(); - files["unexpected"] = hash; - files - } else { - json!("wrong") - }; - fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); - sign(&fixture.app); - assert!(verify(&fixture.directory, &fixture.executable, "true").is_ok()); - assert!(fixture.load().is_err(), "signed invalid {field} must fail"); - } - } -} diff --git a/crates/agent-controller/src/buzz_cli_skill.md b/crates/agent-controller/src/buzz_cli_skill.md deleted file mode 100644 index 05e02a4f2..000000000 --- a/crates/agent-controller/src/buzz_cli_skill.md +++ /dev/null @@ -1,173 +0,0 @@ ---- -name: buzz-cli -description: > - Buzz CLI for relay operations: owner-reviewed agent drafts, messaging, - channels, DMs, users, workflows, feed, reactions, canvas, social, repos, - uploads, and agent memory. -version: 1 ---- - -# Buzz CLI Skill - -## Environment - -`BUZZ_PRIVATE_KEY` is set by the harness at runtime or by the developer's environment. If missing, tell the user to set it (hex or nsec format). Never read or echo the value. - -`BUZZ_RELAY_URL` defaults to `http://localhost:3000`. In development, the user may need to set this to a staging or production relay URL. - -`BUZZ_AUTH_TAG` is required for `buzz agents draft-create` and `buzz agents draft-update` because those commands send owner-reviewed Desktop drafts. If missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. - -Run the bundled CLI with `--help` and ` --help` to discover all flags, arguments, and usage. This skill documents only what `--help` cannot tell you. - -## Conversational Agent Management - -When someone naturally asks to create an agent, ask for at most two things: the agent's **name** and **what it should do day-to-day**. Turn the user's rough purpose into the system prompt yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the request is genuinely ambiguous. Then run: - -```bash -buzz agents draft-create \ - --channel \ - --display-name "Research helper" \ - --system-prompt "Find reliable sources and summarize them concisely." -``` - -Use the UUID from the current Buzz `[Context]`; do not ask the user for it. Do not ask about runtime, provider, model, credentials, environment variables, or access. Desktop uses the machine's real defaults, and new agents start as **Only me**. The command sends an encrypted draft to the owner's Desktop. It does not create the agent until the owner reviews and saves the form, so report the result as “ready for review,” never “created.” - -For an explicit change to an existing personal agent, use: - -```bash -buzz agents draft-update --channel --agent-name "Current name" \ - --system-prompt "Updated instructions" -``` - -Run `buzz agents draft-update --help` for optional runtime, provider, model, rename, and access changes. Prefer these CLI commands over any legacy MCP agent-management tools. - -## Git Repositories - -Buzz hosts real git repos, and **you can own one yourself** — no human key needed. `repos create` signs the announcement with *your* key, so the repo is owned by whoever runs it; the owner segment in the clone URL is your own pubkey (hex, not a username). Git auth is automatic: the harness configures the `git-credential-nostr` helper, so plain `git clone`/`push`/`pull` against `/git//` just work over NIP-98 — never put a private key on a git command line. Announce with `repos create --id --clone /git//`, then `git remote add origin ` and `git push -u origin main` (the relay seeds an empty repo on announce, so it's immediately pushable). Requires git 2.46+ for the credential protocol. - -Manage your repository's enforced branch and tag rules with `repos protect list|set|remove`. Ref patterns must use full Git names such as `refs/heads/main` or `refs/tags/*`; supported rules are `--push owner|admin|member`, `--no-force-push`, `--no-delete`, and `--require-patch`. `protect set` replaces the complete rule for that exact pattern, so omitted constraints are removed. Protection updates preserve every unrelated metadata tag and return exit code 5 when a newer NIP-33 head wins a concurrent write. - -## Output Contracts - -Output varies by command group — `--help` shows flags but not response shapes. - -**Read commands** return JSON arrays. Event reads (`messages get/thread/search`, `feed get`) return normalized, complete signed Nostr events with `{id, pubkey, kind, content, created_at, tags, sig}`. Other reads use command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), and workflows (`{workflow_id, content, created_at, pubkey}`). - -**Write commands**: all return `{event_id, accepted, message}`. Create commands add the generated entity ID: `channels create` → `channel_id`, `dms open` → `dm_id`, `workflows create` → `workflow_id`. Agent draft commands add `{request_id, action, saved: false}` because they only open an owner-reviewed Desktop draft. - -**Exceptions to the above patterns:** - -| Command | Output | -|---------|--------| -| `canvas get` | raw markdown string or `null` — NOT a JSON envelope | -| `social *`, `repos get/list` | raw Nostr event JSON INCLUDING `sig` — different contract than read commands above | -| `repos protect list` | `{repo_id, protections: [{ref, rules}], unknown_rules, validation_error}` | -| `upload file` | pretty-printed multi-line `BlobDescriptor`: `{url, sha256, size, type, uploaded}` | -| `mem get` | raw bytes to stdout, no trailing newline | -| `mem hash` | SHA-256 hex string | -| `mem set/patch/rm` | nothing to stdout; progress to stderr | -| `mem ls` | tab-delimited (`slug\tcreated_at\tevent_id`) by default; `--json` for JSON array | -| `reactions get` | `{"reactions": [{emoji, count, pubkeys}]}` — aggregated, not raw events | -| `pack validate/inspect` | human-readable text, not JSON | - -**Errors** go to stderr as `{"error": "", "message": ""}`. Exit codes: 0 = success, 1 = input/not-found, 2 = relay/network, 3 = auth, 4 = other, 5 = write conflict (value superseded). - -## Compact Format - -`--format compact` is a global flag — position it before the subcommand: - -```bash -buzz --format compact channels list # [{channel_id, name}] -buzz --format compact messages get --channel # [{id, content, created_at}] -buzz --format compact users get # [{pubkey, display_name}] -buzz --format compact feed get # [{id, content, created_at}] -``` - -Write commands are unaffected. `--format json` (default) returns full fields. - -## Communication Patterns - -**Mentions that notify:** Keep readable `@Name` text in message content and, when intended pubkeys are known, pass the identities in the same send with repeatable `--mention `. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add recipients. Include a pubkey for every presentation-only name that should notify. The CLI reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without explicit identities, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, then retry; sending never changes membership automatically. - -```bash -buzz messages send --channel \ - --content "@Alice check this" --mention -``` - -## DM Management - -`dms hide --channel ` hides a DM from the agent's DM list. Restore by re-opening with `dms open --pubkey `. - -## Channel Policies - -`channels set-add-policy --policy ` controls who can add you to channels: -- `anyone` (default) — any authenticated user can add you to open channels -- `owner_only` — only your provisioned owner can add you -- `nobody` — no one can add you; self-join via `channels join` - -## Workflow Inputs - -`workflows trigger --workflow --inputs ''` passes input variables as the trigger event's content. Omit `--inputs` for parameterless workflows. - -## Feed Filtering - -`feed get --types ` filters by category. Valid types: `mentions`, `needs_action`, `activity`, `agent_activity`. Omit for all categories. - -## Pagination - -`messages thread --depth-limit ` caps reply nesting depth (relay extension hint — may be ignored). - -`social notes --before-id ` enables composite cursor pagination. Use with `--before ` to avoid skipping same-second events. - -## Gotchas - -1. **`feed get` sorts newest-first** — every other list command sorts oldest-first. Don't assume consistent sort order. -2. **`users set-presence` is broken** — sends ephemeral kind:20001 via HTTP POST; relay rejects ephemeral kinds over HTTP. Will fail until WebSocket support is added. -3. **`workflow runs` always returns `[]`** — run history lives in the relay's database, not as Nostr events. -4. **`dms open` returns `dm_id`** — use this value as `--channel` for subsequent `messages send/get` commands on that DM. -5. **Content max 65,536 bytes** (exit 1 if exceeded). Diffs auto-truncate at 61,440 bytes at a hunk boundary. -6. **`users get` always returns an array** — even for a single pubkey lookup. Never expect a bare object. -7. **All `mem` subcommands accept `--owner `** — for querying or writing memories owned by a different pubkey in multi-agent scenarios. Defaults to the owner from `BUZZ_AUTH_TAG`. -8. **`mem rm` cannot delete `core`** — use `mem set core ''` instead. - -## Forum Posts - -`messages send --kind` routes to different event builders: - -- Omitted or `9` → stream message (default) -- `45001` → forum post (thread root) -- `45003` → forum comment (requires `--reply-to `) - -Other kind values are rejected. Use `messages vote --event --direction up|down` to vote on forum posts. - -## Message Formatting - -Message content is rendered as GitHub-flavored Markdown on both desktop and mobile. Key formatting: - -- **Fenced code blocks**: triple-backtick with a language tag for syntax highlighting (190+ languages supported). Omitting the language tag renders a styled monochrome block. -- **Inline code**: single backticks for inline monospace. -- **Mentions**: plain `@name` — do NOT bold or italicize (formatting prevents alert delivery). -- **Links, images, tables, blockquotes, headings**: standard GFM. - -## Mem Patch Workflow - -For safe concurrent writes, use hash-based conflict detection: - -```bash -HASH=$(buzz mem hash ) # 1. get current SHA-256 -# ... build unified diff ... -buzz mem patch --base-hash "$HASH" --patch-file diff.patch # 2. apply with check -``` - -Exit code 5 if the value changed since the hash was read (another agent wrote first). Retry by re-reading, re-diffing, and re-patching. - -Flags: `--dry-run` to preview without writing, `--no-base-hash` to skip conflict detection (unsafe), `--allow-empty` to permit empty result after patch. - -## Catching Up - -An @mention wakes you, so do not loop or sleep waiting for messages. To read what arrived since you last looked, do one read with a `--since` cursor: - -1. `buzz messages get --channel --since --limit 50` -2. Keep the maximum `created_at` from the results as the next cursor - -`feed get` always returns newest-first regardless of `--since`. diff --git a/crates/agent-controller/src/codex.rs b/crates/agent-controller/src/codex.rs deleted file mode 100644 index 7ccd89192..000000000 --- a/crates/agent-controller/src/codex.rs +++ /dev/null @@ -1,395 +0,0 @@ -//! Canonical Codex CLI/adapter binding shared by discovery and launch. -//! Resolution never authenticates, reads a model catalog, or mutates Codex state. -use crate::{installed, Result}; -use std::{ - collections::BTreeMap, - ffi::{OsStr, OsString}, - io::Read, - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; - -const MAX_SHEBANG: usize = 512; -const PASSTHROUGH: &[&str] = &[ - "HOME", - "CODEX_HOME", - "TMPDIR", - "USER", - "LOGNAME", - "LANG", - "SSL_CERT_FILE", - "SSL_CERT_DIR", -]; - -/// Stable identity for the native-owned Codex integration. -pub const INTEGRATION_ID: &str = "codex"; - -/// A resolved executable and, for a script, its exact interpreter. -#[derive(Clone, PartialEq, Eq)] -struct BoundExecutable { - selected: PathBuf, - program: PathBuf, - script: Option, -} - -/// Native-only binding. Config files and authentication can change without any -/// path or environment value changing, so equality never proves a working login. -#[derive(Clone, PartialEq, Eq)] -pub struct CodexContext { - /// Canonical selected ACP adapter path. - pub adapter: PathBuf, - /// Canonical selected Codex CLI path. - pub cli: PathBuf, - /// Exact adapter script interpreter, when the adapter is a script. - pub interpreter: Option, - /// Exact CLI script interpreter, when the CLI is a script. - pub cli_interpreter: Option, - /// Canonical workspace used by every command in this context. - pub workspace: PathBuf, - adapter_command: BoundExecutable, - cli_command: BoundExecutable, - environment: BTreeMap, -} - -impl CodexContext { - /// Resolve the device-selected adapter and CLI without falling back to an - /// adapter-bundled Codex engine. - pub fn installed(workspace: &Path, app_data: Option<&Path>) -> Result { - Self::installed_with(workspace, &BTreeMap::new(), app_data) - } - - /// Resolve the installed pair with the agent's effective environment. Only - /// the fixed Codex binding allowlist is admitted into child processes. - pub fn installed_with( - workspace: &Path, - overrides: &BTreeMap, - app_data: Option<&Path>, - ) -> Result { - let adapter = installed_adapter(app_data).ok_or("Codex ACP adapter not found")?; - let cli = installed("codex").ok_or("Codex CLI not found")?; - Self::new(&adapter, &cli, workspace, overrides) - } - - /// Resolve the agent's selected adapter without replacing it with a newly - /// discovered global install. A missing saved adapter is a launch error. - pub fn for_agent( - adapter: &str, - workspace: &Path, - effective: &BTreeMap, - ) -> Result { - let cli = installed("codex").ok_or("Codex CLI not found")?; - Self::new( - Path::new(adapter), - &cli, - workspace, - &agent_environment(effective), - ) - } - - /// Resolve an explicit pair. This is the later per-agent binding seam and - /// also keeps tests from depending on ambient tools. - pub fn new( - adapter: &Path, - cli: &Path, - workspace: &Path, - overrides: &BTreeMap, - ) -> Result { - let workspace = workspace - .canonicalize() - .map_err(|_| "Codex workspace does not exist")?; - if !workspace.is_dir() { - return Err("Codex workspace is not a directory".into()); - } - let mut directories = Vec::new(); - for path in [adapter, cli] { - if let Some(parent) = path.parent().filter(|path| path.is_absolute()) { - directories.push(parent.to_path_buf()); - } - } - directories.extend(default_directories()); - // An app-owned adapter runs on its pinned Node; the CLI keeps its own. - let adapter_directories: Vec<_> = managed_node(adapter) - .and_then(|node| node.parent().map(Path::to_path_buf)) - .into_iter() - .chain(directories.iter().cloned()) - .collect(); - let adapter_command = bind(adapter, &adapter_directories, "Codex ACP adapter")?; - let cli_command = bind(cli, &directories, "Codex CLI")?; - if let Some(path) = &adapter_command.script { - let value = path.as_os_str().to_string_lossy(); - if value.contains(',') || value.trim() != value { - return Err( - "Codex ACP adapter path cannot contain a comma or surrounding whitespace" - .into(), - ); - } - } - let adapter = adapter_command.selected.clone(); - let cli = cli_command.selected.clone(); - let interpreter = adapter_command - .script - .as_ref() - .map(|_| adapter_command.program.clone()); - let cli_interpreter = cli_command - .script - .as_ref() - .map(|_| cli_command.program.clone()); - - if std::env::var_os("CODEX_CONFIG").is_some() || overrides.contains_key("CODEX_CONFIG") { - return Err("CODEX_CONFIG is not supported by Codex agents yet".into()); - } - let mut environment = BTreeMap::new(); - for key in PASSTHROUGH { - if let Some(value) = std::env::var_os(key) { - environment.insert(OsString::from(key), value); - } - } - for (key, value) in overrides { - if !PASSTHROUGH.contains(&key.as_str()) { - return Err(format!("Codex binding does not permit {key}")); - } - environment.insert(key.into(), value.into()); - } - environment.insert("CODEX_PATH".into(), cli.as_os_str().to_owned()); - environment.insert("INITIAL_AGENT_MODE".into(), "agent-full-access".into()); - let path = std::env::join_paths( - [ - cli_interpreter.as_deref().and_then(Path::parent), - interpreter.as_deref().and_then(Path::parent), - cli.parent(), - adapter.parent(), - Some(Path::new("/usr/bin")), - Some(Path::new("/bin")), - Some(Path::new("/usr/sbin")), - Some(Path::new("/sbin")), - ] - .into_iter() - .flatten() - .map(Path::to_path_buf) - .chain(default_directories()), - ) - .map_err(|_| "Invalid Codex tools path")?; - environment.insert("PATH".into(), path); - Ok(Self { - adapter, - cli, - interpreter, - cli_interpreter, - workspace, - adapter_command, - cli_command, - environment, - }) - } - - /// Exact adapter command with isolated effective context. - pub fn adapter_command(&self) -> Command { - self.command(&self.adapter_command) - } - - /// Exact installed CLI command with the same context as the adapter. - pub fn cli_command(&self) -> Command { - self.command(&self.cli_command) - } - - /// Exact executable and comma-transport-safe arguments for bundled Buzz ACP. - pub(crate) fn adapter_launch(&self, configured: &[String]) -> Result<(PathBuf, Vec)> { - if !configured.is_empty() { - return Err("Native Codex does not accept custom adapter arguments".into()); - } - let mut args = Vec::with_capacity(usize::from(self.adapter_command.script.is_some())); - if let Some(script) = &self.adapter_command.script { - args.push(script.to_string_lossy().into_owned()); - } - if args - .iter() - .any(|arg| arg.is_empty() || arg.contains(',') || arg.trim() != arg) - { - return Err("Codex ACP adapter arguments must be nonempty, comma-free, and have no surrounding whitespace".into()); - } - Ok((self.adapter_command.program.clone(), args)) - } - - /// Apply the isolated CLI, interpreter, full-access, and path binding. - /// Host-owned identity and relay values are added later. - pub(crate) fn apply_launch_environment( - &self, - command: &mut Command, - runtime_directory: &Path, - ) -> Result<()> { - let path = self - .environment - .get(OsStr::new("PATH")) - .ok_or("Codex binding has no tools path")?; - let path = std::env::join_paths( - std::iter::once(runtime_directory.to_path_buf()).chain(std::env::split_paths(path)), - ) - .map_err(|_| "Invalid Codex runtime tools path")?; - command - .env_clear() - .envs(&self.environment) - .env("PATH", path) - .current_dir(&self.workspace); - // Like every other harness, agents reach the host SSH agent for git. - if let Some(socket) = std::env::var_os("SSH_AUTH_SOCK") { - command.env("SSH_AUTH_SOCK", socket); - } - Ok(()) - } - - fn command(&self, executable: &BoundExecutable) -> Command { - let mut command = Command::new(&executable.program); - if let Some(script) = &executable.script { - command.arg(script); - } - command - .env_clear() - .envs(&self.environment) - .current_dir(&self.workspace) - .stdin(Stdio::null()); - command - } -} - -fn agent_environment(effective: &BTreeMap) -> BTreeMap { - effective - .iter() - .filter(|(key, _)| PASSTHROUGH.contains(&key.as_str()) || key.as_str() == "CODEX_CONFIG") - .map(|(key, value)| (key.clone(), value.clone())) - .collect() -} - -/// Native Codex records accept only binding inputs plus the existing host-owned -/// worker-count preference. Legacy records are not retroactively constrained. -pub(crate) fn validate_native_environment(effective: &BTreeMap) -> Result<()> { - if let Some(key) = effective - .keys() - .find(|key| !PASSTHROUGH.contains(&key.as_str()) && key.as_str() != "BUZZ_ACP_AGENTS") - { - return Err(format!("Codex binding does not permit {key}")); - } - Ok(()) -} - -/// The one adapter lookup shared by Settings, discovery, and new selections. -pub fn installed_adapter(app_data: Option<&Path>) -> Option { - adapter_choice(installed("codex-acp"), app_data) -} - -/// As for Claude Code, a user install wins. Otherwise the app-owned adapter is -/// used only when its pinned Node is installed to run it. -fn adapter_choice(user: Option, app_data: Option<&Path>) -> Option { - user.or_else(|| { - let app_data = app_data?; - crate::managed_tool(app_data, "node")?; - crate::managed_tool(app_data, "codex-acp") - }) -} - -/// The pinned Node beside an app-owned `/codex-tools/bin` adapter. -fn managed_node(adapter: &Path) -> Option { - let bin = adapter - .parent() - .filter(|bin| bin.ends_with("codex-tools/bin"))?; - crate::managed_tool(bin.parent()?.parent()?, "node") -} - -fn bind(path: &Path, directories: &[PathBuf], label: &str) -> Result { - if !path.is_absolute() { - return Err(format!("{label} path must be absolute")); - } - executable(path).map_err(|_| format!("{label} is missing or not executable"))?; - let selected = path - .canonicalize() - .map_err(|_| format!("Could not canonicalize {label}"))?; - let Some(interpreter) = shebang(&selected)? else { - return Ok(BoundExecutable { - selected: selected.clone(), - program: selected, - script: None, - }); - }; - let program = if interpreter == Path::new("/usr/bin/env") { - resolve("node", directories).ok_or("Codex adapter and CLI scripts require Node.js")? - } else { - if !interpreter.is_absolute() { - return Err(format!("{label} uses a relative interpreter")); - } - resolve(interpreter.as_os_str(), directories) - .ok_or_else(|| format!("{label} interpreter is missing"))? - }; - Ok(BoundExecutable { - selected: selected.clone(), - program, - script: Some(selected), - }) -} - -fn shebang(path: &Path) -> Result> { - let mut bytes = [0u8; MAX_SHEBANG + 1]; - let count = std::fs::File::open(path) - .and_then(|mut file| file.read(&mut bytes)) - .map_err(|_| "Could not inspect Codex executable")?; - if !bytes[..count].starts_with(b"#!") { - return Ok(None); - } - let end = bytes[..count] - .iter() - .position(|byte| *byte == b'\n') - .ok_or("Codex executable has an oversized interpreter line")?; - let line = std::str::from_utf8(&bytes[2..end]) - .map_err(|_| "Codex executable has an invalid interpreter line")?; - let mut words = line.split_whitespace(); - let interpreter = words - .next() - .filter(|value| !value.is_empty()) - .ok_or("Codex executable has no interpreter")?; - if interpreter == "/usr/bin/env" { - if words.next() != Some("node") || words.next().is_some() { - return Err("Codex executable uses an unsupported env interpreter".into()); - } - } else if words.next().is_some() { - return Err("Codex executable uses unsupported interpreter arguments".into()); - } - Ok(Some(interpreter.into())) -} - -fn resolve(name: impl AsRef, directories: &[PathBuf]) -> Option { - let name = Path::new(name.as_ref()); - let candidates: Vec<_> = if name.is_absolute() { - vec![name.to_path_buf()] - } else { - directories - .iter() - .map(|directory| directory.join(name)) - .collect() - }; - candidates.into_iter().find_map(|path| { - executable(&path).ok()?; - path.canonicalize().ok() - }) -} - -fn executable(path: &Path) -> Result<()> { - let metadata = path.metadata().map_err(|_| "Executable is missing")?; - if !metadata.is_file() { - return Err("Executable is not a file".into()); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if metadata.permissions().mode() & 0o111 == 0 { - return Err("File is not executable".into()); - } - } - Ok(()) -} - -fn default_directories() -> Vec { - crate::tools_path() - .map(|path| std::env::split_paths(&path).collect()) - .unwrap_or_default() -} - -#[cfg(all(test, unix))] -mod tests; diff --git a/crates/agent-controller/src/codex/tests.rs b/crates/agent-controller/src/codex/tests.rs deleted file mode 100644 index 6ad3e5192..000000000 --- a/crates/agent-controller/src/codex/tests.rs +++ /dev/null @@ -1,247 +0,0 @@ -use super::*; -use std::os::unix::fs::PermissionsExt; - -fn tool(path: &Path, body: &str) { - std::fs::write(path, body).unwrap(); - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); -} - -#[test] -fn canonical_binding_uses_one_exact_context_for_adapter_and_cli() { - let root = tempfile::tempdir().unwrap(); - let bin = root.path().join("bin"); - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&bin).unwrap(); - std::fs::create_dir(&workspace).unwrap(); - tool(&bin.join("node"), "#!/bin/sh\nexit 0\n"); - tool(&bin.join("codex-acp"), "#!/usr/bin/env node\n"); - tool(&bin.join("codex"), "#!/usr/bin/env node\n"); - let context = CodexContext::new( - &bin.join("codex-acp"), - &bin.join("codex"), - &workspace, - &BTreeMap::new(), - ) - .unwrap(); - assert_eq!( - context.interpreter, - Some(bin.join("node").canonicalize().unwrap()) - ); - for command in [context.adapter_command(), context.cli_command()] { - assert_eq!( - command.get_current_dir(), - Some(workspace.canonicalize().unwrap().as_path()) - ); - let env: BTreeMap<_, _> = command - .get_envs() - .filter_map(|(key, value)| value.map(|value| (key, value))) - .collect(); - assert_eq!( - env[OsStr::new("CODEX_PATH")], - bin.join("codex").canonicalize().unwrap() - ); - assert_eq!( - env[OsStr::new("INITIAL_AGENT_MODE")], - OsStr::new("agent-full-access") - ); - assert!(!env.contains_key(OsStr::new("OPENAI_API_KEY"))); - } -} - -#[test] -fn adapter_launches_the_exact_selected_cli_with_the_isolated_path() { - let root = tempfile::tempdir().unwrap(); - let adapter_bin = root.path().join("adapter-bin"); - let cli_bin = root.path().join("cli-bin"); - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&adapter_bin).unwrap(); - std::fs::create_dir_all(&cli_bin).unwrap(); - std::fs::create_dir(&workspace).unwrap(); - let marker = root.path().join("selected-cli"); - tool( - &adapter_bin.join("codex-acp"), - "#!/bin/sh\n\"$CODEX_PATH\" --version\n", - ); - tool(&cli_bin.join("node"), "#!/bin/sh\nexec /bin/sh \"$@\"\n"); - tool( - &cli_bin.join("codex"), - &format!( - "#!/usr/bin/env node\nprintf selected > '{}'\n", - marker.display() - ), - ); - let context = CodexContext::new( - &adapter_bin.join("codex-acp"), - &cli_bin.join("codex"), - &workspace, - &BTreeMap::new(), - ) - .unwrap(); - assert!(context.adapter_command().status().unwrap().success()); - assert_eq!(std::fs::read_to_string(marker).unwrap(), "selected"); -} - -#[test] -fn adapter_uses_the_cli_bound_interpreter_when_interpreters_differ() { - let root = tempfile::tempdir().unwrap(); - let adapter_bin = root.path().join("adapter-bin"); - let adapter_runtime = root.path().join("adapter-runtime"); - let cli_bin = root.path().join("cli-bin"); - let workspace = root.path().join("workspace"); - for directory in [&adapter_bin, &adapter_runtime, &cli_bin, &workspace] { - std::fs::create_dir_all(directory).unwrap(); - } - let adapter = adapter_bin.join("codex-acp"); - let cli = cli_bin.join("codex"); - let adapter_node = adapter_runtime.join("node"); - tool( - &adapter_node, - "#!/bin/sh\ncase \"$1\" in */codex-acp) ;; *) exit 9 ;; esac\nexec /bin/sh \"$@\"\n", - ); - tool( - &adapter, - &format!("#!{}\n\"$CODEX_PATH\" --version\n", adapter_node.display()), - ); - tool( - &adapter_bin.join("node"), - "#!/bin/sh\nexec /bin/sh \"$@\"\n", - ); - tool(&cli, "#!/usr/bin/env node\nprintf cli-interpreter\n"); - let context = CodexContext::new(&adapter, &cli, &workspace, &BTreeMap::new()).unwrap(); - let output = context.adapter_command().output().unwrap(); - assert!(output.status.success(), "{output:?}"); - assert_eq!(output.stdout, b"cli-interpreter"); -} - -#[test] -fn rejects_unsupported_or_unresolved_interpreters_and_context() { - let root = tempfile::tempdir().unwrap(); - let workspace = root.path().join("workspace"); - std::fs::create_dir(&workspace).unwrap(); - let adapter = root.path().join("codex-acp"); - let cli = root.path().join("codex"); - tool(&cli, "#!/bin/sh\nexit 0\n"); - for body in ["#!/usr/bin/env -S node\n", "#!relative\n", "#!"] { - tool(&adapter, body); - assert!(CodexContext::new(&adapter, &cli, &workspace, &BTreeMap::new()).is_err()); - } - let mut overrides = BTreeMap::from([("SECRET".into(), "value".into())]); - tool(&adapter, "#!/bin/sh\nexit 0\n"); - assert!(CodexContext::new(&adapter, &cli, &workspace, &overrides).is_err()); - overrides = BTreeMap::from([("CODEX_CONFIG".into(), "{}".into())]); - assert!(CodexContext::new(&adapter, &cli, &workspace, &overrides).is_err()); -} - -#[test] -fn rejects_adapter_paths_or_arguments_that_buzz_acp_cannot_represent() { - let root = tempfile::tempdir().unwrap(); - let workspace = root.path().join("workspace"); - std::fs::create_dir(&workspace).unwrap(); - let cli = root.path().join("codex"); - tool(&cli, "#!/bin/sh\nexit 0\n"); - for name in ["codex,acp", " codex-acp "] { - let adapter = root.path().join(name); - tool(&adapter, "#!/bin/sh\nexit 0\n"); - assert!(CodexContext::new(&adapter, &cli, &workspace, &BTreeMap::new()).is_err()); - } - let adapter = root.path().join("codex-acp"); - tool(&adapter, "#!/bin/sh\nexit 0\n"); - let context = CodexContext::new(&adapter, &cli, &workspace, &BTreeMap::new()).unwrap(); - assert!(context.adapter_launch(&["--model=other".into()]).is_err()); -} - -#[test] -fn binding_equality_fences_path_workspace_and_configuration_context() { - let root = tempfile::tempdir().unwrap(); - let first = root.path().join("first"); - let second = root.path().join("second"); - std::fs::create_dir(&first).unwrap(); - std::fs::create_dir(&second).unwrap(); - let adapter = root.path().join("codex-acp"); - let cli = root.path().join("codex"); - tool(&adapter, "#!/bin/sh\nexit 0\n"); - tool(&cli, "#!/bin/sh\nexit 0\n"); - let base = CodexContext::new(&adapter, &cli, &first, &BTreeMap::new()).unwrap(); - assert!(base == CodexContext::new(&adapter, &cli, &first, &BTreeMap::new()).unwrap()); - assert!(base != CodexContext::new(&adapter, &cli, &second, &BTreeMap::new()).unwrap()); - assert!( - base != CodexContext::new( - &adapter, - &cli, - &first, - &BTreeMap::from([("CODEX_HOME".into(), second.display().to_string())]), - ) - .unwrap() - ); -} - -#[test] -fn agent_context_excludes_defaults_owned_by_other_harnesses() { - let effective = BTreeMap::from([ - ("DATABRICKS_HOST".into(), "https://private.example".into()), - ("GOOSE_PROVIDER".into(), "private-provider".into()), - ("OPENAI_API_KEY".into(), "private-key".into()), - ("CODEX_HOME".into(), "/tmp/codex-home".into()), - ]); - assert_eq!( - agent_environment(&effective), - BTreeMap::from([("CODEX_HOME".into(), "/tmp/codex-home".into())]) - ); -} - -#[test] -fn app_owned_adapter_is_a_fallback_bound_to_its_pinned_node() { - let root = tempfile::tempdir().unwrap(); - let app_data = root.path().join("app-data"); - let workspace = root.path().join("workspace"); - let user = root.path().join("user-bin"); - let release = app_data.join("codex-tools/releases/r1"); - let script = release.join("lib/node_modules/@agentclientprotocol/codex-acp/dist/index.js"); - for dir in [ - &workspace, - &user, - &release.join("bin"), - &script.parent().unwrap().into(), - ] { - std::fs::create_dir_all(dir).unwrap(); - } - std::fs::create_dir_all(app_data.join("codex-tools/bin")).unwrap(); - tool(&script, "#!/usr/bin/env node\n"); - let shim = app_data.join("codex-tools/bin/codex-acp"); - // The npm layout: bin shim -> release shim -> package script. - std::os::unix::fs::symlink(&script, release.join("bin/codex-acp")).unwrap(); - std::os::unix::fs::symlink("../releases/r1/bin/codex-acp", &shim).unwrap(); - tool(&user.join("codex-acp"), "#!/bin/sh\nexit 0\n"); - tool(&user.join("codex"), "#!/usr/bin/env node\n"); - tool(&user.join("node"), "#!/bin/sh\nexit 0\n"); - - assert_eq!( - adapter_choice(None, Some(&app_data)), - None, - "no pinned Node" - ); - for platform in ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"] { - let bin = app_data - .join("runtimes/node/v24.18.0") - .join(platform) - .join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - tool(&bin.join("node"), "#!/bin/sh\nexit 0\n"); - } - let node = crate::managed_tool(&app_data, "node").unwrap(); - assert_eq!(adapter_choice(None, Some(&app_data)), Some(shim.clone())); - assert_eq!(adapter_choice(None, None), None); - assert_eq!( - adapter_choice(Some(user.join("codex-acp")), Some(&app_data)), - Some(user.join("codex-acp")) - ); - - let context = - CodexContext::new(&shim, &user.join("codex"), &workspace, &BTreeMap::new()).unwrap(); - assert_eq!(context.adapter, script.canonicalize().unwrap()); - assert_eq!(context.interpreter, Some(node.canonicalize().unwrap())); - assert_eq!( - context.cli_interpreter, - Some(user.join("node").canonicalize().unwrap()) - ); -} diff --git a/crates/agent-controller/src/community.rs b/crates/agent-controller/src/community.rs deleted file mode 100644 index bd5803eec..000000000 --- a/crates/agent-controller/src/community.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Owner-signed intent to configure one identity/community pair. -//! Local import and existing setups do not depend on this confirmation. -use crate::config::{canonical_key, canonical_relay}; -use crate::Result; -use secp256k1::{schnorr::Signature, Secp256k1, XOnlyPublicKey}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -#[derive(Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct CommunityResolution { - pub pubkey: String, - pub relay_url: String, - pub owner: String, - pub signature: String, -} -impl CommunityResolution { - pub fn verify(&self, auth: &str) -> Result<()> { - crate::secret::validate_attestation(auth, &self.pubkey)?; - let tag: Vec = serde_json::from_str(auth).map_err(|_| "Invalid source owner")?; - if !canonical_key(&self.pubkey) - || tag[1] != self.owner - || canonical_relay(&self.relay_url)? != self.relay_url - { - return Err( - "Community resolution does not match the source owner or destination".into(), - ); - } - let owner: XOnlyPublicKey = self.owner.parse().map_err(|_| "Invalid resolution owner")?; - let signature: Signature = self - .signature - .parse() - .map_err(|_| "Invalid resolution signature")?; - let digest = Sha256::digest(format!( - "nostr:agent-community:{}:{}", - self.pubkey, self.relay_url - )); - Secp256k1::verification_only() - .verify_schnorr(&signature, &digest, &owner) - .map_err(|_| "Community resolution was not signed by the source owner".into()) - } -} -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use secp256k1::{Keypair, SecretKey}; - const PUB: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - pub(crate) fn resolution(relay: &str) -> CommunityResolution { - let secp = Secp256k1::new(); - let mut bytes = [0; 32]; - bytes[31] = 2; - let pair = Keypair::from_secret_key(&secp, &SecretKey::from_byte_array(bytes).unwrap()); - CommunityResolution { - pubkey: PUB.into(), - relay_url: relay.into(), - owner: pair.x_only_public_key().0.to_string(), - signature: secp - .sign_schnorr_no_aux_rand( - &Sha256::digest(format!("nostr:agent-community:{PUB}:{relay}")), - &pair, - ) - .to_string(), - } - } - #[test] - fn owner_can_confirm_multiple_pairs_without_import_or_storage() { - let auth = crate::secret::test_attestation(PUB); - resolution("wss://one.example").verify(&auth).unwrap(); - resolution("wss://two.example").verify(&auth).unwrap(); - } - #[test] - fn unsigned_or_retargeted_resolution_is_rejected() { - let auth = crate::secret::test_attestation(PUB); - for change in ["destination", "identity", "owner", "signature"] { - let mut value = resolution("wss://one.example"); - match change { - "destination" => value.relay_url = "wss://two.example".into(), - "identity" => value.pubkey = "ab".repeat(32), - "owner" => value.owner = "cd".repeat(32), - _ => value.signature = "00".repeat(64), - } - assert!(value.verify(&auth).is_err()); - } - } -} diff --git a/crates/agent-controller/src/config.rs b/crates/agent-controller/src/config.rs index c39479ceb..9d82f5cc6 100644 --- a/crates/agent-controller/src/config.rs +++ b/crates/agent-controller/src/config.rs @@ -1,552 +1,9 @@ +//! Identity validation shared by native agents, credentials and legacy imports. use crate::Result; -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; -use std::path::Path; pub(crate) const MAX_BYTES: usize = 8 * 1024 * 1024; pub(crate) const MAX_AGENTS: usize = 2000; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SessionPolicy { - Channel, - #[default] - Thread, -} - -impl SessionPolicy { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Channel => "channel", - Self::Thread => "thread", - } - } -} -fn is_false(value: &bool) -> bool { - !value -} -fn policy_edit<'de, D: Deserializer<'de>>( - deserializer: D, -) -> std::result::Result>, D::Error> { - Option::::deserialize(deserializer).map(Some) -} - -// Only these explicit projections may cross IPC. Environment values and unknown -// legacy fields remain native-only even when they do not look like credentials. -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ControlSnapshot { - pub agents: Vec, - pub parked: Vec, - pub runtime_available: bool, - pub runtime_message: Option, - /// Device-wide defaults; environment keys only. - pub default_settings: crate::agent_defaults::AgentDefaultsView, -} -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentView { - pub id: String, - pub pubkey: String, - pub relay_url: String, - pub name: String, - #[serde(default)] - pub picture: Option, - pub system_prompt: String, - /// Saved choice; null uses Agent defaults unless an imported policy exists. - pub session_policy: Option, - pub workspace: String, - pub harness: HarnessView, - pub revision: u64, - pub running_revision: Option, - pub enabled: bool, - pub status: ProcessStatus, - pub error: Option, - pub diagnostics: Vec, - pub profile_pending: bool, - /// Effective launch restore intent; legacy records follow `enabled`. - pub start_on_app_launch: bool, - /// Effective response policy for the next start; `None` when it is invalid. - pub respond_to: Option, - /// Imported provider backend id; local agents have none. - pub backend: Option, - /// Names only. Never expose imported records or team instructions through IPC. - pub snapshot_export_limitations: Vec<&'static str>, - pub acp_command: Option, - pub mcp_command: Option, - /// Model/provider the next start passes to the worker from saved selectors, - /// agent defaults or build defaults; `None` when an environment override decides it. - pub launch_model: Option, - pub launch_provider: Option, - /// Effective listener worker count, including the Buzz Agent default of 1. - /// Numeric projection only; other environment values never leave native. - pub launch_parallelism: Option, - /// Effort level the next start applies; `None` when the harness decides. - pub launch_effort: Option, - /// Environment key deciding that selector. Its value never leaves native. - pub launch_model_env: Option<&'static str>, - pub launch_provider_env: Option<&'static str>, - /// Redacted saved-versus-running differences while the process is alive. - pub restart_diff: Vec, - pub deployed_remote: bool, - pub needs_team_import: bool, - pub configured: bool, -} -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct HarnessView { - /// Stable native integration identity. Absent preserves legacy/custom behavior. - #[serde(skip_serializing_if = "Option::is_none")] - pub integration: Option, - pub command: String, - pub args: Vec, - pub model: String, - /// Explicit managed model/effort intent. Absent preserves legacy behavior. - #[serde(skip_serializing_if = "Option::is_none")] - pub configuration: Option, - pub provider: String, - pub environment_keys: Vec, - pub databricks: Option, -} -#[derive(Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum ProcessStatus { - Stopped, - Waiting, - Starting, - Running, - Stopping, - Failed, -} -#[derive(Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AgentEdit { - pub name: String, - #[serde(default)] - pub picture: Option, - pub system_prompt: String, - /// Missing preserves the current choice; null explicitly inherits defaults. - #[serde(default, deserialize_with = "policy_edit")] - pub session_policy: Option>, - pub workspace: String, - pub harness: HarnessEdit, - /// Absence preserves; null deletes; a value replaces. Never a read API. - pub environment: BTreeMap>, - /// Absence preserves; a value replaces. Set by portable imports. - #[serde(default)] - pub effort: Option, -} -/// Saved key for an agent's own effort level. -pub(crate) const OWN_EFFORT: &str = "effort"; -/// Effort levels are short harness names, as for agent defaults. -pub(crate) fn validate_effort(effort: &str) -> Result<()> { - // Same rule as the app's snapshot parser (`EFFORT`), so an export never - // writes an effort that import would reject. - let allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'); - if effort.is_empty() || effort.len() > 64 || !effort.chars().all(allowed) { - return Err("Effort level is empty, too long or invalid".into()); - } - Ok(()) -} -#[derive(Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct HarnessEdit { - /// Stable native integration identity. Editable command names do not grant it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub integration: Option, - pub command: String, - pub args: Vec, - pub model: String, - /// Explicit model/effort intent for native managed integrations. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub configuration: Option, - pub provider: String, - #[serde(default)] - pub databricks: Option, -} - -/// Explicit managed model/effort intent, distinct from legacy blank inheritance. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(tag = "mode", rename_all = "camelCase", deny_unknown_fields)] -pub enum AiConfiguration { - /// Delegate model and effort selection to the integration. - Default, - /// Apply and validate one explicit model and effort choice. - Advanced { effort: EffortSelection }, -} - -/// An explicit effort choice, including confirmed absence of an effort control. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] -pub enum EffortSelection { - /// Stable adapter value, never its display label. - Value { value: String }, - /// The integration explicitly reported no effort choices for this model. - Unsupported, -} - -impl HarnessEdit { - fn validate_configuration(&self) -> Result<()> { - if self.integration == Some(crate::HarnessIntegration::Codex) && !self.args.is_empty() { - return Err("Native Codex does not accept custom adapter arguments".into()); - } - match (self.integration, &self.configuration) { - (None, None) => Ok(()), - (Some(crate::HarnessIntegration::Codex), Some(AiConfiguration::Default)) => { - if !self.model.is_empty() { - return Err( - "Default Codex configuration must not contain a model override".into(), - ); - } - if !self.provider.is_empty() { - return Err("Codex uses its own provider configuration".into()); - } - Ok(()) - } - ( - Some(crate::HarnessIntegration::Codex), - Some(AiConfiguration::Advanced { effort }), - ) => { - if self.model.trim().is_empty() { - return Err("Choose a model for Advanced Codex configuration".into()); - } - if !self.provider.is_empty() { - return Err("Codex uses its own provider configuration".into()); - } - if let EffortSelection::Value { value } = effort { - if value.trim().is_empty() || value.chars().any(char::is_control) { - return Err("Choose a valid Codex effort value".into()); - } - text(value, 128, "Codex effort")?; - } - Ok(()) - } - (Some(crate::HarnessIntegration::Codex), None) => { - Err("Choose Default or Advanced Codex configuration".into()) - } - (None, Some(_)) => Err("Managed configuration requires a native integration".into()), - (Some(_), _) => { - Err("This native integration does not support saved managed configuration".into()) - } - } - } - - pub(crate) fn codex_effort(&self) -> Option<&str> { - match &self.configuration { - Some(AiConfiguration::Advanced { - effort: EffortSelection::Value { value }, - }) if self.integration == Some(crate::HarnessIntegration::Codex) => Some(value), - _ => None, - } - } -} -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct Agent { - pub id: String, - pub pubkey: String, - pub relay_url: String, - pub name: String, - #[serde(default)] - pub picture: Option, - pub system_prompt: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_policy: Option, - /// Distinguish an explicit default choice from a legacy imported policy. - #[serde(default, skip_serializing_if = "is_false")] - pub session_policy_inherit: bool, - pub workspace: String, - pub harness: HarnessEdit, - pub environment: BTreeMap, - pub revision: u64, - pub enabled: bool, - /// Explicit launch preference. Absent keeps the legacy `enabled` restore. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub start_on_app_launch: Option, - /// Credential reference only. Secret key resides in the native credential store. - pub credential_id: String, - pub auth_tag: Option, - /// Original source records/config; edits never rewrite or discard these fields. - pub imported: Value, - #[serde(flatten)] - pub extra: BTreeMap, -} -impl Agent { - pub(crate) fn configured(&self) -> bool { - self.extra.get("configured") != Some(&Value::Bool(false)) - } - /// `inherited` supplies launch selectors; the saved harness is shown as saved. - pub(crate) fn view(&self, inherited: &crate::agent_defaults::AgentDefaults) -> AgentView { - let defaults = crate::build_defaults(); - let effective = crate::agent_defaults::effective(self, inherited); - let launch = defaults.launch_view(&effective.harness, &effective.environment); - AgentView { - id: self.id.clone(), - pubkey: self.pubkey.clone(), - relay_url: self.relay_url.clone(), - name: self.name.clone(), - picture: self.picture.clone(), - system_prompt: self.system_prompt.clone(), - session_policy: self.selected_session_policy(), - workspace: self.workspace.clone(), - harness: HarnessView { - integration: self.harness.integration, - command: self.harness.command.clone(), - args: self.harness.args.clone(), - model: self.harness.model.clone(), - configuration: self.harness.configuration.clone(), - provider: self.harness.provider.clone(), - environment_keys: self.environment.keys().cloned().collect(), - databricks: self.harness.databricks.clone(), - }, - revision: self.revision, - running_revision: None, - enabled: self.enabled, - status: ProcessStatus::Stopped, - error: None, - diagnostics: Vec::new(), - configured: self.configured(), - profile_pending: self.extra.get("profilePending") == Some(&Value::Bool(true)) - || self.extra.get("profileNamePending") == Some(&Value::Bool(true)), - start_on_app_launch: self.starts_on_launch(), - respond_to: self.respond_to(defaults.owner_only).ok().map(str::to_owned), - backend: (self.imported["record"]["backend"]["type"] == "provider") - .then(|| self.imported["record"]["backend"]["id"].as_str()) - .flatten() - .map(str::to_owned), - snapshot_export_limitations: { - let record = &self.imported["record"]; - let mut limits = Vec::new(); - if crate::import::team_text(&self.imported["teamInstructions"]) - .map_or(true, |text| !text.is_empty()) - { - limits.push("team instructions"); - } - if !record["idle_timeout_seconds"].is_null() { - limits.push("idle timeout"); - } - if !record["max_turn_duration_seconds"].is_null() { - limits.push("turn timeout"); - } - if effective.environment.keys().any(|key| { - (key.starts_with("BUZZ_ACP_") && key != "BUZZ_ACP_AGENTS") - || (crate::agent_defaults::harness_kind(&effective.harness.command) - == Some("buzz-agent") - && matches!( - key.as_str(), - "BUZZ_AGENT_SYSTEM_PROMPT" - | "BUZZ_AGENT_SYSTEM_PROMPT_FILE" - | "BUZZ_AGENT_MAX_ROUNDS" - | "BUZZ_AGENT_MAX_OUTPUT_TOKENS" - | "BUZZ_AGENT_MAX_TOKEN_RECOVERIES" - | "BUZZ_AGENT_LLM_TIMEOUT_SECS" - | "BUZZ_AGENT_TOOL_TIMEOUT_SECS" - | "BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS" - | "BUZZ_AGENT_MCP_RESTART_MAX_ATTEMPTS" - | "BUZZ_AGENT_MCP_RESTART_BASE_MS" - | "BUZZ_AGENT_MCP_RESTART_MAX_MS" - | "BUZZ_AGENT_MAX_SESSIONS" - | "BUZZ_AGENT_MAX_LINE_BYTES" - | "BUZZ_AGENT_MAX_HISTORY_BYTES" - | "BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES" - | "BUZZ_AGENT_MAX_CONTEXT_TOKENS" - | "BUZZ_AGENT_MAX_HANDOFFS" - | "BUZZ_AGENT_MAX_PARALLEL_TOOLS" - | "BUZZ_AGENT_MAX_PENDING_PERMISSIONS" - | "BUZZ_AGENT_PERMISSION_TIMEOUT_SECS" - | "BUZZ_AGENT_HOOK_TIMEOUT_MS" - | "BUZZ_AGENT_STOP_MAX_REJECTIONS" - | "BUZZ_AGENT_REQUIRE_REPLY" - | "BUZZ_AGENT_NO_HINTS" - | "BUZZ_AGENT_THINKING_EFFORT" - | "BUZZ_AGENT_THINKING_SUMMARY" - | "BUZZ_AGENT_PROMPT_CACHING" - )) - }) { - limits.push("behavioral environment overrides"); - } - limits - }, - acp_command: None, - mcp_command: None, - launch_model: launch.model, - launch_provider: launch.provider, - launch_parallelism: effective - .environment - .get("BUZZ_ACP_AGENTS") - .and_then(|value| value.parse::().ok()) - .or_else(|| { - self.imported["record"]["parallelism"] - .as_u64() - .and_then(|count| u32::try_from(count).ok()) - }) - .or_else(|| { - (crate::agent_defaults::harness_kind(&self.harness.command) - == Some("buzz-agent")) - .then_some(1) - }), - launch_effort: crate::agent_defaults::launch_effort(&effective).map(str::to_owned), - launch_model_env: launch.model_env, - launch_provider_env: launch.provider_env, - restart_diff: Vec::new(), - deployed_remote: self.deployed_remote(), - needs_team_import: self.needs_team_import(), - } - } - pub fn needs_team_import(&self) -> bool { - let record = &self.imported["record"]; - ["team_id", "persona_team_dir"].iter().any(|field| { - record[field] - .as_str() - .is_some_and(|value| !value.is_empty()) - }) && self.imported.get("teamInstructions").is_none() - } - pub fn starts_on_launch(&self) -> bool { - self.start_on_app_launch.unwrap_or(self.enabled) - } - pub(crate) fn selected_session_policy(&self) -> Option { - if self.session_policy_inherit { - return None; - } - self.session_policy.or_else(|| { - // Old Buzz omitted Channel on serialization; a linked definition - // still wins over the record when its field is absent. - let imported = self - .imported - .get("definition") - .filter(|value| value.is_object()) - .or_else(|| { - self.imported - .get("record") - .filter(|value| value.is_object()) - })?; - Some(match imported["session_policy"].as_str() { - Some("thread") => SessionPolicy::Thread, - _ => SessionPolicy::Channel, - }) - }) - } - pub fn respond_to(&self, owner_only: bool) -> Result<&str> { - let respond_to = if owner_only { - "owner-only" - } else { - self.imported["record"]["respond_to"] - .as_str() - .unwrap_or("owner-only") - }; - if matches!(respond_to, "owner-only" | "allowlist" | "anyone") { - Ok(respond_to) - } else { - Err("Invalid imported response policy".into()) - } - } - /// An imported record for an agent hosted by a remote backend. - pub fn deployed_remote(&self) -> bool { - let record = &self.imported["record"]; - record["backend"]["type"] - .as_str() - .is_some_and(|s| s != "local") - && !record["backend_agent_id"].is_null() - } - pub fn apply(&mut self, edit: AgentEdit) -> Result<()> { - visible_agent_text(&edit.name, false)?; - visible_agent_text(&edit.system_prompt, true)?; - if let Some(picture) = edit.picture { - validate_picture(&picture)?; - if self.picture.as_ref() != Some(&picture) { - self.picture = Some(picture); - self.extra - .insert("profilePending".into(), Value::Bool(true)); - } - } - if self.name != edit.name { - self.extra - .insert("profileNamePending".into(), Value::Bool(true)); - } - self.name = edit.name; - self.system_prompt = edit.system_prompt; - if let Some(policy) = edit.session_policy { - self.session_policy = policy; - self.session_policy_inherit = policy.is_none(); - } - self.workspace = edit.workspace; - let harness_changed = crate::agent_defaults::harness_kind(&self.harness.command) - != crate::agent_defaults::harness_kind(&edit.harness.command); - self.harness = edit.harness; - if let Some(effort) = edit.effort { - validate_effort(&effort)?; - self.extra.insert(OWN_EFFORT.into(), Value::String(effort)); - } else if harness_changed { - // Effort names belong to one harness, as for agent defaults. Null - // also retires an old Buzz import's `effort_level`. - self.extra.insert(OWN_EFFORT.into(), Value::Null); - } - for (key, value) in edit.environment { - if let Some(value) = value { - validate_env_key(&key, &self.harness.command)?; - text(&value, 32 * 1024, "Environment value")?; - self.environment.insert(key, value); - } else { - self.environment.remove(&key); - } - } - self.revision = self - .revision - .checked_add(1) - .filter(|n| *n <= 9_007_199_254_740_991) - .ok_or("Agent revision exhausted")?; - self.validate() - } - pub fn validate(&self) -> Result<()> { - if !canonical_key(&self.pubkey) || self.id != agent_id(&self.pubkey, &self.relay_url) { - return Err("Invalid agent identity".into()); - } - if canonical_relay(&self.relay_url)? != self.relay_url { - return Err("Agent community must be canonical".into()); - } - if self.revision == 0 || self.revision > 9_007_199_254_740_991 { - return Err("Invalid agent revision".into()); - } - if self.name.trim().is_empty() { - return Err("Agent name is required".into()); - } - text(&self.name, 256, "Agent name")?; - if let Some(picture) = &self.picture { - validate_picture(picture)?; - } - text(&self.system_prompt, 128 * 1024, "System prompt")?; - text(&self.workspace, 4096, "Workspace")?; - if !Path::new(&self.workspace).is_absolute() { - return Err("Choose an absolute workspace path".into()); - } - text(&self.harness.command, 4096, "Harness command")?; - if self.harness.command.trim().is_empty() { - return Err("Harness command is required".into()); - } - if self.harness.args.len() > 128 { - return Err("Too many harness arguments".into()); - } - for arg in &self.harness.args { - text(arg, 8192, "Harness argument")?; - // Existing ACP uses clap's comma-delimited --agent-args transport. - if arg.contains(',') || arg.is_empty() { - return Err("ACP arguments must be nonempty and cannot contain commas".into()); - } - } - text(&self.harness.model, 512, "Model")?; - self.harness.validate_configuration()?; - text(&self.harness.provider, 128, "Provider")?; - if let Some(settings) = &self.harness.databricks { - settings.validate()?; - } - validate_environment(&self.environment, &self.harness.command)?; - if self.harness.integration == Some(crate::HarnessIntegration::Codex) { - crate::codex::validate_native_environment(&self.environment)?; - } - Ok(()) - } -} pub(crate) fn canonical_key(key: &str) -> bool { key.len() == 64 && key @@ -649,111 +106,6 @@ pub(crate) fn visible_agent_text(value: &str, prompt: bool) -> Result<()> { Ok(()) } -fn text(value: &str, limit: usize, label: &str) -> Result<()> { - if value.len() > limit || value.contains('\0') { - Err(format!("{label} is too long or contains a NUL byte")) - } else { - Ok(()) - } -} -pub(crate) fn validate_environment( - environment: &BTreeMap, - command: &str, -) -> Result<()> { - if environment.len() > 128 { - return Err("Too many environment entries".into()); - } - for (key, value) in environment { - validate_env_key(key, command)?; - text(value, 32 * 1024, "Environment value")?; - if key == "BUZZ_ACP_AGENTS" - && !value - .parse::() - .is_ok_and(|count| (1..=32).contains(&count)) - { - return Err("Agent worker count must be an integer from 1 to 32".into()); - } - } - Ok(()) -} -pub(crate) fn validate_env_key(key: &str, command: &str) -> Result<()> { - let upper = key.to_ascii_uppercase(); - let behavior_overrides = matches!( - crate::agent_defaults::harness_kind(command), - Some("pi" | "goose") - ); - if key.is_empty() - || key.len() > 128 - || !key - .bytes() - .enumerate() - .all(|(i, c)| c == b'_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())) - // Pi/Goose retain old Buzz's behavior overrides. Other harnesses keep - // their namespace restrictions; Create's worker count is editable. - || (!behavior_overrides - && ((upper.starts_with("BUZZ_ACP_") && key != "BUZZ_ACP_AGENTS") - || upper.starts_with("BUZZ_MANAGED_"))) - || (upper == "BUZZ_ACP_AGENTS" && key != "BUZZ_ACP_AGENTS") - || upper.starts_with("BUZZ_APP_") - || upper.starts_with("GIT_CONFIG_") - || matches!( - upper.as_str(), - "BUZZ_PRIVATE_KEY" - | "NOSTR_PRIVATE_KEY" - | "BUZZ_AUTH_TAG" - | "BUZZ_RELAY_URL" - | "BUZZ_API_TOKEN" - | "BUZZ_AGENT_CONFIG_DIR" - | "PI_ACP_PI_COMMAND" - // Identity, routing, execution and saved response policy. - | "BUZZ_ACP_PRIVATE_KEY" - | "BUZZ_ACP_API_TOKEN" - | "BUZZ_ACP_AGENT_COMMAND" - | "BUZZ_ACP_AGENT_ARGS" - | "BUZZ_ACP_MCP_COMMAND" - | "BUZZ_ACP_LAUNCH_PREFIX" - | "BUZZ_ACP_RESPOND_TO" - | "BUZZ_ACP_RESPOND_TO_ALLOWLIST" - | "BUZZ_ACP_ALLOWED_RESPOND_TO" - | "BUZZ_ACP_AGENT_OWNER" - | "BUZZ_ACP_DISPLAY_NAME" - | "BUZZ_ACP_TEAM_INSTRUCTIONS" - // Host-owned lifetime, readiness and per-send replay policy. - | "BUZZ_ACP_EXIT_AFTER_INACTIVITY" - | "BUZZ_ACP_IDLE_POOL_SLEEP" - | "BUZZ_ACP_SESSION_POLICY" - | "BUZZ_ACP_NO_PRESENCE" - | "BUZZ_ACP_SETUP_PAYLOAD" - | "BUZZ_ACP_REPLAY_FLOOR" - | "BUZZ_MANAGED_AGENT" - | "BUZZ_MANAGED_AGENT_START_NONCE" - ) - { - // Do not interpolate arbitrary user text into diagnostics. - Err("Invalid or host-reserved environment key".into()) - } else { - Ok(()) - } -} - -pub(crate) fn validate_picture(value: &str) -> Result<()> { - if value.is_empty() { - return Ok(()); - } - if value.len() <= 2048 { - if let Ok(url) = url::Url::parse(value) { - if url.scheme() == "https" - && url.host_str().is_some() - && url.username().is_empty() - && url.password().is_none() - { - return Ok(()); - } - } - } - Err("Avatar must be an HTTPS image URL without credentials".into()) -} - #[cfg(test)] mod snapshot_visible_text_tests { use super::visible_agent_text; diff --git a/crates/agent-controller/src/connection.rs b/crates/agent-controller/src/connection.rs index 3f98b9592..610e79c20 100644 --- a/crates/agent-controller/src/connection.rs +++ b/crates/agent-controller/src/connection.rs @@ -1,57 +1,9 @@ -//! App-owned persistent settings and the existing engine's OAuth cache layout. -//! This is not an OAuth implementation; the pinned engine owns acquisition/refresh. +//! Private storage directory used by credential locking, which only macOS +//! (and tests) compile. use crate::Result; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::path::{Path, PathBuf}; +use std::path::Path; -#[derive(Clone, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct DatabricksSettings { - pub host: String, - pub filter: String, -} -impl DatabricksSettings { - pub(crate) fn validate(&self) -> Result<()> { - if self.host.len() > 4096 || self.filter.len() > 4096 || self.filter.contains('\0') { - return Err("Connection settings are too long or invalid".into()); - } - // An empty draft can be saved offline; Start requires a workspace. - if !self.host.is_empty() { - origin(&self.host)?; - } - Ok(()) - } -} -/// The pinned engine keeps non-Unix OAuth tokens in memory only, so neither a -/// later request nor a worker could reuse a sign-in. Deferred on Windows. -pub const DATABRICKS_WINDOWS: &str = - "Databricks sign-in is not supported on Windows yet. Choose OpenAI for this agent"; -pub fn origin(raw: &str) -> Result { - if raw.is_empty() { - return Err("Databricks workspace is not configured. Edit the agent, open Advanced → Model, and set Databricks workspace (HTTPS origin).".into()); - } - let invalid = || { - "Enter a Databricks HTTPS workspace origin without credentials, path, query or fragment" - .to_owned() - }; - let authority = raw.strip_prefix("https://").ok_or_else(invalid)?; - let authority = authority.strip_suffix('/').unwrap_or(authority); - let url = url::Url::parse(raw).map_err(|_| invalid())?; - if raw.trim() != raw - || raw.chars().any(char::is_control) - || raw.contains('\\') - || authority.contains(['/', '@']) - || url.host_str().is_none() - || url.query().is_some() - || url.fragment().is_some() - || url.path() != "/" - { - return Err(invalid()); - } - Ok(url.as_str().trim_end_matches('/').into()) -} -pub fn private_directory(path: &Path) -> Result<()> { +pub(crate) fn private_directory(path: &Path) -> Result<()> { match std::fs::symlink_metadata(path) { Ok(meta) if meta.is_dir() && !meta.file_type().is_symlink() => {} Ok(_) => return Err("App storage must be a private directory, not a link".into()), @@ -68,34 +20,3 @@ pub fn private_directory(path: &Path) -> Result<()> { } Ok(()) } -/// C is the native profile, P is exactly C/buzz-agent/oauth for both consumers. -pub fn oauth_root(config: &Path) -> Result { - if !config.is_absolute() { - return Err("App connection storage must be absolute".into()); - } - private_directory(config)?; - let agent = config.join("buzz-agent"); - private_directory(&agent)?; - let root = agent.join("oauth"); - private_directory(&root)?; - private_directory(&root.join("databricks"))?; - Ok(root) -} -/// Keep the engine's lock inode. Only a serialized native controller with no -/// running users of this workspace may call this. Other workspace caches survive. -pub fn disconnect(cache: &Path, workspace: &str) -> Result<()> { - let workspace = origin(workspace)?; - let namespace = cache.join("databricks"); - private_directory(&namespace)?; - let key = format!("{workspace}/oidc/.well-known/oauth-authorization-server|databricks-cli|all-apis,offline_access"); - let hash = format!("{:x}", Sha256::digest(key.as_bytes())); - for suffix in ["json", "json.cooldown", "json.attempt"] { - let path = namespace.join(format!("{hash}.{suffix}")); - match std::fs::remove_file(path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err("Could not remove this app's connection credentials".into()), - } - } - Ok(()) -} diff --git a/crates/agent-controller/src/create.rs b/crates/agent-controller/src/create.rs index 0af5368ef..7933f9131 100644 --- a/crates/agent-controller/src/create.rs +++ b/crates/agent-controller/src/create.rs @@ -1,222 +1,7 @@ -//! Native-only key creation. Retrying a prepared identity never generates a second key. -use crate::config::{agent_id, canonical_key, canonical_relay, Agent, HarnessEdit}; -use crate::{AgentEdit, Controller, Credentials, Result, Secret}; +//! Verified profile publication helpers, also used when adopting an existing identity. +use crate::{Result, Secret}; use serde_json::Value; -use std::collections::BTreeMap; -pub struct NewAgent { - pub id: String, - pub key: Secret, - relay: String, - owner: String, -} -impl NewAgent { - pub fn prepare(destination: &str, owner: &str) -> Result { - if !canonical_key(owner) { - return Err("Choose a signed-in owner".into()); - } - let relay = canonical_relay(destination)?; - let key = Secret::generate()?; - Ok(Self { - id: agent_id(key.pubkey(), &relay), - key, - relay, - owner: owner.into(), - }) - } - pub fn matches(&self, destination: &str, owner: &str) -> Result { - Ok(self.relay == canonical_relay(destination)? && self.owner == owner) - } - pub(crate) fn agent(&self, edit: AgentEdit, auth: &str) -> Result { - crate::secret::validate_attestation(auth, self.key.pubkey())?; - let tag: Vec = - serde_json::from_str(auth).map_err(|_| "Invalid owner authorization")?; - if tag[1] != self.owner { - return Err("Agent authorization belongs to another owner".into()); - } - let mut agent = Agent { - picture: None, - id: self.id.clone(), - pubkey: self.key.pubkey().into(), - relay_url: self.relay.clone(), - name: String::new(), - system_prompt: String::new(), - session_policy: None, - session_policy_inherit: false, - workspace: String::new(), - harness: HarnessEdit { - integration: None, - command: String::new(), - args: vec![], - model: String::new(), - configuration: None, - provider: String::new(), - databricks: None, - }, - environment: BTreeMap::new(), - revision: 0, - enabled: false, - start_on_app_launch: Some(false), - credential_id: self.id.clone(), - auth_tag: Some(auth.into()), - imported: Value::Null, - extra: BTreeMap::from([("nativeCreated".into(), Value::Bool(true))]), - }; - agent.apply(edit)?; - Ok(agent) - } - pub fn validate(&self, edit: AgentEdit, auth: &str) -> Result<()> { - self.agent(edit, auth).map(|_| ()) - } - pub fn save_key(&self, credentials: &dyn Credentials) -> Result<()> { - if credentials.read(&self.id, self.key.pubkey())?.is_none() { - credentials.add(&self.id, &self.key)?; - } - credentials - .read(&self.id, self.key.pubkey())? - .ok_or("New agent key could not be verified")?; - Ok(()) - } -} -impl Controller { - pub fn create(&mut self, prepared: &NewAgent, edit: AgentEdit, auth: &str) -> Result<()> { - let mut agent = prepared.agent(edit, auth)?; - if self.store.agents()?.iter().any(|a| a.id == agent.id) { - return Ok(()); - } - // Kind-0 is a replaceable profile, not an append-only command. Retry with - // this saved key and a fresh timestamp so delayed retries remain admissible. - agent - .extra - .insert("profilePending".into(), Value::Bool(true)); - self.store.insert(vec![agent]) - } - /// Scope memory writes to a saved native-created identity and its owner attestation. - pub fn memory_target(&self, id: &str) -> Result { - let target = self.profile_target(id, false)?; - let native = self - .store - .agents()? - .into_iter() - .find(|agent| agent.id == id) - .is_some_and(|agent| agent.extra.get("nativeCreated") == Some(&Value::Bool(true))); - if !native { - return Err("Snapshot memory requires a native-created agent".into()); - } - Ok(target) - } - /// Persist the request receipt with the identity; retries survive renderer and host reloads. - pub fn created_request( - &self, - request: &str, - destination: &str, - owner: &str, - ) -> Result> { - let relay = canonical_relay(destination)?; - let defaults = self.store.defaults()?; - let agent = self - .store - .agents()? - .into_iter() - .find(|a| a.extra.get("bundleRequest").and_then(Value::as_str) == Some(request)); - if let Some(agent) = &agent { - let tag: Vec = serde_json::from_str( - agent - .auth_tag - .as_deref() - .ok_or("Missing owner authorization")?, - ) - .map_err(|_| "Invalid owner authorization")?; - if agent.relay_url != relay || tag.get(1).map(String::as_str) != Some(owner) { - return Err("Create destination or owner changed".into()); - } - } - Ok(agent.map(|a| a.view(&defaults))) - } - pub fn create_bundle_member( - &mut self, - prepared: &NewAgent, - edit: AgentEdit, - auth: &str, - request: &str, - bundle: &crate::teams::BundleMember, - ) -> Result<()> { - bundle.validate()?; - if self - .created_request(request, &prepared.relay, &prepared.owner)? - .is_some() - { - return Ok(()); - } - let mut agent = prepared.agent(edit, auth)?; - let instructions = - crate::import::team_text(&serde_json::json!(bundle.instructions))?.to_owned(); - let keep_allowlist = bundle.keep_allowlist; - let d = &bundle.member.definition; - let policy = if d.respond_to.as_deref() == Some("allowlist") && !keep_allowlist { - "owner-only" - } else { - d.respond_to.as_deref().unwrap_or("owner-only") - }; - agent.imported = serde_json::json!({"teamInstructions": instructions, "teamBindings": [bundle.team], "record": { - "respond_to": policy, "respond_to_allowlist": if keep_allowlist { d.respond_to_allowlist.clone() } else { vec![] }, - "parallelism": d.parallelism, "name_pool": d.name_pool, "idle_timeout_seconds": d.idle_timeout_seconds, - "source_is_builtin": d.source_is_builtin, "profile": bundle.member.profile, - "max_turn_duration_seconds": d.max_turn_duration_seconds, - }}); - agent - .extra - .insert("bundleRequest".into(), Value::String(request.into())); - agent - .extra - .insert("profilePending".into(), Value::Bool(true)); - agent - .extra - .insert("importAboutPending".into(), Value::Bool(true)); - self.store.insert(vec![agent]) - } - pub fn creation_profile(&self, id: &str) -> Result { - self.profile_target(id, true) - } - fn profile_target(&self, id: &str, pending: bool) -> Result { - let agent = self - .store - .agents()? - .into_iter() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - let profile_pending = agent.extra.get("profilePending") == Some(&Value::Bool(true)); - let name_pending = agent.extra.get("profileNamePending") == Some(&Value::Bool(true)); - if pending && !profile_pending && !name_pending { - return Err("No pending profile update".into()); - } - let auth = agent.auth_tag.ok_or("Missing owner authorization")?; - crate::secret::validate_attestation(&auth, &agent.pubkey)?; - Ok(CreationProfile { - credential_id: agent.credential_id, - pubkey: agent.pubkey, - url: format!( - "{}/events", - agent.relay_url.replacen("wss://", "https://", 1) - ), - auth, - name: agent.name, - picture: agent.picture.filter(|_| profile_pending), - name_pending, - about: if agent.extra.get("importAboutPending") == Some(&Value::Bool(true)) { - agent.imported["record"]["profile"]["about"] - .as_str() - .map(str::to_owned) - } else { - None - }, - revision: agent.revision, - }) - } - pub fn profile_published(&mut self, id: &str, revision: u64) -> Result<()> { - self.store.profile_published(id, revision) - } -} /// Native-only publication input, never serialized across IPC. pub struct CreationProfile { pub credential_id: String, diff --git a/crates/agent-controller/src/credentials/tests.rs b/crates/agent-controller/src/credentials/tests.rs index 1392aaf95..c45955692 100644 --- a/crates/agent-controller/src/credentials/tests.rs +++ b/crates/agent-controller/src/credentials/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::{config::agent_id, Imports, Store}; +use crate::{config::agent_id, AppAgents, OldStores, PortSource}; use serde_json::json; use std::sync::Mutex; const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; @@ -273,7 +273,7 @@ fn destination_is_separate_exact_bound_create_only_and_verified() { } #[test] -fn preview_commit_uses_production_adapter_and_reads_back_create_only_destination() { +fn adoption_uses_production_adapter_and_reads_back_create_only_destination() { let legacy = tempfile::tempdir().unwrap(); let dest = tempfile::tempdir().unwrap(); let root = legacy @@ -291,26 +291,15 @@ fn preview_commit_uses_production_adapter_and_reads_back_create_only_destination .unwrap(); let (credentials, fake) = fixture(); fake.put("buzz-desktop-dev", "secrets", blob()); - let mut imports = Imports::default(); - let preview = imports - .preview( - LegacySource::Development, - legacy.path().into(), - dest.path().into(), - "wss://relay.example", - ) - .unwrap(); + let old = OldStores { + harness: dest.path().join("missing.json"), + data_dir: legacy.path().into(), + }; + assert_eq!(old.list().agents.len(), 1); assert!(fake.calls.lock().unwrap().is_empty()); - let id = preview.candidates[0].id.clone(); - let mut store = Store::open(dest.path().into()).unwrap(); - imports - .commit( - &preview.token, - std::slice::from_ref(&id), - &mut store, - &credentials, - ) - .unwrap(); + let store = AppAgents::open(dest.path().join("identities.json")); + adopt(&old, &store, &credentials); + let id = agent_id(PUB, "wss://relay.example"); let account = format!("agent:{id}"); assert_eq!( *fake.calls.lock().unwrap(), @@ -333,8 +322,8 @@ fn preview_commit_uses_production_adapter_and_reads_back_create_only_destination ), ] ); - let snapshot = store.snapshot().unwrap(); - assert!(!snapshot.agents[0].enabled); + let snapshot = store.list().unwrap(); + assert_eq!(snapshot[0].pubkey, PUB); assert!(!serde_json::to_string(&snapshot).unwrap().contains(KEY)); } @@ -503,25 +492,14 @@ fn rollback_delete_then_reimport_reuses_retained_bundle_without_writing() { .unwrap(); let (old_credentials, fake) = fixture(); fake.put("buzz-desktop-dev", "secrets", blob()); - let mut store = Store::open(dest.path().into()).unwrap(); - let mut imports = Imports::default(); - let preview = imports - .preview( - LegacySource::Development, - legacy.path().into(), - dest.path().into(), - "wss://relay.example", - ) - .unwrap(); - let id = preview.candidates[0].id.clone(); - imports - .commit( - &preview.token, - std::slice::from_ref(&id), - &mut store, - &old_credentials, - ) - .unwrap(); + let path = dest.path().join("identities.json"); + let store = AppAgents::open(path.clone()); + let old = OldStores { + harness: dest.path().join("missing.json"), + data_dir: legacy.path().into(), + }; + adopt(&old, &store, &old_credentials); + let id = agent_id(PUB, "wss://relay.example"); // Upgrade migrates the individual credential, retaining the rollback copy. let credentials = bundled(fake.clone()); @@ -529,35 +507,18 @@ fn rollback_delete_then_reimport_reuses_retained_bundle_without_writing() { drop(credentials); // An older Foundation build removes only the individual item and settings. old_credentials.delete(&id, PUB).unwrap(); - store - .remove(&id, store.agents().unwrap()[0].revision) - .unwrap(); - assert!(store.agents().unwrap().is_empty()); + std::fs::remove_file(&path).unwrap(); + assert!(store.list().unwrap().is_empty()); assert!(old_credentials.read(&id, PUB).unwrap().is_none()); let before = fake.entries.lock().unwrap().clone(); assert!(before.contains_key(&(SERVICE.into(), bundle::ACCOUNT.into()))); // Re-upgrade takes the actual native import path, not a direct add probe. let credentials = bundled(fake.clone()); - let preview = imports - .preview( - LegacySource::Development, - legacy.path().into(), - dest.path().into(), - "wss://relay.example", - ) - .unwrap(); fake.calls.lock().unwrap().clear(); - imports - .prepare(&preview.token, std::slice::from_ref(&id), &store) - .unwrap() - .acquire(&credentials) - .unwrap() - .commit(&mut store) - .unwrap(); - assert_eq!(store.agents().unwrap().len(), 1); - assert_eq!(store.agents().unwrap()[0].id, id); - assert!(!store.agents().unwrap()[0].enabled); + adopt(&old, &store, &credentials); + assert_eq!(store.list().unwrap().len(), 1); + assert_eq!(store.list().unwrap()[0].pubkey, PUB); assert_eq!( *fake.calls.lock().unwrap(), vec![ @@ -571,3 +532,20 @@ fn rollback_delete_then_reimport_reuses_retained_bundle_without_writing() { .add(&id, &Secret::parse(KEY, PUB).unwrap()) .is_err()); } + +/// Exercise adoption through the real OS adapter with an isolated keychain. +fn adopt(old: &OldStores, store: &AppAgents, credentials: &PlatformCredentials) { + let (found, key) = old.take(PortSource::Development, PUB, credentials).unwrap(); + let auth: Vec = serde_json::from_str(&crate::secret::test_attestation(PUB)).unwrap(); + let prepared = AppAgents::adopt( + "wss://relay.example", + &auth[1], + "p/t", + key, + &found.name, + found.picture, + found.about, + ) + .unwrap(); + store.commit(prepared, &auth, credentials).unwrap(); +} diff --git a/crates/agent-controller/src/defaults.rs b/crates/agent-controller/src/defaults.rs deleted file mode 100644 index a5d75706f..000000000 --- a/crates/agent-controller/src/defaults.rs +++ /dev/null @@ -1,154 +0,0 @@ -use crate::{connection::DatabricksSettings, HarnessEdit}; -use serde::Serialize; -use std::{collections::BTreeMap, path::Path}; - -/// Public, nonsecret build configuration. Saved values are never projected here. -#[derive(Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BuildDefaults { - pub host: String, - pub filter: String, - pub model: String, - pub provider: String, - pub owner_only: bool, -} -include!(concat!(env!("OUT_DIR"), "/agent_defaults.rs")); - -impl BuildDefaults { - // Resolve a temporary copy, never mutate the stored agent or its revision. - pub(crate) fn resolve( - &self, - saved: &HarnessEdit, - env: &BTreeMap, - ) -> HarnessEdit { - let mut harness = saved.clone(); - if harness.integration == Some(crate::HarnessIntegration::Codex) { - return harness; - } - if Path::new(&harness.command) - .file_name() - .and_then(|s| s.to_str()) - != Some("buzz-agent") - { - return harness; - } - if harness.provider.is_empty() { - harness.provider.clone_from(&self.provider); - } - if let Some(provider) = env.get("BUZZ_AGENT_PROVIDER") { - harness.provider.clone_from(provider); - } - if databricks(&harness.provider) { - if harness.model.is_empty() { - harness.model = env.get("DATABRICKS_MODEL").unwrap_or(&self.model).clone(); - } - if harness.databricks.is_none() && (!self.host.is_empty() || !self.filter.is_empty()) { - harness.databricks = Some(DatabricksSettings { - host: self.host.clone(), - filter: self.filter.clone(), - }); - } - } - if let Some(model) = env.get("BUZZ_AGENT_MODEL") { - harness.model.clone_from(model); - } - harness - } - - /// Next-start selectors safe to project. Environment values stay native: - /// a selector an environment override decides is named by its key only. - pub(crate) fn launch_view( - &self, - saved: &HarnessEdit, - env: &BTreeMap, - ) -> LaunchView { - let none = BTreeMap::new(); - let public = self.resolve(saved, &none); - let selected = selectors(&public, &none); - let set = |key: &'static str| env.contains_key(key).then_some(key); - let (model_key, provider_key) = selected.keys.unzip(); - let provider_env = provider_key.and_then(set); - let acp_model = matches!( - crate::agent_defaults::harness_kind(&saved.command), - Some("pi" | "goose") - ) - .then_some("BUZZ_ACP_MODEL") - .and_then(set); - let model_env = acp_model.or_else(|| model_key.and_then(set)).or_else(|| { - // A blank buzz-agent model follows the provider, which may be hidden. - if model_key != Some("BUZZ_AGENT_MODEL") || !saved.model.is_empty() { - None - } else if provider_env.is_some() { - provider_env - } else if databricks(&public.provider) { - set("DATABRICKS_MODEL") - } else { - None - } - }); - LaunchView { - model: selected - .model - .filter(|_| model_env.is_none()) - .map(str::to_owned), - // Unmapped workers (Pi) take the saved provider directly. - provider: selected - .provider - .or_else(|| (!public.provider.is_empty()).then_some(public.provider.as_str())) - .filter(|_| provider_env.is_none()) - .map(str::to_owned), - model_env, - provider_env, - } - } -} - -fn databricks(provider: &str) -> bool { - matches!(provider, "databricks_v2" | "databricks-v2" | "databricks") -} - -pub(crate) struct LaunchView { - pub model: Option, - pub provider: Option, - pub model_env: Option<&'static str>, - pub provider_env: Option<&'static str>, -} - -/// Worker selector variables and the model/provider values a start passes to -/// them. Explicit per-agent environment overrides the resolved selectors, and a -/// blank selector never erases it. `None` keys: the worker has no mapping. -pub(crate) struct Selectors<'a> { - pub keys: Option<(&'static str, &'static str)>, - pub model: Option<&'a str>, - pub provider: Option<&'a str>, -} -pub(crate) fn selectors<'a>( - resolved: &'a HarnessEdit, - env: &'a BTreeMap, -) -> Selectors<'a> { - if resolved.integration == Some(crate::HarnessIntegration::Codex) { - return Selectors { - keys: None, - model: (!resolved.model.is_empty()).then_some(resolved.model.as_str()), - provider: None, - }; - } - let keys = crate::HarnessConfigurationPolicy::for_command(&resolved.command) - .selector_environment - .map(|keys| (keys.model, keys.provider)); - let saved = |value: &'a String| (!value.is_empty()).then_some(value.as_str()); - let mut model = saved(&resolved.model); - let mut provider = None; - if let Some((model_key, provider_key)) = keys { - model = env.get(model_key).map(String::as_str).or(model); - provider = env - .get(provider_key) - .map(String::as_str) - .or_else(|| saved(&resolved.provider)); - } - Selectors { - keys, - model, - provider, - } -} diff --git a/crates/agent-controller/src/harness-presets.json b/crates/agent-controller/src/harness-presets.json deleted file mode 100644 index 54439ef3c..000000000 --- a/crates/agent-controller/src/harness-presets.json +++ /dev/null @@ -1,20 +0,0 @@ -[ - { - "id": "hermes", - "label": "Hermes Agent", - "command": "hermes-acp", - "args": [], - "includeBuzzDevMcp": true, - "setupUrl": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/", - "setupHint": "Install Hermes with ACP support and configure it with `hermes model`. Store provider API keys in `~/.hermes/.env`; Buzz doesn’t inherit shell exports. Verify with `hermes acp --check`." - }, - { - "id": "claude", - "label": "Claude Code", - "command": "claude-agent-acp", - "args": [], - "includeBuzzDevMcp": false, - "setupUrl": "https://github.com/agentclientprotocol/claude-agent-acp", - "setupHint": "Install and sign in to Claude Code under Settings → Agents → Harnesses. Buzz uses Claude’s own model and credentials; shell credentials are not inherited." - } -] diff --git a/crates/agent-controller/src/harness_policy.rs b/crates/agent-controller/src/harness_policy.rs deleted file mode 100644 index d480762de..000000000 --- a/crates/agent-controller/src/harness_policy.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Integration policy, shared by native validation and the editor snapshot. -use crate::{agent_defaults::harness_kind, Result}; -use serde::{Deserialize, Serialize}; - -/// Static integration rules, not authentication or model capability evidence. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct HarnessConfigurationPolicy { - /// Who owns authentication; provider-specific credentials stay provider-specific. - pub authentication: AuthenticationPolicy, - /// How the provider selector is configured. - pub provider: ProviderPolicy, - /// Persisted managed modes admitted by this integration. Legacy fields are separate. - pub supported_modes: &'static [ConfigurationMode], - /// Requirement at the existing launch/default-save boundary, not during store reads. - pub model: ModelRequirement, - /// Whether this integration reports model-specific effort capabilities. - pub effort_discovery: EffortDiscovery, - /// Worker environment selector keys; their values never cross this contract. - pub selector_environment: Option, - /// Native launch policy; not an editable setting or an IPC capability. - #[serde(skip)] - pub(crate) include_buzz_dev_mcp: bool, -} - -/// Stable native integration identity. Editable executable names never grant -/// managed policy or access semantics. -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum HarnessIntegration { - /// Bundled Buzz Agent integration. - BuzzAgent, - /// Bundled Goose integration. - Goose, - /// Installed Pi adapter integration. - Pi, - /// Installed Codex CLI and ACP adapter integration. - Codex, - /// Arbitrary executable without managed semantics. - External, -} - -/// Authentication ownership does not imply an API key is required. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum AuthenticationPolicy { - /// Buzz configures authentication for the selected provider. - Provider, - /// The harness owns sign-in; Buzz can supply provider-specific overrides. - HarnessWithOverrides, - /// Authentication belongs to the custom executable. - External, -} - -/// Provider configuration strategy; no provider registry or credential values. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum ProviderPolicy { - /// A scalar provider mapped into worker environment. - Selector, - /// Providers are discovered from the harness's model catalog. - Discovered, - /// Custom values remain editable; launch requires external configuration. - External, -} - -/// Explicit managed intent, distinct from legacy blank-field inheritance. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum ConfigurationMode { - /// Delegate model and effort to the integration. - Default, - /// Explicit model and discovered effort selection. - Advanced, -} - -/// Model requirements for the existing selector interface. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum ModelRequirement { - /// Blank selectors retain the existing inheritance rules. - Optional, - /// Selecting a provider also requires a model. - WithProvider, -} - -/// Static discovery support; unknown is never confirmed lack of effort control. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum EffortDiscovery { - /// This integration does not yet report model-specific effort metadata. - Unknown, - /// This integration reports effort choices for the selected model. - ModelSpecific, -} - -/// Environment keys used by the worker's provider/model selectors. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -pub struct SelectorEnvironment { - /// Model selector key. - pub model: &'static str, - /// Provider selector key. - pub provider: &'static str, -} - -impl HarnessConfigurationPolicy { - /// Policy selected by a native-owned integration identity. - pub fn for_integration(integration: HarnessIntegration) -> Self { - let mut policy = Self { - authentication: AuthenticationPolicy::External, - provider: ProviderPolicy::External, - supported_modes: &[], - model: ModelRequirement::Optional, - effort_discovery: EffortDiscovery::Unknown, - selector_environment: None, - include_buzz_dev_mcp: false, - }; - match integration { - HarnessIntegration::BuzzAgent => { - policy.authentication = AuthenticationPolicy::Provider; - policy.provider = ProviderPolicy::Selector; - policy.selector_environment = Some(SelectorEnvironment { - model: "BUZZ_AGENT_MODEL", - provider: "BUZZ_AGENT_PROVIDER", - }); - } - HarnessIntegration::Goose => { - policy.authentication = AuthenticationPolicy::HarnessWithOverrides; - policy.provider = ProviderPolicy::Selector; - policy.selector_environment = Some(SelectorEnvironment { - model: "GOOSE_MODEL", - provider: "GOOSE_PROVIDER", - }); - } - HarnessIntegration::Pi => { - policy.authentication = AuthenticationPolicy::HarnessWithOverrides; - policy.provider = ProviderPolicy::Discovered; - policy.model = ModelRequirement::WithProvider; - } - HarnessIntegration::Codex => { - policy.authentication = AuthenticationPolicy::External; - policy.provider = ProviderPolicy::External; - policy.supported_modes = &[ConfigurationMode::Default, ConfigurationMode::Advanced]; - policy.effort_discovery = EffortDiscovery::ModelSpecific; - } - HarnessIntegration::External => {} - } - policy - } - - /// Existing harness classification only; this grants no executable trust. - pub fn for_command(command: &str) -> Self { - let mut policy = Self::for_integration(match harness_kind(command) { - Some("buzz-agent") => HarnessIntegration::BuzzAgent, - Some("goose") => HarnessIntegration::Goose, - Some("pi") => HarnessIntegration::Pi, - _ => HarnessIntegration::External, - }); - policy.include_buzz_dev_mcp = match std::path::Path::new(command) - .file_name() - .and_then(|name| name.to_str()) - { - Some("buzz-agent" | "buzz-agent.exe") => true, - Some("goose" | "goose.exe" | "goose-acp" | "goose-acp.exe") => false, - Some("buzz-pi-acp") => false, - Some("codex-acp" | "codex-acp.exe" | "codex-acp.cmd" | "codex-acp.bat") => false, - _ => match crate::harness_preset(command) { - Some(preset) => preset.include_buzz_dev_mcp, - None => false, - }, - }; - policy - } - - /// Validate only at the existing selection/launch admission points. - /// Stored legacy values remain readable and editable even when launch fails. - pub(crate) fn validate_selection(self, provider: &str, model: &str) -> Result<()> { - if self.model == ModelRequirement::WithProvider && !provider.is_empty() && model.is_empty() - { - return Err("Choose a Pi model for the selected provider, or clear both fields to use Pi defaults".into()); - } - if self.provider == ProviderPolicy::External && !provider.is_empty() { - return Err("Set provider configuration through this external harness's environment; a provider selector mapping is not available".into()); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/agent-controller/src/harness_policy/tests.rs b/crates/agent-controller/src/harness_policy/tests.rs deleted file mode 100644 index 53c151305..000000000 --- a/crates/agent-controller/src/harness_policy/tests.rs +++ /dev/null @@ -1,149 +0,0 @@ -use super::*; -use crate::{defaults::selectors, store::tests::fixture, Store}; -use std::collections::BTreeMap; - -#[test] -fn existing_integrations_expose_policy_without_advertising_unimplemented_modes_or_effort() { - for (command, auth, provider, model, keys) in [ - ( - "buzz-agent", - AuthenticationPolicy::Provider, - ProviderPolicy::Selector, - ModelRequirement::Optional, - Some(("BUZZ_AGENT_MODEL", "BUZZ_AGENT_PROVIDER")), - ), - ( - "/tools/buzz-agent", - AuthenticationPolicy::Provider, - ProviderPolicy::Selector, - ModelRequirement::Optional, - Some(("BUZZ_AGENT_MODEL", "BUZZ_AGENT_PROVIDER")), - ), - ( - "goose", - AuthenticationPolicy::HarnessWithOverrides, - ProviderPolicy::Selector, - ModelRequirement::Optional, - Some(("GOOSE_MODEL", "GOOSE_PROVIDER")), - ), - ( - "/tools/goose-acp.exe", - AuthenticationPolicy::HarnessWithOverrides, - ProviderPolicy::Selector, - ModelRequirement::Optional, - Some(("GOOSE_MODEL", "GOOSE_PROVIDER")), - ), - ( - "/tools/buzz-pi-acp", - AuthenticationPolicy::HarnessWithOverrides, - ProviderPolicy::Discovered, - ModelRequirement::WithProvider, - None, - ), - ( - "/tools/custom-acp", - AuthenticationPolicy::External, - ProviderPolicy::External, - ModelRequirement::Optional, - None, - ), - ( - "codex-acp", - AuthenticationPolicy::External, - ProviderPolicy::External, - ModelRequirement::Optional, - None, - ), - ] { - let policy = HarnessConfigurationPolicy::for_command(command); - assert_eq!( - (policy.authentication, policy.provider, policy.model), - (auth, provider, model), - "{command}" - ); - assert!(policy.supported_modes.is_empty()); - assert_eq!(policy.effort_discovery, EffortDiscovery::Unknown); - let mut agent = fixture(); - agent.harness.command = command.into(); - let env = keys.map_or_else(BTreeMap::new, |(model, provider)| { - BTreeMap::from([ - (model.into(), "override-model".into()), - (provider.into(), "override-provider".into()), - ]) - }); - let selected = selectors(&agent.harness, &env); - assert_eq!(selected.keys, keys); - if keys.is_some() { - assert_eq!(selected.model, Some("override-model")); - assert_eq!(selected.provider, Some("override-provider")); - } else { - assert_eq!(selected.model, Some("test-model")); - assert_eq!(selected.provider, None); - } - } -} - -#[test] -fn codex_policy_requires_explicit_native_identity() { - let managed = HarnessConfigurationPolicy::for_integration(HarnessIntegration::Codex); - let basename = HarnessConfigurationPolicy::for_command("codex-acp"); - assert_eq!(managed.authentication, AuthenticationPolicy::External); - assert_eq!(managed.provider, ProviderPolicy::External); - assert_eq!( - managed.supported_modes, - &[ConfigurationMode::Default, ConfigurationMode::Advanced] - ); - assert_eq!(managed.effort_discovery, EffortDiscovery::ModelSpecific); - assert!(basename.supported_modes.is_empty()); - assert_eq!(basename.effort_discovery, EffortDiscovery::Unknown); -} - -#[test] -fn policy_does_not_migrate_or_reject_legacy_records_on_store_read() { - for command in [ - "buzz-agent", - "goose", - "/tools/goose-acp", - "/tools/buzz-pi-acp", - "/tools/custom-acp", - ] { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let mut agent = fixture(); - agent.harness.command = command.into(); - // Pi with only a provider, and unmapped custom providers, must remain - // readable/editable even though launch admission refuses them. - agent.harness.model.clear(); - let saved = serde_json::to_value(&agent).unwrap(); - store.insert(vec![agent]).unwrap(); - let before = std::fs::read(dir.path().join("agents.json")).unwrap(); - drop(store); - let reopened = Store::open(dir.path().to_owned()).unwrap(); - assert_eq!( - serde_json::to_value(&reopened.agents().unwrap()[0]).unwrap(), - saved - ); - assert_eq!( - std::fs::read(dir.path().join("agents.json")).unwrap(), - before - ); - } -} - -#[test] -fn pi_selection_keeps_pair_and_transport_constraints() { - for (provider, model, valid) in [ - ("", "", true), - ("", "model", true), - ("provider", "model", true), - ("provider", "", false), - ("bad/provider", "model", false), - ("provider", "-flag", false), - ("provider", "a,b", false), - ] { - assert_eq!( - crate::pi::validate_selection(provider, model).is_ok(), - valid - ); - } -} diff --git a/crates/agent-controller/src/harness_presets.rs b/crates/agent-controller/src/harness_presets.rs deleted file mode 100644 index b73e74613..000000000 --- a/crates/agent-controller/src/harness_presets.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Tier 2 metadata shared with the frontend; runtime quirks remain in buzz-acp. -use serde::Deserialize; -use std::path::Path; -use std::sync::OnceLock; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct HarnessPreset { - pub id: String, - pub label: String, - pub command: String, - pub args: Vec, - /// Required integration choice in each bundled preset. - pub include_buzz_dev_mcp: bool, - pub setup_url: String, - pub setup_hint: String, -} - -pub fn harness_presets() -> &'static [HarnessPreset] { - static PRESETS: OnceLock> = OnceLock::new(); - PRESETS.get_or_init(|| { - serde_json::from_str(include_str!("harness-presets.json")) - .expect("bundled harness presets must be valid") - }) -} - -/// Saved paths retain their identity independently of current discovery. -pub fn harness_preset(command: &str) -> Option<&'static HarnessPreset> { - let name = Path::new(command).file_name()?.to_str()?; - let stem = [".exe", ".cmd", ".bat"] - .iter() - .find_map(|suffix| name.strip_suffix(suffix)) - .unwrap_or(name); - harness_presets() - .iter() - .find(|preset| preset.command == stem) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn presets_require_an_explicit_developer_mcp_choice() { - let definitions: Vec = - serde_json::from_str(include_str!("harness-presets.json")).unwrap(); - for mut definition in definitions { - serde_json::from_value::(definition.clone()).unwrap(); - definition - .as_object_mut() - .unwrap() - .remove("includeBuzzDevMcp"); - let error = serde_json::from_value::(definition) - .err() - .expect("a missing MCP choice must be rejected"); - assert!( - error - .to_string() - .contains("missing field `includeBuzzDevMcp`"), - "{error}" - ); - } - } -} diff --git a/crates/agent-controller/src/import.rs b/crates/agent-controller/src/import.rs index 367a956ea..e8eadb114 100644 --- a/crates/agent-controller/src/import.rs +++ b/crates/agent-controller/src/import.rs @@ -1,16 +1,10 @@ -//! Snapshot import, not a second writer of the old library. Chosen files are -//! re-read before commit; key access happens only after explicit selection. -use crate::config::{ - agent_id, canonical_key, canonical_relay, Agent, HarnessEdit, MAX_AGENTS, MAX_BYTES, -}; -use crate::{Credentials, Result, Secret, Store}; +//! Read-only access to earlier Buzz identity stores. +use crate::{config::MAX_BYTES, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, OpenOptions}; use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::Path; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -32,534 +26,6 @@ impl LegacySource { } } } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportPreview { - pub token: String, - pub source_path: String, - pub candidates: Vec, - pub warnings: Vec, -} -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Candidate { - pub id: String, - pub pubkey: String, - pub relay_url: String, - pub name: String, - /// The imported prompt drops a team section old Buzz baked into it. - pub strips_team_instructions: bool, -} -/// Reviewed text only. Never project legacy environment, commands, arguments, -/// credentials, paths, owner authorization or retained source records for cloning. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CloneSettings { - pub name: String, - pub system_prompt: String, -} -#[derive(Clone)] -struct Pending { - preview: ImportPreview, - source: PathBuf, - source_kind: LegacySource, - digest: String, - workspace: PathBuf, -} -#[derive(Default, Clone)] -pub struct Imports { - sequence: u64, - pending: Option, -} -struct Source { - records: Vec, - global: Value, - teams: Vec, - custom: BTreeMap, - digest: String, -} -impl Imports { - pub fn clone_settings( - source_kind: LegacySource, - app_data_parent: PathBuf, - pubkey: &str, - ) -> Result { - if !canonical_key(pubkey) { - return Err("Invalid source identity".into()); - } - let data = read_source(&app_data_parent.join(source_kind.app_directory()))?; - let records: Vec<_> = data - .records - .iter() - .filter(|record| string(record, "pubkey") == pubkey) - .collect(); - if records.len() != 1 { - return Err("Choose one existing source identity".into()); - } - let record = records[0]; - let definition = source_definition(&data, record)?; - Ok(CloneSettings { - name: string(record, "name").into(), - system_prompt: imported_prompt(string(definition, "system_prompt")).into(), - }) - } - pub fn discard(&mut self) { - self.pending = None; - } - /// Native resolves the app-data parent, never from a browser-supplied path. - /// Bind config directory and credential service to the same explicit choice. - pub fn preview( - &mut self, - source_kind: LegacySource, - app_data_parent: PathBuf, - workspace: PathBuf, - destination: &str, - ) -> Result { - self.pending = None; - // Browsing local files needs no destination and grants no import authority. - let relay = if destination.is_empty() { - String::new() - } else { - canonical_relay(destination)? - }; - let source = app_data_parent.join(source_kind.app_directory()); - let data = read_source(&source)?; - let mut candidates = Vec::new(); - let mut seen = BTreeSet::new(); - for record in &data.records { - let key = string(record, "pubkey"); - if key.is_empty() { - continue; - } - if !canonical_key(key) { - return Err("Source contains an invalid agent identity".into()); - } - let id = agent_id(key, &relay); - if !seen.insert(id.clone()) { - return Err("Source contains duplicate agent identities".into()); - } - candidates.push(Candidate { - id, - pubkey: key.into(), - relay_url: relay.clone(), - name: string(record, "name").into(), - strips_team_instructions: source_definition(&data, record).is_ok_and(|d| { - imported_prompt(string(d, "system_prompt")).len() - != string(d, "system_prompt").len() - }), - }); - } - self.sequence = self - .sequence - .checked_add(1) - .ok_or("Import preview exhausted")?; - let mut preview = ImportPreview { - token: format!("{}-{}", self.sequence, data.digest), - source_path: source.join("agents/managed-agents.json").display().to_string(), - candidates, - warnings: vec![ - "Import uses the destination shown below, not old saved relay values. Community membership is not established by importing.".into(), - "Imports stay disabled. Stop old Buzz before enabling an imported identity.".into(), - "Provider defaults baked into the old app cannot be inferred from these files; review the imported harness before starting.".into(), - "This copies selected identities and resolved settings; old Buzz remains unchanged.".into(), - ], - }; - if relay.is_empty() { - preview.token.clear(); - preview.warnings = vec![ - "Local identities only. Choose a destination before reviewing an import.".into(), - ]; - return Ok(preview); - } - self.pending = Some(Pending { - preview: preview.clone(), - source, - source_kind, - digest: data.digest, - workspace, - }); - Ok(preview) - } - pub fn prepare( - &mut self, - token: &str, - ids: &[String], - store: &Store, - ) -> Result { - let pending = self - .pending - .as_ref() - .filter(|p| p.preview.token == token) - .ok_or("Import preview expired; choose the source again")?; - if ids.is_empty() - || ids.len() > MAX_AGENTS - || ids.iter().collect::>().len() != ids.len() - { - return Err("Select distinct identities to import".into()); - } - let data = read_source(&pending.source)?; - if data.digest != pending.digest { - return Err("Source changed after preview; preview it again".into()); - } - let reservation = store.reserve_import()?; - let existing = store.agents()?; - let mut agents = Vec::new(); - let mut repairs = Vec::new(); - for id in ids { - let candidate = pending - .preview - .candidates - .iter() - .find(|c| &c.id == id) - .ok_or("Identity was not in this preview")?; - let record = data - .records - .iter() - .find(|r| string(r, "pubkey") == candidate.pubkey) - .ok_or("Import identity disappeared")?; - if let Some(saved) = existing.iter().find(|a| a.id == *id) { - if !saved.needs_team_import() { - return Err("Selected identity is already imported".into()); - } - let original = &saved.imported["record"]; - if ["team_id", "persona_team_dir"] - .iter() - .any(|field| string(original, field) != string(record, field)) - { - return Err("Source team binding differs from the imported agent; choose its original library".into()); - } - repairs.push(( - saved.id.clone(), - saved.revision, - team_instructions(&data, original)?, - )); - continue; - } - if existing.iter().any(|a| a.pubkey == candidate.pubkey) { - return Err("Selected identity is already imported".into()); - } - let agent = resolve(&data, record, &pending.workspace, &candidate.relay_url)?; - agent.validate()?; - agents.push((agent, string(record, "private_key_nsec").to_owned())); - } - Ok(PreparedImport { - reservation, - agents, - repairs, - source_kind: pending.source_kind, - source: pending.source.clone(), - digest: pending.digest.clone(), - }) - } - pub fn commit( - &mut self, - token: &str, - ids: &[String], - store: &mut Store, - credentials: &dyn Credentials, - ) -> Result<()> { - let prepared = self.prepare(token, ids, store)?; - prepared.acquire(credentials)?.commit(store)?; - self.pending = None; - Ok(()) - } -} -/// Native-only import plan; never serialized. Credential operations can happen -/// outside the controller mutex. The source snapshot is copied, never mutated. -pub struct PreparedImport { - reservation: crate::store::ImportReservation, - agents: Vec<(Agent, String)>, - repairs: Vec<(String, u64, String)>, - source_kind: LegacySource, - source: PathBuf, - digest: String, -} -pub struct CredentialedImport { - reservation: crate::store::ImportReservation, - agents: Vec, - repairs: Vec<(String, u64, String)>, - source: PathBuf, - digest: String, -} -impl PreparedImport { - pub fn acquire(self, credentials: &dyn Credentials) -> Result { - let agents = self.agents; - // All config validation precedes credential writes. Retry can reuse a - // verified identical key saved by a partial prior attempt, never replace it. - for (agent, inline) in &agents { - let key = if inline.is_empty() { - credentials.read_legacy(self.source_kind, &agent.pubkey)? - } else { - Secret::parse(inline, &agent.pubkey)? - }; - if let Some(saved) = credentials.read(&agent.credential_id, &agent.pubkey)? { - if *saved.hex() != *key.hex() { - return Err("Saved credential differs; refusing to overwrite it".into()); - } - } else { - credentials.add(&agent.credential_id, &key)?; - } - let saved = credentials - .read(&agent.credential_id, &agent.pubkey)? - .ok_or("Credential read-back failed; import was not completed")?; - if *saved.hex() != *key.hex() { - return Err("Credential read-back differed; import was not completed".into()); - } - } - Ok(CredentialedImport { - reservation: self.reservation, - agents: agents.into_iter().map(|(a, _)| a).collect(), - repairs: self.repairs, - source: self.source, - digest: self.digest, - }) - } -} -impl CredentialedImport { - pub fn commit(self, store: &mut Store) -> Result<()> { - if !self.reservation.belongs_to(store) { - return Err("Import belongs to another agent store".into()); - } - if read_source(&self.source)?.digest != self.digest { - return Err("Source changed during credential access; preview again".into()); - } - let existing = store.agents()?; - if self - .agents - .iter() - .any(|incoming| existing.iter().any(|saved| saved.pubkey == incoming.pubkey)) - { - return Err("Selected identity is already imported".into()); - } - let agents = self - .agents - .into_iter() - .map(|mut agent| { - agent.extra.insert("configured".into(), Value::Bool(true)); - agent.enabled = false; - agent - }) - .collect(); - store.import(agents, self.repairs) - } -} -fn string<'a>(value: &'a Value, key: &str) -> &'a str { - value.get(key).and_then(Value::as_str).unwrap_or("") -} -fn object(value: &Value) -> Result> { - if value.is_null() { - return Ok(BTreeMap::new()); - } - serde_json::from_value(value.clone()) - .map_err(|_| "Source environment must contain string values".into()) -} -fn source_definition<'a>(data: &'a Source, record: &'a Value) -> Result<&'a Value> { - Ok(if string(record, "persona_id").is_empty() { - record - } else { - data.records - .iter() - .find(|r| { - string(r, "pubkey").is_empty() && string(r, "slug") == string(record, "persona_id") - }) - .ok_or("Linked agent definition is missing; source left unchanged")? - }) -} -/// Old Buzz once baked team instructions into stored prompts behind this exact -/// producer boundary. The runtime now adds the team section itself, so a copied -/// suffix would reach the agent twice. Byte-exact: a bare `---` or a heading -/// elsewhere is the author's own content. Applied to the imported copy only. -const BAKED_TEAM_DELIMITER: &str = "\n\n---\n# Team Instructions\n"; -pub(crate) fn imported_prompt(prompt: &str) -> &str { - prompt - .rfind(BAKED_TEAM_DELIMITER) - .map_or(prompt, |at| &prompt[..at]) -} -fn resolve(data: &Source, record: &Value, workspace: &Path, destination: &str) -> Result { - let definition = source_definition(data, record)?; - let fallback = |key| { - let selected = string(definition, key); - if selected.trim().is_empty() { - string(&data.global, key).to_owned() - } else { - selected.to_owned() - } - }; - // Legacy spawn's actual resolver is record-first, not its outdated comment: - // override > record runtime > linked definition runtime > buzz-agent. - let runtime_id = record - .get("runtime") - .and_then(Value::as_str) - .or_else(|| definition.get("runtime").and_then(Value::as_str)); - let custom = runtime_id.and_then(|id| data.custom.get(id)); - let command = if !string(record, "agent_command_override").trim().is_empty() { - string(record, "agent_command_override").trim().to_owned() - } else { - match (runtime_id, custom) { - (_, Some(c)) => string(c, "command").to_owned(), - (Some("claude"), _) => "claude-agent-acp".into(), - (Some("codex"), _) => "codex-acp".into(), - (Some("goose" | "goose-bundled"), _) => "goose".into(), - (None | Some("buzz-agent"), _) => "buzz-agent".into(), - _ => return Err("Source uses an unsupported harness definition; restore its custom definition before importing".into()), - } - }; - let mut env = custom - .map(|c| object(&c["env"])) - .transpose()? - .unwrap_or_default(); - env.extend(object(&data.global["env_vars"])?); - if !std::ptr::eq(definition, record) { - env.extend(object(&definition["env_vars"])?); - } - env.extend(object(&record["env_vars"])?); - let args: Vec = match record - .get("agent_args") - .filter(|v| v.as_array().is_none_or(|a| !a.is_empty())) - { - Some(value) => { - serde_json::from_value(value.clone()).map_err(|_| "Invalid source harness arguments")? - } - None => match custom.and_then(|c| c.get("args")) { - Some(value) => serde_json::from_value(value.clone()) - .map_err(|_| "Invalid custom harness arguments")?, - None if command == "goose" => vec!["acp".into()], - None => vec![], - }, - }; - let pubkey = string(record, "pubkey").to_owned(); - // Legacy pins are ignored by old Buzz at runtime. Only the destination - // confirmed in this native preview may route the imported identity. - let relay_url = destination.to_owned(); - let id = agent_id(&pubkey, &relay_url); - let mut retained = record.clone(); - if let Some(record) = retained.as_object_mut() { - record.remove("private_key_nsec"); - } - Ok(Agent { - picture: None, - id: id.clone(), - pubkey, - relay_url, - name: string(record, "name").into(), - system_prompt: imported_prompt(string(definition, "system_prompt")).into(), - session_policy: None, - session_policy_inherit: false, - workspace: workspace.display().to_string(), - harness: HarnessEdit { - integration: None, - databricks: None, - command, - args, - model: fallback("model"), - configuration: None, - provider: fallback("provider"), - }, - environment: env, - revision: 1, - enabled: false, - start_on_app_launch: Some(false), - credential_id: id, - auth_tag: record - .get("auth_tag") - .and_then(Value::as_str) - .map(str::to_owned), - imported: json!({ "record": retained, "definition": if std::ptr::eq(definition, record) { Value::Null } else { definition.clone() }, "global": data.global, "teamSuffixCleaned": true, "harness": custom, "teamInstructions": team_instructions(data, record)? }), - extra: BTreeMap::new(), - }) -} -// Match old Buzz's deployment-team lookup: a deleted team contributes no section. -fn team_instructions(data: &Source, record: &Value) -> Result { - let id = string(record, "team_id"); - let team = data - .teams - .iter() - .find(|team| !id.is_empty() && string(team, "id") == id); - team_text( - team.map(|team| &team["instructions"]) - .unwrap_or(&Value::Null), - ) - .map(str::to_owned) -} -pub(crate) fn team_text(value: &Value) -> Result<&str> { - let text = if value.is_null() { - "" - } else { - value.as_str().ok_or("Invalid source team instructions")? - }; - if text.len() > 128 * 1024 || text.contains('\0') { - return Err("Invalid source team instructions".into()); - } - Ok(text.trim()) -} -fn read_source(root: &Path) -> Result { - let records = read_json(&root.join("agents/managed-agents.json"), false)?; - let global = read_json(&root.join("agents/global-agent-config.json"), true)?; - let records: Vec = - serde_json::from_value(records).map_err(|_| "Source agent library must be an array")?; - if records.len() > MAX_AGENTS || records.iter().any(|r| !r.is_object()) { - return Err("Invalid source agent library".into()); - } - if !global.is_object() { - return Err("Invalid source global configuration".into()); - } - let team_path = root.join("agents/teams.json"); - let teams: Vec = if records - .iter() - .any(|record| !string(record, "team_id").is_empty()) - { - match fs::symlink_metadata(&team_path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), - _ => serde_json::from_value(read_json(&team_path, false)?) - .map_err(|_| "Source team library must be an array")?, - } - } else { - Vec::new() - }; - let mut team_ids = BTreeSet::new(); - if teams.len() > MAX_AGENTS - || teams.iter().any(|team| { - let id = string(team, "id"); - !team.is_object() || id.is_empty() || !team_ids.insert(id) - }) - { - return Err("Invalid or duplicate source teams".into()); - } - let mut custom = BTreeMap::new(); - let directory = root.join("custom_harnesses"); - match fs::read_dir(directory) { - Ok(entries) => { - for entry in entries { - let entry = entry.map_err(|_| "Could not read source harness definitions")?; - if entry.path().extension().is_none_or(|e| e != "json") { - continue; - } - if custom.len() >= 128 { - return Err("Too many source harness definitions".into()); - } - let value = read_json(&entry.path(), false)?; - let id = string(&value, "id").to_owned(); - if id.is_empty() || custom.insert(id, value).is_some() { - return Err("Invalid or duplicate source harness definition".into()); - } - } - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err("Could not read source harness definitions".into()), - } - let bytes = serde_json::to_vec(&(&records, &global, &teams, &custom)) - .map_err(|_| "Could not snapshot import source")?; - if bytes.len() > MAX_BYTES { - return Err("Import source exceeds size limit".into()); - } - Ok(Source { - records, - global, - teams, - custom, - digest: format!("{:x}", Sha256::digest(bytes)), - }) -} pub(crate) fn read_json(path: &Path, optional: bool) -> Result { let meta = match fs::symlink_metadata(path) { Err(e) if optional && e.kind() == std::io::ErrorKind::NotFound => return Ok(json!({})), @@ -588,5 +54,3 @@ pub(crate) fn read_json(path: &Path, optional: bool) -> Result { } serde_json::from_slice(&bytes).map_err(|_| "Import source is malformed; left unchanged".into()) } -#[cfg(test)] -mod tests; diff --git a/crates/agent-controller/src/import/tests.rs b/crates/agent-controller/src/import/tests.rs deleted file mode 100644 index 9463eed1d..000000000 --- a/crates/agent-controller/src/import/tests.rs +++ /dev/null @@ -1,1165 +0,0 @@ -use super::*; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Mutex, -}; -const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; -const PUB: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; -#[derive(Default)] -struct Memory { - keys: Mutex>, - reads: AtomicUsize, - fail: bool, - sources: Mutex>, - expected_source: Option, -} -impl Credentials for Memory { - fn delete(&self, id: &str, _: &str) -> Result<()> { - self.keys.lock().unwrap().remove(id); - Ok(()) - } - fn read_legacy(&self, source: LegacySource, pubkey: &str) -> Result { - self.reads.fetch_add(1, Ordering::SeqCst); - self.sources.lock().unwrap().push(source); - if self - .expected_source - .is_some_and(|expected| expected != source) - { - return Err("Selected fixture credential is absent".into()); - } - Secret::parse(KEY, pubkey) - } - fn read(&self, id: &str, pubkey: &str) -> Result> { - self.keys - .lock() - .unwrap() - .get(id) - .map(|key| Secret::parse(key, pubkey)) - .transpose() - } - fn add(&self, id: &str, key: &Secret) -> Result<()> { - if self.fail { - return Err("Test credential store refused".into()); - } - let mut keys = self.keys.lock().unwrap(); - if keys.contains_key(id) { - return Err("Already exists".into()); - } - keys.insert(id.into(), key.hex().to_string()); - Ok(()) - } -} -fn source(root: &Path) -> Vec { - let root = root.join(LegacySource::Installed.app_directory()); - fs::create_dir_all(root.join("agents")).unwrap(); - let records = json!([ - {"pubkey":"", "slug":"brain", "system_prompt":"definition-prompt", "model":"definition-model", "runtime":"buzz-agent", "env_vars":{"DEF_VALUE":"definition"}}, - {"pubkey":PUB, "relay_url":"https://RELAY.example/", "name":"Brain", "persona_id":"brain", "system_prompt":"stale-prompt", "model":"stale-model", "agent_command":"goose", "agent_args":[], "auth_tag":"attestation", "env_vars":{"PRIVATE_PROVIDER_TOKEN":"never-project"}, "future":{"preserve":true}, "start_on_app_launch":true} - ]); - let bytes = serde_json::to_vec(&records).unwrap(); - fs::write(root.join("agents/managed-agents.json"), &bytes).unwrap(); - fs::write( - root.join("agents/global-agent-config.json"), - br#"{"provider":"global-provider","env_vars":{"GLOBAL_VALUE":"global"}}"#, - ) - .unwrap(); - bytes -} -#[test] -fn preview_is_keyless_commit_resolves_preserves_and_never_enables_or_mutates_source() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - let before = source(old.path()); - let mut imports = Imports::default(); - let keys = Memory::default(); - let preview = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://relay.example", - ) - .unwrap(); - let serialized = serde_json::to_string(&preview).unwrap(); - for hidden in [ - "never-project", - "definition-prompt", - "attestation", - "global-provider", - ] { - assert!(!serialized.contains(hidden)); - } - assert_eq!(keys.reads.load(Ordering::SeqCst), 0); - let mut store = Store::open(dest.path().into()).unwrap(); - imports - .commit( - &preview.token, - &[preview.candidates[0].id.clone()], - &mut store, - &keys, - ) - .unwrap(); - let saved = &store.agents().unwrap()[0]; - assert_eq!(saved.pubkey, PUB); - assert!(!saved.enabled); - // Source `start_on_app_launch: true` does not auto-start an imported record. - assert_eq!(saved.start_on_app_launch, Some(false)); - assert!(saved.configured()); - assert_eq!(saved.relay_url, "wss://relay.example"); - assert_eq!(saved.system_prompt, "definition-prompt"); - assert_eq!(saved.harness.model, "definition-model"); - assert_eq!(saved.harness.provider, "global-provider"); - assert_eq!(saved.harness.command, "buzz-agent"); - assert_eq!(saved.environment.len(), 3); - assert_eq!(saved.imported["record"]["future"]["preserve"], true); - assert_eq!(saved.auth_tag.as_deref(), Some("attestation")); - assert_eq!( - fs::read( - old.path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json") - ) - .unwrap(), - before - ); - assert_eq!(keys.reads.load(Ordering::SeqCst), 1); - let snapshot = serde_json::to_string(&store.snapshot().unwrap()).unwrap(); - assert!(!snapshot.contains("never-project")); - assert!(!snapshot.contains(KEY)); - assert!(imports - .commit( - &preview.token, - std::slice::from_ref(&saved.id), - &mut store, - &keys - ) - .is_err()); -} -#[test] -fn changed_source_duplicate_selection_and_credential_failure_do_not_commit() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let mut imports = Imports::default(); - let preview = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://relay.example", - ) - .unwrap(); - let id = preview.candidates[0].id.clone(); - let mut store = Store::open(dest.path().into()).unwrap(); - let keys = Memory::default(); - assert!(imports - .commit(&preview.token, &[id.clone(), id.clone()], &mut store, &keys) - .is_err()); - fs::write( - old.path() - .join(LegacySource::Installed.app_directory()) - .join("agents/global-agent-config.json"), - b"{}", - ) - .unwrap(); - assert!(imports - .commit(&preview.token, std::slice::from_ref(&id), &mut store, &keys) - .unwrap_err() - .contains("Source changed")); - assert_eq!(keys.reads.load(Ordering::SeqCst), 0); - let preview = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://relay.example", - ) - .unwrap(); - let unavailable = Memory { - fail: true, - ..Default::default() - }; - assert!(imports - .commit(&preview.token, &[id], &mut store, &unavailable) - .is_err()); - assert!(store.agents().unwrap().is_empty()); -} -#[test] -fn inline_key_is_verified_and_not_copied_to_native_config() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let path = old - .path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json"); - let mut records: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - records[1]["private_key_nsec"] = json!(KEY); - fs::write(&path, serde_json::to_vec(&records).unwrap()).unwrap(); - let mut imports = Imports::default(); - let keys = Memory::default(); - let preview = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://relay.example", - ) - .unwrap(); - let mut store = Store::open(dest.path().into()).unwrap(); - imports - .commit( - &preview.token, - &[preview.candidates[0].id.clone()], - &mut store, - &keys, - ) - .unwrap(); - assert_eq!(keys.reads.load(Ordering::SeqCst), 0); - assert!( - !String::from_utf8(fs::read(dest.path().join("agents.json")).unwrap()) - .unwrap() - .contains(KEY) - ); - assert!(Secret::parse(KEY, &"aa".repeat(32)).is_err()); -} -#[test] -fn orphan_or_reserved_env_refuses_before_any_key_read() { - for update in [ - json!({"persona_id":"missing"}), - json!({"env_vars":{"BUZZ_MANAGED_AGENT":"old-owner"}}), - ] { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let path = old - .path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json"); - let mut records: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - for (k, v) in update.as_object().unwrap() { - records[1][k] = v.clone(); - } - fs::write(&path, serde_json::to_vec(&records).unwrap()).unwrap(); - let mut imports = Imports::default(); - let keys = Memory::default(); - let preview = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://relay.example", - ) - .unwrap(); - let mut store = Store::open(dest.path().into()).unwrap(); - assert!(imports - .commit( - &preview.token, - &[preview.candidates[0].id.clone()], - &mut store, - &keys - ) - .is_err()); - assert_eq!(keys.reads.load(Ordering::SeqCst), 0); - } -} - -#[test] -fn chosen_source_binds_config_and_credentials_without_fallback() { - for selected in [LegacySource::Installed, LegacySource::Development] { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let development = old.path().join(LegacySource::Development.app_directory()); - fs::create_dir_all(development.join("agents")).unwrap(); - fs::write( - development.join("agents/managed-agents.json"), - serde_json::to_vec(&json!([ - {"pubkey": PUB, "relay_url": "wss://development.example", "name": "Development"} - ])) - .unwrap(), - ) - .unwrap(); - let mut imports = Imports::default(); - let preview = imports - .preview( - selected, - old.path().into(), - dest.path().into(), - "wss://chosen.example", - ) - .unwrap(); - assert!(preview.source_path.contains(selected.app_directory())); - assert_eq!(preview.candidates[0].relay_url, "wss://chosen.example"); - let mut store = Store::open(dest.path().into()).unwrap(); - let other = match selected { - LegacySource::Installed => LegacySource::Development, - LegacySource::Development => LegacySource::Installed, - }; - let keys = Memory { - expected_source: Some(other), - ..Default::default() - }; - // Another service could satisfy this key, but it must never be consulted. - assert!(imports - .commit( - &preview.token, - &[preview.candidates[0].id.clone()], - &mut store, - &keys - ) - .is_err()); - assert_eq!(*keys.sources.lock().unwrap(), vec![selected]); - assert!(store.agents().unwrap().is_empty()); - let keys = Memory { - expected_source: Some(selected), - ..Default::default() - }; - imports - .commit( - &preview.token, - &[preview.candidates[0].id.clone()], - &mut store, - &keys, - ) - .unwrap(); - assert_eq!(*keys.sources.lock().unwrap(), vec![selected]); - assert_eq!( - store.agents().unwrap()[0].relay_url, - preview.candidates[0].relay_url - ); - } - assert_eq!(LegacySource::Installed.keyring_service(), "buzz-desktop"); - assert_eq!( - LegacySource::Development.keyring_service(), - "buzz-desktop-dev" - ); -} - -#[test] -fn changed_source_during_credential_acquisition_never_commits_settings() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let mut imports = Imports::default(); - let preview = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://relay.example", - ) - .unwrap(); - let mut store = Store::open(dest.path().into()).unwrap(); - let prepared = imports - .prepare(&preview.token, &[preview.candidates[0].id.clone()], &store) - .unwrap(); - let credentials = Memory::default(); - let acquired = prepared.acquire(&credentials).unwrap(); - fs::write( - old.path() - .join(LegacySource::Installed.app_directory()) - .join("agents/global-agent-config.json"), - "{}", - ) - .unwrap(); - assert!(acquired - .commit(&mut store) - .unwrap_err() - .contains("Source changed")); - assert!(store.agents().unwrap().is_empty()); - // Create-only app custody can remain after a cancelled/failed import; never - // delete keys that a prior successful import may already reference. - assert_eq!(credentials.keys.lock().unwrap().len(), 1); -} - -#[test] -fn explicit_destination_replaces_legacy_pins_without_hiding_keys_or_reading_credentials() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let path = old - .path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json"); - let pins = [ - Value::Null, - json!(""), - json!(" "), - json!("not a URL"), - json!("ws://insecure.example"), - json!("wss://raw.example/path"), - json!("wss://raw.example?private=query"), - json!("wss://user:secret@raw.example"), - json!("wss://stale.example"), - ]; - let mut records = vec![json!({"pubkey":"", "slug":"keyless", "relay_url":""})]; - for (index, pin) in pins.iter().enumerate() { - let mut record = json!({"pubkey": if index == 0 { PUB.into() } else { format!("{:064x}", index + 100) }, - "name":format!("Agent {index}"), "private_key_nsec":KEY, "system_prompt":"private-prompt"}); - if !pin.is_null() { - record["relay_url"] = pin.clone(); - } - records.push(record); - } - let bytes = serde_json::to_vec(&records).unwrap(); - fs::write(&path, &bytes).unwrap(); - let mut imports = Imports::default(); - let mut store = Store::open(dest.path().into()).unwrap(); - let keys = Memory::default(); - let preview = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "https://CHOSEN.example/", - ) - .unwrap(); - assert_eq!(preview.candidates.len(), pins.len()); - for candidate in &preview.candidates { - assert_eq!(candidate.relay_url, "wss://chosen.example"); - assert_eq!( - candidate.id, - agent_id(&candidate.pubkey, "wss://chosen.example") - ); - } - let serialized = serde_json::to_string(&preview).unwrap(); - for hidden in [ - "raw.example", - "insecure.example", - "stale.example", - "not a URL", - "private-prompt", - KEY, - ] { - assert!(!serialized.contains(hidden)); - } - assert_eq!(keys.reads.load(Ordering::SeqCst), 0); - assert!(keys.keys.lock().unwrap().is_empty()); - assert_eq!(fs::read(&path).unwrap(), bytes); - // A missing legacy pin is ordinary source data, not a reason to skip this key. - let selected = &preview.candidates[0]; - imports - .commit( - &preview.token, - std::slice::from_ref(&selected.id), - &mut store, - &keys, - ) - .unwrap(); - let saved = &store.agents().unwrap()[0]; - assert_eq!(saved.pubkey, PUB); - assert_eq!(saved.relay_url, "wss://chosen.example"); - assert_eq!(saved.id, selected.id); - assert_eq!(saved.credential_id, selected.id); - assert!(!saved.enabled); - assert_eq!(keys.keys.lock().unwrap().len(), 1); - assert_eq!(fs::read(&path).unwrap(), bytes); -} - -#[test] -fn commit_routes_blank_malformed_and_valid_stale_pins_only_to_the_confirmed_destination() { - for pin in [ - "", - "ws://insecure.example", - "wss://stale.example", - "wss://user:secret@raw.example/path?token=private", - ] { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let path = old - .path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json"); - let bytes = - serde_json::to_vec(&json!([{"pubkey": PUB, "name":"Selected", "relay_url":pin}])) - .unwrap(); - fs::write(&path, &bytes).unwrap(); - let mut imports = Imports::default(); - let keys = Memory::default(); - let mut store = Store::open(dest.path().into()).unwrap(); - let a = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://first.example", - ) - .unwrap(); - let b = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "https://CONFIRMED.example/", - ) - .unwrap(); - assert_ne!(a.token, b.token); - // An old token must not authorize even IDs from the new preview. - assert!(imports - .commit(&a.token, &[b.candidates[0].id.clone()], &mut store, &keys) - .is_err()); - assert!(imports - .commit(&a.token, &[a.candidates[0].id.clone()], &mut store, &keys) - .is_err()); - assert!(imports - .commit(&b.token, &[a.candidates[0].id.clone()], &mut store, &keys) - .is_err()); - assert_eq!(keys.reads.load(Ordering::SeqCst), 0); - assert!(keys.keys.lock().unwrap().is_empty()); - imports - .commit(&b.token, &[b.candidates[0].id.clone()], &mut store, &keys) - .unwrap(); - let saved = &store.agents().unwrap()[0]; - assert_eq!(saved.relay_url, "wss://confirmed.example"); - assert_eq!(saved.id, agent_id(PUB, "wss://confirmed.example")); - assert_eq!(saved.credential_id, saved.id); - assert_eq!(saved.imported["record"]["relay_url"], pin); - assert!(!saved.enabled); - assert_eq!(fs::read(&path).unwrap(), bytes); - } -} - -#[test] -fn invalid_destination_discards_pending_without_echoing_inputs_or_acquiring_keys() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - let bytes = source(old.path()); - let mut imports = Imports::default(); - let mut store = Store::open(dest.path().into()).unwrap(); - let keys = Memory::default(); - for invalid in [ - " ", - "not a URL", - "ws://raw.example", - "http://raw.example", - "wss://user:secret@raw.example", - "wss://raw.example/path", - "wss://raw.example?token=private", - "wss://raw.example#private", - ] { - let prior = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://chosen.example", - ) - .unwrap(); - assert_eq!( - imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - invalid - ) - .err() - .unwrap(), - "Choose a secure community origin without credentials, path or query" - ); - assert!(imports - .commit( - &prior.token, - &[prior.candidates[0].id.clone()], - &mut store, - &keys - ) - .is_err()); - } - assert_eq!(keys.reads.load(Ordering::SeqCst), 0); - assert!(keys.keys.lock().unwrap().is_empty()); - assert!(store.agents().unwrap().is_empty()); - assert_eq!( - fs::read( - old.path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json") - ) - .unwrap(), - bytes - ); -} - -#[test] -fn duplicate_keys_ignore_pin_differences_and_source_pin_changes_still_invalidate() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let path = old - .path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json"); - let mut imports = Imports::default(); - let mut store = Store::open(dest.path().into()).unwrap(); - let keys = Memory::default(); - for other_pin in ["", "wss://stale.example", "wss://different.example"] { - fs::write(&path, serde_json::to_vec(&json!([ - {"pubkey":PUB,"relay_url":"wss://stale.example"}, {"pubkey":PUB,"relay_url":other_pin} - ])).unwrap()).unwrap(); - assert_eq!( - imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://chosen.example" - ) - .err() - .unwrap(), - "Source contains duplicate agent identities" - ); - } - let mut records = json!([{"pubkey":PUB,"relay_url":""}]); - fs::write(&path, serde_json::to_vec(&records).unwrap()).unwrap(); - let preview = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://chosen.example", - ) - .unwrap(); - records[0]["relay_url"] = json!("wss://ignored-but-changed.example"); - fs::write(&path, serde_json::to_vec(&records).unwrap()).unwrap(); - assert!(imports - .commit( - &preview.token, - &[preview.candidates[0].id.clone()], - &mut store, - &keys - ) - .unwrap_err() - .contains("Source changed")); - assert_eq!(keys.reads.load(Ordering::SeqCst), 0); - assert!(keys.keys.lock().unwrap().is_empty()); - assert!(store.agents().unwrap().is_empty()); -} - -fn team_source(root: &Path, instructions: Value) -> PathBuf { - source(root); - let root = root.join(LegacySource::Installed.app_directory()); - let path = root.join("agents/managed-agents.json"); - let mut records: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - records[1]["team_id"] = json!("crew"); - records[1]["backend"] = json!({"type":"local"}); - fs::write(path, serde_json::to_vec(&records).unwrap()).unwrap(); - let teams = root.join("agents/teams.json"); - fs::write( - &teams, - serde_json::to_vec(&json!([{"id":"crew","instructions":instructions}])).unwrap(), - ) - .unwrap(); - teams -} -fn team_preview(imports: &mut Imports, old: &Path, workspace: &Path) -> ImportPreview { - imports - .preview( - LegacySource::Installed, - old.into(), - workspace.into(), - "wss://relay.example", - ) - .unwrap() -} -#[test] -fn team_import_snapshots_instructions_and_fences_both_source_changes() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - let teams = team_source(old.path(), json!(" team prompt\n")); - let before = fs::read(&teams).unwrap(); - let mut imports = Imports::default(); - let keys = Memory::default(); - let mut store = Store::open(dest.path().into()).unwrap(); - let preview = team_preview(&mut imports, old.path(), dest.path()); - let ids = [preview.candidates[0].id.clone()]; - assert!(!serde_json::to_string(&preview) - .unwrap() - .contains("team prompt")); - fs::write(&teams, b"[]").unwrap(); - assert!(imports - .prepare(&preview.token, &ids, &store) - .err() - .unwrap() - .contains("Source changed")); - fs::write(&teams, &before).unwrap(); - let prepared = imports - .prepare(&preview.token, &ids, &store) - .unwrap() - .acquire(&keys) - .unwrap(); - fs::write(&teams, b"[]").unwrap(); - assert!(prepared - .commit(&mut store) - .unwrap_err() - .contains("Source changed")); - assert!(store.agents().unwrap().is_empty()); - fs::write(&teams, &before).unwrap(); - imports - .commit(&preview.token, &ids, &mut store, &keys) - .unwrap(); - let agent = &store.agents().unwrap()[0]; - assert_eq!(agent.imported["teamInstructions"], "team prompt"); - assert_eq!(agent.imported["record"]["team_id"], "crew"); - assert!(!agent.needs_team_import()); - assert!(!agent.enabled); - assert_eq!(fs::read(teams).unwrap(), before); -} -#[test] -fn repair_only_adds_team_snapshot_without_keys_or_overwriting_edits() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - team_source(old.path(), json!("team prompt")); - let mut imports = Imports::default(); - let keys = Memory::default(); - let mut store = Store::open(dest.path().into()).unwrap(); - let preview = team_preview(&mut imports, old.path(), dest.path()); - let ids = [preview.candidates[0].id.clone()]; - let data = read_source(&old.path().join(LegacySource::Installed.app_directory())).unwrap(); - let mut agent = resolve(&data, &data.records[1], dest.path(), "wss://relay.example").unwrap(); - agent - .imported - .as_object_mut() - .unwrap() - .remove("teamInstructions"); - agent.name = "Edited name".into(); - agent.harness.model = "edited-model".into(); - agent.environment.insert("KEEP".into(), "private".into()); - agent.enabled = true; - assert!(agent.view(&Default::default()).needs_team_import); - let mut expected = serde_json::to_value(&agent).unwrap(); - expected["revision"] = json!(2); - expected["imported"]["teamInstructions"] = json!("team prompt"); - store.insert(vec![agent]).unwrap(); - let prepared = imports.prepare(&preview.token, &ids, &store).unwrap(); - // Stop is allowed during an import wait; repair must not re-enable it. - store.enabled(&ids[0], false).unwrap(); - expected["enabled"] = json!(false); - prepared.acquire(&keys).unwrap().commit(&mut store).unwrap(); - assert_eq!(keys.reads.load(Ordering::SeqCst), 0); - assert!(keys.keys.lock().unwrap().is_empty()); - assert_eq!( - serde_json::to_value(&store.agents().unwrap()[0]).unwrap(), - expected - ); - assert!(imports.prepare(&preview.token, &ids, &store).is_err()); -} -#[test] -fn repair_refuses_changed_settings_and_wrong_source_team_binding() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - team_source(old.path(), json!("team prompt")); - let mut imports = Imports::default(); - let keys = Memory::default(); - let mut store = Store::open(dest.path().into()).unwrap(); - let data = read_source(&old.path().join(LegacySource::Installed.app_directory())).unwrap(); - let mut agent = resolve(&data, &data.records[1], dest.path(), "wss://relay.example").unwrap(); - agent - .imported - .as_object_mut() - .unwrap() - .remove("teamInstructions"); - let ids = [agent.id.clone()]; - store.insert(vec![agent.clone()]).unwrap(); - let preview = team_preview(&mut imports, old.path(), dest.path()); - let prepared = imports - .prepare(&preview.token, &ids, &store) - .unwrap() - .acquire(&keys) - .unwrap(); - let mut doc: Value = - serde_json::from_slice(&fs::read(dest.path().join("agents.json")).unwrap()).unwrap(); - doc["agents"][0]["revision"] = json!(2); - fs::write( - dest.path().join("agents.json"), - serde_json::to_vec(&doc).unwrap(), - ) - .unwrap(); - assert!(prepared - .commit(&mut store) - .unwrap_err() - .contains("settings changed")); - assert!(store.agents().unwrap()[0].needs_team_import()); - let path = old - .path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json"); - let mut records: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - records[1]["team_id"] = json!("different"); - fs::write(path, serde_json::to_vec(&records).unwrap()).unwrap(); - let preview = team_preview(&mut imports, old.path(), dest.path()); - assert!(imports - .prepare(&preview.token, &ids, &store) - .err() - .unwrap() - .contains("binding differs")); -} -#[test] -fn team_snapshot_handles_empty_deleted_and_invalid_teams() { - let old = tempfile::tempdir().unwrap(); - let teams = team_source(old.path(), json!(" ")); - let root = old.path().join(LegacySource::Installed.app_directory()); - let data = read_source(&root).unwrap(); - assert_eq!(team_instructions(&data, &data.records[1]).unwrap(), ""); - fs::remove_file(&teams).unwrap(); - let data = read_source(&root).unwrap(); - assert_eq!(team_instructions(&data, &data.records[1]).unwrap(), ""); - // A directory-only legacy binding does not resolve instructions in old Buzz. - assert_eq!( - team_instructions(&data, &json!({"persona_team_dir":"/old/pack"})).unwrap(), - "" - ); - for value in [json!({}), json!([{"id":"crew"},{"id":"crew"}])] { - fs::write(&teams, serde_json::to_vec(&value).unwrap()).unwrap(); - assert!(read_source(&root).is_err()); - } - for value in [ - json!(4), - json!("nul\0text"), - json!("x".repeat(128 * 1024 + 1)), - ] { - fs::write( - &teams, - serde_json::to_vec(&json!([{"id":"crew","instructions":value}])).unwrap(), - ) - .unwrap(); - let data = read_source(&root).unwrap(); - assert!(team_instructions(&data, &data.records[1]).is_err()); - } -} -#[test] -fn local_browse_without_destination_cannot_commit_or_reuse_prior_authority() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - let bytes = source(old.path()); - let mut imports = Imports::default(); - let mut store = Store::open(dest.path().into()).unwrap(); - let keys = Memory::default(); - let prior = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://chosen.example", - ) - .unwrap(); - let browse = imports - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "", - ) - .unwrap(); - assert_eq!(browse.candidates.len(), 1); - assert_eq!(browse.candidates[0].pubkey, PUB); - assert!(browse.candidates[0].relay_url.is_empty()); - assert!(browse.token.is_empty()); - for (token, id) in [ - (&browse.token, &browse.candidates[0].id), - (&prior.token, &prior.candidates[0].id), - ] { - assert!(imports - .commit(token, std::slice::from_ref(id), &mut store, &keys) - .is_err()); - } - assert_eq!(keys.reads.load(Ordering::SeqCst), 0); - assert!(keys.keys.lock().unwrap().is_empty()); - assert!(store.agents().unwrap().is_empty()); - assert_eq!( - fs::read( - old.path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json") - ) - .unwrap(), - bytes - ); - let serialized = serde_json::to_string(&browse).unwrap(); - assert!(!serialized.contains("private_key")); -} - -#[test] -fn clone_settings_projects_only_reviewed_text_without_source_or_credential_writes() { - let old = tempfile::tempdir().unwrap(); - let before = source(old.path()); - let settings = - Imports::clone_settings(LegacySource::Installed, old.path().into(), PUB).unwrap(); - assert_eq!( - serde_json::to_value(settings).unwrap(), - json!({ - "name": "Brain", "systemPrompt": "definition-prompt" - }) - ); - assert_eq!( - fs::read( - old.path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json") - ) - .unwrap(), - before - ); - assert!(Imports::clone_settings(LegacySource::Development, old.path().into(), PUB).is_err()); - assert!( - Imports::clone_settings(LegacySource::Installed, old.path().into(), "../path").is_err() - ); - assert!( - Imports::clone_settings(LegacySource::Installed, old.path().into(), &"ab".repeat(32)) - .is_err() - ); - let path = old - .path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json"); - let mut records: Vec = serde_json::from_slice(&before).unwrap(); - records.push(records[1].clone()); - fs::write(path, serde_json::to_vec(&records).unwrap()).unwrap(); - assert!(Imports::clone_settings(LegacySource::Installed, old.path().into(), PUB).is_err()); -} - -#[test] -fn parked_migration_is_keyless_idempotent_and_follows_source_removal() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - let before = source(old.path()); - let source_path = old.path().join(LegacySource::Installed.app_directory()); - let mut store = Store::open(dest.path().into()).unwrap(); - assert!(store.migrate_legacy(old.path()).is_empty()); - let saved = fs::read(dest.path().join("agents.json")).unwrap(); - assert!(store.migrate_legacy(old.path()).is_empty()); - assert_eq!(saved, fs::read(dest.path().join("agents.json")).unwrap()); - assert!(!dest.path().join("agents.previous.json").exists()); - let snapshot = store.snapshot().unwrap(); - assert!(snapshot.agents.is_empty()); // Nothing can be started/restored. - assert_eq!(snapshot.parked.len(), 1); - assert_eq!(snapshot.parked[0].pubkey, PUB); - assert_eq!(snapshot.parked[0].sources, vec![LegacySource::Installed]); - let text = String::from_utf8(saved).unwrap(); - for hidden in [ - "never-project", - "definition-prompt", - "attestation", - "relay.example", - "start_on_app_launch", - ] { - assert!(!text.contains(hidden)); - } - assert_eq!( - fs::read(source_path.join("agents/managed-agents.json")).unwrap(), - before - ); - drop(store); - fs::remove_dir_all(source_path).unwrap(); - let mut reopened = Store::open(dest.path().into()).unwrap(); - assert_eq!(reopened.snapshot().unwrap().parked[0].name, "Brain"); - // A removed installation can no longer supply an import or a clone. - assert!(reopened.migrate_legacy(old.path()).is_empty()); - assert!(reopened.snapshot().unwrap().parked.is_empty()); -} - -#[test] -fn parked_migration_replaces_each_source_and_keeps_other_sources() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - let bytes = source(old.path()); - let development = old - .path() - .join(LegacySource::Development.app_directory()) - .join("agents"); - fs::create_dir_all(&development).unwrap(); - fs::write(development.join("managed-agents.json"), &bytes).unwrap(); - let mut store = Store::open(dest.path().into()).unwrap(); - assert!(store.migrate_legacy(old.path()).is_empty()); - assert_eq!(store.snapshot().unwrap().parked[0].sources.len(), 2); - // The identity is deleted from Development only: it stays listed for Installed. - fs::write(development.join("managed-agents.json"), b"[]").unwrap(); - assert!(store.migrate_legacy(old.path()).is_empty()); - assert_eq!( - store.snapshot().unwrap().parked[0].sources, - vec![LegacySource::Installed] - ); - // Deleted from the last source that listed it: it leaves the inventory. - let installed = old - .path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json"); - fs::write(&installed, b"[]").unwrap(); - assert!(store.migrate_legacy(old.path()).is_empty()); - assert!(store.snapshot().unwrap().parked.is_empty()); -} - -#[test] -fn parked_migration_merges_sources_and_preserves_inventory_on_bad_source() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - let bytes = source(old.path()); - let development = old - .path() - .join(LegacySource::Development.app_directory()) - .join("agents"); - fs::create_dir_all(&development).unwrap(); - fs::write(development.join("managed-agents.json"), &bytes).unwrap(); - let mut store = Store::open(dest.path().into()).unwrap(); - assert!(store.migrate_legacy(old.path()).is_empty()); - let snapshot = store.snapshot().unwrap(); - assert_eq!(snapshot.parked.len(), 1); - assert_eq!(snapshot.parked[0].sources.len(), 2); - fs::write(development.join("managed-agents.json"), b"broken").unwrap(); - assert_eq!(store.migrate_legacy(old.path()).len(), 1); - assert_eq!(store.snapshot().unwrap().parked[0].sources.len(), 2); - assert_eq!( - fs::read(development.join("managed-agents.json")).unwrap(), - b"broken" - ); -} - -#[test] -fn import_excludes_overlapping_destinations_before_credentials_through_commit() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let keys = Memory::default(); - let mut store = Store::open(dest.path().into()).unwrap(); - let mut first = Imports::default(); - let preview = first - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://first.example", - ) - .unwrap(); - let mut second = Imports::default(); - let other = second - .preview( - LegacySource::Installed, - old.path().into(), - dest.path().into(), - "wss://second.example", - ) - .unwrap(); - let ids = [preview.candidates[0].id.clone()]; - let other_ids = [other.candidates[0].id.clone()]; - let prepared = first.prepare(&preview.token, &ids, &store).unwrap(); - // Separate preview owners still share the same store reservation. - assert!(second - .prepare(&other.token, &other_ids, &store) - .err() - .unwrap() - .contains("import is in progress")); - assert!(keys.keys.lock().unwrap().is_empty()); - // Cancellation before credential access releases the reservation. - drop(prepared); - let prepared = first.prepare(&preview.token, &ids, &store).unwrap(); - let unavailable = Memory { - fail: true, - ..Default::default() - }; - assert!(prepared.acquire(&unavailable).is_err()); - // Credential failure releases it too, so the same preview can be retried. - let pending = first - .prepare(&preview.token, &ids, &store) - .unwrap() - .acquire(&keys) - .unwrap(); - let reads = keys.reads.load(Ordering::SeqCst); - assert!(second - .commit(&other.token, &other_ids, &mut store, &keys) - .unwrap_err() - .contains("import is in progress")); - assert_eq!(keys.reads.load(Ordering::SeqCst), reads); - assert_eq!( - keys.keys - .lock() - .unwrap() - .keys() - .cloned() - .collect::>(), - ids - ); - pending.commit(&mut store).unwrap(); - let before = fs::read(dest.path().join("agents.json")).unwrap(); - assert!(second - .commit(&other.token, &other_ids, &mut store, &keys) - .unwrap_err() - .contains("already imported")); - assert_eq!(keys.reads.load(Ordering::SeqCst), reads); - assert_eq!( - keys.keys - .lock() - .unwrap() - .keys() - .cloned() - .collect::>(), - ids - ); - assert_eq!(fs::read(dest.path().join("agents.json")).unwrap(), before); - assert_eq!(store.agents().unwrap().len(), 1); -} -#[test] -fn imported_prompt_strips_only_the_last_exact_baked_team_suffix() { - let baked = "\n\n---\n# Team Instructions\n"; - assert_eq!(imported_prompt(&format!("role{baked}team")), "role"); - assert_eq!( - imported_prompt(&format!("role{baked}old{baked}team")), - format!("role{baked}old") - ); - assert_eq!(imported_prompt(&format!("{baked}team")), ""); - for own in [ - "no delimiter at all", - "role\n\n---\nauthor rule", - "role\n---\n# Team Instructions\nauthor heading", - "# Team Instructions\nauthor heading", - ] { - assert_eq!(imported_prompt(own), own); - } -} -#[test] -fn import_strips_baked_team_suffix_from_the_copy_and_flags_it_in_preview() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let path = old - .path() - .join(LegacySource::Installed.app_directory()) - .join("agents/managed-agents.json"); - let mut records: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - let kept = "role\n\n---\n# Team Instructions\nkept"; - records[0]["system_prompt"] = json!(format!("{kept}\n\n---\n# Team Instructions\nbaked team")); - fs::write(&path, serde_json::to_vec(&records).unwrap()).unwrap(); - let before = fs::read(&path).unwrap(); - let mut imports = Imports::default(); - let preview = team_preview(&mut imports, old.path(), dest.path()); - assert!(preview.candidates[0].strips_team_instructions); - assert!(!serde_json::to_string(&preview) - .unwrap() - .contains("baked team")); - let clone = Imports::clone_settings(LegacySource::Installed, old.path().into(), PUB).unwrap(); - assert_eq!(clone.system_prompt, kept); - let mut store = Store::open(dest.path().into()).unwrap(); - imports - .commit( - &preview.token, - &[preview.candidates[0].id.clone()], - &mut store, - &Memory::default(), - ) - .unwrap(); - assert_eq!(store.agents().unwrap()[0].system_prompt, kept); - assert_eq!(fs::read(&path).unwrap(), before); - // Reopening never cuts the fresh import a second time. - drop(store); - let mut store = Store::open(dest.path().into()).unwrap(); - assert_eq!(store.clean_imported_prompts().unwrap(), None); - assert_eq!(store.agents().unwrap()[0].system_prompt, kept); -} -#[test] -fn preview_does_not_flag_a_prompt_without_the_baked_suffix() { - let old = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - source(old.path()); - let preview = team_preview(&mut Imports::default(), old.path(), dest.path()); - assert!(!preview.candidates[0].strips_team_instructions); -} diff --git a/crates/agent-controller/src/legacy.rs b/crates/agent-controller/src/legacy.rs new file mode 100644 index 000000000..01385f97a --- /dev/null +++ b/crates/agent-controller/src/legacy.rs @@ -0,0 +1,89 @@ +//! The retired controller's registry is read only, including during sign-out. +//! Keep just the identity fields needed to remove its local credential copies. +use crate::config::{agent_id, canonical_key, canonical_relay, MAX_AGENTS}; +use crate::{Credentials, Result}; +use serde::Deserialize; +use std::collections::BTreeSet; +use std::fs::OpenOptions; +use std::path::PathBuf; + +#[derive(Deserialize)] +struct Registry { + version: u32, + agents: Vec, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Identity { + id: String, + pubkey: String, + relay_url: String, + credential_id: String, +} + +/// Remove keys listed by the v1 registry, without rewriting it. A failed deletion +/// can be retried, including keys already removed. Retired harness settings do +/// not control whether an identity can be erased. +pub fn delete_local_agent_keys(root: PathBuf, credentials: &dyn Credentials) -> Result<()> { + if !root.is_absolute() { + return Err("Agent storage requires an absolute directory".into()); + } + match std::fs::symlink_metadata(&root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Ok(meta) if meta.is_dir() => {} + _ => return Err("Agent storage must be a directory, not a link".into()), + } + // Older app instances still use this lock. Never erase keys under a writer. + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let lock = options + .open(root.join("controller.lock")) + .map_err(|_| "Could not open agent storage lock")?; + if !lock + .metadata() + .map_err(|_| "Could not inspect agent storage lock")? + .is_file() + { + return Err("Agent storage lock must be a regular file".into()); + } + lock.try_lock() + .map_err(|_| "Another Buzz app owns this agent storage")?; + let path = root.join("agents.json"); + match std::fs::symlink_metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err("Could not inspect saved agents".into()), + Ok(_) => {} + } + let registry: Registry = serde_json::from_value(crate::import::read_json(&path, false)?) + .map_err(|_| "Saved agent identities are malformed; left unchanged")?; + if registry.version != 1 || registry.agents.len() > MAX_AGENTS { + return Err("Unsupported agent storage version or size; left unchanged".into()); + } + let mut ids = BTreeSet::new(); + // Validate the entire inventory before the first destructive operation. + for agent in ®istry.agents { + if !canonical_key(&agent.pubkey) + || canonical_relay(&agent.relay_url)? != agent.relay_url + || agent.id != agent_id(&agent.pubkey, &agent.relay_url) + || !ids.insert(&agent.id) + || agent + .credential_id + .split_once('-') + .is_none_or(|(key, digest)| key != agent.pubkey || !canonical_key(digest)) + { + return Err("Invalid saved agent identity; left unchanged".into()); + } + } + for agent in registry.agents { + credentials.delete(&agent.credential_id, &agent.pubkey)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/agent-controller/src/legacy/tests.rs b/crates/agent-controller/src/legacy/tests.rs new file mode 100644 index 000000000..180e3ff53 --- /dev/null +++ b/crates/agent-controller/src/legacy/tests.rs @@ -0,0 +1,135 @@ +use super::*; +use crate::{LegacySource, Secret}; +use serde_json::{json, Value}; +use std::sync::Mutex; + +#[derive(Default)] +struct Keychain(Mutex<(Vec, bool)>); +impl Credentials for Keychain { + fn delete(&self, id: &str, _: &str) -> Result<()> { + let mut state = self.0.lock().unwrap(); + if state.1 && id.starts_with('b') { + state.1 = false; + return Err("keychain interrupted".into()); + } + state.0.push(id.into()); + Ok(()) + } + fn read_legacy(&self, _: LegacySource, _: &str) -> Result { + unreachable!() + } + fn read(&self, _: &str, _: &str) -> Result> { + unreachable!() + } + fn add(&self, _: &str, _: &Secret) -> Result<()> { + unreachable!() + } +} +fn row(key: &str) -> Value { + let relay = "wss://relay.example"; + let id = agent_id(key, relay); + json!({"id": id, "pubkey": key, "relayUrl": relay, "credentialId": id, + "imported": {"record": {"backend": {"type": "provider"}, "backend_agent_id": "remote"}}}) +} +fn registry(dir: &std::path::Path, value: Value) -> Vec { + let bytes = serde_json::to_vec(&value).unwrap(); + std::fs::write(dir.join("agents.json"), &bytes).unwrap(); + bytes +} +#[test] +fn deletion_resumes_and_keeps_remote_and_local_records_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let first = row(&"a".repeat(64)); + let second = row(&"b".repeat(64)); + let bytes = registry(dir.path(), json!({"version":1,"agents":[first,second]})); + let credentials = Keychain(Mutex::new((vec![], true))); + assert!(delete_local_agent_keys(dir.path().into(), &credentials).is_err()); + assert_eq!(credentials.0.lock().unwrap().0.len(), 1); + delete_local_agent_keys(dir.path().into(), &credentials).unwrap(); + let calls = &credentials.0.lock().unwrap().0; + assert_eq!( + calls, + &[ + first["credentialId"].as_str().unwrap(), + first["credentialId"].as_str().unwrap(), + second["credentialId"].as_str().unwrap() + ] + ); + assert_eq!( + std::fs::read(dir.path().join("agents.json")).unwrap(), + bytes + ); +} +#[test] +fn missing_registry_is_done_but_bad_or_future_registry_removes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Keychain::default(); + delete_local_agent_keys(dir.path().join("absent"), &credentials).unwrap(); + delete_local_agent_keys(dir.path().into(), &credentials).unwrap(); + for value in [ + json!({}), + json!({"version":2,"agents":[]}), + json!({"version":1,"agents":[row(&"a".repeat(64)),{}]}), + json!({"version":1,"agents":[row(&"a".repeat(64)),row(&"a".repeat(64))]}), + ] { + registry(dir.path(), value); + assert!(delete_local_agent_keys(dir.path().into(), &credentials).is_err()); + } + let mut wrong = row(&"a".repeat(64)); + wrong["credentialId"] = row(&"b".repeat(64))["credentialId"].clone(); + registry(dir.path(), json!({"version":1,"agents":[wrong]})); + assert!(delete_local_agent_keys(dir.path().into(), &credentials).is_err()); + assert!(credentials.0.lock().unwrap().0.is_empty()); +} +#[test] +fn a_live_controller_lock_prevents_key_deletion() { + let dir = tempfile::tempdir().unwrap(); + registry( + dir.path(), + json!({"version":1,"agents":[row(&"a".repeat(64))]}), + ); + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(dir.path().join("controller.lock")) + .unwrap(); + lock.lock().unwrap(); + let credentials = Keychain::default(); + assert!(delete_local_agent_keys(dir.path().into(), &credentials).is_err()); + assert!(credentials.0.lock().unwrap().0.is_empty()); + drop(lock); + delete_local_agent_keys(dir.path().into(), &credentials).unwrap(); + assert_eq!(credentials.0.lock().unwrap().0.len(), 1); +} +#[cfg(unix)] +#[test] +fn symlinked_registry_directory_file_or_lock_never_deletes_a_key() { + use std::os::unix::fs::symlink; + for target in ["directory", "agents.json", "controller.lock"] { + let dir = tempfile::tempdir().unwrap(); + let actual = dir.path().join("actual"); + std::fs::create_dir(&actual).unwrap(); + registry( + &actual, + json!({"version":1,"agents":[row(&"a".repeat(64))]}), + ); + let root = dir.path().join("config"); + if target == "directory" { + symlink(&actual, &root).unwrap(); + } else { + std::fs::create_dir(&root).unwrap(); + registry(&root, json!({"version":1,"agents":[row(&"a".repeat(64))]})); + if target == "agents.json" { + std::fs::remove_file(root.join(target)).unwrap(); + } else { + std::fs::write(actual.join(target), "").unwrap(); + } + symlink(actual.join(target), root.join(target)).unwrap(); + } + let credentials = Keychain::default(); + assert!(delete_local_agent_keys(root, &credentials).is_err()); + assert!(credentials.0.lock().unwrap().0.is_empty()); + } +} diff --git a/crates/agent-controller/src/lib.rs b/crates/agent-controller/src/lib.rs index 2a1af0d0b..bada96a2e 100644 --- a/crates/agent-controller/src/lib.rs +++ b/crates/agent-controller/src/lib.rs @@ -1,66 +1,22 @@ -//! Local configuration and process ownership; never tied to a page or relay session. -//! No legacy desktop dependency, implicit identity creation, or credential projection. -mod agent_defaults; +//! Native agent identities, signing, credential custody and read-only legacy import. +//! Plugin runtimes own execution; this crate does not launch agent processes. mod app_agent; -mod bundle; -pub mod codex; -mod community; mod config; -mod harness_policy; -pub use community::CommunityResolution; -pub use harness_policy::{ - AuthenticationPolicy, ConfigurationMode, EffortDiscovery, HarnessConfigurationPolicy, - HarnessIntegration, ModelRequirement, ProviderPolicy, SelectorEnvironment, -}; -pub mod connection; +#[cfg(any(target_os = "macos", test))] +mod connection; mod create; mod credentials; -mod defaults; -mod harness_presets; mod import; -pub mod logs; -mod ownership; -pub mod pi; +mod legacy; mod port; -mod process; mod profile; -mod restart; -mod runtime; mod secret; -pub mod security; -mod skills; -mod store; -mod teams; -pub use teams::{ - BundleMember, MemberSnapshot, Memory, MemoryEntry, TeamCatalogEntry, TeamMeta, TeamSnapshot, -}; -mod supervisor; -#[cfg(test)] -mod test_executable; -pub use supervisor::dispatch as dispatch_agent_supervisor; -pub use agent_defaults::{AgentDefaultsEdit, AgentDefaultsView}; pub use app_agent::{AppAgent, AppAgents, NewAppAgent, Published}; -pub use bundle::RuntimeBundle; -pub use config::{ - AgentEdit, AgentView, AiConfiguration, ControlSnapshot, EffortSelection, HarnessEdit, - ProcessStatus, -}; -pub use create::{CreationProfile, NewAgent}; +pub use create::CreationProfile; pub use credentials::{PlatformCredentials, BUSY as CREDENTIALS_BUSY}; -pub use defaults::{build_defaults, BuildDefaults}; -pub use harness_presets::{harness_preset, harness_presets, HarnessPreset}; -pub use import::{ - CloneSettings, CredentialedImport, ImportPreview, Imports, LegacySource, PreparedImport, -}; +pub use import::LegacySource; +pub use legacy::delete_local_agent_keys; pub use port::{OldStores, PortSource, Portable, Portables}; -pub use restart::{RestartChange, RestartDiffEntry}; -pub use runtime::path::{prepare_tools_path, tools_path, warm_tools_path}; -pub use runtime::{ - check_owner, delete_local_agent_keys, installed, installed_npm_tool, managed_tool, Action, - Controller, GooseModelContext, ModelContext, -}; pub use secret::{validate_snapshot_memory_envelope, Credentials, Secret}; -pub use skills::ensure_buzz_cli_skill; -pub use store::{ParkedIdentity, Store}; type Result = std::result::Result; diff --git a/crates/agent-controller/src/logs.rs b/crates/agent-controller/src/logs.rs deleted file mode 100644 index fc4d755cb..000000000 --- a/crates/agent-controller/src/logs.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! Private bounded listener output, separate from sanitized controller snapshots. -use crate::Result; -use sha2::{Digest, Sha256}; -use std::fs::{File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; - -/// Domain-separated proof input; only the native-generated single-use nonce is -/// signed by the connected community's owner signer. The relay URL is canonical wss. -pub fn proof_message(id: &str, pubkey: &str, relay: &str, nonce: &str) -> String { - format!("buzz-app:harness-log:v1:{id}:{pubkey}:{relay}:{nonce}") -} - -pub(crate) fn verify_owner_proof( - owner: &str, - id: &str, - pubkey: &str, - relay: &str, - nonce: &str, - signature: &str, -) -> Result<()> { - use secp256k1::{schnorr::Signature, Secp256k1, XOnlyPublicKey}; - let invalid = || "Owner authorization is unavailable".to_string(); - if nonce.len() != 36 || !uuid_shape(nonce) || signature.len() != 128 { - return Err(invalid()); - } - let key: XOnlyPublicKey = owner.parse().map_err(|_| invalid())?; - let sig: Signature = signature.parse().map_err(|_| invalid())?; - let digest = Sha256::digest(proof_message(id, pubkey, relay, nonce)); - Secp256k1::verification_only() - .verify_schnorr(&sig, &digest, &key) - .map_err(|_| invalid()) -} - -fn uuid_shape(value: &str) -> bool { - value.bytes().enumerate().all(|(i, b)| { - if [8, 13, 18, 23].contains(&i) { - b == b'-' - } else { - b.is_ascii_hexdigit() && !b.is_ascii_uppercase() - } - }) -} - -const MAX_BYTES: u64 = 1024 * 1024; -const VISIBLE_LINES: usize = 120; -const MAX_RESPONSE_BYTES: usize = 64 * 1024; - -pub(crate) fn path(root: &Path, id: &str) -> Result { - // IDs are native identity/community hashes, never caller-supplied paths. - if id.len() != 129 - || !id.bytes().enumerate().all(|(i, b)| { - if i == 64 { - b == b'-' - } else { - b.is_ascii_digit() || (b'a'..=b'f').contains(&b) - } - }) - { - return Err("Invalid agent identifier".into()); - } - let dir = root.join("logs"); - crate::connection::private_directory(&dir)?; - Ok(dir.join(format!("{id}.log"))) -} - -fn open(path: &Path, write: bool) -> std::io::Result { - let mut options = OpenOptions::new(); - options.read(true).write(write).create(write); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); - } - let file = options.open(path)?; - let meta = file.metadata()?; - if !meta.is_file() { - return Err(std::io::Error::other("Invalid log file")); - } - #[cfg(unix)] - { - use std::os::unix::fs::{MetadataExt, PermissionsExt}; - if meta.nlink() != 1 { - return Err(std::io::Error::other("Invalid log file")); - } - if write { - file.set_permissions(std::fs::Permissions::from_mode(0o600))?; - } - } - Ok(file) -} - -// Only the supervisor writes logs. -pub(crate) struct Writer { - file: File, -} -impl Writer { - pub(crate) fn new(path: &Path) -> Result { - let file = open(path, true).map_err(|_| "Could not open harness log")?; - Ok(Self { file }) - } - pub(crate) fn append(&mut self, bytes: &[u8]) -> Result<()> { - // Serialized by the supervisor. Lock readers across compaction so a poll - // cannot observe a half-written tail. Work per write and retention are bounded. - self.file.lock().map_err(|_| "Could not lock harness log")?; - let result = (|| -> std::io::Result<()> { - let len = self.file.metadata()?.len(); - let bytes = &bytes[bytes.len().saturating_sub(MAX_BYTES as usize)..]; - if len + bytes.len() as u64 > MAX_BYTES { - // Drop half the retained history at a time, so steady output - // does not rewrite 1 MiB for every small append. - let keep = (MAX_BYTES / 2).min(len).min(MAX_BYTES - bytes.len() as u64); - self.file.seek(SeekFrom::End(-(keep as i64)))?; - let mut tail = vec![0; keep as usize]; - self.file.read_exact(&mut tail)?; - self.file.seek(SeekFrom::Start(0))?; - self.file.write_all(&tail)?; - self.file.set_len(keep)?; - } - self.file.seek(SeekFrom::End(0))?; - self.file.write_all(bytes) - })(); - let unlocked = self.file.unlock(); - result - .and(unlocked) - .map_err(|_| "Could not write harness log".into()) - } -} - -pub(crate) fn read(path: &Path) -> Result { - let mut file = match open(path, false) { - Ok(file) => file, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(String::new()), - Err(_) => return Err("Could not read harness log".into()), - }; - file.lock_shared() - .map_err(|_| "Could not lock harness log")?; - let result = (|| -> std::io::Result { - let len = file.metadata()?.len(); - file.seek(SeekFrom::Start(0))?; - let mut bytes = Vec::with_capacity(len.min(MAX_BYTES) as usize); - file.take(MAX_BYTES + 1).read_to_end(&mut bytes)?; - if bytes.len() > MAX_BYTES as usize { - return Err(std::io::Error::other("Harness log exceeds retention limit")); - } - // Match the base profile view's last 120 lines. Bound the native IPC - // response independently of the private on-disk retention budget. - let text = strip_ansi_escapes::strip_str(String::from_utf8_lossy(&bytes)); - let lines: Vec<&str> = text.lines().collect(); - let tail = lines[lines.len().saturating_sub(VISIBLE_LINES)..].join("\n"); - // A single enormous line must not turn a focused read into a 1 MiB IPC. - let start = tail.len().saturating_sub(MAX_RESPONSE_BYTES); - let start = (start..tail.len()) - .find(|&index| tail.is_char_boundary(index)) - .unwrap_or(tail.len()); - Ok(tail[start..].to_string()) - })(); - // Dropping the file releases the read lock, including on failure. - result.map_err(|_| "Could not read harness log".into()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn retained_history_and_bounded_profile_tail_survive_restart() { - let dir = tempfile::tempdir().unwrap(); - let id = format!("{}-{}", "a".repeat(64), "b".repeat(64)); - let path = path(dir.path(), &id).unwrap(); - assert_eq!(read(&path).unwrap(), ""); - assert!(super::path(dir.path(), "../escape").is_err()); - let mut writer = Writer::new(&path).unwrap(); - for line in 0..250 { - writer.append(format!("line {line}\n").as_bytes()).unwrap(); - } - let tail = read(&path).unwrap(); - assert_eq!(tail.lines().count(), VISIBLE_LINES); - assert!(tail.starts_with("line 130\n")); - assert!(tail.ends_with("line 249")); - writer.append(b"\x1b[31mcolored\x1b[0m\n").unwrap(); - assert!(read(&path).unwrap().ends_with("colored")); - assert!(!read(&path).unwrap().contains('\x1b')); - writer.append(&vec![b'x'; MAX_BYTES as usize]).unwrap(); - assert!(std::fs::metadata(&path).unwrap().len() <= MAX_BYTES); - assert_eq!(read(&path).unwrap().len(), MAX_RESPONSE_BYTES); - drop(writer); - let mut restarted = Writer::new(&path).unwrap(); - restarted.append(b"\nrestart\n").unwrap(); - assert!(read(&path).unwrap().ends_with("restart")); - assert!(std::fs::metadata(&path).unwrap().len() <= MAX_BYTES / 2 + 9); - restarted.append(b"more\n").unwrap(); - assert!(std::fs::metadata(&path).unwrap().len() <= MAX_BYTES / 2 + 14); - let tail = read(&path).unwrap(); - assert_eq!(tail.lines().count(), 3); - assert!(tail.lines().next().unwrap().bytes().all(|b| b == b'x')); - assert!(tail.ends_with("restart\nmore")); - assert!(super::path(dir.path(), "../escape").is_err()); - } - - #[cfg(unix)] - #[test] - fn symlink_is_not_read_or_written() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("secret"); - std::fs::write(&target, "private").unwrap(); - let link = dir.path().join("log"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - assert!(read(&link).is_err()); - assert!(Writer::new(&link).is_err()); - assert_eq!(std::fs::read_to_string(&target).unwrap(), "private"); - } -} diff --git a/crates/agent-controller/src/ownership.rs b/crates/agent-controller/src/ownership.rs deleted file mode 100644 index 0d0676be5..000000000 --- a/crates/agent-controller/src/ownership.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Cooperating new-app profiles only. Old Buzz does not participate in this lock. -use crate::Result; -use std::fs::{File, OpenOptions}; -use std::path::Path; -pub(crate) struct Ownership(File); -impl Ownership { - pub fn acquire(root: &Path, id: &str) -> Result { - let (key, community) = id - .split_once('-') - .ok_or("Invalid agent ownership identity")?; - if !crate::config::canonical_key(key) || !crate::config::canonical_key(community) { - return Err("Invalid agent ownership identity".into()); - } - if !root.is_absolute() { - return Err("Agent ownership storage must be absolute".into()); - } - crate::connection::private_directory(root)?; - let mut options = OpenOptions::new(); - options.read(true).write(true).create(true).truncate(false); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600).custom_flags(libc::O_NOFOLLOW); - } - let file = options - .open(root.join(format!("{id}.lock"))) - .map_err(|_| "Could not open agent ownership lock")?; - if !file - .metadata() - .map_err(|_| "Could not inspect agent ownership lock")? - .is_file() - { - return Err("Invalid agent ownership lock".into()); - } - file.try_lock().map_err(|_| { - "Another buzz-app profile is already running this exact agent/community" - })?; - Ok(Self(file)) - } -} -impl Drop for Ownership { - fn drop(&mut self) { - let _ = self.0.unlock(); - } -} diff --git a/crates/agent-controller/src/pi.rs b/crates/agent-controller/src/pi.rs deleted file mode 100644 index 493b2f0f0..000000000 --- a/crates/agent-controller/src/pi.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! Pi's supported CLI boundary, shared by discovery and ACP launch. -use crate::{ - runtime::{executable, installed}, - HarnessEdit, Result, -}; -use std::{ - collections::BTreeMap, - io::{Read, Seek, SeekFrom}, - path::{Path, PathBuf}, - process::{Command, Stdio}, - time::{Duration, Instant}, -}; - -/// Native only: contains local environment values, never serialized over IPC. -#[derive(PartialEq)] -pub struct PiContext { - pub command: PathBuf, - pub workspace: PathBuf, - pub args: Vec, - pub environment: BTreeMap, - pub path: std::ffi::OsString, -} -impl PiContext { - pub(crate) fn new( - harness: &HarnessEdit, - workspace: &str, - environment: &BTreeMap, - ) -> Result { - let adapter = Path::new(&harness.command); - if !adapter.is_absolute() - || adapter.file_name().and_then(|n| n.to_str()) != Some("buzz-pi-acp") - { - return Err("Pi requires an absolute buzz-pi-acp executable path".into()); - } - executable(adapter)?; - if !Path::new(workspace).is_absolute() || !Path::new(workspace).is_dir() { - return Err("Choose an existing absolute workspace before browsing Pi models".into()); - } - crate::config::validate_environment(environment, &harness.command)?; - // The adapter owns its supported runtime flags. Discovery applies its - // narrower, prompt-free contract separately. - let args = &harness.args; - if !args.is_empty() && args.first().map(String::as_str) != Some("--") { - return Err("Pi arguments must follow --".into()); - } - if args.len() > 128 - || args - .iter() - .any(|a| a.is_empty() || a.len() > 8192 || a.contains([',', '\0'])) - { - return Err("Invalid Pi arguments".into()); - } - let resolve = |name| { - let sibling = adapter.parent().unwrap().join(name); - if executable(&sibling).is_ok() { - Some(sibling) - } else { - installed(name) - } - }; - let managed_data = adapter - .parent() - .filter(|bin| bin.file_name().is_some_and(|name| name == "bin")) - .and_then(Path::parent) - .filter(|prefix| prefix.file_name().is_some_and(|name| name == "node-tools")) - .and_then(Path::parent); - let command = resolve("pi").ok_or("Install Pi and reopen the desktop app")?; - // A managed adapter always uses the pinned Node beside its app-owned - // prefix. A user-global adapter keeps its existing user-global resolution. - let node = if let Some(app_data) = managed_data { - crate::runtime::managed_tool(app_data, "node") - } else { - resolve("node") - } - .ok_or("Install Node.js for the Pi ACP adapter")?; - let path = crate::runtime::path::compose( - [ - node.parent().unwrap().to_path_buf(), - command.parent().unwrap().to_path_buf(), - ] - .into_iter() - .chain(std::env::split_paths(&crate::runtime::path::tools_path()?)) - .chain( - environment - .get("PATH") - .into_iter() - .flat_map(std::env::split_paths), - ), - )?; - let mut environment = environment.clone(); - environment.insert( - "PI_ACP_PI_COMMAND".into(), - command.to_string_lossy().into_owned(), - ); - Ok(Self { - command, - workspace: workspace.into(), - args: args.iter().skip(1).cloned().collect(), - environment, - path, - }) - } - - /// A prompt-free probe; callers own its bounded lifetime and cancellation. - pub fn version_command(&self) -> Command { - let mut command = version_command(&self.command, &self.path); - command.current_dir(&self.workspace); - command - } - pub fn version_error(&self, reason: &str) -> String { - format!( - "{reason}. Update the Pi CLI at {} to version 0.99.0 or later, then retry", - self.command.display() - ) - } - pub fn accept_version(self, output: &str) -> Result { - let version = output.trim(); - let parts = version - .split('.') - .map(str::parse::) - .collect::, _>>() - .unwrap_or_default(); - if parts.len() != 3 { - return Err(self.version_error("Could not verify the Pi CLI version")); - } - if (parts[0], parts[1], parts[2]) < (0, 99, 0) { - return Err(self.version_error(&format!("Pi CLI {version} is too old"))); - } - Ok(VerifiedPiContext(self)) - } - fn verify(self) -> Result { - let output = - version_output(self.version_command()).map_err(|error| self.version_error(&error))?; - self.accept_version(&output) - } - - pub fn catalog_args(&self) -> Result> { - let mut result = Vec::new(); - let mut args = self.args.iter(); - while let Some(arg) = args.next() { - // Match Pi's separate-token CLI syntax exactly. Do not normalize - // options differently from runtime or rebind --api-key to a default - // provider after removing selection for this catalog-only process. - let flag = arg.as_str(); - let selection = matches!(flag, "--provider" | "--model"); - let takes_value = selection - || matches!( - flag, - "--extension" - | "-e" - | "--skill" - | "--prompt-template" - | "--theme" - | "--thinking" - | "--tools" - | "-t" - | "--exclude-tools" - | "-xt" - | "--models" - ); - if takes_value { - let value = args - .next() - .map(String::as_str) - .filter(|v| !v.is_empty() && !v.starts_with('-')) - .ok_or("Pi option is missing a value; check Advanced arguments")?; - if !selection { - result.extend([flag.to_owned(), value.to_owned()]); - } - } else if matches!( - flag, - "--no-extensions" - | "-ne" - | "--no-skills" - | "-ns" - | "--no-prompt-templates" - | "-np" - | "--no-themes" - | "--no-tools" - | "-nt" - | "--no-builtin-tools" - | "-nbt" - | "--no-context-files" - | "-nc" - | "--offline" - | "--approve" - | "-a" - | "--no-approve" - | "-na" - ) { - result.push(arg.clone()); - } else { - return Err("Pi model browsing does not support these Advanced arguments. Runtime arguments are preserved; use a custom model ID or adjust the arguments to browse".into()); - } - } - Ok(result) - } - - pub(crate) fn adapter_args(&self, harness: &HarnessEdit) -> Result> { - validate_selection(&harness.provider, &harness.model)?; - let mut args = vec!["--".into()]; - args.extend(self.args.clone()); - if !harness.provider.is_empty() { - args.extend(["--provider".into(), harness.provider.clone()]); - } - if !harness.model.is_empty() { - args.extend(["--model".into(), harness.model.clone()]); - } - Ok(args) - } -} - -/// Verification stays native and cannot be serialized or supplied over IPC. -pub struct VerifiedPiContext(PiContext); -impl VerifiedPiContext { - pub fn into_context(self) -> PiContext { - self.0 - } -} -/// A captured launch check, including the absence of Pi. Launch re-resolves the -/// effective settings and rejects changes made while this check was pending. -pub struct LaunchPreflight(Option); -impl LaunchPreflight { - pub fn new(pi: Option) -> Self { - Self(pi.map(|pi| pi.0)) - } - pub(crate) fn check(&self, pi: &Option) -> Result<()> { - if &self.0 != pi { - return Err("Pi launch settings changed; retry Start".into()); - } - Ok(()) - } -} -pub(crate) fn verify_launch( - pi: Option, - preflight: Option<&LaunchPreflight>, -) -> Result> { - if let Some(preflight) = preflight { - preflight.check(&pi)?; - return Ok(pi); - } - pi.map(|pi| pi.verify().map(|pi| pi.0)).transpose() -} -fn version_command(command: &Path, path: &std::ffi::OsStr) -> Command { - let mut probe = Command::new(command); - probe - .arg("--version") - .env_clear() - .env("PATH", path) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - for name in [ - "HOME", - "TMPDIR", - "USER", - "LOGNAME", - "LANG", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - ] { - if let Some(value) = std::env::var_os(name) { - probe.env(name, value); - } - } - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - probe.process_group(0); - } - probe -} - -// No pipe reader can outlive the probe, even if a wrapper's helper keeps stdout -// open. The tiny file is private and output is never included in an error. -fn version_output(mut probe: Command) -> Result { - const FAILED: &str = "Could not verify the Pi CLI version"; - let mut output = tempfile::tempfile().map_err(|_| FAILED)?; - probe.stdout(output.try_clone().map_err(|_| FAILED)?); - let mut child = probe.spawn().map_err(|_| FAILED)?; - let deadline = Instant::now() + Duration::from_secs(5); - let result = loop { - if output.metadata().map_or(true, |meta| meta.len() > 128) { - break Err(FAILED); - } - match child.try_wait() { - Ok(Some(status)) => { - break if status.success() { - Ok(()) - } else { - Err(FAILED) - } - } - Err(_) => break Err(FAILED), - Ok(None) if Instant::now() >= deadline => break Err("Pi CLI version check timed out"), - Ok(None) => std::thread::sleep(Duration::from_millis(25)), - } - }; - // Retire helpers on success too; --version has no continuing work. - #[cfg(unix)] - unsafe { - libc::kill(-(child.id() as i32), libc::SIGKILL); - } - let _ = child.kill(); - child.wait().map_err(|_| FAILED)?; - result?; - output.seek(SeekFrom::Start(0)).map_err(|_| FAILED)?; - let mut version = String::new(); - output - .take(129) - .read_to_string(&mut version) - .map_err(|_| FAILED)?; - if version.len() > 128 { - return Err(FAILED.into()); - } - Ok(version) -} - -/// Selection constraints shared by catalog discovery and ACP launch. -/// Empty selectors retain Pi defaults; model IDs may contain namespace slashes. -pub fn validate_selection(provider: &str, model: &str) -> Result<()> { - crate::HarnessConfigurationPolicy::for_command("buzz-pi-acp") - .validate_selection(provider, model)?; - if provider.contains('/') { - return Err("Invalid Pi provider or model ID".into()); - } - for (value, limit) in [(provider, 128), (model, 512)] { - if value.len() > limit - || value.starts_with('-') - || value.contains(',') - || value.chars().any(char::is_control) - { - return Err("Invalid Pi provider or model ID".into()); - } - } - Ok(()) -} diff --git a/crates/agent-controller/src/process.rs b/crates/agent-controller/src/process.rs deleted file mode 100644 index 371ac486d..000000000 --- a/crates/agent-controller/src/process.rs +++ /dev/null @@ -1,597 +0,0 @@ -//! Every listener gets its own Unix session. ACP's worker process groups stay -//! inside that session, so teardown is not limited to the listener's group. -//! On Windows, a kill-on-close Job Object contains the listener before it runs. -use crate::Result; -#[cfg(unix)] -use std::process::ExitStatus; -#[cfg(unix)] -use std::process::Stdio; -use std::process::{Child, Command}; -#[cfg(unix)] -use std::time::{Duration, Instant}; - -pub(crate) struct Process { - child: Child, - #[cfg(unix)] - session: u32, - #[cfg(windows)] - job: job::Job, - stopped: bool, -} -impl Process { - pub fn spawn(command: &mut Command) -> Result { - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - // setsid is async-signal-safe and performs no allocation in pre_exec. - unsafe { - command.pre_exec(|| { - if libc::setsid() == -1 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - } - #[cfg(windows)] - { - let job = job::Job::create()?; - let child = job.spawn(command)?; - Ok(Self { - child, - job, - stopped: false, - }) - } - #[cfg(unix)] - { - let child = command.spawn().map_err(|_| { - "Could not start bundled agent listener; check the runtime installation" - })?; - let session = child.id(); - Ok(Self { - child, - session, - stopped: false, - }) - } - } - pub fn alive(&mut self) -> Result { - if self.stopped { - return Ok(false); - } - #[cfg(windows)] - self.job.sweep(); - match self - .child - .try_wait() - .map_err(|_| "Could not inspect agent process")? - { - None => Ok(true), - Some(_) => { - self.stop()?; - Ok(false) - } - } - } - #[cfg(unix)] - pub fn try_wait(&mut self) -> Result> { - self.child - .try_wait() - .map_err(|_| "Could not inspect owned process".into()) - } - /// Short-lived discovery has no cooperative listener shutdown to wait for. - #[cfg(unix)] - pub fn kill(&mut self) -> Result<()> { - self.stop_with_grace(Duration::ZERO) - } - pub fn stop(&mut self) -> Result<()> { - #[cfg(unix)] - return self.stop_with_grace(Duration::from_secs(2)); - #[cfg(windows)] - self.stop_with_grace() - } - fn stop_with_grace(&mut self, #[cfg(unix)] grace: Duration) -> Result<()> { - if self.stopped { - return Ok(()); - } - #[cfg(unix)] - { - // First let ACP cancel turns and shut down its workers itself. - signal_in_session(self.session, self.session, libc::SIGTERM)?; - let deadline = Instant::now() + grace; - loop { - let members = session_members(self.session)?; - if members.is_empty() { - break; - } - if Instant::now() >= deadline { - // Freeze first so a terminating child cannot create a fresh - // descendant between the enumeration and the kill pass. - for pid in &members { - signal_in_session(*pid, self.session, libc::SIGSTOP)?; - } - let frozen = session_members(self.session)?; - for pid in frozen { - signal_in_session(pid, self.session, libc::SIGKILL)?; - } - break; - } - // Reap the leader if it exited; session ID is retained by living members. - self.child - .try_wait() - .map_err(|_| "Could not reap agent listener")?; - std::thread::sleep(Duration::from_millis(25)); - } - self.child - .wait() - .map_err(|_| "Could not reap agent listener")?; - let deadline = Instant::now() + Duration::from_secs(2); - while !session_members(self.session)?.is_empty() { - if Instant::now() >= deadline { - return Err("Agent descendants have not exited; shutdown is incomplete".into()); - } - std::thread::sleep(Duration::from_millis(25)); - } - self.stopped = true; - Ok(()) - } - #[cfg(windows)] - { - // A windowless listener has no cooperative stop signal. - self.job.stop()?; - self.child - .wait() - .map_err(|_| "Could not reap agent listener")?; - self.stopped = true; - Ok(()) - } - } -} -impl Drop for Process { - fn drop(&mut self) { - let _ = self.stop(); - } -} -#[cfg(unix)] -fn signal(pid: u32, value: i32) -> Result<()> { - if pid <= 1 || pid > i32::MAX as u32 { - return Err("Invalid owned process identifier".into()); - } - if unsafe { libc::kill(pid as i32, value) } == -1 - && std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) - { - return Err("Could not signal owned agent process".into()); - } - Ok(()) -} -#[cfg(unix)] -fn signal_in_session(pid: u32, session: u32, value: i32) -> Result<()> { - if unsafe { libc::getsid(pid as i32) } == session as i32 { - signal(pid, value)?; - } - Ok(()) -} -#[cfg(unix)] -fn session_members(session: u32) -> Result> { - // ps exposes only IDs/state, never environment or command lines. getsid is - // the authority, not platform-dependent ps SID formatting (macOS differs). - let output = Command::new("/bin/ps") - .args(["-axo", "pid=,stat="]) - .env_clear() - .stdin(Stdio::null()) - .output() - .map_err(|_| "Could not inspect agent descendants")?; - if !output.status.success() || output.stdout.len() > 4 * 1024 * 1024 { - return Err("Could not inspect agent descendants".into()); - } - let mut members = Vec::new(); - for line in String::from_utf8_lossy(&output.stdout).lines() { - let mut fields = line.split_whitespace(); - let pid = fields.next().and_then(|p| p.parse::().ok()); - let status = fields.next().unwrap_or(""); - if status.starts_with('Z') { - continue; - } // exited; its parent/init owns reaping - if let Some(pid) = pid.filter(|p| *p > 1 && *p <= i32::MAX as u32) { - if unsafe { libc::getsid(pid as i32) } == session as i32 { - members.push(pid); - } - } - } - Ok(members) -} - -#[cfg(windows)] -mod job { - use crate::Result; - use std::collections::HashSet; - use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; - use std::os::windows::process::CommandExt; - use std::process::{Child, Command}; - use std::sync::{mpsc, Arc}; - use std::time::{Duration, Instant}; - use windows_sys::Win32::Foundation::{ - GetLastError, ERROR_NO_MORE_FILES, FILETIME, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, - }; - use windows_sys::Win32::System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, - }; - use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, IsProcessInJob, - JobObjectAssociateCompletionPortInformation, JobObjectBasicAccountingInformation, - JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject, - SetInformationJobObject, TerminateJobObject, JOBOBJECT_ASSOCIATE_COMPLETION_PORT, - JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, - }; - use windows_sys::Win32::System::SystemServices::JOB_OBJECT_MSG_NEW_PROCESS; - use windows_sys::Win32::System::Threading::{ - GetProcessTimes, OpenProcess, OpenThread, ResumeThread, WaitForSingleObject, - CREATE_NO_WINDOW, CREATE_SUSPENDED, INFINITE, PROCESS_QUERY_LIMITED_INFORMATION, - PROCESS_SYNCHRONIZE, THREAD_SUSPEND_RESUME, - }; - use windows_sys::Win32::System::IO::{ - CreateIoCompletionPort, GetQueuedCompletionStatus, PostQueuedCompletionStatus, - }; - - /// Completion key of job messages; the watcher stops on any other. - const JOB: usize = 1; - /// A member's process object, keyed by ID and creation time. - type Member = ((u32, u64), OwnedHandle); - - /// Only a member's own signaled process object proves that it has exited. - /// A watcher opens each process as it joins; `seen` counts the processes - /// opened and `held` keeps those not yet signaled, across Stop attempts. - pub(super) struct Job { - handle: Arc, - port: Arc, - joined: mpsc::Receiver, - held: Vec, - seen: HashSet<(u32, u64)>, - } - - impl Job { - /// Unnamed and without breakaway: members cannot leave, and closing the - /// last handle (including on owner death) terminates every member. - pub(super) fn create() -> Result { - let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; - if handle.is_null() { - return Err("Could not create agent process container".into()); - } - let handle = Arc::new(unsafe { OwnedHandle::from_raw_handle(handle) }); - let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - if unsafe { - SetInformationJobObject( - handle.as_raw_handle(), - JobObjectExtendedLimitInformation, - (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), - std::mem::size_of_val(&limits) as u32, - ) - } == 0 - { - return Err("Could not configure agent process container".into()); - } - // Associated before the listener joins, so no member predates the port. - let port = - unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, std::ptr::null_mut(), 0, 1) }; - if port.is_null() { - return Err("Could not configure agent process container".into()); - } - let port = Arc::new(unsafe { OwnedHandle::from_raw_handle(port) }); - let association = JOBOBJECT_ASSOCIATE_COMPLETION_PORT { - CompletionKey: JOB as *mut _, - CompletionPort: port.as_raw_handle(), - }; - if unsafe { - SetInformationJobObject( - handle.as_raw_handle(), - JobObjectAssociateCompletionPortInformation, - (&association as *const JOBOBJECT_ASSOCIATE_COMPLETION_PORT).cast(), - std::mem::size_of_val(&association) as u32, - ) - } == 0 - { - return Err("Could not configure agent process container".into()); - } - let (found, joined) = mpsc::channel(); - let (job, queue) = (handle.clone(), port.clone()); - std::thread::Builder::new() - .spawn(move || watch(&job, &queue, &found)) - .map_err(|_| "Could not configure agent process container")?; - Ok(Self { - handle, - port, - joined, - held: Vec::new(), - seen: HashSet::new(), - }) - } - - pub(super) fn spawn(&self, command: &mut Command) -> Result { - spawn(&self.handle, command) - } - - /// Collect opened members and retire only signaled handles. - pub(super) fn sweep(&mut self) { - while let Ok(member) = self.joined.try_recv() { - self.add(member); - } - self.held.retain( - |held| unsafe { WaitForSingleObject(held.as_raw_handle(), 0) } != WAIT_OBJECT_0, - ); - } - - fn add(&mut self, (key, handle): Member) { - if self.seen.insert(key) { - self.held.push(handle); - } - } - - /// Terminating only requests exit. Succeed once every process that ever - /// joined was opened and each opened process is signaled; a lost - /// message or a member gone before it was opened fails closed. - pub(super) fn stop(&mut self) -> Result<()> { - // Open live members directly in case their messages are still queued. - for id in listed(&self.handle) { - if let Some(member) = member(&self.handle, id) { - self.add(member); - } - } - if unsafe { TerminateJobObject(self.handle.as_raw_handle(), 1) } == 0 { - return Err("Could not stop agent processes".into()); - } - let deadline = Instant::now() + Duration::from_secs(5); - loop { - self.sweep(); - if self.held.is_empty() && self.seen.len() == joined(&self.handle)? as usize { - return Ok(()); - } - if Instant::now() >= deadline { - return Err("Agent descendants have not exited; shutdown is incomplete".into()); - } - std::thread::sleep(Duration::from_millis(25)); - } - } - } - - impl Drop for Job { - /// Wake the watcher so it releases its job handle. - fn drop(&mut self) { - unsafe { - PostQueuedCompletionStatus(self.port.as_raw_handle(), 0, 0, std::ptr::null()) - }; - } - } - - /// Try to open each process as it joins. Delivery is not guaranteed and a - /// fast member can exit first; a missed process stays uncounted, never inferred. - fn watch(job: &OwnedHandle, port: &OwnedHandle, found: &mpsc::Sender) { - loop { - let (mut message, mut key, mut id) = (0, 0, std::ptr::null_mut()); - if unsafe { - GetQueuedCompletionStatus( - port.as_raw_handle(), - &mut message, - &mut key, - &mut id, - INFINITE, - ) - } == 0 - || key != JOB - { - return; - } - if message == JOB_OBJECT_MSG_NEW_PROCESS { - // For job messages the overlapped pointer carries the process ID. - if let Some(member) = member(job, id as usize as u32) { - if found.send(member).is_err() { - return; - } - } - } - } - } - - /// A reused ID names a process outside the job. Creation time tells a - /// member reopened for a later message from one that is not yet counted. - fn member(job: &OwnedHandle, id: u32) -> Option { - let access = PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION; - let handle = unsafe { OpenProcess(access, 0, id) }; - if handle.is_null() { - return None; - } - let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; - let mut inside = 0; - let mut times = [FILETIME::default(); 4]; - let [created, exited, kernel, user] = &mut times; - if unsafe { IsProcessInJob(handle.as_raw_handle(), job.as_raw_handle(), &mut inside) } == 0 - || inside == 0 - || unsafe { GetProcessTimes(handle.as_raw_handle(), created, exited, kernel, user) } - == 0 - { - return None; - } - let created = (created.dwHighDateTime as u64) << 32 | created.dwLowDateTime as u64; - Some(((id, created), handle)) - } - - /// Fail closed: a child that is not contained never runs its first instruction. - fn spawn(job: &OwnedHandle, command: &mut Command) -> Result { - command.creation_flags(CREATE_SUSPENDED | CREATE_NO_WINDOW); - let mut child = command.spawn().map_err(|_| { - "Could not start bundled agent listener; check the runtime installation" - })?; - if let Err(error) = contain(job, &child) { - let _ = child.kill(); - let _ = child.wait(); - return Err(error); - } - Ok(child) - } - - /// Assign a suspended child, then resume its only thread. - fn contain(job: &OwnedHandle, child: &Child) -> Result<()> { - let thread = primary_thread(child.id()).ok_or("Could not contain agent listener")?; - if unsafe { AssignProcessToJobObject(job.as_raw_handle(), child.as_raw_handle()) } == 0 { - return Err("Could not contain agent listener".into()); - } - if unsafe { ResumeThread(thread.as_raw_handle()) } != 1 { - return Err("Could not resume contained agent listener".into()); - } - Ok(()) - } - - /// Every process ever associated with the job, including exited ones. - fn joined(job: &OwnedHandle) -> Result { - let mut info = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); - if unsafe { - QueryInformationJobObject( - job.as_raw_handle(), - JobObjectBasicAccountingInformation, - (&mut info as *mut JOBOBJECT_BASIC_ACCOUNTING_INFORMATION).cast(), - std::mem::size_of_val(&info) as u32, - std::ptr::null_mut(), - ) - } == 0 - { - return Err("Could not inspect agent descendants".into()); - } - Ok(info.TotalProcesses) - } - - /// IDs of the active members. Any it misses is left to the joined count. - fn listed(job: &OwnedHandle) -> Vec { - // JOBOBJECT_BASIC_PROCESS_ID_LIST with room for any listener tree. - #[repr(C)] - struct List { - assigned: u32, - listed: u32, - ids: [usize; 1024], - } - let mut list = List { - assigned: 0, - listed: 0, - ids: [0; 1024], - }; - let queried = unsafe { - QueryInformationJobObject( - job.as_raw_handle(), - JobObjectBasicProcessIdList, - (&mut list as *mut List).cast(), - std::mem::size_of_val(&list) as u32, - std::ptr::null_mut(), - ) - }; - if queried == 0 { - return Vec::new(); - } - list.ids - .iter() - .take(list.listed as usize) - .map(|&id| id as u32) - .collect() - } - - // Same exactly-one-thread check as the host command container. - fn primary_thread(process_id: u32) -> Option { - let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; - if snapshot == INVALID_HANDLE_VALUE { - return None; - } - let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot) }; - let mut entry = THREADENTRY32 { - dwSize: std::mem::size_of::() as u32, - ..Default::default() - }; - if unsafe { Thread32First(snapshot.as_raw_handle(), &mut entry) } == 0 { - return None; - } - let mut thread_id = None; - loop { - if entry.th32OwnerProcessID == process_id - && thread_id.replace(entry.th32ThreadID).is_some() - { - return None; - } - entry.dwSize = std::mem::size_of::() as u32; - if unsafe { Thread32Next(snapshot.as_raw_handle(), &mut entry) } == 0 { - break; - } - } - if unsafe { GetLastError() } != ERROR_NO_MORE_FILES { - return None; - } - let handle = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, thread_id?) }; - if handle.is_null() { - return None; - } - Some(unsafe { OwnedHandle::from_raw_handle(handle) }) - } - - #[cfg(test)] - #[test] - fn uncontained_child_never_runs() { - let dir = tempfile::tempdir().unwrap(); - let marker = dir.path().join("ran"); - let mut command = Command::new(std::env::var_os("ComSpec").unwrap()); - command.raw_arg(format!("/d /c type nul > \"{}\"", marker.display())); - // A handle that is not a job: assignment fails after the suspended spawn. - let not_a_job = std::fs::File::create(dir.path().join("not-a-job")) - .unwrap() - .into(); - assert_eq!( - spawn(¬_a_job, &mut command).unwrap_err(), - "Could not contain agent listener" - ); - assert!(!marker.exists(), "uncontained child ran"); - let job = Job::create().unwrap(); - let mut child = job.spawn(&mut command).unwrap(); - assert!(child.wait().unwrap().success()); - assert!(marker.exists(), "contained child was not resumed"); - } - - #[cfg(test)] - #[test] - fn member_gone_before_it_was_opened_fails_stop() { - let shell = std::env::var_os("ComSpec").unwrap(); - let mut job = Job::create().unwrap(); - let mut root = job - .spawn(Command::new(&shell).raw_arg("/d /c ping -n 600 127.0.0.1 >nul")) - .unwrap(); - let mut gone = job - .spawn(Command::new(&shell).raw_arg("/d /c exit")) - .unwrap(); - // Its own signaled handle proves the exit; holding it pins the ID. - assert!(gone.wait().unwrap().success()); - let deadline = Instant::now() + Duration::from_secs(30); - // Once received, a sweep after the exit also retires the watcher's handle. - let key = loop { - job.sweep(); - if let Some(&key) = job.seen.iter().find(|&&(id, _)| id == gone.id()) { - break key; - } - assert!(Instant::now() < deadline, "watcher did not open the member"); - std::thread::sleep(Duration::from_millis(10)); - }; - drop(gone); - // Wait until Stop could no longer reopen that exact process. - while member(&job.handle, key.0).is_some_and(|(found, _)| found == key) { - assert!(Instant::now() < deadline, "exited member was not released"); - std::thread::sleep(Duration::from_millis(10)); - } - // As if its notification were lost. - job.seen.remove(&key); - assert_eq!( - job.stop().unwrap_err(), - "Agent descendants have not exited; shutdown is incomplete" - ); - assert!(root.try_wait().unwrap().is_some(), "root survived Stop"); - assert!(job.held.is_empty(), "an opened member survived Stop"); - assert!( - !job.seen.contains(&key), - "Stop recaptured the missing member" - ); - } -} diff --git a/crates/agent-controller/src/profile.rs b/crates/agent-controller/src/profile.rs index ebe3d5fca..9099e6d27 100644 --- a/crates/agent-controller/src/profile.rs +++ b/crates/agent-controller/src/profile.rs @@ -15,24 +15,6 @@ pub(crate) fn bounded_content(content: Map) -> Result { Ok(serialized) } -pub(crate) fn initial_content( - name: &str, - about: Option<&str>, - picture: Option<&str>, -) -> Result { - let mut content = Map::new(); - content.insert("name".into(), json!(name)); - content.insert("display_name".into(), json!(name)); - content.insert("bot".into(), json!(true)); - if let Some(about) = about { - content.insert("about".into(), json!(about)); - } - if let Some(picture) = picture { - content.insert("picture".into(), json!(picture)); - } - bounded_content(content) -} - #[derive(Deserialize)] struct Profile { id: String, diff --git a/crates/agent-controller/src/restart.rs b/crates/agent-controller/src/restart.rs deleted file mode 100644 index 0b385175d..000000000 --- a/crates/agent-controller/src/restart.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Redacted saved-versus-running differences for the restart-required notice. -//! Raw values are compared natively; only the redacted entries cross IPC. -use crate::config::Agent; -use serde::Serialize; -use serde_json::{json, Map, Value}; -use std::path::Path; - -const MASK: &str = "••••"; - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RestartDiffEntry { - pub field: String, - pub change: RestartChange, -} -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum RestartChange { - /// Safe scalar or array shown verbatim. - Value { - before: Value, - after: Value, - }, - /// Large text shown as character counts only. - #[serde(rename_all = "camelCase")] - Text { - before_chars: Option, - after_chars: Option, - }, - /// Secret-bearing value shown only as a mask. - Masked { - before: Option, - after: Option, - }, - /// Environment key present only on one side; its value is never shown. - Added, - Removed, -} - -/// Effective settings a start applies. Identity, relay and imported response -/// policy are immutable. Values stay native; `diff` redacts environment-derived -/// selectors before producing an IPC projection. -pub(crate) fn spawn_config(agent: &Agent) -> Value { - let env = &agent.environment; - let harness = crate::build_defaults().resolve(&agent.harness, env); - let worker = Path::new(&harness.command) - .file_name() - .and_then(|name| name.to_str()); - let selected = crate::defaults::selectors(&harness, env); - let provider = selected - .provider - .or_else(|| (!harness.provider.is_empty()).then_some(harness.provider.as_str())); - let databricks = if worker == Some("buzz-agent") - && matches!( - provider, - Some("databricks_v2" | "databricks-v2" | "databricks") - ) { - Some(harness.databricks.as_ref()) - } else { - None - }; - let host = databricks.and_then(|stored| { - env.get("DATABRICKS_HOST") - .map(String::as_str) - .or_else(|| stored.map(|settings| settings.host.as_str())) - }); - let filter = databricks.and_then(|stored| { - env.get("DATABRICKS_MODEL_FILTER") - .map(String::as_str) - .or_else(|| stored.map(|settings| settings.filter.as_str())) - }); - json!({ - "name": agent.name, - "system_prompt": agent.system_prompt, - "team_instructions": crate::import::team_text(&agent.imported["teamInstructions"]).unwrap_or(""), - "workspace": agent.workspace, - "integration": agent.harness.integration, - "configuration": agent.harness.configuration, - "command": harness.command, - "args": harness.args, - "model": selected.model, - "provider": provider, - "databricks_host": host, - "databricks_filter": filter, - "env": env, - "effort": crate::agent_defaults::effort(agent), - "session_policy": agent.session_policy.unwrap_or_default(), - "launch_protection": agent.extra.get("launchProtection"), - }) -} - -pub(crate) fn diff(before: &Value, after: &Value) -> Vec { - // These selectors may be derived from private environment values. Compare - // their actual values, but mask the corresponding field if either side used - // an environment key; the env.* difference is always redacted as well. - let masked: Vec<_> = [ - ( - "model", - &["BUZZ_AGENT_MODEL", "GOOSE_MODEL", "DATABRICKS_MODEL"][..], - ), - ("provider", &["BUZZ_AGENT_PROVIDER", "GOOSE_PROVIDER"][..]), - ("databricks_host", &["DATABRICKS_HOST"][..]), - ("databricks_filter", &["DATABRICKS_MODEL_FILTER"][..]), - ] - .into_iter() - .filter_map(|(field, keys)| { - keys.iter() - .any(|key| before["env"].get(key).is_some() || after["env"].get(key).is_some()) - .then_some(field) - }) - .collect(); - let mut out = Vec::new(); - walk("", before, after, &masked, &mut out); - out -} - -fn walk( - path: &str, - before: &Value, - after: &Value, - masked: &[&str], - out: &mut Vec, -) { - if before == after { - return; - } - if let (Value::Object(before), Value::Object(after)) = (before, after) { - for key in keys(before, after) { - let child = if path.is_empty() { - key.to_owned() - } else { - format!("{path}.{key}") - }; - match (before.get(key), after.get(key)) { - (Some(b), Some(a)) => walk(&child, b, a, masked, out), - (None, _) => out.push(entry(child, RestartChange::Added)), - (_, None) => out.push(entry(child, RestartChange::Removed)), - } - } - return; - } - let change = match path { - "system_prompt" | "team_instructions" => RestartChange::Text { - before_chars: before.as_str().map(|s| s.chars().count()), - after_chars: after.as_str().map(|s| s.chars().count()), - }, - // Arguments and environment values may carry credentials. - _ if path == "args" || path.starts_with("env.") || masked.contains(&path) => { - RestartChange::Masked { - before: (!before.is_null()).then(|| MASK.into()), - after: (!after.is_null()).then(|| MASK.into()), - } - } - _ => RestartChange::Value { - before: before.clone(), - after: after.clone(), - }, - }; - out.push(entry(path.to_owned(), change)); -} - -fn keys<'a>(before: &'a Map, after: &'a Map) -> Vec<&'a str> { - let mut keys: Vec<_> = before - .keys() - .chain(after.keys()) - .map(String::as_str) - .collect(); - keys.sort_unstable(); - keys.dedup(); - keys -} - -fn entry(field: String, change: RestartChange) -> RestartDiffEntry { - RestartDiffEntry { field, change } -} - -#[cfg(test)] -mod tests; diff --git a/crates/agent-controller/src/restart/tests.rs b/crates/agent-controller/src/restart/tests.rs deleted file mode 100644 index 4857f5604..000000000 --- a/crates/agent-controller/src/restart/tests.rs +++ /dev/null @@ -1,95 +0,0 @@ -use super::*; -use crate::store::tests::fixture; - -#[test] -fn diff_is_empty_until_a_saved_start_setting_changes() { - let agent = fixture(); - let spawned = spawn_config(&agent); - assert!(diff(&spawned, &spawn_config(&agent)).is_empty()); - let mut saved = agent.clone(); - saved.revision += 1; - saved.start_on_app_launch = Some(true); - assert!(diff(&spawned, &spawn_config(&saved)).is_empty()); -} - -#[test] -fn diff_itemizes_changes_and_redacts_arguments_prompt_and_environment() { - let mut before = fixture(); - before.harness.args = vec!["--token=old-secret".into()]; - before.environment.insert("KEEP".into(), "old-value".into()); - before - .environment - .insert("GONE".into(), "gone-value".into()); - let mut after = before.clone(); - after.harness.model = "new-model".into(); - after.harness.args = vec!["--token=new-secret".into()]; - after.system_prompt = "four".into(); - after.environment.insert("KEEP".into(), "new-value".into()); - after.environment.remove("GONE"); - after.environment.insert("NEW".into(), "new-secret".into()); - let entries = diff(&spawn_config(&before), &spawn_config(&after)); - let wire = serde_json::to_string(&entries).unwrap(); - for secret in [ - "old-secret", - "new-secret", - "old-value", - "new-value", - "gone-value", - ] { - assert!(!wire.contains(secret), "{secret} leaked: {wire}"); - } - let fields: Vec<_> = entries.iter().map(|e| e.field.as_str()).collect(); - assert_eq!( - fields, - [ - "args", - "env.GONE", - "env.KEEP", - "env.NEW", - "model", - "system_prompt" - ] - ); - assert_eq!(entries[1].change, RestartChange::Removed); - assert_eq!( - entries[2].change, - RestartChange::Masked { - before: Some(MASK.into()), - after: Some(MASK.into()) - } - ); - assert_eq!(entries[3].change, RestartChange::Added); - assert_eq!( - entries[4].change, - RestartChange::Value { - before: before.harness.model.clone().into(), - after: "new-model".into() - } - ); - assert_eq!( - entries[5].change, - RestartChange::Text { - before_chars: Some(before.system_prompt.chars().count()), - after_chars: Some(4) - } - ); - assert!(wire.contains(r#""kind":"text","beforeChars""#), "{wire}"); -} - -#[test] -fn team_instruction_changes_need_a_restart_and_show_only_lengths() { - let before = crate::store::tests::fixture(); - let mut after = before.clone(); - after.imported = serde_json::json!({"teamInstructions": "PRIVATE TEAM"}); - let entries = diff(&spawn_config(&before), &spawn_config(&after)); - assert_eq!( - entries, - vec![entry( - "team_instructions".into(), - RestartChange::Text { - before_chars: Some(0), - after_chars: Some(12) - } - )] - ); -} diff --git a/crates/agent-controller/src/runtime.rs b/crates/agent-controller/src/runtime.rs deleted file mode 100644 index e0b0198d2..000000000 --- a/crates/agent-controller/src/runtime.rs +++ /dev/null @@ -1,1385 +0,0 @@ -use crate::bundle::RuntimeBundle; -use crate::config::Agent; -use crate::supervisor::Supervised; -use crate::{AgentEdit, ControlSnapshot, Credentials, ProcessStatus, Result, Store}; -use serde::Deserialize; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::Arc; - -impl RuntimeBundle { - fn command(&self, agent: &Agent, key: &crate::Secret) -> Result { - self.command_with_defaults(agent, key, &crate::build_defaults()) - } - fn command_with_defaults( - &self, - agent: &Agent, - key: &crate::Secret, - defaults: &crate::BuildDefaults, - ) -> Result { - self.command_checked(agent, key, defaults, None) - } - fn command_checked( - &self, - agent: &Agent, - key: &crate::Secret, - defaults: &crate::BuildDefaults, - preflight: Option<&crate::pi::LaunchPreflight>, - ) -> Result { - agent.validate()?; - let harness = defaults.resolve(&agent.harness, &agent.environment); - if let Some(preset) = crate::harness_preset(&harness.command) { - if !harness.model.is_empty() || !harness.provider.is_empty() { - return Err(format!( - "Use {} defaults in the agent editor before starting this agent", - preset.label - )); - } - } - if key.pubkey() != agent.pubkey { - return Err("Credential does not match the saved agent".into()); - } - if !Path::new(&agent.workspace).is_dir() { - return Err("Agent workspace does not exist".into()); - } - // Like Claude Code, Start resolves the saved binding and launches it; - // adapter and login failures surface in the agent log. - let codex = (harness.integration == Some(crate::HarnessIntegration::Codex)) - .then(|| { - crate::codex::CodexContext::for_agent( - &harness.command, - Path::new(&agent.workspace), - &agent.environment, - ) - }) - .transpose()?; - let codex = codex.as_ref(); - let (worker, codex_args) = if let Some(context) = codex { - let (worker, args) = context.adapter_launch(&harness.args)?; - (worker, Some(args)) - } else { - let worker = if matches!(harness.command.as_str(), "goose" | "goose-acp") { - self.executable("goose-acp")? - } else if harness.command == "buzz-agent" { - self.executable("buzz-agent")? - } else { - let path = PathBuf::from(&harness.command); - if !path.is_absolute() { - return Err("Choose the installed harness's absolute executable path".into()); - } - executable(&path)?; - path - }; - (worker, None) - }; - let record = &agent.imported["record"]; - if record["backend"]["type"] - .as_str() - .is_some_and(|s| s != "local") - || harness.provider == "relay-mesh" - || !record["relay_mesh"].is_null() - { - return Err("This imported agent requires a remote/mesh integration not supported by the local controller".into()); - } - if agent.needs_team_import() { - return Err("Import this agent's team instructions from old Buzz under Agents → Import or repair from old Buzz → Repair team import before starting".into()); - } - let team_instructions = crate::import::team_text(&agent.imported["teamInstructions"])?; - let respond_to = agent.respond_to(defaults.owner_only)?; - if agent.auth_tag.is_none() { - return Err("This identity has no saved owner attestation; native owner binding is required before starting".into()); - } - crate::secret::validate_attestation( - agent.auth_tag.as_deref().unwrap_or(""), - &agent.pubkey, - )?; - let mut command = Command::new(self.executable("buzz-acp")?); - // Never inherit the current managed agent's key, owner, relay, replay floor, - // process marker, git injection or provider credentials into the new agent. - command - .env_clear() - .current_dir(&agent.workspace) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - // Windows system, profile and tool-discovery locations; none are secrets. - #[cfg(windows)] - let platform = [ - "SystemRoot", - "windir", - "SystemDrive", - "ComSpec", - "PATHEXT", - "USERPROFILE", - "HOMEDRIVE", - "HOMEPATH", - "USERNAME", - "APPDATA", - "LOCALAPPDATA", - "ProgramData", - "ProgramFiles", - "ProgramFiles(x86)", - "ProgramW6432", - "CommonProgramFiles", - "CommonProgramFiles(x86)", - "CommonProgramW6432", - "PROCESSOR_ARCHITECTURE", - "NUMBER_OF_PROCESSORS", - "OS", - // Runtime shell overrides; Git Bash is otherwise discovered from PATH. - "BUZZ_SHELL", - "GIT_BASH", - ]; - #[cfg(not(windows))] - let platform: [&str; 0] = []; - for name in [ - "HOME", - "TMPDIR", - "USER", - "LOGNAME", - "LANG", - "SSH_AUTH_SOCK", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - ] - .into_iter() - .chain(platform) - { - if let Some(value) = std::env::var_os(name) { - command.env(name, value); - } - } - let pi = (codex.is_none() - && worker.file_name().and_then(|n| n.to_str()) == Some("buzz-pi-acp")) - .then(|| crate::pi::PiContext::new(&agent.harness, &agent.workspace, &agent.environment)) - .transpose()?; - let pi = crate::pi::verify_launch(pi, preflight)?; - let claude = (codex.is_none() - && crate::agent_defaults::harness_kind(&harness.command) == Some("claude")) - .then(|| { - claude_tools( - &worker, - agent - .environment - .get("CLAUDE_CODE_EXECUTABLE") - .map(String::as_str), - ) - }) - .transpose()?; - let (args, environment, tools_path) = if let Some(args) = codex_args { - (args, None, None) - } else if let Some(pi) = &pi { - ( - pi.adapter_args(&agent.harness)?, - Some(&pi.environment), - Some(pi.path.clone()), - ) - } else { - ( - goose_args(&harness.command, &agent.harness.args), - Some(&agent.environment), - Some(if let Some((path, _)) = &claude { - path.clone() - } else { - tools_path()? - }), - ) - }; - let path = tools_path - .map(|tools_path| { - path::compose( - std::iter::once(self.directory.clone()) - .chain(std::env::split_paths(&tools_path)) - .chain( - environment - .and_then(|env| env.get("PATH")) - .into_iter() - .flat_map(std::env::split_paths), - ), - ) - }) - .transpose()?; - if let Some(context) = codex { - context.apply_launch_environment(&mut command, &self.directory)?; - } else if let (Some(environment), Some(path)) = (environment, path.as_ref()) { - command.envs(environment).env("PATH", path); - } - if let Some((_, Some(cli))) = &claude { - if !agent.environment.contains_key("CLAUDE_CODE_EXECUTABLE") { - // Do not depend on the SDK's optional native-binary download. - command.env("CLAUDE_CODE_EXECUTABLE", cli); - } - } - let key_hex = key.hex(); - command - .env("BUZZ_PRIVATE_KEY", &*key_hex) - .env("NOSTR_PRIVATE_KEY", &*key_hex) - .env("BUZZ_RELAY_URL", &agent.relay_url) - .env("BUZZ_AUTH_TAG", agent.auth_tag.as_deref().unwrap_or("")) - .env("BUZZ_ACP_AGENT_COMMAND", worker) - .env("BUZZ_ACP_AGENT_ARGS", args.join(",")) - .env("BUZZ_ACP_SYSTEM_PROMPT", &agent.system_prompt) - .env("BUZZ_ACP_TEAM_INSTRUCTIONS", team_instructions) - .env("BUZZ_ACP_DISPLAY_NAME", &agent.name) - .env("BUZZ_ACP_LAZY_POOL", "true") - .env("BUZZ_ACP_IDLE_POOL_SLEEP", "900") - .env("BUZZ_ACP_SUBSCRIBE", "mentions") - .env("BUZZ_ACP_RESPOND_TO", respond_to) - .env( - "BUZZ_ACP_SESSION_POLICY", - agent.session_policy.unwrap_or_default().as_str(), - ) - .env("BUZZ_ACP_DEDUP", "queue") - .env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer") - .env( - "BUZZ_ACP_MCP_COMMAND", - if uses_buzz_dev_mcp(&harness.command) { - self.executable("buzz-dev-mcp")? - } else { - PathBuf::new() - }, - ) - .env("BUZZ_ACP_RELAY_OBSERVER", "true"); - if defaults.owner_only { - command - .env("BUZZ_ACP_ALLOWED_RESPOND_TO", "owner-only") - .env_remove("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); - } - let selected = crate::defaults::selectors(&harness, &agent.environment); - let model = selected.model; - if codex.is_some() { - if matches!( - harness.configuration, - Some(crate::AiConfiguration::Advanced { .. }) - ) { - command.env("BUZZ_ACP_MODEL", &harness.model); - } - } else { - if let Some((model_key, provider_key)) = selected.keys { - if let Some(value) = model { - command.env(model_key, value); - } - if let Some(value) = selected.provider { - command.env(provider_key, value); - } - } else if pi.is_none() { - crate::HarnessConfigurationPolicy::for_command(&harness.command) - .validate_selection(&harness.provider, &harness.model)?; - } - if let Some(value) = model { - let value = if pi.is_some() && !agent.harness.provider.is_empty() { - format!("{}/{value}", agent.harness.provider) - } else { - value.to_owned() - }; - command.env("BUZZ_ACP_MODEL", value); - } - } - if respond_to == "allowlist" { - let values = record["respond_to_allowlist"] - .as_array() - .ok_or("Missing imported response allowlist")?; - let mut keys = Vec::new(); - for value in values { - let key = value - .as_str() - .filter(|k| crate::config::canonical_key(k)) - .ok_or("Invalid imported response allowlist")?; - keys.push(key); - } - if keys.is_empty() { - return Err("Imported response allowlist is empty".into()); - } - command.env("BUZZ_ACP_RESPOND_TO_ALLOWLIST", keys.join(",")); - } - for (field, env) in [ - ("idle_timeout_seconds", "BUZZ_ACP_IDLE_TIMEOUT"), - ("max_turn_duration_seconds", "BUZZ_ACP_MAX_TURN_DURATION"), - ("parallelism", "BUZZ_ACP_AGENTS"), - ] { - if !record[field].is_null() { - let n = record[field] - .as_u64() - .filter(|n| *n > 0 && *n <= 86400) - .ok_or("Invalid imported execution limit")?; - command.env(env, n.to_string()); - } - } - if let Some(effort) = crate::agent_defaults::effort(agent) { - command.env("BUZZ_ACP_EFFORT_LEVEL", effort); - } - if codex.is_some() { - if let Some(workers) = agent.environment.get("BUZZ_ACP_AGENTS") { - command.env("BUZZ_ACP_AGENTS", workers); - } - } else if matches!( - crate::agent_defaults::harness_kind(&harness.command), - Some("pi" | "goose") - ) { - // Validated user behavior overrides win over saved/imported fields. - // Tool discovery remains host-owned, including Pi's pinned Node. - command - .envs(environment.ok_or("Missing harness environment")?) - .env("PATH", path.ok_or("Missing harness path")?); - } else if let Some(workers) = - environment.and_then(|environment| environment.get("BUZZ_ACP_AGENTS")) - { - // The editable worker count wins over imported parallelism for every harness. - command.env("BUZZ_ACP_AGENTS", workers); - } - Ok(command) - } -} -fn effective_databricks(agent: &Agent) -> Result> { - databricks_with_defaults(agent, &crate::build_defaults()) -} -fn databricks_with_defaults( - agent: &Agent, - defaults: &crate::BuildDefaults, -) -> Result> { - let harness = defaults.resolve(&agent.harness, &agent.environment); - let buzz_agent = Path::new(&harness.command) - .file_name() - .and_then(|s| s.to_str()) - == Some("buzz-agent"); - if !buzz_agent { - return Ok(None); - } - if !harness.args.is_empty() { - return Err( - "Buzz Agent runs in ACP mode without arguments; use Connect for sign-in".into(), - ); - } - let provider = agent - .environment - .get("BUZZ_AGENT_PROVIDER") - .unwrap_or(&harness.provider); - if !matches!( - provider.as_str(), - "databricks_v2" | "databricks-v2" | "databricks" - ) { - return Ok(None); - } - // Before any OAuth-setting check: Windows never asks for a workspace. - if cfg!(windows) { - return Err(crate::connection::DATABRICKS_WINDOWS.into()); - } - if agent.environment.contains_key("DATABRICKS_TOKEN") { - return Err("Remove DATABRICKS_TOKEN to use this app's persistent OAuth connection".into()); - } - let mut settings = harness.databricks.clone().unwrap_or_default(); - if let Some(host) = agent.environment.get("DATABRICKS_HOST") { - settings.host = host.clone(); - } - if let Some(filter) = agent.environment.get("DATABRICKS_MODEL_FILTER") { - settings.filter = filter.clone(); - } - settings.host = crate::connection::origin(&settings.host)?; - settings.validate()?; - Ok(Some(settings)) -} -pub(crate) mod path; -use path::tools_path; - -/// Claude's npm launcher needs Node even when a desktop app has no shell PATH. -/// An app-owned adapter keeps using its pinned Node, independently of global tools. -fn claude_tools( - adapter: &Path, - cli_override: Option<&str>, -) -> Result<(std::ffi::OsString, Option)> { - let bin = adapter.parent().ok_or("Invalid Claude ACP adapter path")?; - let managed_data = bin - .file_name() - .filter(|name| *name == "bin") - .and_then(|_| bin.parent()) - .filter(|prefix| { - prefix - .file_name() - .is_some_and(|name| name == "claude-tools") - }) - .and_then(Path::parent); - let node = if let Some(app_data) = managed_data { - managed_tool(app_data, "node") - } else { - let sibling = bin.join(if cfg!(windows) { "node.exe" } else { "node" }); - if executable(&sibling).is_ok() { - Some(sibling) - } else { - installed_npm_tool("node") - } - } - .ok_or("Install Node.js for Claude Code in Settings → Agents → Harnesses")?; - let cli = if cli_override.is_some() { - // Advanced owns this value; do not require an unrelated discoverable CLI. - None - } else { - let cli = if let Some(app_data) = managed_data { - managed_tool(app_data, "claude").or_else(|| installed_npm_tool("claude")) - } else { - installed_npm_tool("claude") - } - .ok_or("Install Claude Code in Settings → Agents → Harnesses")?; - // Rust can run Windows batch launchers; the adapter's JavaScript SDK cannot. - // In that case leave native binary resolution to the SDK itself. - let batch = cfg!(windows) - && cli.extension().is_some_and(|ext| { - ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat") - }); - (!batch).then_some(cli) - }; - let path = std::env::join_paths( - [node.parent().ok_or("Invalid Node.js path")?, bin] - .into_iter() - .map(Path::to_path_buf) - .chain(std::env::split_paths(&tools_path()?)), - ) - .map_err(|_| "Invalid Claude tools path")?; - Ok((path, cli)) -} -/// App-owned npm shims and the pinned Node binary are separate from user-global tools. -/// `app_data` is Tauri's resolved app-data directory, never browser input. -pub fn managed_tool(app_data: &Path, name: &str) -> Option { - let path = match name { - "pi" | "buzz-pi-acp" => app_data.join("node-tools/bin").join(name), - "claude" | "claude-agent-acp" => app_data.join("claude-tools/bin").join(name), - "codex-acp" => app_data.join("codex-tools/bin").join(name), - "node" => app_data.join("runtimes/node/v24.18.0").join( - match (std::env::consts::OS, std::env::consts::ARCH) { - ("macos", "aarch64") => "darwin-arm64/bin/node", - ("macos", "x86_64") => "darwin-x64/bin/node", - ("linux", "aarch64") => "linux-arm64/bin/node", - ("linux", "x86_64") => "linux-x64/bin/node", - _ => return None, - }, - ), - _ => return None, - }; - executable(&path).ok()?; - Some(path) -} - -pub fn installed(name: &str) -> Option { - installed_names(&[name.to_owned()]) -} - -/// Claude's setup supports Windows npm launchers; other harnesses retain their -/// existing discovery until their launch contracts support those paths too. -pub fn installed_npm_tool(name: &str) -> Option { - // npm also writes an extensionless POSIX shim on Windows. Prefer launchers - // that Rust and the displayed PowerShell sign-in command can actually run. - #[cfg(windows)] - let names = if Path::new(name).extension().is_some() { - vec![name.to_owned()] - } else { - ["exe", "cmd", "bat"] - .map(|extension| format!("{name}.{extension}")) - .to_vec() - }; - #[cfg(not(windows))] - let names = [name.to_owned()]; - installed_names(&names) -} - -fn installed_names(names: &[String]) -> Option { - let dirs: Vec<_> = std::env::split_paths(&tools_path().ok()?).collect(); - // Preserve Windows discovery; Unix discovery and launch share one PATH. - #[cfg(windows)] - let dirs = { - let mut dirs = dirs; - if let Some(home) = std::env::var_os("HOME") { - dirs.insert(0, PathBuf::from(home).join(".local/bin")); - } - dirs.extend([ - PathBuf::from("/opt/homebrew/bin"), - PathBuf::from("/usr/local/bin"), - ]); - dirs - }; - dirs.into_iter() - .filter(|p| p.is_absolute()) - .flat_map(|p| names.iter().map(move |name| p.join(name))) - .find(|p| executable(p).is_ok()) -} - -pub(crate) fn executable(path: &Path) -> Result<()> { - let metadata = path - .metadata() - .map_err(|_| "Required runtime executable is missing")?; - if !metadata.is_file() { - return Err("Required runtime executable is not a file".into()); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if metadata.permissions().mode() & 0o111 == 0 { - return Err("Runtime file is not executable".into()); - } - } - Ok(()) -} -#[derive(Clone, Copy, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Action { - Start, - Stop, - Restart, -} -struct Running { - process: Supervised, - revision: u64, - /// Native-only: holds environment values and is never serialized. - spawned: serde_json::Value, - databricks_host: Option, - #[cfg(all(test, unix))] - temporary: Option, -} -impl Drop for Running { - fn drop(&mut self) { - let _ = self.process.stop(); - } -} -/// Deliberately not serializable: only the native connection owner consumes it. -pub struct ModelContext { - pub host: Option, - pub filter: Option, - pub model_overridden: bool, -} -/// Native-only Goose model context; environment values never enter a snapshot. -pub struct GooseModelContext { - pub command: PathBuf, - pub args: Vec, - pub workspace: PathBuf, - pub provider_id: String, - pub model_id: String, - pub environment: BTreeMap, - pub model_overridden: bool, -} -pub struct Controller { - pub(crate) store: Store, - credentials: Arc, - pub(crate) bundle: Result, - running: BTreeMap, - errors: BTreeMap, - pub(crate) ownership_root: PathBuf, - pub(crate) protection_paths: Result>, - pub(crate) security_providers: BTreeMap, -} -impl Controller { - pub fn new( - store: Store, - credentials: Arc, - bundle: Result, - ownership_root: PathBuf, - ) -> Self { - Self { - store, - credentials, - bundle, - running: BTreeMap::new(), - errors: BTreeMap::new(), - ownership_root, - security_providers: BTreeMap::new(), - protection_paths: Ok(Vec::new()), - } - } - pub fn snapshot(&mut self) -> Result { - let (saved, parked) = self.store.inventory()?; - let defaults = self.store.defaults()?; - let command = |name| { - let path = self.bundle.as_ref().ok()?.display_path(name)?; - Some(path.to_string_lossy().into_owned()) - }; - let (acp_command, mcp_command) = (command("buzz-acp"), command("buzz-dev-mcp")); - let mut snapshot = ControlSnapshot { - agents: saved.iter().map(|a| a.view(&defaults)).collect(), - parked, - runtime_available: self.bundle.is_ok(), - runtime_message: self.bundle.as_ref().err().cloned(), - default_settings: defaults.view(), - }; - for (saved, agent) in saved.iter().zip(&mut snapshot.agents) { - agent.acp_command.clone_from(&acp_command); - agent.mcp_command = if uses_buzz_dev_mcp(&agent.harness.command) { - mcp_command.clone() - } else { - None - }; - if let Some(run) = self.running.get_mut(&agent.id) { - match run.process.alive() { - Ok(true) => { - agent.status = ProcessStatus::Running; - agent.running_revision = Some(run.revision); - // Effective settings, so inherited default changes count. - agent.restart_diff = crate::restart::diff( - &run.spawned, - &crate::restart::spawn_config(&crate::agent_defaults::effective( - saved, &defaults, - )), - ); - } - Ok(false) => { - self.running.remove(&agent.id); - self.errors.insert( - agent.id.clone(), - "Agent listener exited; restart to retry".into(), - ); - } - Err(error) => { - if run.process.stopped() { - self.running.remove(&agent.id); - } - self.errors.insert(agent.id.clone(), error); - } - } - } - if let Some(error) = self.errors.get(&agent.id) { - agent.status = ProcessStatus::Failed; - agent.error = Some(error.clone()); - } - } - Ok(snapshot) - } - /// Native-only catalog configuration. Never serialize environment values or - /// lend runtime credentials to model discovery. Resolve an unsaved edit on a - /// clone using the same validation and precedence as Save/runtime. - fn edited_agent(&self, id: &str, revision: u64, edit: AgentEdit) -> Result { - let mut agent = self - .store - .agents()? - .into_iter() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - if agent.revision != revision { - return Err( - "Saved settings changed; discard or reconcile the draft before connecting".into(), - ); - } - agent.apply(edit)?; - Ok(crate::agent_defaults::effective( - &agent, - &self.store.defaults()?, - )) - } - pub fn model_context(&self, id: &str, revision: u64, edit: AgentEdit) -> Result { - let agent = self.edited_agent(id, revision, edit)?; - model_context(&agent.harness, &agent.environment) - } - pub fn goose_model_context( - &self, - id: &str, - revision: u64, - edit: AgentEdit, - ) -> Result { - let agent = self.edited_agent(id, revision, edit)?; - self.resolve_goose_model_context(&agent.harness, &agent.workspace, &agent.environment) - } - pub fn pi_model_context( - &self, - id: &str, - revision: u64, - edit: AgentEdit, - ) -> Result { - let agent = self.edited_agent(id, revision, edit)?; - crate::pi::PiContext::new(&agent.harness, &agent.workspace, &agent.environment) - } - /// Resolve a saved Codex draft only after its revision has been fenced. - /// Discovery precedes the Advanced model/effort choice and does not depend - /// on it, so validate that intent at Save, as Create does. - pub fn codex_model_context( - &self, - id: &str, - revision: u64, - mut edit: AgentEdit, - ) -> Result { - if matches!( - edit.harness.configuration, - Some(crate::AiConfiguration::Advanced { .. }) - ) { - edit.harness.configuration = Some(crate::AiConfiguration::Default); - edit.harness.model.clear(); - } - let agent = self.edited_agent(id, revision, edit)?; - crate::codex::CodexContext::for_agent( - &agent.harness.command, - Path::new(&agent.workspace), - &agent.environment, - ) - } - pub fn pi_launch_context( - &self, - id: &str, - revision: u64, - ) -> Result> { - let agent = self - .store - .agents()? - .into_iter() - .find(|agent| agent.id == id) - .ok_or("Agent no longer exists")?; - if agent.revision != revision { - return Err("Saved settings changed; retry Start".into()); - } - let agent = crate::agent_defaults::effective(&agent, &self.store.defaults()?); - if Path::new(&agent.harness.command) - .file_name() - .and_then(|name| name.to_str()) - != Some("buzz-pi-acp") - { - return Ok(None); - } - crate::pi::PiContext::new(&agent.harness, &agent.workspace, &agent.environment).map(Some) - } - /// Resolve unsaved Create drafts against the same native defaults as a saved start. - pub fn effective_draft(&self, mut edit: AgentEdit) -> Result { - let mut environment = draft_environment(edit.environment); - crate::agent_defaults::effective_settings( - &mut edit.harness, - &mut environment, - &self.store.defaults()?, - ); - edit.environment = environment - .into_iter() - .map(|(key, value)| (key, Some(value))) - .collect(); - Ok(edit) - } - pub fn draft_pi_model_context(edit: AgentEdit) -> Result { - crate::pi::PiContext::new( - &edit.harness, - &edit.workspace, - &draft_environment(edit.environment), - ) - } - /// Resolve an unsaved Codex draft against the same effective defaults used - /// by a later save or launch. - pub fn draft_codex_model_context(&self, edit: AgentEdit) -> Result { - crate::codex::CodexContext::for_agent( - &edit.harness.command, - Path::new(&edit.workspace), - &draft_environment(edit.environment), - ) - } - pub fn draft_goose_model_context(&self, edit: AgentEdit) -> Result { - let environment = draft_environment(edit.environment); - self.resolve_goose_model_context(&edit.harness, &edit.workspace, &environment) - } - fn resolve_goose_model_context( - &self, - harness: &crate::HarnessEdit, - workspace: &str, - environment: &BTreeMap, - ) -> Result { - crate::config::validate_environment(environment, &harness.command)?; - let command = if matches!(harness.command.as_str(), "goose" | "goose-acp") { - self.bundle - .as_ref() - .map_err(Clone::clone)? - .executable("goose-acp")? - } else { - PathBuf::from(&harness.command) - }; - let name = command - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .trim_end_matches(".exe"); - if !matches!(name, "goose" | "goose-acp") || !command.is_absolute() { - return Err("Model discovery requires an absolute Goose executable path".into()); - } - executable(&command)?; - let provider = environment - .get("GOOSE_PROVIDER") - .unwrap_or(&harness.provider); - if provider.trim().is_empty() - || provider.len() > 128 - || provider.chars().any(char::is_control) - { - return Err("Choose a valid Goose provider before browsing models".into()); - } - Ok(GooseModelContext { - args: if name == "goose" { - vec!["acp".into()] - } else { - vec![] - }, - command, - workspace: workspace.into(), - provider_id: provider.clone(), - model_id: environment - .get("GOOSE_MODEL") - .unwrap_or(&harness.model) - .clone(), - environment: environment.clone(), - model_overridden: environment.contains_key("GOOSE_MODEL"), - }) - } - pub fn draft_model_context(edit: AgentEdit) -> Result { - let environment = draft_environment(edit.environment); - model_context(&edit.harness, &environment) - } - pub fn requires_legacy_handover(&self, id: &str) -> Result { - let agent = self - .store - .agents()? - .into_iter() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - Ok( - agent.extra.get("nativeCreated") != Some(&serde_json::Value::Bool(true)) - || !agent.imported.is_null(), - ) - } - pub fn use_here( - &mut self, - id: &str, - resolution: crate::CommunityResolution, - ) -> Result { - self.store.use_here(id, resolution)?; - self.snapshot() - } - pub fn local_clone_settings(&self, id: &str) -> Result { - self.store.local_clone_settings(id) - } - pub fn prepare_import( - &self, - imports: &mut crate::Imports, - token: &str, - ids: &[String], - ) -> Result { - imports.prepare(token, ids, &self.store) - } - pub fn commit_import(&mut self, prepared: crate::CredentialedImport) -> Result<()> { - prepared.commit(&mut self.store) - } - /// Check an exact saved instance has an unconditional signed owner attestation. - /// This is not read authorization; the caller must still prove that owner key. - pub fn log_target(&self, id: &str, pubkey: &str, relay_url: &str) -> Result<()> { - let relay = crate::config::canonical_relay(relay_url)?; - let agents = self.store.agents()?; - let agent = agents - .iter() - .find(|agent| agent.id == id && agent.pubkey == pubkey && agent.relay_url == relay) - .ok_or("Agent no longer exists")?; - crate::secret::validate_attestation( - agent - .auth_tag - .as_deref() - .ok_or("Owner authorization is unavailable")?, - pubkey, - ) - } - /// Read retained output for an exact locally managed identity and community. - /// Raw output is never included in a snapshot or published to the relay. - pub fn read_log( - &self, - id: &str, - pubkey: &str, - relay_url: &str, - nonce: &str, - signature: &str, - ) -> Result { - let relay = crate::config::canonical_relay(relay_url)?; - let agents = self.store.agents()?; - let agent = agents - .iter() - .find(|agent| agent.id == id && agent.pubkey == pubkey && agent.relay_url == relay) - .ok_or("Agent no longer exists")?; - let auth = agent - .auth_tag - .as_deref() - .ok_or("Owner authorization is unavailable")?; - crate::secret::validate_attestation(auth, pubkey)?; - let tag: Vec = - serde_json::from_str(auth).map_err(|_| "Owner authorization is unavailable")?; - crate::logs::verify_owner_proof(&tag[1], id, pubkey, &relay, nonce, signature)?; - let path = crate::logs::path(self.store.root(), id)?; - crate::logs::read(&path) - } - pub fn save(&mut self, id: &str, revision: u64, edit: AgentEdit) -> Result { - self.store.save(id, revision, edit)?; - self.snapshot() - } - /// Native-only Agent defaults `DATABRICKS_HOST`, for Disconnect recovery of - /// an inherited workspace the renderer never sees. Never serialized. - pub fn inherited_workspace(&self) -> Result> { - Ok(self - .store - .defaults()? - .environment - .get("DATABRICKS_HOST") - .cloned()) - } - pub fn save_defaults(&mut self, edit: crate::AgentDefaultsEdit) -> Result { - let mut defaults = self.store.defaults()?; - defaults.apply(edit)?; - self.store.save_defaults(&defaults)?; - self.snapshot() - } - /// Effective launch settings of each live agent, for restart-on-save. Values - /// stay native; callers compare before/after a save. - pub fn running_settings(&mut self) -> Result> { - let defaults = self.store.defaults()?; - let live: Vec<_> = self - .snapshot()? - .agents - .into_iter() - .filter(|a| a.enabled && a.status == ProcessStatus::Running) - .map(|a| a.id) - .collect(); - Ok(self - .store - .agents()? - .iter() - .filter(|a| live.contains(&a.id)) - .map(|a| { - let effective = crate::agent_defaults::effective(a, &defaults); - (a.id.clone(), crate::restart::spawn_config(&effective)) - }) - .collect()) - } - /// Kept agents answer only to the owner who authorized them, not to whoever - /// signs in next. `signed_in` is `None` when no human identity is available. - /// A missing or malformed attestation is refused later by launch validation. - pub fn check_owner(&self, id: &str, signed_in: Option<&str>) -> Result<()> { - check_owner(self.attested_owner(id)?.as_deref(), signed_in) - } - /// The human owner named in the agent's saved authorization, if any. - pub fn attested_owner(&self, id: &str) -> Result> { - let agent = self - .store - .agents()? - .into_iter() - .find(|agent| agent.id == id) - .ok_or("Agent no longer exists")?; - Ok(agent - .auth_tag - .as_deref() - .and_then(|raw| serde_json::from_str::>(raw).ok()) - .and_then(|tag| tag.into_iter().nth(1))) - } - pub fn delete(&mut self, id: &str, revision: u64) -> Result { - let agents = self.store.agents()?; - let agent = agents - .iter() - .find(|agent| agent.id == id) - .cloned() - .ok_or("Agent no longer exists")?; - // Use here setups of one identity share its key; keep it for the others. - let shared = agents.iter().any(|other| { - other.id != agent.id - && other.credential_id == agent.credential_id - && other.pubkey == agent.pubkey - }); - if agent.revision != revision { - return Err("Agent settings changed. Reload before deleting".into()); - } - if agent.deployed_remote() { - return Err("Deployed remote agents can't be deleted from this app".into()); - } - // Stop must be confirmed before removing custody or durable settings. - self.stop(id)?; - self.store.enabled(id, false)?; - // A failed settings write leaves the card available for an explicit retry. - // Credential deletion is idempotent, so that retry can finish cleanup. - if !shared { - self.credentials - .delete(&agent.credential_id, &agent.pubkey)?; - } - self.store.remove(id, revision)?; - self.errors.remove(id); - self.snapshot() - } - pub fn action(&mut self, id: &str, action: Action) -> Result { - // Start/restart still require a saved identity; Stop must not depend on it. - if !matches!(action, Action::Stop) && !self.store.agents()?.iter().any(|a| a.id == id) { - return Err("Agent no longer exists".into()); - } - let mut disable_failed = false; - let result = match action { - Action::Stop => { - // Stop even if disabling fails. Report cleanup first, but still - // reject Stop when its durable disable did not succeed. - let stopped = self.stop(id); - let saved = self.store.enabled(id, false); - disable_failed = saved.is_err(); - stopped.and(saved) - } - Action::Start => self.store.enabled(id, true).and_then(|_| self.start(id)), - Action::Restart => self - .store - .enabled(id, true) - .and_then(|_| self.stop(id)) - .and_then(|_| self.start(id)), - }; - match &result { - Ok(()) => { - self.errors.remove(id); - } - Err(error) => { - self.errors.insert(id.into(), error.clone()); - } - } - if disable_failed { - result?; - } - self.snapshot() - } - /// Persists the launch preference only; the running process is unchanged. - pub fn set_start_on_app_launch(&mut self, id: &str, value: bool) -> Result { - self.store.start_on_app_launch(id, value)?; - self.snapshot() - } - pub fn restore(&mut self) -> Result { - for a in self - .store - .agents()? - .into_iter() - .filter(|a| a.starts_on_launch() && a.configured()) - { - // Like the host restore, a launch preference is a Start: it enables. - if let Err(error) = self - .store - .enabled(&a.id, true) - .and_then(|_| self.start(&a.id)) - { - self.errors.insert(a.id, error); - } - } - self.snapshot() - } - pub fn credential_request(&self, id: &str) -> Result<(String, String, u64, Option)> { - let agent = self - .store - .agents()? - .into_iter() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - let agent = crate::agent_defaults::effective(&agent, &self.store.defaults()?); - if !agent.configured() { - return Err("Choose Use here before opening this identity’s credentials".into()); - } - let workspace = effective_databricks(&agent)?.map(|s| s.host); - self.bundle.as_ref().map_err(Clone::clone)?; - Ok((agent.credential_id, agent.pubkey, agent.revision, workspace)) - } - /// Record the action outcome; the native host projects one final snapshot. - pub fn action_with_key( - &mut self, - id: &str, - action: Action, - revision: u64, - key: &crate::Secret, - replay_floor: Option, - ) -> Result<()> { - self.action_checked(id, action, revision, key, replay_floor, None) - } - pub fn action_with_preflight( - &mut self, - id: &str, - action: Action, - revision: u64, - key: &crate::Secret, - replay_floor: Option, - preflight: &crate::pi::LaunchPreflight, - ) -> Result<()> { - self.action_checked(id, action, revision, key, replay_floor, Some(preflight)) - } - fn action_checked( - &mut self, - id: &str, - action: Action, - revision: u64, - key: &crate::Secret, - replay_floor: Option, - preflight: Option<&crate::pi::LaunchPreflight>, - ) -> Result<()> { - if self.credential_request(id)?.2 != revision { - return Err("Saved settings changed while opening credentials; retry Start".into()); - } - if let Some(preflight) = preflight { - preflight.check(&self.pi_launch_context(id, revision)?)?; - } - self.store.enabled(id, true)?; - if matches!(action, Action::Restart) { - if let Err(error) = self.stop(id) { - self.errors.insert(id.into(), error); - return Ok(()); - } - } - match self.start_with_key(id, Some(key), replay_floor, preflight) { - Ok(()) => { - self.errors.remove(id); - } - Err(error) => { - self.errors.insert(id.into(), error); - } - } - Ok(()) - } - pub fn record_error(&mut self, id: &str, error: String) { - self.errors.insert(id.into(), error); - } - pub fn launch_ids(&self) -> Result> { - Ok(self - .store - .agents()? - .into_iter() - .filter(|a| a.starts_on_launch() && a.configured()) - .map(|a| a.id) - .collect()) - } - fn start(&mut self, id: &str) -> Result<()> { - self.start_with_key(id, None, None, None) - } - fn start_with_key( - &mut self, - id: &str, - supplied: Option<&crate::Secret>, - replay_floor: Option, - preflight: Option<&crate::pi::LaunchPreflight>, - ) -> Result<()> { - if let Some(run) = self.running.get_mut(id) { - if run.process.alive()? { - return Ok(()); - } - } - self.running.remove(id); - let agent = self - .store - .agents()? - .into_iter() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - if !agent.configured() { - return Err("Choose Use here before starting this imported identity".into()); - } - if !agent.enabled { - return Err("Agent is disabled".into()); - } - // Blank fields inherit agent defaults at each start; never saved back. - let agent = crate::agent_defaults::effective(&agent, &self.store.defaults()?); - let bundle = self.bundle.as_ref().map_err(Clone::clone)?; - let stored; - let key = match supplied { - Some(key) => key, - None => { - stored = self - .credentials - .read(&agent.credential_id, &agent.pubkey)? - .ok_or("Saved agent key is unavailable; nothing was started")?; - &stored - } - }; - let settings = effective_databricks(&agent)?; - let config = self.store.root(); - crate::connection::oauth_root(config)?; - let runs = config.join("runs"); - crate::connection::private_directory(&runs)?; - let temporary = tempfile::Builder::new() - .prefix("agent-") - .tempdir_in(&runs) - .map_err(|_| "Could not create private runtime directory")?; - let scratch = temporary.path().join("tmp"); - crate::connection::private_directory(&scratch)?; - let mut command = if let Some(preflight) = preflight { - bundle.command_checked(&agent, key, &crate::build_defaults(), Some(preflight))? - } else { - bundle.command(&agent, key)? - }; - // Per-send startup input, never saved configuration or inherited environment. - if let Some(floor) = replay_floor { - command.env("BUZZ_ACP_REPLAY_FLOOR", floor.to_string()); - } - // Last writer wins: neither user environment nor relay persona extra_env - // may redirect credentials/temp signing material outside this app profile. - command - .env("BUZZ_AGENT_CONFIG_DIR", config) - .env("TMPDIR", &scratch) - .env("TMP", &scratch) - .env("TEMP", &scratch); - if let Some(settings) = &settings { - command - .env("DATABRICKS_HOST", &settings.host) - .env("DATABRICKS_MODEL_FILTER", &settings.filter) - .env_remove("DATABRICKS_TOKEN"); - } - let control = self.wrap_protected_worker(&agent, &mut command)?; - // Disarm app-side deletion before a child can use this directory. The - // supervisor deletes it only after confirmed whole-session teardown. - let log_path = crate::logs::path(config, &agent.id)?; - let temporary = temporary.keep(); - let control = control.map(tempfile::TempDir::keep); - let process = Supervised::spawn( - &command, - &self.ownership_root, - &agent.id, - &temporary, - control.as_deref(), - &log_path, - )?; - self.running.insert( - id.into(), - Running { - process, - revision: agent.revision, - spawned: crate::restart::spawn_config(&agent), - databricks_host: settings.map(|s| s.host), - #[cfg(all(test, unix))] - temporary: Some(temporary), - }, - ); - Ok(()) - } - fn stop(&mut self, id: &str) -> Result<()> { - if let Some(run) = self.running.get_mut(id) { - let result = run.process.stop(); - if run.process.stopped() { - // E confirms worker exit even when private-dir removal failed. - self.running.remove(id); - } - result?; - } - self.running.remove(id); - Ok(()) - } - /// Caller holds the same native mutex used for Start/Restart. Use captured - /// running settings, never a later saved edit, to determine credential users. - pub fn disconnect(&mut self, workspace: &str) -> Result<()> { - let workspace = crate::connection::origin(workspace)?; - // Reap exits before deciding; failed teardown retains ownership and blocks. - let ids: Vec<_> = self.running.keys().cloned().collect(); - for id in ids { - let run = self.running.get_mut(&id).unwrap(); - if !run.process.alive()? { - self.running.remove(&id); - } - } - if self - .running - .values() - .any(|r| r.databricks_host.as_deref() == Some(&workspace)) - { - return Err("Stop agents using this Databricks workspace before Disconnect".into()); - } - let cache = crate::connection::oauth_root(self.store.root())?; - crate::connection::disconnect(&cache, &workspace) - } - pub fn shutdown(&mut self) -> Result<()> { - let ids: Vec<_> = self.running.keys().cloned().collect(); - let mut result = Ok(()); - for id in ids { - if let Err(e) = self.stop(&id) { - result = Err(e); - } - } - result - } -} -impl Drop for Controller { - fn drop(&mut self) { - let _ = self.shutdown(); - } -} -#[cfg(test)] -mod tests; - -fn draft_environment(patch: BTreeMap>) -> BTreeMap { - patch - .into_iter() - .filter_map(|(key, value)| value.map(|v| (key, v))) - .collect() -} - -fn model_context( - harness: &crate::HarnessEdit, - environment: &BTreeMap, -) -> Result { - model_context_with_defaults(harness, environment, &crate::build_defaults()) -} -fn model_context_with_defaults( - harness: &crate::HarnessEdit, - environment: &BTreeMap, - defaults: &crate::BuildDefaults, -) -> Result { - let harness = defaults.resolve(harness, environment); - if Path::new(&harness.command) - .file_name() - .and_then(|s| s.to_str()) - != Some("buzz-agent") - { - return Err("Model discovery requires the Buzz Agent harness".into()); - } - let provider = environment - .get("BUZZ_AGENT_PROVIDER") - .unwrap_or(&harness.provider); - if !matches!(provider.as_str(), "databricks_v2" | "databricks-v2") { - return Err( - "Effective provider is not Databricks v2; check the provider and environment overrides" - .into(), - ); - } - if environment.contains_key("DATABRICKS_TOKEN") { - return Err("A saved or draft token override conflicts with this app-isolated OAuth connection. Remove it explicitly or keep manual model entry".into()); - } - Ok(ModelContext { - host: environment - .get("DATABRICKS_HOST") - .cloned() - .or_else(|| harness.databricks.as_ref().map(|s| s.host.clone())), - filter: environment - .get("DATABRICKS_MODEL_FILTER") - .cloned() - .or_else(|| harness.databricks.as_ref().map(|s| s.filter.clone())), - model_overridden: environment.contains_key("BUZZ_AGENT_MODEL"), - }) -} - -fn uses_buzz_dev_mcp(command: &str) -> bool { - crate::HarnessConfigurationPolicy::for_command(command).include_buzz_dev_mcp -} - -// Saved legacy Goose selections may still carry the CLI's ACP subcommand. -// Explicit external paths keep their original arguments. -fn goose_args(command: &str, args: &[String]) -> Vec { - if !matches!(command, "goose" | "goose-acp") { - return args.to_vec(); - } - // Match the pinned ACP runner's trim/filter rule before removing the CLI subcommand. - let mut normalized: Vec<_> = args - .iter() - .map(|arg| arg.trim()) - .filter(|arg| !arg.is_empty()) - .map(str::to_owned) - .collect(); - if normalized - .first() - .is_some_and(|arg| arg.eq_ignore_ascii_case("acp")) - { - normalized.remove(0); - } - normalized -} - -/// Kept agents answer to the owner who authorized them, not whoever signs in next. -pub fn check_owner(attested: Option<&str>, signed_in: Option<&str>) -> Result<()> { - match attested { - Some(owner) if signed_in != Some(owner) => Err( - "This agent belongs to a different Buzz identity. Sign in with its owner's key to start it." - .into(), - ), - _ => Ok(()), - } -} - -/// Sign out's "Also remove my agents", run at launch before the wipe: delete every -/// local agent's key from the registry at `root`. Repeatable, because deleting an -/// absent key succeeds; the registry itself is left for the wipe. Import keeps a -/// local copy of every agent's key, deployed remote ones included, so each copy -/// goes; the remote deployment itself is neither stopped nor deleted. -pub fn delete_local_agent_keys(root: PathBuf, credentials: &dyn Credentials) -> Result<()> { - for agent in Store::open(root)?.agents()? { - credentials.delete(&agent.credential_id, &agent.pubkey)?; - } - Ok(()) -} diff --git a/crates/agent-controller/src/runtime/path.rs b/crates/agent-controller/src/runtime/path.rs deleted file mode 100644 index 272828ac4..000000000 --- a/crates/agent-controller/src/runtime/path.rs +++ /dev/null @@ -1,397 +0,0 @@ -//! Machine tool discovery is independent of the managed agent's environment. -use crate::Result; -#[cfg(unix)] -use std::ffi::OsStr; -use std::ffi::OsString; -use std::path::PathBuf; - -pub fn tools_path() -> Result { - #[cfg(windows)] - return Ok(std::env::var_os("PATH").unwrap_or_default()); - #[cfg(unix)] - { - let shell = login_cache() - .0 - .lock() - .ok() - .and_then(|state| state.path.clone()); - let inherited = std::env::var_os("PATH"); - let home = std::env::var_os("HOME").map(PathBuf::from); - let local = home - .filter(|h| h.is_absolute()) - .map(|h| h.join(".local/bin")); - compose( - local - .into_iter() - .chain(shell.iter().flat_map(std::env::split_paths)) - .chain(inherited.iter().flat_map(std::env::split_paths)) - .chain( - [ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - ] - .map(PathBuf::from), - ), - ) - } -} - -/// Begin startup discovery without holding native agent-operation admission. -pub fn warm_tools_path() { - #[cfg(unix)] - { - let (state, completed) = login_cache(); - let Ok(mut state) = state.lock() else { return }; - if state.path.is_some() || state.running { - return; - } - state.running = true; - if std::thread::Builder::new() - .name("agent-tool-path".into()) - .spawn(move || { - let path = login_path(&shell(), std::time::Duration::from_secs(15)); - if let Ok(mut state) = login_cache().0.lock() { - state.path = path; - state.running = false; - completed.notify_all(); - } - }) - .is_err() - { - state.running = false; - completed.notify_all(); - } - } -} - -/// Explicit discovery/launch waits run outside native admission. Only successful -/// results are cached; a later refresh can retry a failed attempt. -pub fn prepare_tools_path() { - warm_tools_path(); - #[cfg(unix)] - { - let (state, completed) = login_cache(); - if let Ok(state) = state.lock() { - let _unused = completed.wait_while(state, |state| state.running); - } - } -} -#[cfg(unix)] -#[derive(Default)] -struct LoginCache { - path: Option, - running: bool, -} -#[cfg(unix)] -fn login_cache() -> &'static (std::sync::Mutex, std::sync::Condvar) { - static CACHE: std::sync::OnceLock<(std::sync::Mutex, std::sync::Condvar)> = - std::sync::OnceLock::new(); - CACHE.get_or_init(Default::default) -} - -/// Keep caller-owned bundle/runtime entries first; never interpret an empty or -/// relative tool directory against an agent's workspace. -pub(crate) fn compose(dirs: impl IntoIterator) -> Result { - #[cfg(windows)] - return std::env::join_paths(dirs).map_err(|_| "Invalid runtime tools path".into()); - #[cfg(unix)] - { - let mut paths = Vec::new(); - for dir in dirs { - if dir.is_absolute() && !paths.contains(&dir) { - paths.push(dir); - } - } - std::env::join_paths(paths).map_err(|_| "Invalid runtime tools path".into()) - } -} - -#[cfg(unix)] -fn shell() -> PathBuf { - std::env::var_os("SHELL") - .map(PathBuf::from) - .filter(|p| p.is_absolute() && super::executable(p).is_ok()) - .or_else(passwd_shell) - .unwrap_or_else(|| "/bin/sh".into()) -} - -#[cfg(unix)] -fn passwd_shell() -> Option { - use std::ffi::CStr; - use std::os::unix::ffi::OsStrExt; - let mut buffer = vec![0u8; 16 * 1024]; - let mut entry = std::mem::MaybeUninit::::uninit(); - let mut result = std::ptr::null_mut(); - // SAFETY: writable entry/buffer/result; copy the returned buffer-backed path - // before the buffer goes away. Reentrant lookup permits concurrent launches. - unsafe { - if libc::getpwuid_r( - libc::getuid(), - entry.as_mut_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - &mut result, - ) != 0 - || result.is_null() - { - return None; - } - let ptr = (*result).pw_shell; - if ptr.is_null() { - return None; - } - let path = PathBuf::from(OsStr::from_bytes(CStr::from_ptr(ptr).to_bytes())); - (path.is_absolute() && super::executable(&path).is_ok()).then_some(path) - } -} - -#[cfg(unix)] -fn login_path(shell: &std::path::Path, timeout: std::time::Duration) -> Option { - use std::io::{Read, Seek, SeekFrom}; - use std::os::unix::ffi::OsStringExt; - use std::process::{Command, Stdio}; - use std::time::{Duration, Instant}; - // Rc files can emit banners or retain stdout in helpers. A bounded private - // file avoids pipe readers surviving the probe. Only the framed PATH is used; - // shell-exported credentials never become the launch environment. - let mut output = tempfile::tempfile().ok()?; - let mut probe = Command::new(shell); - probe - .args([ - "-ilc", - "printf '\\0BUZZ_TOOL_PATH\\0'; command printenv PATH; printf '\\0'", - ]) - .env_clear() - .env("PATH", "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin") - .env("SHELL", shell) - .current_dir("/") - .stdin(Stdio::null()) - .stdout(output.try_clone().ok()?) - .stderr(Stdio::null()); - for key in [ - "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", - ] { - if let Some(value) = std::env::var_os(key) { - probe.env(key, value); - } - } - if let Some(home) = std::env::var_os("HOME") - .map(PathBuf::from) - .filter(|h| h.is_absolute() && h.is_dir()) - { - probe.current_dir(home); - } - let mut child = crate::process::Process::spawn(&mut probe).ok()?; - let deadline = Instant::now() + timeout; - let success = loop { - if output.metadata().map_or(true, |m| m.len() > 64 * 1024) { - break false; - } - match child.try_wait() { - Ok(Some(status)) => break status.success(), - Err(_) => break false, - Ok(None) if Instant::now() >= deadline => break false, - Ok(None) => std::thread::sleep(Duration::from_millis(20)), - } - }; - // Startup jobs may have their own job-control groups. Retire the entire - // owned session on success as well as timeout, using listener ownership. - child.kill().ok()?; - if !success { - return None; - } - output.seek(SeekFrom::Start(0)).ok()?; - let mut bytes = Vec::new(); - output.take(64 * 1024 + 1).read_to_end(&mut bytes).ok()?; - if bytes.len() > 64 * 1024 { - return None; - } - let marker = b"\0BUZZ_TOOL_PATH\0"; - let start = bytes.windows(marker.len()).rposition(|w| w == marker)? + marker.len(); - let mut length = bytes[start..].iter().position(|b| *b == 0)?; - // printenv preserves the shell's exported PATH (including fish's list form) - // and adds one newline; do not trim whitespace in directory names. - if length > 0 && bytes[start + length - 1] == b'\n' { - length -= 1; - } - (length > 0).then(|| OsString::from_vec(bytes[start..start + length].to_vec())) -} - -#[cfg(all(test, unix))] -mod tests { - use super::*; - #[test] - #[cfg(unix)] - fn composition_keeps_priority_and_ignores_workspace_relative_entries() { - let root = tempfile::tempdir().unwrap(); - let bundle = root.path().join("bundle"); - let installed = root.path().join("tool manager/bin"); - let result = compose([ - bundle.clone(), - installed.clone(), - PathBuf::new(), - "relative/bin".into(), - bundle.clone(), - ]) - .unwrap(); - assert_eq!( - std::env::split_paths(&result).collect::>(), - [bundle, installed] - ); - } - #[test] - #[cfg(unix)] - fn login_probe_ignores_banners_and_bounds_failed_shells() { - use std::time::Duration; - let root = tempfile::tempdir().unwrap(); - let shell = root.path().join("shell"); - for (script, expected) in [ - ( - "#!/bin/sh\nprintf 'welcome\\n\\0BUZZ_TOOL_PATH\\0/custom/bin:/usr/bin\\0bye\\n'", - Some("/custom/bin:/usr/bin"), - ), - ("#!/bin/sh\nexit 1", None), - ("#!/bin/sh\nprintf 'no framed path'", None), - ("#!/bin/sh\nprintf '\\0BUZZ_TOOL_PATH\\0\\0'", None), - ("#!/bin/sh\n/bin/sleep 10", None), - ("#!/bin/sh\n/usr/bin/head -c 70000 /dev/zero", None), - ] { - crate::test_executable::write_executable(&shell, script); - assert_eq!( - login_path(&shell, Duration::from_millis(100)).as_deref(), - expected.map(OsStr::new) - ); - } - assert!(login_path(&root.path().join("missing"), Duration::from_millis(100)).is_none()); - } - - #[test] - #[cfg(unix)] - fn failed_shell_discovery_can_be_retried() { - const FIXTURE: &str = "BUZZ_LOGIN_RETRY_FIXTURE"; - if let Some(root) = std::env::var_os(FIXTURE) { - let root = PathBuf::from(root); - prepare_tools_path(); - assert!(!std::env::split_paths(&tools_path().unwrap()) - .any(|p| p == root.join("recovered tools"))); - crate::test_executable::write_executable( - &root.join("shell"), - format!( - "#!/bin/sh\nprintf '\\0BUZZ_TOOL_PATH\\0{}:/usr/bin:/bin\\0'\n", - root.join("recovered tools").display() - ), - ); - prepare_tools_path(); - assert!( - std::env::split_paths(&tools_path().unwrap()) - .any(|p| p == root.join("recovered tools")), - "failed PATH was cached permanently" - ); - return; - } - let root = tempfile::tempdir().unwrap(); - crate::test_executable::write_executable(&root.path().join("shell"), "#!/bin/sh\nexit 1\n"); - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "runtime::path::tests::failed_shell_discovery_can_be_retried", - "--nocapture", - ]) - .env(FIXTURE, root.path()) - .env("HOME", root.path()) - .env("SHELL", root.path().join("shell")) - .env("PATH", "/usr/bin:/bin") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - #[test] - #[cfg(unix)] - fn shell_discovery_accepts_startup_longer_than_two_seconds() { - const FIXTURE: &str = "BUZZ_LOGIN_SLOW_FIXTURE"; - if let Some(root) = std::env::var_os(FIXTURE) { - let root = PathBuf::from(root); - prepare_tools_path(); - assert!( - std::env::split_paths(&tools_path().unwrap()).any(|p| p == root.join("slow tools")), - "valid slow shell PATH was lost" - ); - return; - } - let root = tempfile::tempdir().unwrap(); - crate::test_executable::write_executable( - &root.path().join("shell"), - format!( - "#!/bin/sh\n/bin/sleep 3\nprintf '\\0BUZZ_TOOL_PATH\\0{}:/usr/bin:/bin\\0'\n", - root.path().join("slow tools").display() - ), - ); - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "runtime::path::tests::shell_discovery_accepts_startup_longer_than_two_seconds", - "--nocapture", - ]) - .env(FIXTURE, root.path()) - .env("HOME", root.path()) - .env("SHELL", root.path().join("shell")) - .env("PATH", "/usr/bin:/bin") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - #[test] - #[cfg(unix)] - fn fallback_path_includes_discovery_directories() { - let paths: Vec<_> = std::env::split_paths(&tools_path().unwrap()).collect(); - for dir in [ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - ] { - assert!( - paths.contains(&PathBuf::from(dir)), - "fallback omitted {dir}" - ); - } - } - #[test] - #[cfg(unix)] - fn login_probe_resolves_printenv_from_shell_path() { - let root = tempfile::tempdir().unwrap(); - crate::test_executable::write_executable( - &root.path().join("printenv"), - "#!/bin/sh\nprintf '/shell-resolved-printenv/bin\\n'\n", - ); - let shell = root.path().join("shell"); - crate::test_executable::write_executable( - &shell, - format!( - "#!/bin/sh\nexport PATH='{}:/usr/bin:/bin'\n/bin/sh -c \"$2\"\n", - root.path().display() - ), - ); - assert_eq!( - login_path(&shell, std::time::Duration::from_secs(1)).as_deref(), - Some(OsStr::new("/shell-resolved-printenv/bin")) - ); - } -} diff --git a/crates/agent-controller/src/runtime/tests.rs b/crates/agent-controller/src/runtime/tests.rs deleted file mode 100644 index 092d4043f..000000000 --- a/crates/agent-controller/src/runtime/tests.rs +++ /dev/null @@ -1,4262 +0,0 @@ -use super::*; -use crate::config::{agent_id, HarnessEdit}; -#[cfg(unix)] -use crate::process::Process; -use crate::Secret; -use serde_json::json; -use std::fs; -use std::time::{Duration, Instant}; -const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; -const PUB: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; -struct Memory; -impl Credentials for Memory { - fn delete(&self, _: &str, _: &str) -> Result<()> { - Ok(()) - } - fn read_legacy(&self, _: crate::LegacySource, _: &str) -> Result { - panic!("Runtime must never import") - } - fn read(&self, _: &str, key: &str) -> Result> { - Secret::parse(KEY, key).map(Some) - } - fn add(&self, _: &str, _: &Secret) -> Result<()> { - panic!("Runtime must never write keys") - } -} -fn agent(workspace: &Path) -> Agent { - let relay_url = "wss://relay.example".to_owned(); - Agent { - picture: None, - id: agent_id(PUB, &relay_url), - pubkey: PUB.into(), - relay_url, - name: "Test agent".into(), - system_prompt: "test prompt".into(), - session_policy: None, - session_policy_inherit: false, - workspace: workspace.display().to_string(), - harness: HarnessEdit { - integration: None, - databricks: None, - command: "buzz-agent".into(), - args: vec![], - model: "test-model".into(), - configuration: None, - provider: "test-provider".into(), - }, - environment: BTreeMap::from([("PROVIDER_TEST_SETTING".into(), "explicit-value".into())]), - revision: 1, - enabled: false, - start_on_app_launch: None, - credential_id: "test".into(), - auth_tag: Some(crate::secret::test_attestation(PUB)), - imported: json!({"record":{"respond_to":"owner-only","parallelism":2,"effort_level":"high"}}), - extra: BTreeMap::new(), - } -} -#[test] -fn rename_publication_applies_saved_picture_only_when_avatar_intent_is_pending() { - for saved_picture in ["https://images.example/saved.png", ""] { - for avatar_pending in [false, true] { - let dir = tempfile::tempdir().unwrap(); - let mut saved = agent(dir.path()); - saved.picture = Some(saved_picture.into()); - if avatar_pending { - saved.extra.insert("profilePending".into(), json!(true)); - } - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![saved.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Err("No fixture runtime".into()), - dir.path().join("ownership"), - ); - let edit = crate::AgentEdit { - name: "Renamed".into(), - picture: None, - system_prompt: saved.system_prompt.clone(), - session_policy: None, - workspace: saved.workspace.clone(), - harness: saved.harness.clone(), - environment: BTreeMap::new(), - effort: None, - }; - controller.save(&saved.id, saved.revision, edit).unwrap(); - let publication = controller.creation_profile(&saved.id).unwrap(); - let key = Secret::parse(KEY, PUB).unwrap(); - let current = key - .profile( - "External name", - Some("https://images.example/relay.png"), - false, - None, - saved.auth_tag.as_ref().unwrap(), - &[], - ) - .unwrap(); - let event = publication.event(&key, &[current]).unwrap(); - let content: serde_json::Value = - serde_json::from_str(event["content"].as_str().unwrap()).unwrap(); - assert_eq!(content["name"], "Renamed"); - assert_eq!(content["display_name"], "Renamed"); - if avatar_pending && saved_picture.is_empty() { - assert!(content.get("picture").is_none()); - } else { - assert_eq!( - content["picture"], - if avatar_pending { - saved_picture - } else { - "https://images.example/relay.png" - } - ); - } - let initial = publication.event(&key, &[]).unwrap(); - let content: serde_json::Value = - serde_json::from_str(initial["content"].as_str().unwrap()).unwrap(); - if avatar_pending && !saved_picture.is_empty() { - assert_eq!(content["picture"], saved_picture); - } else { - assert!(content.get("picture").is_none()); - } - assert!(!controller.snapshot().unwrap().agents[0].enabled); - } - } -} -#[test] -fn kept_agents_start_only_for_their_attested_owner() { - const OWNER: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - let saved = agent(dir.path()); - let mut unattested = agent(dir.path()); - unattested.id = agent_id(&"ab".repeat(32), "wss://relay.example"); - unattested.pubkey = "ab".repeat(32); - unattested.auth_tag = None; - store - .insert(vec![saved.clone(), unattested.clone()]) - .unwrap(); - let controller = Controller::new( - store, - Arc::new(Memory), - Err("No fixture runtime".into()), - dir.path().join("ownership"), - ); - assert!(controller.check_owner(&saved.id, Some(OWNER)).is_ok()); - let refusal = controller.check_owner(&saved.id, Some(PUB)).unwrap_err(); - assert!(refusal.contains("different Buzz identity"), "{refusal}"); - assert_eq!( - controller.check_owner(&saved.id, None).unwrap_err(), - refusal - ); - // Launch validation, not this check, refuses a missing attestation. - assert!(controller.check_owner(&unattested.id, None).is_ok()); -} -/// Agent keys left in a fake keychain; deleting the second one fails once. -struct Keychain(std::sync::Mutex<(Vec, bool)>); -impl Credentials for Keychain { - fn delete(&self, id: &str, _: &str) -> Result<()> { - let mut state = self.0.lock().unwrap(); - if state.1 && id == "second" { - state.1 = false; - return Err("keychain interrupted".into()); - } - state.0.retain(|kept| kept != id); - Ok(()) - } - fn read_legacy(&self, _: crate::LegacySource, _: &str) -> Result { - unreachable!() - } - fn read(&self, _: &str, _: &str) -> Result> { - unreachable!() - } - fn add(&self, _: &str, _: &Secret) -> Result<()> { - unreachable!() - } -} -#[test] -fn sign_out_agent_key_removal_resumes_after_an_interruption() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("config"); - let mut first = agent(dir.path()); - first.credential_id = "first".into(); - let mut second = agent(dir.path()); - second.id = agent_id(&"ab".repeat(32), "wss://relay.example"); - second.pubkey = "ab".repeat(32); - second.credential_id = "second".into(); - Store::open(root.clone()) - .unwrap() - .insert(vec![first, second]) - .unwrap(); - let keychain = Keychain(std::sync::Mutex::new(( - vec!["first".into(), "second".into()], - true, - ))); - // Interrupted between the two deletions: the first key is gone, the registry stays. - assert!(delete_local_agent_keys(root.clone(), &keychain).is_err()); - assert_eq!(keychain.0.lock().unwrap().0, ["second"]); - // Repeating it, including the already-deleted key, finishes the job. - delete_local_agent_keys(root.clone(), &keychain).unwrap(); - assert!(keychain.0.lock().unwrap().0.is_empty()); - assert_eq!(Store::open(root).unwrap().agents().unwrap().len(), 2); -} -#[test] -fn sign_out_agent_key_removal_includes_imported_remote_agents() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("config"); - let mut remote = agent(dir.path()); - remote.credential_id = "second".into(); - remote.imported = serde_json::json!({"record": {"backend": {"type": "provider"}, "backend_agent_id": "deployed"}}); - assert!(remote.deployed_remote()); - Store::open(root.clone()) - .unwrap() - .insert(vec![remote]) - .unwrap(); - let keychain = Keychain(std::sync::Mutex::new((vec!["second".into()], false))); - delete_local_agent_keys(root.clone(), &keychain).unwrap(); - assert!(keychain.0.lock().unwrap().0.is_empty()); - // Only the local key copy goes; the record (and its deployment) is the wipe's. - assert!(Store::open(root).unwrap().agents().unwrap()[0].deployed_remote()); -} -#[test] -fn delete_refuses_stale_revision_and_removes_stopped_agent() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("config"); - let mut store = Store::open(root.clone()).unwrap(); - let saved = agent(dir.path()); - store.insert(vec![saved.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Err("No fixture runtime".into()), - dir.path().join("ownership"), - ); - assert!(controller.delete(&saved.id, saved.revision + 1).is_err()); - assert_eq!(controller.snapshot().unwrap().agents.len(), 1); - assert!(controller - .delete(&saved.id, saved.revision) - .unwrap() - .agents - .is_empty()); - drop(controller); - assert!(Store::open(root).unwrap().agents().unwrap().is_empty()); -} -#[test] -fn delete_keeps_a_key_shared_by_another_setup_of_the_same_identity() { - use std::sync::Mutex; - #[derive(Default)] - struct Tracked(Mutex>); - impl Credentials for Tracked { - fn read_legacy(&self, _: crate::LegacySource, _: &str) -> Result { - unreachable!() - } - fn read(&self, _: &str, _: &str) -> Result> { - unreachable!() - } - fn add(&self, _: &str, _: &Secret) -> Result<()> { - unreachable!() - } - fn delete(&self, id: &str, _: &str) -> Result<()> { - self.0.lock().unwrap().push(id.into()); - Ok(()) - } - } - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - let first = agent(dir.path()); - let mut second = first.clone(); - second.id = agent_id(PUB, "wss://other.example"); - second.relay_url = "wss://other.example".into(); - store.insert(vec![first.clone(), second.clone()]).unwrap(); - let credentials = Arc::new(Tracked::default()); - let mut controller = Controller::new( - store, - credentials.clone(), - Err("No fixture runtime".into()), - dir.path().join("ownership"), - ); - controller.delete(&first.id, first.revision).unwrap(); - assert!(credentials.0.lock().unwrap().is_empty()); - assert!(controller - .delete(&second.id, second.revision) - .unwrap() - .agents - .is_empty()); - assert_eq!(*credentials.0.lock().unwrap(), vec![second.credential_id]); -} -#[test] -fn denied_credential_deletion_keeps_a_disabled_card_for_retry() { - struct Denied; - impl Credentials for Denied { - fn read_legacy(&self, _: crate::LegacySource, _: &str) -> Result { - unreachable!() - } - fn read(&self, _: &str, _: &str) -> Result> { - unreachable!() - } - fn add(&self, _: &str, _: &Secret) -> Result<()> { - unreachable!() - } - fn delete(&self, _: &str, _: &str) -> Result<()> { - Err("Credential deletion denied".into()) - } - } - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - let mut saved = agent(dir.path()); - saved.enabled = true; - store.insert(vec![saved.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Denied), - Err("No fixture runtime".into()), - dir.path().join("ownership"), - ); - assert!(controller - .delete(&saved.id, saved.revision) - .err() - .unwrap() - .contains("denied")); - let remaining = controller.store.agents().unwrap(); - assert_eq!(remaining.len(), 1); - assert!(!remaining[0].enabled); -} -fn bundle(directory: &Path) -> RuntimeBundle { - let names = [ - "buzz-acp", - "buzz-agent", - "buzz-dev-mcp", - "buzz", - "git-credential-nostr", - "goose-acp", - ] - .map(|name| format!("{name}{}", std::env::consts::EXE_SUFFIX)); - for name in &names { - let path = directory.join(name); - // Windows cannot run this sh fixture; the listener is `windows_listener` - // and the other tools are only integrity-checked. - #[cfg(windows)] - if name == "buzz-acp.exe" { - fs::copy(std::env::current_exe().unwrap(), &path).unwrap(); - continue; - } - crate::test_executable::write_executable( - &path, - r#"#!/bin/sh -if [ -n "$BUZZ_ACP_LAUNCH_PREFIX" ]; then - exec /usr/bin/env python3 -c 'import json,os; p=json.loads(os.environ["BUZZ_ACP_LAUNCH_PREFIX"]); os.execv(p[0],p+[os.environ["BUZZ_ACP_AGENT_COMMAND"]]+[x for x in os.environ.get("BUZZ_ACP_AGENT_ARGS", "").split(",") if x])' -fi -printf '%s\n' "$BUZZ_ACP_LAZY_POOL" "$BUZZ_ACP_IDLE_POOL_SLEEP" "$BUZZ_ACP_SYSTEM_PROMPT" "$BUZZ_ACP_MODEL" "$BUZZ_ACP_AGENT_ARGS" "$BUZZ_RELAY_URL" "$BUZZ_ACP_RESPOND_TO" "$BUZZ_MANAGED_AGENT" "$BUZZ_ACP_REPLAY_FLOOR" "$PROVIDER_TEST_SETTING" >> starts -printf '%s' "$BUZZ_ACP_TEAM_INSTRUCTIONS" > team-instructions -printf '%s' "$BUZZ_ACP_AGENTS" > worker-count -printf '%s' "$BUZZ_ACP_MCP_COMMAND" > mcp-command -printf '%s\n' "$BUZZ_AGENT_CONFIG_DIR" "$DATABRICKS_HOST" "$DATABRICKS_MODEL_FILTER" "${DATABRICKS_TOKEN-unset}" "$TMPDIR" "$PATH" > runtime-env -printf 'harness fixture output\n' -trap 'exit 0' TERM INT -while :; do [ -f "$BUZZ_AGENT_CONFIG_DIR/exit-listener" ] && exit 0; /bin/sleep 0.1; done -"#, - ); - } - let files: BTreeMap<_, _> = names - .iter() - .map(|name| { - use sha2::{Digest, Sha256}; - ( - name, - format!( - "{:x}", - Sha256::digest(fs::read(directory.join(name)).unwrap()) - ), - ) - }) - .collect(); - let source: serde_json::Value = - serde_json::from_str(include_str!("../../../../runtime/agent-runtime.json")).unwrap(); - fs::write(directory.join("manifest.json"), serde_json::to_vec(&json!({"version":2,"goose":source["goose"],"revision":source["revision"],"target":env!("BUZZ_RUNTIME_TARGET"),"files":files})).unwrap()).unwrap(); - RuntimeBundle::new(directory.into()).unwrap() -} -fn wait_for_contents(path: &Path, parse: impl Fn(&str) -> Option) -> T { - let deadline = Instant::now() + Duration::from_secs(5); - loop { - if let Some(value) = fs::read_to_string(path).ok().and_then(|text| parse(&text)) { - return value; - } - assert!( - Instant::now() < deadline, - "fixture did not publish complete output" - ); - std::thread::sleep(Duration::from_millis(10)); - } -} - -#[cfg(unix)] -fn fixture_worker(text: &str) -> Option<(i32, i32)> { - let (leader, worker) = text.split_once(' ')?; - let leader = leader.parse().ok()?; - let worker = worker.parse().ok()?; - (leader > 1 && worker > 1 && leader != worker).then_some((leader, worker)) -} - -// Independent of the production teardown being mutation-tested. The fixture -// records both PIDs in its private temp directory; verify session ownership -// before cleanup, including when an assertion unwinds. -#[cfg(unix)] -struct FixtureWorkerCleanup(PathBuf); -#[cfg(unix)] -impl Drop for FixtureWorkerCleanup { - fn drop(&mut self) { - if let Some((leader, worker)) = fs::read_to_string(&self.0) - .ok() - .and_then(|text| fixture_worker(&text)) - { - if unsafe { libc::getsid(worker) } == leader { - unsafe { libc::kill(worker, libc::SIGKILL) }; - } - } - } -} -#[test] -#[cfg(unix)] -fn log_reads_require_exact_instance_and_verified_owner_and_survive_stop() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - let agent = agent(dir.path()); - store.insert(vec![agent.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - let nonce = "12345678-1234-1234-1234-123456789abc"; - let sign = |id: &str, pubkey: &str, relay: &str, nonce: &str| { - use secp256k1::{Keypair, Secp256k1, SecretKey}; - use sha2::{Digest, Sha256}; - let secp = Secp256k1::new(); - let mut bytes = [0; 32]; - bytes[31] = 2; // owner key used by test_attestation - let pair = Keypair::from_secret_key(&secp, &SecretKey::from_byte_array(bytes).unwrap()); - let digest = Sha256::digest(crate::logs::proof_message(id, pubkey, relay, nonce)); - secp.sign_schnorr_no_aux_rand(&digest, &pair).to_string() - }; - let signature = sign(&agent.id, PUB, &agent.relay_url, nonce); - let read = |controller: &Controller| { - controller.read_log(&agent.id, PUB, &agent.relay_url, nonce, &signature) - }; - assert_eq!(read(&controller).unwrap(), ""); - assert!(controller - .log_target(&agent.id, PUB, &agent.relay_url) - .is_ok()); - assert!(controller - .log_target(&agent.id, PUB, "wss://another.example") - .is_err()); - assert!(controller - .read_log(&agent.id, PUB, &agent.relay_url, nonce, &"f".repeat(128)) - .is_err()); - assert!(controller - .read_log( - &agent.id, - PUB, - &agent.relay_url, - "22345678-1234-1234-1234-123456789abc", - &signature - ) - .is_err()); - assert!(controller - .read_log(&agent.id, PUB, "wss://another.example", nonce, &signature) - .is_err()); - assert!(controller - .read_log( - &agent.id, - &"f".repeat(64), - &agent.relay_url, - nonce, - &signature - ) - .is_err()); - let mut other = agent.clone(); - other.relay_url = "wss://another.example".into(); - other.id = agent_id(PUB, &other.relay_url); - controller.store.insert(vec![other.clone()]).unwrap(); - assert!(controller - .read_log(&other.id, PUB, &other.relay_url, nonce, &signature) - .is_err()); - let cross_community = sign(&agent.id, PUB, &other.relay_url, nonce); - assert!(controller - .read_log(&agent.id, PUB, &agent.relay_url, nonce, &cross_community) - .is_err()); - // An otherwise well-formed proof from the agent key is not an owner proof. - use secp256k1::{Keypair, Secp256k1, SecretKey}; - use sha2::{Digest, Sha256}; - let secp = Secp256k1::new(); - let mut wrong_bytes = [0; 32]; - wrong_bytes[31] = 1; - let wrong_key = - Keypair::from_secret_key(&secp, &SecretKey::from_byte_array(wrong_bytes).unwrap()); - let wrong_digest = Sha256::digest(crate::logs::proof_message( - &agent.id, - PUB, - &agent.relay_url, - nonce, - )); - let wrong_signature = secp - .sign_schnorr_no_aux_rand(&wrong_digest, &wrong_key) - .to_string(); - assert!(controller - .read_log(&agent.id, PUB, &agent.relay_url, nonce, &wrong_signature) - .is_err()); - controller.action(&agent.id, Action::Start).unwrap(); - let log_path = crate::logs::path(dir.path().join("config").as_path(), &agent.id).unwrap(); - wait_for_contents(&log_path, |text| (!text.is_empty()).then_some(())); - controller.action(&agent.id, Action::Stop).unwrap(); - assert!(!read(&controller).unwrap().is_empty()); - controller.action(&agent.id, Action::Start).unwrap(); - controller.action(&agent.id, Action::Stop).unwrap(); - assert!(!read(&controller).unwrap().is_empty()); -} - -#[test] -#[cfg(unix)] -fn actual_spawn_save_restart_stop_and_restore_contract() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let store_root = dir.path().join("config"); - let mut store = Store::open(store_root.clone()).unwrap(); - let a = agent(dir.path()); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - let snapshot = controller.action(&a.id, Action::Start).unwrap(); - assert!(matches!(snapshot.agents[0].status, ProcessStatus::Running)); - let first = wait_for_contents(&dir.path().join("starts"), |text| { - (text.lines().count() == 10).then(|| text.to_owned()) - }); - assert_eq!(first, "true\n900\ntest prompt\ntest-model\n\nwss://relay.example\nowner-only\n\n\nexplicit-value\n"); - controller.action(&a.id, Action::Start).unwrap(); - assert_eq!(controller.running.len(), 1); - let edit = AgentEdit { - effort: None, - picture: None, - name: "Edited".into(), - system_prompt: "changed prompt".into(), - session_policy: Some(None), - workspace: a.workspace.clone(), - harness: a.harness.clone(), - environment: BTreeMap::new(), - }; - let saved = controller.save(&a.id, 1, edit).unwrap(); - assert_eq!(saved.agents[0].revision, 2); - assert_eq!(saved.agents[0].running_revision, Some(1)); - let fields: Vec<_> = saved.agents[0] - .restart_diff - .iter() - .map(|e| e.field.as_str()) - .collect(); - // Inheriting the device default moves this imported (Channel) agent to Thread, so the restart badge includes it. - assert_eq!(fields, ["name", "session_policy", "system_prompt"]); - assert_eq!( - fs::read_to_string(dir.path().join("starts")).unwrap(), - first - ); - let restarted = controller.action(&a.id, Action::Restart).unwrap(); - assert_eq!(restarted.agents[0].running_revision, Some(2)); - assert!(restarted.agents[0].restart_diff.is_empty()); - let deadline = Instant::now() + Duration::from_secs(5); - while !fs::read_to_string(dir.path().join("starts")) - .unwrap() - .contains("changed prompt") - { - assert!(Instant::now() < deadline); - std::thread::sleep(Duration::from_millis(10)); - } - controller.shutdown().unwrap(); - assert!(controller.store.agents().unwrap()[0].enabled); - let restored = controller.restore().unwrap(); - assert!(matches!(restored.agents[0].status, ProcessStatus::Running)); - let stopped = controller.action(&a.id, Action::Stop).unwrap(); - assert!(!stopped.agents[0].enabled); - assert!(matches!(stopped.agents[0].status, ProcessStatus::Stopped)); - drop(controller); - let mut controller = Controller::new( - Store::open(store_root).unwrap(), - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - let restored = controller.restore().unwrap(); - assert!(!restored.agents[0].enabled); - assert!(controller.running.is_empty()); - let preferred = controller.set_start_on_app_launch(&a.id, true).unwrap(); - assert!(preferred.agents[0].start_on_app_launch); - assert!(controller.running.is_empty()); - let restored = controller.restore().unwrap(); - assert!( - matches!(restored.agents[0].status, ProcessStatus::Running), - "{:?}", - restored.agents[0].error - ); - controller.shutdown().unwrap(); - controller.set_start_on_app_launch(&a.id, false).unwrap(); - assert!(controller.store.agents().unwrap()[0].enabled); - assert!(controller.launch_ids().unwrap().is_empty()); - controller.restore().unwrap(); - assert!(controller.running.is_empty()); - controller.action(&a.id, Action::Start).unwrap(); - assert_eq!(controller.running.len(), 1); - assert!(controller.delete(&a.id, 2).unwrap().agents.is_empty()); - assert!(controller.running.is_empty()); -} -#[test] -#[cfg(unix)] -fn new_records_launch_preference_is_independent_of_start_and_stop() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let owner: Vec = serde_json::from_str(&crate::secret::test_attestation(PUB)).unwrap(); - let prepared = crate::NewAgent::prepare("wss://relay.example", &owner[1]).unwrap(); - let a = agent(dir.path()); - let mut controller = Controller::new( - Store::open(dir.path().join("config")).unwrap(), - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - let edit = AgentEdit { - effort: None, - name: a.name.clone(), - picture: None, - system_prompt: a.system_prompt.clone(), - session_policy: Some(None), - workspace: a.workspace.clone(), - harness: a.harness.clone(), - environment: BTreeMap::new(), - }; - let auth = crate::secret::test_attestation(prepared.key.pubkey()); - controller.create(&prepared, edit, &auth).unwrap(); - let created = &controller.store.agents().unwrap()[0]; - assert_eq!(created.start_on_app_launch, Some(false)); - // Start/Stop change execution intent only, never the launch preference. - let mut explicit = a.clone(); - explicit.start_on_app_launch = Some(false); - controller.store.insert(vec![explicit]).unwrap(); - controller.action(&a.id, Action::Start).unwrap(); - assert!(controller.launch_ids().unwrap().is_empty()); - controller.action(&a.id, Action::Stop).unwrap(); - controller.set_start_on_app_launch(&a.id, true).unwrap(); - controller.action(&a.id, Action::Stop).unwrap(); - assert_eq!(controller.launch_ids().unwrap(), vec![a.id.clone()]); -} - -#[test] -#[cfg(unix)] -fn failed_temp_cleanup_reports_error_and_allows_explicit_retry() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let a = agent(dir.path()); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - let key = Secret::parse(KEY, PUB).unwrap(); - for action in [Action::Stop, Action::Restart] { - assert!(matches!( - controller.action(&a.id, Action::Start).unwrap().agents[0].status, - ProcessStatus::Running - )); - let temp = controller.running[&a.id] - .temporary - .as_ref() - .unwrap() - .clone(); - let moved = temp.with_extension("moved"); - fs::rename(&temp, &moved).unwrap(); - fs::write(&temp, b"block directory removal").unwrap(); - let stopped = if matches!(action, Action::Restart) { - controller - .action_with_key(&a.id, action, 1, &key, None) - .unwrap(); - controller.snapshot().unwrap() - } else { - controller.action(&a.id, action).unwrap() - }; - let error = "Agent stopped, but its private runtime directory could not be removed"; - assert!(matches!(stopped.agents[0].status, ProcessStatus::Failed)); - assert_eq!(stopped.agents[0].error.as_deref(), Some(error)); - assert_eq!( - controller.snapshot().unwrap().agents[0].error.as_deref(), - Some(error) - ); - assert!(controller.running.is_empty()); - fs::remove_file(temp).unwrap(); - let retry = controller.action(&a.id, Action::Start).unwrap(); - assert!(matches!(retry.agents[0].status, ProcessStatus::Running)); - controller.action(&a.id, Action::Stop).unwrap(); - fs::remove_dir_all(moved).unwrap(); - } -} - -#[test] -#[cfg(unix)] -fn listener_self_exit_with_failed_cleanup_retires_entry_and_reports_error() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let a = agent(dir.path()); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - controller.action(&a.id, Action::Start).unwrap(); - wait_for_contents(&dir.path().join("starts"), |text| { - (text.lines().count() == 10).then_some(()) - }); - let temp = controller.running[&a.id] - .temporary - .as_ref() - .unwrap() - .clone(); - let moved = temp.with_extension("moved"); - fs::rename(&temp, &moved).unwrap(); - fs::write(&temp, b"block directory removal").unwrap(); - controller.record_error(&a.id, "credential read failed".into()); - fs::write(dir.path().join("config/exit-listener"), b"").unwrap(); - let deadline = Instant::now() + Duration::from_secs(8); - loop { - let snapshot = controller.snapshot().unwrap(); - if controller.running.is_empty() { - assert_eq!( - snapshot.agents[0].error.as_deref(), - Some("Agent stopped, but its private runtime directory could not be removed") - ); - break; - } - assert!(Instant::now() < deadline, "listener did not exit"); - std::thread::sleep(Duration::from_millis(10)); - } - fs::remove_file(temp).unwrap(); - fs::remove_dir_all(moved).unwrap(); -} - -#[test] -#[cfg(unix)] -fn listener_exit_replaces_stale_credential_error() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let a = agent(dir.path()); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - controller.action(&a.id, Action::Start).unwrap(); - wait_for_contents(&dir.path().join("starts"), |text| { - (text.lines().count() == 10).then_some(()) - }); - controller.record_error(&a.id, "credential read failed".into()); - fs::write(dir.path().join("config/exit-listener"), b"").unwrap(); - let deadline = Instant::now() + Duration::from_secs(8); - loop { - let snapshot = controller.snapshot().unwrap(); - if controller.running.is_empty() { - assert_eq!( - snapshot.agents[0].error.as_deref(), - Some("Agent listener exited; restart to retry") - ); - break; - } - assert!(Instant::now() < deadline, "listener did not exit"); - std::thread::sleep(Duration::from_millis(10)); - } -} - -#[test] -#[cfg(unix)] -fn stop_reports_cleanup_before_durable_disable_failure() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let config = dir.path().join("config"); - let mut store = Store::open(config.clone()).unwrap(); - let a = agent(dir.path()); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - controller.action(&a.id, Action::Start).unwrap(); - let temp = controller.running[&a.id] - .temporary - .as_ref() - .unwrap() - .clone(); - let moved = temp.with_extension("moved"); - fs::rename(&temp, &moved).unwrap(); - fs::write(&temp, b"block directory removal").unwrap(); - fs::write(config.join("agents.json"), b"{malformed").unwrap(); - assert!( - matches!(controller.action(&a.id, Action::Stop), Err(ref error) if error == "Agent stopped, but its private runtime directory could not be removed") - ); - assert!(controller.running.is_empty()); - assert_eq!( - controller.errors[&a.id], - "Agent stopped, but its private runtime directory could not be removed" - ); - fs::remove_file(temp).unwrap(); - fs::remove_dir_all(moved).unwrap(); -} - -#[test] -fn exact_command_has_no_ambient_identity_and_launch_failure_is_truthful() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let a = agent(dir.path()); - let runtime = bundle(tools.path()); - let command = runtime - .command_with_defaults( - &a, - &Secret::parse(KEY, PUB).unwrap(), - &crate::BuildDefaults::default(), - ) - .unwrap(); - let env: BTreeMap<_, _> = command - .get_envs() - .filter_map(|(k, v)| { - v.map(|v| { - ( - k.to_string_lossy().to_string(), - v.to_string_lossy().to_string(), - ) - }) - }) - .collect(); - assert_eq!(env["BUZZ_PRIVATE_KEY"], KEY); - assert_eq!(env["NOSTR_PRIVATE_KEY"], KEY); - assert_eq!(env["BUZZ_AUTH_TAG"], crate::secret::test_attestation(PUB)); - assert_eq!(env["BUZZ_ACP_AGENTS"], "2"); - assert_eq!(env["BUZZ_ACP_EFFORT_LEVEL"], "high"); - for absent in [ - "BUZZ_MANAGED_AGENT", - "BUZZ_MANAGED_AGENT_START_NONCE", - "BUZZ_ACP_REPLAY_FLOOR", - "BUZZ_API_TOKEN", - "GIT_CONFIG_COUNT", - ] { - assert!(!env.contains_key(absent)); - } - assert_eq!(command.get_current_dir(), Some(dir.path())); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Err("Runtime bundle is missing".into()), - dir.path().join("ownership"), - ); - let failed = controller.action(&a.id, Action::Start).unwrap(); - assert!(matches!(failed.agents[0].status, ProcessStatus::Failed)); - assert!(failed.agents[0].enabled); - assert!(controller.running.is_empty()); - let stopped = controller.action(&a.id, Action::Stop).unwrap(); - assert!(!stopped.agents[0].enabled); -} - -#[cfg(unix)] -fn codex_fixture(workspace: &Path) -> (Agent, crate::codex::CodexContext) { - use std::os::unix::fs::PermissionsExt; - - let tools = workspace.join("codex-tools"); - fs::create_dir_all(&tools).unwrap(); - let adapter = tools.join("codex-acp-script"); - let cli = tools.join("codex-cli-script"); - fs::write(&adapter, "#!/bin/sh\nexit 0\n").unwrap(); - fs::write(&cli, "#!/usr/bin/python3\n").unwrap(); - fs::set_permissions(&adapter, fs::Permissions::from_mode(0o700)).unwrap(); - fs::set_permissions(&cli, fs::Permissions::from_mode(0o700)).unwrap(); - let context = - crate::codex::CodexContext::new(&adapter, &cli, workspace, &BTreeMap::new()).unwrap(); - let mut saved = agent(workspace); - saved.harness = HarnessEdit { - integration: Some(crate::HarnessIntegration::Codex), - command: context.adapter.to_string_lossy().into_owned(), - args: vec![], - model: String::new(), - configuration: Some(crate::AiConfiguration::Default), - provider: String::new(), - databricks: None, - }; - saved.environment = BTreeMap::from([("BUZZ_ACP_AGENTS".into(), "3".into())]); - (saved, context) -} - -/// Launch resolves the Codex CLI from the user's tools, so Codex launch tests -/// rerun themselves with a private HOME whose `~/.local/bin/codex` is the -/// fixture CLI. Returns true in that child. -#[cfg(unix)] -fn codex_launch_child(test: &str) -> bool { - const CHILD: &str = "BUZZ_TEST_CODEX_LAUNCH"; - if std::env::var_os(CHILD).is_some() { - return true; - } - let home = tempfile::tempdir().unwrap(); - let output = Command::new(std::env::current_exe().unwrap()) - .args(["--exact", test, "--nocapture"]) - .env_clear() - .env("HOME", home.path()) - .env("PATH", "/usr/bin:/bin") - .env(CHILD, "1") - .env("SSH_AUTH_SOCK", "/tmp/buzz-test-ssh.sock") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - false -} - -#[cfg(unix)] -fn select_codex_cli(context: &crate::codex::CodexContext) { - let user = PathBuf::from(std::env::var_os("HOME").unwrap()).join(".local/bin"); - fs::create_dir_all(&user).unwrap(); - std::os::unix::fs::symlink(&context.cli, user.join("codex")).unwrap(); -} - -fn command_environment(command: &Command) -> BTreeMap { - command - .get_envs() - .filter_map(|(key, value)| { - value.map(|value| { - ( - key.to_string_lossy().into_owned(), - value.to_string_lossy().into_owned(), - ) - }) - }) - .collect() -} - -#[test] -#[cfg(unix)] -fn codex_command_binds_exact_interpreters_and_default_selection() { - if !codex_launch_child( - "runtime::tests::codex_command_binds_exact_interpreters_and_default_selection", - ) { - return; - } - let dir = tempfile::tempdir().unwrap(); - let runtime = bundle(dir.path()); - let (saved, context) = codex_fixture(dir.path()); - assert_ne!(context.interpreter, context.cli_interpreter); - select_codex_cli(&context); - let command = runtime - .command_checked( - &saved, - &Secret::parse(KEY, PUB).unwrap(), - &crate::BuildDefaults { - model: "unrelated-default-model".into(), - provider: "unrelated-default-provider".into(), - ..Default::default() - }, - None, - ) - .unwrap(); - let environment = command_environment(&command); - assert_eq!( - environment["BUZZ_ACP_AGENT_COMMAND"], - context.interpreter.as_ref().unwrap().to_string_lossy() - ); - assert_eq!( - environment["BUZZ_ACP_AGENT_ARGS"], - context.adapter.to_string_lossy() - ); - assert_eq!(environment["CODEX_PATH"], context.cli.to_string_lossy()); - assert_eq!(environment["INITIAL_AGENT_MODE"], "agent-full-access"); - assert_eq!(environment["BUZZ_ACP_AGENTS"], "3"); - assert_eq!(environment["BUZZ_ACP_MCP_COMMAND"], ""); - assert!(!environment.contains_key("BUZZ_ACP_MODEL")); - assert!(!environment.contains_key("BUZZ_ACP_EFFORT_LEVEL")); - assert!(!environment.contains_key("BUZZ_AGENT_MODEL")); - assert!(!environment.contains_key("BUZZ_AGENT_PROVIDER")); - assert_eq!(environment["SSH_AUTH_SOCK"], "/tmp/buzz-test-ssh.sock"); - let path: Vec<_> = std::env::split_paths(&environment["PATH"]).collect(); - assert_eq!(path.first(), Some(&dir.path().to_path_buf())); - assert!(path[0].join("buzz").is_file()); - assert!(path.contains( - &context - .interpreter - .as_ref() - .unwrap() - .parent() - .unwrap() - .to_path_buf() - )); - assert!(path.contains( - &context - .cli_interpreter - .as_ref() - .unwrap() - .parent() - .unwrap() - .to_path_buf() - )); -} - -#[test] -#[cfg(unix)] -fn codex_saved_default_agent_discovers_models_before_advanced_choice() { - if !codex_launch_child( - "runtime::tests::codex_saved_default_agent_discovers_models_before_advanced_choice", - ) { - return; - } - let dir = tempfile::tempdir().unwrap(); - let (saved, context) = codex_fixture(dir.path()); - select_codex_cli(&context); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![saved.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Err("No runtime".into()), - dir.path().join("ownership"), - ); - // Default -> Advanced before a model or effort has been chosen. - let mut harness = saved.harness.clone(); - harness.configuration = Some(crate::AiConfiguration::Advanced { - effort: crate::EffortSelection::Value { - value: String::new(), - }, - }); - let edit: AgentEdit = serde_json::from_value(json!({ - "name": saved.name, "systemPrompt": saved.system_prompt, "workspace": saved.workspace, - "harness": harness, "environment": {} - })) - .unwrap(); - assert!( - controller - .codex_model_context(&saved.id, saved.revision, edit.clone()) - .unwrap() - == context - ); - assert!(controller - .codex_model_context(&saved.id, saved.revision + 1, edit.clone()) - .is_err()); - assert_eq!( - controller - .save(&saved.id, saved.revision, edit) - .err() - .as_deref(), - Some("Choose a model for Advanced Codex configuration") - ); -} - -#[test] -#[cfg(unix)] -fn codex_command_applies_advanced_selection_and_never_falls_back() { - use std::os::unix::fs::PermissionsExt; - if !codex_launch_child( - "runtime::tests::codex_command_applies_advanced_selection_and_never_falls_back", - ) { - return; - } - let dir = tempfile::tempdir().unwrap(); - let runtime = bundle(dir.path()); - let (mut saved, context) = codex_fixture(dir.path()); - select_codex_cli(&context); - saved.harness.configuration = Some(crate::AiConfiguration::Advanced { - effort: crate::EffortSelection::Value { - value: "xhigh".into(), - }, - }); - saved.harness.model = "gpt-test".into(); - let launch = |saved: &Agent| { - runtime.command_checked( - saved, - &Secret::parse(KEY, PUB).unwrap(), - &crate::BuildDefaults::default(), - None, - ) - }; - let environment = command_environment(&launch(&saved).unwrap()); - assert_eq!(environment["BUZZ_ACP_MODEL"], "gpt-test"); - assert_eq!(environment["BUZZ_ACP_EFFORT_LEVEL"], "xhigh"); - - // A removed saved adapter fails Start even when another adapter is - // installed; launch never substitutes a newly discovered one. - let global = PathBuf::from(std::env::var_os("HOME").unwrap()).join(".local/bin/codex-acp"); - fs::write(&global, "#!/bin/sh\nexit 0\n").unwrap(); - fs::set_permissions(&global, fs::Permissions::from_mode(0o700)).unwrap(); - assert_eq!(crate::codex::installed_adapter(None), Some(global)); - fs::remove_file(&saved.harness.command).unwrap(); - assert_eq!( - launch(&saved).unwrap_err(), - "Codex ACP adapter is missing or not executable" - ); -} -/// Windows listener stand-in: `bundle` installs this test binary as -/// buzz-acp.exe, and the guardian runs only this test from that copy. -#[test] -#[cfg(windows)] -fn windows_listener() { - let exe = std::env::current_exe().unwrap(); - if exe.file_stem() != Some("buzz-acp".as_ref()) { - return; - } - let env: BTreeMap<_, _> = std::env::vars() - .map(|(key, value)| (key.to_ascii_uppercase(), value)) - .collect(); - let mut line = serde_json::to_vec(&env).unwrap(); - line.push(b'\n'); - let mut starts = fs::OpenOptions::new() - .create(true) - .append(true) - .open("starts") - .unwrap(); - std::io::Write::write_all(&mut starts, &line).unwrap(); - // Bounded, so a containment failure cannot leave it running indefinitely. - std::thread::sleep(Duration::from_secs(300)); -} -#[test] -#[cfg(windows)] -fn windows_start_restart_shutdown_hold_custody_and_isolate_the_listener() { - let dir = tempfile::tempdir().unwrap(); - // Spaces reach the guardian's argv, the workspace and PATH. - let root = dir.path().join("Buzz profile"); - let tools = root.join("agent runtime"); - fs::create_dir_all(&tools).unwrap(); - let mut store = Store::open(root.join("config")).unwrap(); - let a = agent(&root); - store.insert(vec![a.clone()]).unwrap(); - let ownership = root.join("ownership"); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(&tools)), - ownership.clone(), - ); - let mut runs = Vec::new(); - for action in [Action::Start, Action::Restart] { - let snapshot = controller.action(&a.id, action).unwrap(); - assert!( - matches!(snapshot.agents[0].status, ProcessStatus::Running), - "{:?}", - snapshot.agents[0].error - ); - assert!(crate::ownership::Ownership::acquire(&ownership, &a.id).is_err()); - // Running is acknowledged at spawn, before the listener records its - // environment; wait so the next action cannot kill it before that row. - let count = runs.len() + 1; - runs = wait_for_contents(&root.join("starts"), |text| { - let runs = text - .lines() - .map(|line| serde_json::from_str(line).ok()) - .collect::>>>()?; - (runs.len() == count).then_some(runs) - }); - } - // Restart confirmed the first session's full teardown before starting again. - assert!(!Path::new(&runs[0]["TEMP"]).exists()); - controller.shutdown().unwrap(); - assert!(controller.running.is_empty()); - assert!(!Path::new(&runs[1]["TEMP"]).exists()); - let _released = crate::ownership::Ownership::acquire(&ownership, &a.id).unwrap(); - assert!(controller.store.agents().unwrap()[0].enabled); - // The listener got the launch environment, not the test process's. - let env = &runs[1]; - assert_eq!(env["BUZZ_PRIVATE_KEY"], KEY); - assert_eq!(env["SYSTEMROOT"], std::env::var("SystemRoot").unwrap()); - assert!(std::env::var_os("CARGO_MANIFEST_DIR").is_some()); - assert!(!env.contains_key("CARGO_MANIFEST_DIR")); - let native = std::env::var_os("PATH").unwrap(); - assert_eq!( - std::env::split_paths(&env["PATH"]).collect::>(), - std::iter::once(tools) - .chain(std::env::split_paths(&native)) - .collect::>() - ); -} -#[test] -#[cfg(windows)] -fn windows_refuses_databricks_before_workspace_validation_or_start() { - let refused = Some(crate::connection::DATABRICKS_WINDOWS); - let dir = tempfile::tempdir().unwrap(); - let mut a = agent(dir.path()); - a.harness.provider = "databricks_v2".into(); - let mut valid = a.clone(); - valid.harness.databricks = Some(crate::connection::DatabricksSettings { - host: "https://agent.example".into(), - filter: "agent-*".into(), - }); - // Empty, build-default and valid workspaces are all refused, never validated. - for (saved, defaults) in [ - (&a, crate::BuildDefaults::default()), - (&a, deployment_defaults()), - (&valid, crate::BuildDefaults::default()), - ] { - assert_eq!( - databricks_with_defaults(saved, &defaults).err().as_deref(), - refused - ); - } - let mut openai = a.clone(); - openai.harness.provider = "openai".into(); - assert!(databricks_with_defaults(&openai, &deployment_defaults()) - .unwrap() - .is_none()); - let tools = dir.path().join("tools"); - fs::create_dir_all(&tools).unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(&tools)), - dir.path().join("ownership"), - ); - assert_eq!( - controller.credential_request(&a.id).err().as_deref(), - refused - ); - let snapshot = controller.action(&a.id, Action::Start).unwrap(); - assert_eq!(snapshot.agents[0].error.as_deref(), refused); - assert!(controller.running.is_empty()); - assert!(!dir.path().join("starts").exists()); -} -#[test] -#[cfg(unix)] -fn teardown_reaps_a_worker_in_a_separate_process_group() { - // Python is an isolated fixture only, not a production runtime dependency. - if !Path::new("/usr/bin/python3").is_file() { - panic!("Fixture requires /usr/bin/python3"); - } - let dir = tempfile::tempdir().unwrap(); - let mut command = Command::new("/usr/bin/python3"); - command - .args([ - "-c", - r#"import os, subprocess, time, signal -signal.signal(signal.SIGTERM, signal.SIG_IGN) -# A stopped orphan group may receive SIGHUP when its leader dies. Ignore it so -# killing only the leader cannot accidentally satisfy the containment assertion. -signal.signal(signal.SIGHUP, signal.SIG_IGN) -worker = subprocess.Popen(['/bin/sleep', '60'], preexec_fn=os.setpgrp) -with open('worker.tmp', 'w') as f: f.write(str(os.getpid()) + ' ' + str(worker.pid)) -os.rename('worker.tmp', 'worker') -while True: time.sleep(.1) -"#, - ]) - .current_dir(dir.path()) - .env_clear() - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - let mut process = Process::spawn(&mut command).unwrap(); - let cleanup = FixtureWorkerCleanup(dir.path().join("worker")); - let (leader, pid) = wait_for_contents(&cleanup.0, fixture_worker); - assert_eq!(unsafe { libc::getsid(pid) }, leader); - assert_eq!(unsafe { libc::getpgid(pid) }, pid); - process.stop().unwrap(); - assert!(!process.alive().unwrap()); - // No live worker; a transient zombie awaiting init's reap is already exited. - let output = Command::new("/bin/ps") - .args(["-p", &pid.to_string(), "-o", "stat="]) - .output() - .unwrap(); - let state = String::from_utf8_lossy(&output.stdout); - assert!( - state.trim().is_empty() || state.trim().starts_with('Z'), - "worker survived: {state}" - ); -} - -#[test] -fn attestation_cannot_change_owner_identity_or_conditions() { - let valid = crate::secret::test_attestation(PUB); - assert!(crate::secret::validate_attestation(&valid, PUB).is_ok()); - assert!(crate::secret::validate_attestation(&valid, &"ab".repeat(32)).is_err()); - let mut tag: Vec = serde_json::from_str(&valid).unwrap(); - tag[2] = "kind=9".into(); - assert!( - crate::secret::validate_attestation(&serde_json::to_string(&tag).unwrap(), PUB).is_err() - ); - assert!(crate::secret::validate_attestation("not-a-tag", PUB).is_err()); -} - -#[test] -#[cfg(unix)] -fn start_refuses_an_attestation_for_a_different_agent() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let mut a = agent(dir.path()); - a.auth_tag = Some(crate::secret::test_attestation(&"ab".repeat(32))); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - let failed = controller.action(&a.id, Action::Start).unwrap(); - assert!(matches!(failed.agents[0].status, ProcessStatus::Failed)); - assert_eq!( - failed.agents[0].error.as_deref(), - Some("Owner attestation does not authorize this agent key") - ); - assert_eq!(failed.agents[0].running_revision, None); - assert!(controller.running.is_empty()); - assert!(!dir.path().join("starts").exists()); -} - -#[test] -#[cfg(unix)] -fn stop_reaches_owned_process_when_store_is_malformed_or_row_disappears() { - for contents in ["{malformed", r#"{"version":1,"agents":[]}"#] { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let config = dir.path().join("config"); - let mut store = Store::open(config.clone()).unwrap(); - let a = agent(dir.path()); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - controller.action(&a.id, Action::Start).unwrap(); - wait_for_contents(&dir.path().join("starts"), |text| { - (text.lines().count() == 10).then_some(()) - }); - fs::write(config.join("agents.json"), contents).unwrap(); - assert!( - controller.action(&a.id, Action::Stop).is_err(), - "must report failed durable disable" - ); - assert!( - controller.running.is_empty(), - "Stop must reach owned process despite invalid storage" - ); - assert_eq!( - fs::read_to_string(config.join("agents.json")).unwrap(), - contents - ); - } -} - -#[test] -#[cfg(unix)] -fn delete_stops_the_listener_and_refuses_deployed_remote_records() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - let a = agent(dir.path()); - let mut remote = agent(dir.path()); - remote.relay_url = "wss://remote.example".into(); - remote.id = agent_id(PUB, &remote.relay_url); - remote.imported = serde_json::json!({"record": {"backend": {"type": "provider"}, "backend_agent_id": "deployed"}}); - store.insert(vec![a.clone(), remote.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - controller.action(&a.id, Action::Start).unwrap(); - wait_for_contents(&dir.path().join("starts"), |text| { - (text.lines().count() == 10).then_some(()) - }); - controller.delete(&a.id, a.revision).unwrap(); - assert!(controller.running.is_empty()); - assert!(controller - .delete(&a.id, a.revision) - .err() - .unwrap() - .contains("no longer exists")); - // Base Buzz refuses to orphan a deployed remote agent; the view says so - // before any caller starts work that depends on deletion. - assert!( - remote.view(&Default::default()).deployed_remote - && !a.view(&Default::default()).deployed_remote - ); - assert!(controller - .delete(&remote.id, remote.revision) - .err() - .unwrap() - .contains("Deployed remote agents can't be deleted")); - let remaining = controller.store.agents().unwrap(); - assert_eq!(remaining.len(), 1); - assert_eq!(remaining[0].id, remote.id); - assert_eq!(remaining[0].enabled, remote.enabled); -} - -#[test] -#[cfg(unix)] -fn explicit_provider_environment_wins_and_blank_selectors_do_not_erase_it() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let runtime = bundle(tools.path()); - crate::test_executable::copy_executable( - &tools.path().join("buzz-agent"), - &tools.path().join("goose"), - ); - for (worker, model_key, provider_key) in [ - ("buzz-agent", "BUZZ_AGENT_MODEL", "BUZZ_AGENT_PROVIDER"), - ("goose", "GOOSE_MODEL", "GOOSE_PROVIDER"), - ] { - for selectors in ["", "conflicting-selector"] { - let mut a = agent(dir.path()); - if worker == "goose" { - a.harness.command = tools.path().join(worker).display().to_string(); - } - a.harness.provider = selectors.into(); - a.harness.model = selectors.into(); - a.environment - .insert(provider_key.into(), "databricks".into()); - a.environment - .insert(model_key.into(), "fixture-model".into()); - let command = runtime - .command_with_defaults( - &a, - &Secret::parse(KEY, PUB).unwrap(), - &crate::BuildDefaults::default(), - ) - .unwrap(); - let env: BTreeMap<_, _> = command - .get_envs() - .filter_map(|(k, v)| { - v.map(|v| { - ( - k.to_string_lossy().into_owned(), - v.to_string_lossy().into_owned(), - ) - }) - }) - .collect(); - assert_eq!(env[provider_key], "databricks"); - assert_eq!(env[model_key], "fixture-model"); - assert_eq!(env["BUZZ_ACP_MODEL"], "fixture-model"); - } - } -} - -#[test] -#[cfg(unix)] -fn custom_provider_is_editable_but_requires_external_configuration_at_launch() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let runtime = bundle(tools.path()); - let custom = tools.path().join("custom-acp"); - fs::copy(tools.path().join("buzz-agent"), &custom).unwrap(); - let mut a = agent(dir.path()); - a.harness.command = custom.display().to_string(); - a.validate().unwrap(); - let key = Secret::parse(KEY, PUB).unwrap(); - assert!(runtime - .command_with_defaults(&a, &key, &crate::BuildDefaults::default()) - .unwrap_err() - .contains("a provider selector mapping is not available")); - a.harness.provider.clear(); - assert!(runtime - .command_with_defaults(&a, &key, &crate::BuildDefaults::default()) - .is_ok()); -} - -#[test] -#[cfg(unix)] -fn blank_selectors_without_overrides_leave_harness_defaults_intact() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let runtime = bundle(tools.path()); - let mut a = agent(dir.path()); - a.harness.provider.clear(); - a.harness.model.clear(); - let command = runtime - .command_with_defaults( - &a, - &Secret::parse(KEY, PUB).unwrap(), - &crate::BuildDefaults::default(), - ) - .unwrap(); - let env: BTreeMap<_, _> = command.get_envs().collect(); - for key in ["BUZZ_AGENT_PROVIDER", "BUZZ_AGENT_MODEL", "BUZZ_ACP_MODEL"] { - assert!(!env.contains_key(std::ffi::OsStr::new(key))); - } -} - -#[test] -#[cfg(unix)] -fn hermes_launch_requires_defaults_and_preserves_saved_values_for_recovery() { - let dir = tempfile::tempdir().unwrap(); - let runtime = bundle(dir.path()); - let launcher = dir.path().join("hermes-acp"); - crate::test_executable::copy_executable(&dir.path().join("buzz-agent"), &launcher); - let key = Secret::parse(KEY, PUB).unwrap(); - let mut saved = agent(dir.path()); - saved.harness.command = launcher.to_string_lossy().into_owned(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![saved.clone()]).unwrap(); - let mut recovered = store.agents().unwrap().remove(0); - for (model, provider) in [("mistyped/model", ""), ("", "old-provider")] { - recovered.harness.model = model.into(); - recovered.harness.provider = provider.into(); - assert!(runtime - .command(&recovered, &key) - .unwrap_err() - .contains("Use Hermes Agent defaults")); - } - // Reading and a failed launch must leave the original values repairable. - let unchanged = store.agents().unwrap().remove(0); - assert_eq!(unchanged.harness.model, saved.harness.model); - assert_eq!(unchanged.harness.provider, saved.harness.provider); - recovered.harness.model.clear(); - recovered.harness.provider.clear(); - let mut defaults = crate::agent_defaults::AgentDefaults { - model: "another-harness-model".into(), - provider: "another-provider".into(), - ..Default::default() - }; - defaults - .environment - .insert("BUZZ_ACP_MODEL".into(), "another-model".into()); - let effective = crate::agent_defaults::effective(&recovered, &defaults); - let command = runtime - .command_with_defaults(&effective, &key, &deployment_defaults()) - .unwrap(); - let environment: BTreeMap<_, _> = command.get_envs().collect(); - let env = |name| environment[std::ffi::OsStr::new(name)].unwrap(); - assert_eq!(env("BUZZ_ACP_AGENT_COMMAND"), launcher.as_os_str()); - assert_eq!(env("BUZZ_ACP_AGENT_ARGS"), ""); - assert!(!environment.contains_key(std::ffi::OsStr::new("BUZZ_ACP_MODEL"))); - assert_eq!(command.get_current_dir(), Some(dir.path())); - recovered - .environment - .insert("BUZZ_ACP_MODEL".into(), "override".into()); - assert!(runtime - .command(&recovered, &key) - .unwrap_err() - .contains("host-reserved")); -} - -#[test] -fn launch_path_puts_bundled_tools_before_platform_tools() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let command = bundle(tools.path()) - .command(&agent(dir.path()), &Secret::parse(KEY, PUB).unwrap()) - .unwrap(); - let env: BTreeMap<_, _> = command.get_envs().collect(); - let path = env[std::ffi::OsStr::new("PATH")].unwrap(); - let paths = std::env::split_paths(path).collect::>(); - assert_eq!(paths.first(), Some(&tools.path().to_owned())); - if cfg!(windows) { - let mut expected = vec![tools.path().to_owned()]; - expected.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap())); - assert!(env.contains_key(std::ffi::OsStr::new("SystemRoot"))); - assert_eq!(paths, expected); - } else { - for dir in [ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - ] { - assert!(paths.contains(&PathBuf::from(dir)), "launch omitted {dir}"); - } - if let Some(home) = std::env::var_os("HOME") - .map(PathBuf::from) - .filter(|h| h.is_absolute()) - { - assert!(paths.contains(&home.join(".local/bin"))); - } - for dir in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) - .filter(|p| p.is_absolute()) - { - assert!( - paths.contains(&dir), - "launch omitted inherited directory {dir:?}" - ); - } - assert!(paths.iter().all(|p| p.is_absolute())); - assert_eq!( - paths.iter().collect::>().len(), - paths.len() - ); - } -} - -#[test] -#[cfg(unix)] -fn claude_launch_uses_its_node_and_preserves_harness_defaults() { - let dir = tempfile::Builder::new() - .prefix("Claude tools ") - .tempdir() - .unwrap(); - let runtime = bundle(dir.path()); - let key = Secret::parse(KEY, PUB).unwrap(); - let mut saved = agent(dir.path()); - saved.harness.model.clear(); - saved.harness.provider.clear(); - let bin = dir.path().join("claude-tools/bin"); - fs::create_dir_all(&bin).unwrap(); - let adapter = bin.join("claude-agent-acp"); - let cli = bin.join("claude"); - for path in [&adapter, &cli] { - fs::copy(dir.path().join("buzz-agent"), path).unwrap(); - } - let node = dir.path().join("runtimes/node/v24.18.0").join( - match (std::env::consts::OS, std::env::consts::ARCH) { - ("macos", "aarch64") => "darwin-arm64/bin/node", - ("macos", _) => "darwin-x64/bin/node", - ("linux", "aarch64") => "linux-arm64/bin/node", - _ => "linux-x64/bin/node", - }, - ); - fs::create_dir_all(node.parent().unwrap()).unwrap(); - fs::copy(dir.path().join("buzz-agent"), &node).unwrap(); - saved.harness.command = adapter.to_string_lossy().into_owned(); - let command = runtime - .command_with_defaults(&saved, &key, &deployment_defaults()) - .unwrap(); - let environment: BTreeMap<_, _> = command.get_envs().collect(); - let env = |name| environment[std::ffi::OsStr::new(name)].unwrap(); - assert_eq!(env("BUZZ_ACP_AGENT_COMMAND"), adapter.as_os_str()); - assert_eq!(env("BUZZ_ACP_AGENT_ARGS"), ""); - let path: Vec<_> = std::env::split_paths(env("PATH")).collect(); - assert_eq!( - &path[..3], - &[dir.path(), node.parent().unwrap(), bin.as_path()] - ); - assert!(!environment.contains_key(std::ffi::OsStr::new("BUZZ_ACP_MODEL"))); - assert_eq!(env("CLAUDE_CODE_EXECUTABLE"), cli.as_os_str()); - saved - .environment - .insert("CLAUDE_CODE_EXECUTABLE".into(), "/custom/claude".into()); - let override_command = runtime.command(&saved, &key).unwrap(); - assert!(override_command - .get_envs() - .any(|(name, value)| name == "CLAUDE_CODE_EXECUTABLE" - && value == Some(std::ffi::OsStr::new("/custom/claude")))); - saved.harness.model = "old-model".into(); - assert!(runtime - .command(&saved, &key) - .unwrap_err() - .contains("Use Claude Code defaults")); - saved.harness.model.clear(); - fs::remove_file(&cli).unwrap(); - // The managed Node remains required even when another installation is on PATH. - fs::remove_file(node).unwrap(); - assert!(runtime - .command(&saved, &key) - .unwrap_err() - .contains("Install Node.js for Claude Code")); -} - -#[test] -#[cfg(unix)] -fn start_and_restart_reject_missing_saved_identities() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let mut controller = Controller::new( - Store::open(dir.path().join("config")).unwrap(), - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - for action in [Action::Start, Action::Restart] { - assert!(controller.action("missing", action).is_err()); - } - assert!(controller.running.is_empty()); -} - -#[test] -fn external_claude_launch_resolves_node_and_avoids_windows_batch_cli_overrides() { - const FIXTURE: &str = "BUZZ_CLAUDE_LAUNCH_FIXTURE"; - let Some(root) = std::env::var_os(FIXTURE) else { - let dir = tempfile::Builder::new() - .prefix("External Claude ") - .tempdir() - .unwrap(); - let output = Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "runtime::tests::external_claude_launch_resolves_node_and_avoids_windows_batch_cli_overrides", "--nocapture"]) - .env(FIXTURE, dir.path()) - .env("HOME", dir.path().join("home")) - .env("PATH", dir.path().join("tools")) - .output().unwrap(); - assert!( - output.status.success(), - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - return; - }; - let root = PathBuf::from(root); - crate::prepare_tools_path(); - let runtime = bundle(&root); - let bin = root.join("tools"); - fs::create_dir_all(&bin).unwrap(); - let adapter = bin.join(if cfg!(windows) { - "claude-agent-acp.cmd" - } else { - "claude-agent-acp" - }); - let cli = bin.join(if cfg!(windows) { - "claude.cmd" - } else { - "claude" - }); - let node = bin.join(if cfg!(windows) { "node.exe" } else { "node" }); - let fixture = root.join(format!("buzz-agent{}", std::env::consts::EXE_SUFFIX)); - for path in [&adapter, &cli, &node] { - fs::copy(&fixture, path).unwrap(); - } - let mut saved = agent(&root); - saved.harness.command = adapter.to_string_lossy().into_owned(); - saved.harness.model.clear(); - saved.harness.provider.clear(); - let key = Secret::parse(KEY, PUB).unwrap(); - let command = runtime.command(&saved, &key).unwrap(); - let env: BTreeMap<_, _> = command.get_envs().collect(); - assert_eq!( - env[std::ffi::OsStr::new("BUZZ_ACP_AGENT_COMMAND")], - Some(adapter.as_os_str()) - ); - let path: Vec<_> = std::env::split_paths(env[std::ffi::OsStr::new("PATH")].unwrap()).collect(); - assert_eq!(path[1], bin); - if cfg!(windows) { - assert!(!env.contains_key(std::ffi::OsStr::new("CLAUDE_CODE_EXECUTABLE"))); - let native = bin.join("claude.exe"); - fs::copy(&fixture, &native).unwrap(); - let command = runtime.command(&saved, &key).unwrap(); - assert!(command.get_envs().any( - |(key, value)| key == "CLAUDE_CODE_EXECUTABLE" && value == Some(native.as_os_str()) - )); - } else { - assert_eq!( - env[std::ffi::OsStr::new("CLAUDE_CODE_EXECUTABLE")], - Some(cli.as_os_str()) - ); - } - fs::remove_file(&cli).unwrap(); - if cfg!(windows) { - fs::remove_file(bin.join("claude.exe")).unwrap(); - } - let custom_cli = root.join(format!("custom-claude{}", std::env::consts::EXE_SUFFIX)); - fs::copy(&fixture, &custom_cli).unwrap(); - saved.environment.insert( - "CLAUDE_CODE_EXECUTABLE".into(), - custom_cli.to_string_lossy().into_owned(), - ); - let command = runtime.command(&saved, &key).unwrap(); - assert!(command.get_envs().any(|(name, value)| { - name == "CLAUDE_CODE_EXECUTABLE" && value == Some(custom_cli.as_os_str()) - })); -} - -#[test] -#[cfg(unix)] -fn shared_cache_spawn_capture_disconnect_snapshot_and_private_temp_cleanup() { - use crate::connection::DatabricksSettings; - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let mut a = agent(dir.path()); - a.harness.provider = "databricks_v2".into(); - a.harness.databricks = Some(DatabricksSettings { - host: "https://EXAMPLE.com:443/".into(), - filter: "foo*".into(), - }); - // TMPDIR and PATH can be supplied by settings, but the app's private runtime - // storage and bundled tools are authoritative. - a.environment - .insert("TMPDIR".into(), "/unusable-user-override".into()); - let config = dir.path().join("config"); - let mut store = Store::open(config.clone()).unwrap(); - store.insert(vec![a.clone()]).unwrap(); - let runtime = bundle(tools.path()); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(runtime), - dir.path().join("ownership"), - ); - let started = controller.action(&a.id, Action::Start).unwrap(); - assert!(matches!(started.agents[0].status, ProcessStatus::Running)); - let env = wait_for_contents(&dir.path().join("runtime-env"), |text| { - (text.lines().count() == 6).then(|| text.lines().map(str::to_owned).collect::>()) - }); - assert_eq!(env[0], config.to_str().unwrap()); - assert_eq!(env[1], "https://example.com"); - assert_eq!(env[2], "foo*"); - assert_eq!(env[3], "unset"); - assert!(env[5].starts_with(tools.path().to_str().unwrap())); - let run = &controller.running[&a.id]; - assert_eq!(run.databricks_host.as_deref(), Some("https://example.com")); - let temp = run.temporary.as_ref().unwrap().to_owned(); - assert!(temp.starts_with(config.join("runs"))); - assert_eq!(env[4], temp.join("tmp").to_str().unwrap()); - let cache = config.join("buzz-agent/oauth/databricks"); - assert!(cache.is_dir()); - let edit = AgentEdit { - effort: None, - picture: None, - name: a.name.clone(), - system_prompt: a.system_prompt.clone(), - session_policy: Some(None), - workspace: a.workspace.clone(), - harness: HarnessEdit { - databricks: Some(DatabricksSettings { - host: "https://other.example.com".into(), - filter: "".into(), - }), - ..a.harness.clone() - }, - environment: BTreeMap::new(), - }; - controller.save(&a.id, 1, edit).unwrap(); - assert!(controller - .disconnect("https://example.com") - .unwrap_err() - .contains("Stop agents")); - controller.disconnect("https://other.example.com").unwrap(); - controller.action(&a.id, Action::Stop).unwrap(); - assert!(!temp.exists()); - controller.disconnect("https://example.com").unwrap(); -} - -#[test] -#[cfg(unix)] -fn bundle_rejects_source_revisions_different_from_the_runtime_spec() { - let tools = tempfile::tempdir().unwrap(); - bundle(tools.path()); - let path = tools.path().join("manifest.json"); - let original: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - for pointer in ["/revision", "/goose/revision"] { - let mut manifest = original.clone(); - *manifest.pointer_mut(pointer).unwrap() = json!("0".repeat(40)); - fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap(); - assert!(matches!( - RuntimeBundle::new(tools.path().into()), - Err(error) if error == "Runtime target/revision does not match this app" - )); - } -} - -#[test] -#[cfg(unix)] -fn bundle_accepts_the_dev_goose_profile() { - let tools = tempfile::tempdir().unwrap(); - bundle(tools.path()); - let path = tools.path().join("manifest.json"); - let original: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - let source: serde_json::Value = - serde_json::from_str(include_str!("../../../../runtime/agent-runtime.json")).unwrap(); - for (profile, accepted) in [ - (source["gooseDevProfile"].clone(), true), - (json!("dev-other"), false), - ] { - let mut manifest = original.clone(); - manifest["goose"]["profile"] = profile; - fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap(); - assert_eq!(RuntimeBundle::new(tools.path().into()).is_ok(), accepted); - } -} - -#[test] -#[cfg(unix)] -fn snapshots_project_configured_paths_but_starts_reverify_each_executable() { - for name in ["buzz-acp", "buzz-dev-mcp", "buzz-agent"] { - for removed in [false, true] { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let a = agent(dir.path()); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - let path = tools.path().join(name); - if removed { - fs::remove_file(path).unwrap(); - } else { - fs::write(path, "tampered").unwrap(); - } - // Projection retains configured paths even when runtime files cannot - // be verified. No status poll may treat these as launch authority. - let before = controller.snapshot().unwrap(); - assert!(before.runtime_available); - assert_eq!( - before.agents[0].acp_command.as_deref(), - tools.path().join("buzz-acp").to_str() - ); - assert_eq!( - before.agents[0].mcp_command.as_deref(), - tools.path().join("buzz-dev-mcp").to_str() - ); - let key = Secret::parse(KEY, PUB).unwrap(); - controller - .action_with_key(&a.id, Action::Start, 1, &key, None) - .unwrap(); - let after = controller.snapshot().unwrap(); - assert!(matches!(after.agents[0].status, ProcessStatus::Failed)); - assert!(after.agents[0] - .error - .as_deref() - .unwrap() - .contains(if removed { "missing" } else { "integrity" })); - assert!(after.agents[0].running_revision.is_none()); - assert!(controller.running.is_empty()); - assert!(!dir.path().join("starts").exists()); - } - } -} - -#[test] -#[cfg(unix)] -fn manifest_integrity_and_exact_identity_exclusion_across_profiles() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let a = agent(dir.path()); - let mut first = Store::open(dir.path().join("first")).unwrap(); - first.insert(vec![a.clone()]).unwrap(); - let mut second = Store::open(dir.path().join("second")).unwrap(); - second.insert(vec![a.clone()]).unwrap(); - let shared = dir.path().join("ownership"); - let mut first = Controller::new( - first, - Arc::new(Memory), - Ok(bundle(tools.path())), - shared.clone(), - ); - let mut second = Controller::new( - second, - Arc::new(Memory), - Ok(RuntimeBundle::new(tools.path().into()).unwrap()), - shared, - ); - first.action(&a.id, Action::Start).unwrap(); - let blocked = second.action(&a.id, Action::Start).unwrap(); - assert!(blocked.agents[0] - .error - .as_deref() - .unwrap() - .contains("Another buzz-app profile")); - first.action(&a.id, Action::Stop).unwrap(); - assert!(matches!( - second.action(&a.id, Action::Start).unwrap().agents[0].status, - ProcessStatus::Running - )); - second.action(&a.id, Action::Stop).unwrap(); - fs::write(tools.path().join("buzz-agent"), "tampered").unwrap(); - let failed = second.action(&a.id, Action::Start).unwrap(); - assert!(failed.agents[0] - .error - .as_deref() - .unwrap() - .contains("integrity")); - assert!(second.running.is_empty()); - assert!(RuntimeBundle::new(tools.path().into()).is_err()); -} - -#[test] -#[cfg(unix)] -#[ignore = "requires immutable staged runtime resources; run explicitly after build-agent-runtime"] -fn actual_bundled_acp_lazy_listener_start_restart_stop_and_quit_cleanup() { - let dir = tempfile::tempdir().unwrap(); - let tools = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../src-tauri/resources/agent-runtime") - .canonicalize() - .unwrap(); - let mut a = agent(dir.path()); - a.harness.provider = "databricks_v2".into(); - a.harness.databricks = Some(crate::connection::DatabricksSettings { - host: "https://workspace.example.invalid".into(), - filter: "".into(), - }); - // Bind/retain a closed-to-WS listener locally: no external relay or live agent. - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - a.relay_url = format!("wss://127.0.0.1:{}", listener.local_addr().unwrap().port()); - a.id = agent_id(PUB, &a.relay_url); - let config = dir.path().join("config"); - let mut store = Store::open(config.clone()).unwrap(); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - RuntimeBundle::new(tools), - dir.path().join("ownership"), - ); - for action in [Action::Start, Action::Restart] { - let result = controller.action(&a.id, action).unwrap(); - assert!( - matches!(result.agents[0].status, ProcessStatus::Running), - "{:?}", - result.agents[0].error - ); - std::thread::sleep(Duration::from_millis(250)); - let result = controller.snapshot().unwrap(); - assert!( - matches!(result.agents[0].status, ProcessStatus::Running), - "{:?}", - result.agents[0].error - ); - } - let temp = controller.running[&a.id] - .temporary - .as_ref() - .unwrap() - .to_owned(); - assert!(temp.exists()); - controller.action(&a.id, Action::Stop).unwrap(); - assert!(!temp.exists()); - assert!(!controller.store.agents().unwrap()[0].enabled); - controller.action(&a.id, Action::Start).unwrap(); - let temp = controller.running[&a.id] - .temporary - .as_ref() - .unwrap() - .to_owned(); - controller.shutdown().unwrap(); - assert!(!temp.exists()); - assert!(controller.store.agents().unwrap()[0].enabled); - assert!(controller.running.is_empty()); -} - -#[test] -#[cfg(unix)] -fn mention_start_forwards_replay_floor_without_persisting_or_restoring_it() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - let a = agent(dir.path()); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - let key = Secret::parse(KEY, PUB).unwrap(); - controller - .action_with_key(&a.id, Action::Start, 1, &key, Some(1234567890)) - .unwrap(); - let result = controller.snapshot().unwrap(); - assert!(result.agents[0].enabled); - assert!(matches!(result.agents[0].status, ProcessStatus::Running)); - let output = wait_for_contents(&dir.path().join("starts"), |text| { - (text.lines().count() == 10).then(|| text.to_owned()) - }); - assert_eq!(output.lines().nth(8), Some("1234567890")); - assert!(!fs::read_to_string(dir.path().join("config/agents.json")) - .unwrap() - .contains("1234567890")); - controller.shutdown().unwrap(); - controller.restore().unwrap(); - let output = wait_for_contents(&dir.path().join("starts"), |text| { - (text.lines().count() == 20).then(|| text.to_owned()) - }); - assert_eq!(output.lines().nth(18), Some("")); - controller.action(&a.id, Action::Stop).unwrap(); -} - -fn deployment_defaults() -> crate::BuildDefaults { - crate::BuildDefaults { - host: "https://build.example.com".into(), - filter: "team-*".into(), - model: "build-model".into(), - provider: "databricks_v2".into(), - owner_only: true, - } -} - -#[cfg(unix)] -#[test] -fn build_floor_agrees_at_command_oauth_and_discovery_without_rewriting_saved_agent() { - let dir = tempfile::tempdir().unwrap(); - let bundle = bundle(dir.path()); - let mut agent = agent(dir.path()); - agent.harness.provider.clear(); - agent.harness.model.clear(); - agent.imported["record"]["respond_to"] = json!("anyone"); - let before = serde_json::to_value(&agent).unwrap(); - let defaults = deployment_defaults(); - let key = Secret::parse(KEY, PUB).unwrap(); - let command = bundle - .command_with_defaults(&agent, &key, &defaults) - .unwrap(); - let env: BTreeMap<_, _> = command - .get_envs() - .map(|(k, v)| (k.to_str().unwrap(), v.and_then(|v| v.to_str()))) - .collect(); - assert_eq!(env["BUZZ_AGENT_PROVIDER"], Some("databricks_v2")); - assert_eq!(env["BUZZ_AGENT_MODEL"], Some("build-model")); - assert_eq!(env["BUZZ_ACP_MODEL"], Some("build-model")); - assert_eq!(env["BUZZ_ACP_RESPOND_TO"], Some("owner-only")); - assert_eq!(env["BUZZ_ACP_SESSION_POLICY"], Some("thread")); - assert_eq!(env["BUZZ_ACP_ALLOWED_RESPOND_TO"], Some("owner-only")); - assert_eq!( - env.get("BUZZ_ACP_RESPOND_TO_ALLOWLIST").copied().flatten(), - None - ); - let settings = databricks_with_defaults(&agent, &defaults) - .unwrap() - .unwrap(); - let context = - model_context_with_defaults(&agent.harness, &agent.environment, &defaults).unwrap(); - assert_eq!(context.host.as_deref(), Some(settings.host.as_str())); - assert_eq!(context.filter.as_deref(), Some(settings.filter.as_str())); - assert_eq!(settings.host, defaults.host); - assert_eq!(serde_json::to_value(&agent).unwrap(), before); - // Default-on is a presence capability; unmarked builds retain imported policy. - let public = crate::BuildDefaults::default(); - let command = bundle.command_with_defaults(&agent, &key, &public).unwrap(); - assert!(command - .get_envs() - .any(|(k, v)| k == "BUZZ_ACP_RESPOND_TO" && v == Some(std::ffi::OsStr::new("anyone")))); - agent.session_policy = Some(crate::config::SessionPolicy::Thread); - let command = bundle.command_with_defaults(&agent, &key, &public).unwrap(); - assert!(command - .get_envs() - .any(|(k, v)| k == "BUZZ_ACP_SESSION_POLICY" && v == Some(std::ffi::OsStr::new("thread")))); -} - -#[test] -#[cfg(unix)] // Windows: see windows_refuses_databricks_before_workspace_validation_or_start -fn databricks_workspace_errors_distinguish_missing_configuration_from_invalid_origins() { - let dir = tempfile::tempdir().unwrap(); - let mut agent = agent(dir.path()); - agent.harness.provider = "databricks_v2".into(); - let defaults = crate::BuildDefaults::default(); - let error = databricks_with_defaults(&agent, &defaults).err().unwrap(); - assert!(error.contains("Databricks workspace is not configured")); - assert!(error.contains("Edit the agent, open Advanced → Model")); - assert!(error.contains("Databricks workspace (HTTPS origin)")); - // Empty offline drafts remain saveable; runtime validation supplies guidance. - crate::connection::DatabricksSettings::default() - .validate() - .unwrap(); - for host in [ - "http://workspace.example", - "https://user:SYNTHETIC@workspace.example", - "https://workspace.example/path", - ] { - agent - .environment - .insert("DATABRICKS_HOST".into(), host.into()); - let error = databricks_with_defaults(&agent, &defaults).err().unwrap(); - assert!(error.contains("Enter a Databricks HTTPS workspace origin")); - assert!(!error.contains("SYNTHETIC")); - assert!(!error.contains("not configured")); - } - agent.environment.insert( - "DATABRICKS_HOST".into(), - "https://WORKSPACE.example:443/".into(), - ); - assert_eq!( - databricks_with_defaults(&agent, &defaults) - .unwrap() - .unwrap() - .host, - "https://workspace.example" - ); -} - -#[test] -#[cfg(unix)] // Windows: see windows_refuses_databricks_before_workspace_validation_or_start -fn saved_selectors_and_environment_override_build_floor_including_empty() { - let dir = tempfile::tempdir().unwrap(); - let mut agent = agent(dir.path()); - let defaults = deployment_defaults(); - agent.harness.provider = "databricks_v2".into(); - agent.harness.databricks = Some(crate::connection::DatabricksSettings { - host: "https://saved.example.com".into(), - filter: "".into(), - }); - let harness = defaults.resolve(&agent.harness, &agent.environment); - assert_eq!(harness.model, "test-model"); - assert_eq!( - databricks_with_defaults(&agent, &defaults) - .unwrap() - .unwrap() - .host, - "https://saved.example.com" - ); - agent.environment.insert( - "DATABRICKS_HOST".into(), - "https://override.example.com".into(), - ); - let context = - model_context_with_defaults(&agent.harness, &agent.environment, &defaults).unwrap(); - assert_eq!( - context.host.as_deref(), - Some("https://override.example.com") - ); - assert_eq!(context.filter.as_deref(), Some("")); - agent - .environment - .insert("BUZZ_AGENT_MODEL".into(), "".into()); - assert!(defaults - .resolve(&agent.harness, &agent.environment) - .model - .is_empty()); - agent - .environment - .insert("DATABRICKS_HOST".into(), "".into()); - assert!(databricks_with_defaults(&agent, &defaults).is_err()); - agent - .environment - .insert("BUZZ_AGENT_PROVIDER".into(), "".into()); - assert!(databricks_with_defaults(&agent, &defaults) - .unwrap() - .is_none()); - assert!(model_context_with_defaults(&agent.harness, &agent.environment, &defaults).is_err()); - agent.environment.remove("BUZZ_AGENT_PROVIDER"); - agent - .environment - .insert("DATABRICKS_TOKEN".into(), "SYNTHETIC".into()); - assert!(databricks_with_defaults(&agent, &defaults).is_err()); - assert!(model_context_with_defaults(&agent.harness, &agent.environment, &defaults).is_err()); -} - -#[test] -fn launch_selectors_show_defaults_blanks_and_overrides() { - let dir = tempfile::tempdir().unwrap(); - let mut agent = agent(dir.path()); - agent.harness.provider.clear(); - agent.harness.model.clear(); - let defaults = deployment_defaults(); - let resolved = defaults.resolve(&agent.harness, &agent.environment); - let blank = crate::defaults::selectors(&resolved, &agent.environment); - assert_eq!(blank.provider, Some(defaults.provider.as_str())); - assert_eq!(blank.model, Some(defaults.model.as_str())); - // Saved selector plus an explicit override: the override is launched. - agent.harness.model = "saved-model".into(); - agent - .environment - .insert("BUZZ_AGENT_MODEL".into(), "override-model".into()); - // The view names the deciding override key, never its value. - let view = agent.view(&Default::default()); - assert_eq!(view.harness.model, "saved-model"); - assert_eq!(view.launch_model, None); - assert_eq!(view.launch_model_env, Some("BUZZ_AGENT_MODEL")); - agent.harness.command = "/usr/local/bin/goose".into(); - agent.environment.clear(); - agent.harness.provider = "saved-provider".into(); - agent - .environment - .insert("GOOSE_PROVIDER".into(), "override-provider".into()); - let view = agent.view(&Default::default()); - assert_eq!(view.launch_model.as_deref(), Some("saved-model")); - assert_eq!(view.launch_model_env, None); - assert_eq!(view.launch_provider, None); - assert_eq!(view.launch_provider_env, Some("GOOSE_PROVIDER")); - agent.harness.model.clear(); - agent.environment.clear(); - agent.harness.provider.clear(); - let view = agent.view(&Default::default()); - assert!(view.launch_model.is_none() && view.launch_provider.is_none()); -} - -#[test] -fn external_harnesses_never_receive_buzz_agent_build_defaults() { - let dir = tempfile::tempdir().unwrap(); - let mut agent = agent(dir.path()); - agent.harness.command = "/usr/local/bin/goose".into(); - agent.harness.provider.clear(); - agent.harness.model.clear(); - let harness = deployment_defaults().resolve(&agent.harness, &agent.environment); - assert!(harness.provider.is_empty()); - assert!(harness.model.is_empty()); - assert!(harness.databricks.is_none()); -} - -#[test] -#[cfg(unix)] -fn goose_model_context_uses_effective_draft_provider_without_projecting_secrets() { - let dir = tempfile::tempdir().unwrap(); - let goose = dir.path().join("goose"); - crate::test_executable::write_executable(&goose, "#!/bin/sh\nexit 0\n"); - let controller = Controller::new( - Store::open(dir.path().join("config")).unwrap(), - Arc::new(Memory), - Err("Runtime bundle is missing".into()), - dir.path().join("ownership"), - ); - let edit = |override_provider: Option<&str>| AgentEdit { - effort: None, - picture: None, - name: "Goose".into(), - system_prompt: String::new(), - session_policy: Some(None), - workspace: dir.path().display().to_string(), - harness: HarnessEdit { - integration: None, - command: goose.display().to_string(), - args: vec!["acp".into()], - model: "short-name".into(), - configuration: None, - provider: "databricks_v2".into(), - databricks: None, - }, - environment: BTreeMap::from([ - ( - "DATABRICKS_HOST".into(), - Some("https://workspace.example".into()), - ), - ("GOOSE_MODEL".into(), Some("effective-model".into())), - ( - "GOOSE_PROVIDER".into(), - override_provider.map(str::to_owned), - ), - ]), - }; - let context = controller.draft_goose_model_context(edit(None)).unwrap(); - assert_eq!(context.command, goose); - assert_eq!(context.args, ["acp"]); - assert_eq!(context.provider_id, "databricks_v2"); - assert_eq!(context.model_id, "effective-model"); - assert_eq!(context.workspace, dir.path()); - assert!(context.model_overridden); - assert_eq!( - context.environment["DATABRICKS_HOST"], - "https://workspace.example" - ); - assert!(!context.environment.contains_key("GOOSE_PROVIDER")); - assert_eq!( - controller - .draft_goose_model_context(edit(Some("openai"))) - .unwrap() - .provider_id, - "openai" - ); -} - -#[test] -fn bundled_goose_launch_and_model_lookup_share_the_verified_sidecar() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let runtime = bundle(tools.path()); - let mut saved = agent(dir.path()); - saved.harness.provider = "openai".into(); - saved.harness.model = "fixture-model".into(); - let key = Secret::parse(KEY, PUB).unwrap(); - let sidecar = runtime.display_path("goose-acp").unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - saved.harness.command = "goose".into(); - saved.harness.args = vec!["acp".into()]; - store.insert(vec![saved.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(runtime), - dir.path().join("ownership"), - ); - for (selection, args, expected) in [ - ("goose", vec![], ""), - ("goose", vec!["acp".into()], ""), - ("goose", vec![" acp ".into()], ""), - ("goose", vec![" ".into(), " AcP ".into()], ""), - ( - "goose-acp", - vec!["acp".into(), "--with-builtin".into(), "developer".into()], - "--with-builtin,developer", - ), - ] { - saved.harness.command = selection.into(); - saved.harness.args = args; - let command = controller - .bundle - .as_ref() - .unwrap() - .command_with_defaults(&saved, &key, &crate::BuildDefaults::default()) - .unwrap(); - let env: BTreeMap<_, _> = command - .get_envs() - .filter_map(|(k, v)| { - v.map(|v| { - ( - k.to_string_lossy().into_owned(), - v.to_string_lossy().into_owned(), - ) - }) - }) - .collect(); - assert_eq!(env["BUZZ_ACP_AGENT_COMMAND"], sidecar.to_str().unwrap()); - assert_eq!(env["BUZZ_ACP_AGENT_ARGS"], expected); - assert_eq!(env["GOOSE_PROVIDER"], "openai"); - assert_eq!(env["GOOSE_MODEL"], "fixture-model"); - } - let edit = || AgentEdit { - effort: None, - name: saved.name.clone(), - picture: None, - system_prompt: saved.system_prompt.clone(), - session_policy: Some(None), - workspace: saved.workspace.clone(), - harness: saved.harness.clone(), - environment: BTreeMap::new(), - }; - let context = controller - .goose_model_context(&saved.id, saved.revision, edit()) - .unwrap(); - assert_eq!(context.command, sidecar); - assert!(context.args.is_empty(), "the sidecar runs ACP directly"); - assert!(!context.environment.contains_key("BUZZ_PRIVATE_KEY")); - fs::write(&sidecar, b"corrupt").unwrap(); - assert!(controller - .goose_model_context(&saved.id, saved.revision, edit()) - .is_err()); - let snapshot = controller.action(&saved.id, Action::Start).unwrap(); - assert!(matches!(snapshot.agents[0].status, ProcessStatus::Failed)); - assert!( - controller.running.is_empty(), - "a corrupt sidecar must not fall back to PATH" - ); -} - -#[test] -#[cfg(unix)] -fn opted_in_harnesses_receive_the_developer_mcp() { - for (selection, bundled, expects_mcp) in [ - ("buzz-agent", true, true), - ("buzz-agent", false, true), - ("buzz-agent.exe", false, true), - ("goose", true, false), - ("goose-acp", true, false), - ("goose-acp", false, false), - ("buzz-pi-acp", false, false), - ("claude-agent-acp", false, false), - ("codex-acp", false, false), - ("hermes-acp", false, true), - ("hermes-acp.exe", false, true), - ("hermes-acp.cmd", false, true), - ("hermes-acp.bat", false, true), - ("custom-acp", false, false), - ] { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let runtime = bundle(tools.path()); - let worker = tools.path().join(selection); - if !worker.exists() { - fs::copy(tools.path().join("buzz-agent"), &worker).unwrap(); - } - for name in ["pi", "node"] { - crate::test_executable::write_executable( - &tools.path().join(name), - "#!/bin/sh\nprintf '0.99.1\\n'\n", - ); - } - let mut saved = agent(dir.path()); - saved.harness.command = if bundled { - selection.into() - } else { - worker.display().to_string() - }; - saved.harness.provider.clear(); - saved.harness.model.clear(); - if selection == "claude-agent-acp" { - saved.environment.insert( - "CLAUDE_CODE_EXECUTABLE".into(), - tools.path().join("claude").display().to_string(), - ); - } - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![saved.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(runtime), - dir.path().join("ownership"), - ); - let expected = expects_mcp.then(|| tools.path().join("buzz-dev-mcp").display().to_string()); - assert_eq!( - controller.snapshot().unwrap().agents[0].mcp_command, - expected, - "{selection}: reported MCP server" - ); - let key = Secret::parse(KEY, PUB).unwrap(); - let mut command = controller - .bundle - .as_ref() - .unwrap() - .command(&saved, &key) - .unwrap(); - fs::write(dir.path().join("exit-listener"), "").unwrap(); - command.env("BUZZ_AGENT_CONFIG_DIR", dir.path()); - assert!(command.output().unwrap().status.success(), "{selection}"); - assert_eq!( - fs::read_to_string(dir.path().join("mcp-command")).unwrap(), - expected.unwrap_or_default(), - "{selection}: launched MCP server" - ); - } -} - -#[test] -#[cfg(unix)] // Windows: see windows_refuses_databricks_before_workspace_validation_or_start -fn discovery_accepts_only_v2_from_saved_environment_or_build_provider() { - let dir = tempfile::tempdir().unwrap(); - for provider in ["databricks_v2", "databricks-v2", "databricks"] { - for source in ["saved", "environment", "build"] { - let mut agent = agent(dir.path()); - let mut defaults = deployment_defaults(); - agent.harness.provider.clear(); - match source { - "saved" => agent.harness.provider = provider.into(), - "environment" => { - agent - .environment - .insert("BUZZ_AGENT_PROVIDER".into(), provider.into()); - } - _ => defaults.provider = provider.into(), - } - let context = - model_context_with_defaults(&agent.harness, &agent.environment, &defaults); - assert_eq!( - context.is_ok(), - provider != "databricks", - "{source}: {provider}" - ); - // Legacy manual selection still resolves the same OAuth workspace for Start. - assert_eq!( - databricks_with_defaults(&agent, &defaults) - .unwrap() - .unwrap() - .host, - defaults.host - ); - } - } -} - -#[test] -#[cfg(unix)] -fn pi_launch_rejects_old_user_global_cli() { - // Keep the parent-credential fixture out of the parallel test process. - if std::env::var("BUZZ_PRIVATE_KEY").as_deref() != Ok("synthetic-version-probe-test") { - let output = Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "runtime::tests::pi_launch_rejects_old_user_global_cli", - ]) - .env("BUZZ_PRIVATE_KEY", "synthetic-version-probe-test") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stdout) - ); - return; - } - let dir = tempfile::tempdir().unwrap(); - let adapter = dir.path().join("buzz-pi-acp"); - let pi = dir.path().join("pi"); - for path in [&adapter, &pi, &dir.path().join("node")] { - crate::test_executable::write_executable(path, "#!/bin/sh\nexit 0\n"); - } - let runtime = bundle(dir.path()); - let mut selected = agent(dir.path()); - selected.harness.command = adapter.display().to_string(); - let key = Secret::parse(KEY, PUB).unwrap(); - for (version, accepted) in [ - ("0.87.1", false), - ("0.99.0", true), - ("0.99.1", true), - ("0.99.0-beta.1", false), - ] { - fs::write( - &pi, - format!("#!/bin/sh\n[ -z \"$BUZZ_PRIVATE_KEY\" ] || exit 1\nprintf '{version}\\n'\n"), - ) - .unwrap(); - let launch = runtime.command(&selected, &key); - if accepted { - let launch = launch.unwrap(); - assert!(launch.get_envs().any(|(name, value)| { - name == "PI_ACP_PI_COMMAND" && value == Some(pi.as_os_str()) - })); - } else { - let error = launch.err().unwrap(); - if version == "0.87.1" { - assert!(error.contains("0.87.1"), "{error}"); - } - assert!(error.contains("0.99.0"), "{error}"); - assert!(error.contains(&pi.display().to_string()), "{error}"); - } - } - fs::write(&pi, "#!/bin/sh\nexit 1\n").unwrap(); - let error = runtime.command(&selected, &key).err().unwrap(); - assert!(error.contains("0.99.0"), "{error}"); - let context = crate::pi::PiContext::new( - &selected.harness, - &selected.workspace, - &selected.environment, - ) - .unwrap(); - let preflight = - crate::pi::LaunchPreflight::new(Some(context.accept_version("0.99.1").unwrap())); - selected - .environment - .insert("PI_CODING_AGENT_DIR".into(), "/changed/pi-profile".into()); - let error = runtime - .command_checked(&selected, &key, &crate::build_defaults(), Some(&preflight)) - .err() - .unwrap(); - assert!(error.contains("settings changed"), "{error}"); -} - -#[test] -#[cfg(unix)] -fn pi_version_probe_times_out_and_retires_helpers() { - let dir = tempfile::tempdir().unwrap(); - for name in ["buzz-pi-acp", "node"] { - let file = dir.path().join(name); - crate::test_executable::write_executable(&file, "#!/bin/sh\nexit 0\n"); - } - let pi = dir.path().join("pi"); - let pid_file = dir.path().join("helper.pid"); - crate::test_executable::write_executable( - &pi, - format!( - "#!/bin/sh\nprintf '0.99.1\\n'\n/bin/sleep 30 &\nprintf '%s' $! > '{}'\nwait\n", - pid_file.display() - ), - ); - let runtime = bundle(dir.path()); - let mut selected = agent(dir.path()); - selected.harness.command = dir.path().join("buzz-pi-acp").display().to_string(); - let key = Secret::parse(KEY, PUB).unwrap(); - let error = runtime - .command(&selected, &key) - .expect_err("Stalled Pi was accepted"); - assert!(error.contains("timed out"), "{error}"); - let pid: i32 = fs::read_to_string(pid_file).unwrap().parse().unwrap(); - // SIGKILL can briefly leave a zombie owned by init. Wait on process state, - // not an arbitrary delay after signalling. - let deadline = Instant::now() + Duration::from_secs(2); - loop { - let output = Command::new("/bin/ps") - .args(["-o", "stat=", "-p", &pid.to_string()]) - .output() - .unwrap(); - let state = String::from_utf8(output.stdout).unwrap(); - if state.trim().is_empty() || state.trim().starts_with('Z') { - break; - } - assert!(Instant::now() < deadline, "Helper still running: {state}"); - std::thread::sleep(Duration::from_millis(10)); - } -} - -#[test] -#[cfg(unix)] -fn explicit_snapshot_worker_counts_reach_listener_without_imported_record() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let runtime = bundle(tools.path()); - let mut a = agent(dir.path()); - a.imported = serde_json::Value::Null; - let key = Secret::parse(KEY, PUB).unwrap(); - fs::write(dir.path().join("exit-listener"), "").unwrap(); - for count in [1, 4] { - a.environment - .insert("BUZZ_ACP_AGENTS".into(), count.to_string()); - a.validate().unwrap(); - let mut command = runtime - .command_with_defaults(&a, &key, &deployment_defaults()) - .unwrap(); - command.env("BUZZ_AGENT_CONFIG_DIR", dir.path()); - assert!(command.output().unwrap().status.success()); - assert_eq!( - fs::read_to_string(dir.path().join("worker-count")).unwrap(), - count.to_string() - ); - } -} - -#[test] -#[cfg(unix)] -fn worker_environment_override_beats_imported_parallelism_and_removal_restores_it() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let runtime = bundle(tools.path()); - let mut a = agent(dir.path()); - let key = Secret::parse(KEY, PUB).unwrap(); - fs::write(dir.path().join("exit-listener"), "").unwrap(); - for (override_value, expected) in [(Some("10"), "10"), (None, "2")] { - if let Some(value) = override_value { - a.environment.insert("BUZZ_ACP_AGENTS".into(), value.into()); - } else { - a.environment.remove("BUZZ_ACP_AGENTS"); - } - let mut command = runtime - .command_with_defaults(&a, &key, &deployment_defaults()) - .unwrap(); - command.env("BUZZ_AGENT_CONFIG_DIR", dir.path()); - assert!(command.output().unwrap().status.success()); - assert_eq!( - fs::read_to_string(dir.path().join("worker-count")).unwrap(), - expected - ); - } -} - -#[test] -#[cfg(unix)] -fn pi_and_goose_saved_environment_overrides_reach_the_listener_last() { - for harness in ["goose-acp", "buzz-pi-acp"] { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let runtime = bundle(tools.path()); - if harness == "buzz-pi-acp" { - for name in ["buzz-pi-acp", "pi", "node"] { - let path = tools.path().join(name); - crate::test_executable::write_executable(&path, "#!/bin/sh\nprintf '0.99.1\\n'\n"); - } - } - let mut a = agent(dir.path()); - a.harness.command = tools.path().join(harness).display().to_string(); - let root = dir.path().join("config"); - let mut store = Store::open(root.clone()).unwrap(); - store.insert(vec![a.clone()]).unwrap(); - let overrides = BTreeMap::from([ - ( - "BUZZ_ACP_MODEL".into(), - "override-provider/override-model".into(), - ), - ("BUZZ_ACP_SYSTEM_PROMPT".into(), "override prompt".into()), - ("BUZZ_ACP_AGENTS".into(), "10".into()), - ("BUZZ_ACP_EFFORT_LEVEL".into(), "low".into()), - ("BUZZ_ACP_LAZY_POOL".into(), "false".into()), - ("BUZZ_ACP_RELAY_OBSERVER".into(), "false".into()), - ]); - store - .save( - &a.id, - a.revision, - AgentEdit { - effort: None, - name: a.name.clone(), - picture: None, - system_prompt: a.system_prompt.clone(), - session_policy: Some(None), - workspace: a.workspace.clone(), - harness: a.harness.clone(), - environment: overrides - .iter() - .map(|(k, v): (&String, &String)| (k.clone(), Some(v.clone()))) - .collect(), - }, - ) - .unwrap(); - drop(store); - let saved = Store::open(root).unwrap().agents().unwrap().remove(0); - let key = Secret::parse(KEY, PUB).unwrap(); - let mut command = runtime - .command_with_defaults(&saved, &key, &deployment_defaults()) - .unwrap(); - let env: BTreeMap<_, _> = command - .get_envs() - .filter_map(|(k, v)| v.map(|v| (k.to_str().unwrap(), v.to_str().unwrap()))) - .collect(); - for (key, value) in &overrides { - assert_eq!(env[key.as_str()], value, "{harness}: {key}"); - } - assert_eq!(env["BUZZ_ACP_RESPOND_TO"], "owner-only"); - assert_eq!(env["BUZZ_ACP_ALLOWED_RESPOND_TO"], "owner-only"); - assert_eq!(env["BUZZ_PRIVATE_KEY"], KEY); - // Complete the existing listener fixture immediately after it records - // the child environment; this launch never contacts a relay. - fs::write(dir.path().join("exit-listener"), "").unwrap(); - command.env("BUZZ_AGENT_CONFIG_DIR", dir.path()); - assert!(command.output().unwrap().status.success()); - let output = fs::read_to_string(dir.path().join("starts")).unwrap(); - let lines: Vec<_> = output.lines().collect(); - assert_eq!(lines[0], "false"); - assert_eq!(lines[2], "override prompt"); - assert_eq!(lines[3], "override-provider/override-model"); - - // Removing overrides restores the saved/imported selections. - let mut restored = saved; - restored.environment = a.environment; - let command = runtime - .command_with_defaults(&restored, &key, &deployment_defaults()) - .unwrap(); - let env: BTreeMap<_, _> = command - .get_envs() - .filter_map(|(k, v)| v.map(|v| (k.to_str().unwrap(), v.to_str().unwrap()))) - .collect(); - assert_eq!(env["BUZZ_ACP_SYSTEM_PROMPT"], "test prompt"); - assert_eq!(env["BUZZ_ACP_AGENTS"], "2"); - assert_eq!(env["BUZZ_ACP_EFFORT_LEVEL"], "high"); - assert_eq!(env["BUZZ_ACP_LAZY_POOL"], "true"); - assert_eq!(env["BUZZ_ACP_RELAY_OBSERVER"], "true"); - } -} - -#[test] -#[cfg(unix)] -fn pi_selection_and_extensions_survive_save_reopen_and_reach_adapter() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let runtime = bundle(tools.path()); - let adapter = tools.path().join("buzz-pi-acp"); - crate::test_executable::write_executable(&adapter, "#!/bin/sh\nexit 0\n"); - let extension = tools.path().join("extension with spaces.ts"); - fs::write(&extension, "export default function() {};").unwrap(); - let mut a = agent(dir.path()); - a.harness.command = adapter.display().to_string(); - a.imported["record"]["respond_to"] = json!("anyone"); - a.harness.provider = "custom".into(); - a.harness.model = "namespace/exact.id".into(); - a.harness.args = vec![ - "--".into(), - "--extension".into(), - extension.display().to_string(), - ]; - crate::test_executable::write_executable( - &tools.path().join("pi"), - "#!/bin/sh\nif [ \"$1\" = --version ]; then printf '0.99.1\\n'; fi\n", - ); - crate::test_executable::write_executable(&tools.path().join("node"), "#!/bin/sh\nexit 0\n"); - a.environment.insert( - "PI_CODING_AGENT_DIR".into(), - dir.path().display().to_string(), - ); - let root = dir.path().join("config"); - Store::open(root.clone()) - .unwrap() - .insert(vec![a.clone()]) - .unwrap(); - let store = Store::open(root).unwrap(); - let saved = store.agents().unwrap().remove(0); - let key = Secret::parse(KEY, PUB).unwrap(); - let command = runtime - .command_with_defaults(&saved, &key, &deployment_defaults()) - .unwrap(); - let env: BTreeMap<_, _> = command - .get_envs() - .filter_map(|(k, v)| v.map(|v| (k.to_str().unwrap(), v.to_str().unwrap()))) - .collect(); - assert_eq!(env["BUZZ_ACP_MODEL"], "custom/namespace/exact.id"); - assert_eq!(env["BUZZ_ACP_RESPOND_TO"], "owner-only"); - assert_eq!(env["BUZZ_ACP_ALLOWED_RESPOND_TO"], "owner-only"); - for key in [ - "BUZZ_AGENT_PROVIDER", - "BUZZ_AGENT_MODEL", - "DATABRICKS_HOST", - "DATABRICKS_MODEL_FILTER", - ] { - assert!(!env.contains_key(key)); - } - assert_eq!( - env["BUZZ_ACP_AGENT_ARGS"], - format!( - "--,--extension,{},--provider,custom,--model,namespace/exact.id", - extension.display() - ) - ); - assert!(!env.contains_key("GOOSE_MODEL")); - let controller = Controller::new( - store, - Arc::new(Memory), - Ok(runtime), - dir.path().join("ownership"), - ); - let edit = AgentEdit { - effort: None, - name: a.name.clone(), - picture: None, - system_prompt: a.system_prompt.clone(), - session_policy: Some(None), - workspace: a.workspace.clone(), - harness: a.harness.clone(), - environment: BTreeMap::new(), - }; - let context = controller.pi_model_context(&a.id, 1, edit.clone()).unwrap(); - assert_eq!(context.args, a.harness.args[1..]); - assert_eq!( - context.environment["PI_CODING_AGENT_DIR"], - dir.path().display().to_string() - ); - assert!(controller.pi_model_context(&a.id, 2, edit.clone()).is_err()); - let mut patch = edit.clone(); - patch.environment.insert( - "PI_CODING_AGENT_DIR".into(), - Some("/override/config".into()), - ); - let context = controller - .pi_model_context(&a.id, 1, patch.clone()) - .unwrap(); - assert_eq!( - context.environment["PI_CODING_AGENT_DIR"], - "/override/config" - ); - patch.environment.insert("PI_CODING_AGENT_DIR".into(), None); - assert!(!controller - .pi_model_context(&a.id, 1, patch) - .unwrap() - .environment - .contains_key("PI_CODING_AGENT_DIR")); - // Draft resolution did not mutate the saved override. - assert_eq!( - controller.store.agents().unwrap()[0].environment["PI_CODING_AGENT_DIR"], - a.environment["PI_CODING_AGENT_DIR"] - ); - for (provider, model) in [ - ("custom".to_owned(), "a,b".to_owned()), - ("custom".to_owned(), "-model".to_owned()), - ("custom,other".to_owned(), "model".to_owned()), - ("-provider".to_owned(), "model".to_owned()), - ("bad/provider".to_owned(), "model".to_owned()), - ("p".repeat(129), "model".to_owned()), - ("custom".to_owned(), "m".repeat(513)), - ] { - let mut invalid = saved.clone(); - invalid.harness.provider = provider; - invalid.harness.model = model; - assert!(controller - .bundle - .as_ref() - .unwrap() - .command(&invalid, &key) - .is_err()); - } - let mut configured = saved.clone(); - configured.harness.model.clear(); - assert!(controller - .bundle - .as_ref() - .unwrap() - .command(&configured, &key) - .unwrap_err() - .contains("Choose a Pi model")); - configured.harness.provider.clear(); - configured.harness.args = vec![ - "--".into(), - "--thinking".into(), - "high".into(), - "--skill".into(), - "/local/skill".into(), - "--tools".into(), - "read".into(), - ]; - let launch = controller - .bundle - .as_ref() - .unwrap() - .command_with_defaults(&configured, &key, &deployment_defaults()) - .unwrap(); - assert!(launch.get_envs().all(|(key, _)| key != "BUZZ_ACP_MODEL")); - assert_eq!( - launch - .get_envs() - .find(|(k, _)| *k == "BUZZ_ACP_AGENT_ARGS") - .unwrap() - .1 - .unwrap(), - "--,--thinking,high,--skill,/local/skill,--tools,read" - ); - let mut advanced = edit; - advanced.harness.args = configured.harness.args.clone(); - let context = Controller::draft_pi_model_context(advanced.clone()).unwrap(); - assert_eq!( - context.catalog_args().unwrap(), - configured.harness.args[1..] - ); - advanced.harness.args = vec![ - "--".into(), - "--provider".into(), - "old".into(), - "--model".into(), - "invalid".into(), - ]; - assert!(Controller::draft_pi_model_context(advanced.clone()) - .unwrap() - .catalog_args() - .unwrap() - .is_empty()); - for unsupported in [ - vec!["--custom-extension-flag"], - vec!["--extension=/local/extension.ts"], - vec!["--api-key", "synthetic-key"], - vec!["a prompt"], - ] { - advanced.harness.args = std::iter::once("--") - .chain(unsupported) - .map(str::to_owned) - .collect(); - assert!(Controller::draft_pi_model_context(advanced.clone()) - .unwrap() - .catalog_args() - .is_err()); - configured.harness.args = advanced.harness.args.clone(); - assert!(controller - .bundle - .as_ref() - .unwrap() - .command(&configured, &key) - .is_ok()); - } -} - -#[test] -#[cfg(unix)] -fn imported_team_reaches_acp_without_weakening_remote_mesh_or_owner_guards() { - let dir = tempfile::tempdir().unwrap(); - let runtime = bundle(dir.path()); - let key = Secret::parse(KEY, PUB).unwrap(); - let mut a = agent(dir.path()); - a.imported["record"]["team_id"] = json!("crew"); - a.imported["record"]["persona_team_dir"] = json!("/old/pack"); - assert!(runtime - .command(&a, &key) - .err() - .unwrap() - .contains("Repair team import")); - for text in ["", "review carefully"] { - a.imported["teamInstructions"] = json!(text); - let command = runtime.command(&a, &key).unwrap(); - let env: BTreeMap<_, _> = command.get_envs().collect(); - assert_eq!( - env[std::ffi::OsStr::new("BUZZ_ACP_TEAM_INSTRUCTIONS")], - Some(std::ffi::OsStr::new(text)) - ); - assert_eq!( - env[std::ffi::OsStr::new("BUZZ_ACP_SYSTEM_PROMPT")], - Some(std::ffi::OsStr::new("test prompt")) - ); - } - for (field, value) in [ - ("backend", json!({"type":"provider"})), - ("relay_mesh", json!({})), - ] { - let mut unsupported = a.clone(); - unsupported.imported["record"][field] = value; - assert!(runtime - .command(&unsupported, &key) - .err() - .unwrap() - .contains("remote/mesh")); - } - let mut mesh = a.clone(); - mesh.harness.provider = "relay-mesh".into(); - assert!(runtime - .command(&mesh, &key) - .err() - .unwrap() - .contains("remote/mesh")); - a.auth_tag = None; - assert!(runtime - .command(&a, &key) - .err() - .unwrap() - .contains("owner attestation")); -} - -#[test] -#[cfg(unix)] -fn import_and_repair_deliver_team_instructions_to_a_started_process() { - for repairing in [false, true] { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let old = tempfile::tempdir().unwrap(); - let source = old - .path() - .join(crate::LegacySource::Installed.app_directory()) - .join("agents"); - fs::create_dir_all(&source).unwrap(); - let mut saved = agent(dir.path()); - saved.imported["record"]["team_id"] = json!("crew"); - let record = json!({"pubkey":PUB,"private_key_nsec":KEY,"name":"Old name","team_id":"crew","auth_tag":saved.auth_tag,"system_prompt":"Old prompt"}); - fs::write( - source.join("managed-agents.json"), - serde_json::to_vec(&json!([record])).unwrap(), - ) - .unwrap(); - let instructions = "Team instructions\nKeep the deployment contract."; - fs::write( - source.join("teams.json"), - serde_json::to_vec(&json!([{"id":"crew","instructions":instructions}])).unwrap(), - ) - .unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - if repairing { - store.insert(vec![saved.clone()]).unwrap(); - } - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - if repairing { - let blocked = controller.action(&saved.id, Action::Start).unwrap(); - assert!(blocked.agents[0] - .error - .as_ref() - .unwrap() - .contains("Repair team import")); - assert!(controller.running.is_empty()); - controller.action(&saved.id, Action::Stop).unwrap(); - } - let mut imports = crate::Imports::default(); - let preview = imports - .preview( - crate::LegacySource::Installed, - old.path().into(), - dir.path().into(), - &saved.relay_url, - ) - .unwrap(); - let prepared = controller - .prepare_import(&mut imports, &preview.token, &[saved.id.clone()]) - .unwrap(); - imports.discard(); - controller - .commit_import(prepared.acquire(&Memory).unwrap()) - .unwrap(); - let snapshot = controller.snapshot().unwrap(); - assert!(!snapshot.agents[0].enabled); - assert!(!snapshot.agents[0].needs_team_import); - assert!(controller.running.is_empty()); - assert!(!dir.path().join("team-instructions").exists()); - if repairing { - assert_eq!(snapshot.agents[0].name, saved.name); - assert_eq!(snapshot.agents[0].revision, saved.revision + 1); - } - let started = controller.action(&saved.id, Action::Start).unwrap(); - assert!( - matches!(started.agents[0].status, ProcessStatus::Running), - "{:?}", - started.agents[0].error - ); - wait_for_contents(&dir.path().join("team-instructions"), |text| { - (text == instructions).then_some(()) - }); - assert!(matches!( - controller.action(&saved.id, Action::Stop).unwrap().agents[0].status, - ProcessStatus::Stopped - )); - assert!(controller.running.is_empty()); - } -} - -#[cfg(unix)] -#[test] -fn managed_prefix_detection_and_shim_launch_path_use_pinned_node() { - let dir = tempfile::tempdir().unwrap(); - let app_data = dir.path(); - let prefix = app_data.join("node-tools/bin"); - let platform = match (std::env::consts::OS, std::env::consts::ARCH) { - ("macos", "aarch64") => "darwin-arm64", - ("macos", "x86_64") => "darwin-x64", - ("linux", "x86_64") => "linux-x64", - ("linux", "aarch64") => "linux-arm64", - _ => return, - }; - let node_bin = app_data - .join("runtimes/node/v24.18.0") - .join(platform) - .join("bin"); - fs::create_dir_all(&prefix).unwrap(); - fs::create_dir_all(&node_bin).unwrap(); - for path in [ - prefix.join("pi"), - prefix.join("buzz-pi-acp"), - node_bin.join("node"), - ] { - let script = if path.file_name().is_some_and(|name| name == "node") { - "#!/bin/sh\nif [ \"$2\" = --version ]; then printf '0.99.1\\n'; else printf 'managed-node\\n'; fi\n" - } else { - "#!/usr/bin/env node\n" - }; - crate::test_executable::write_executable(&path, script); - } - assert_eq!( - managed_tool(app_data, "buzz-pi-acp"), - Some(prefix.join("buzz-pi-acp")) - ); - assert_eq!(managed_tool(app_data, "node"), Some(node_bin.join("node"))); - assert!(managed_tool(app_data, "goose").is_none()); - let mut harness = agent(dir.path()).harness; - harness.command = prefix.join("buzz-pi-acp").display().to_string(); - harness.args.clear(); - let context = - crate::pi::PiContext::new(&harness, dir.path().to_str().unwrap(), &BTreeMap::new()) - .unwrap(); - assert_eq!(context.command, prefix.join("pi")); - assert_eq!( - context.environment["PI_ACP_PI_COMMAND"], - prefix.join("pi").display().to_string() - ); - assert_eq!( - std::env::split_paths(&context.path).next().unwrap(), - node_bin - ); - assert_eq!(std::env::split_paths(&context.path).nth(1).unwrap(), prefix); - let output = std::process::Command::new(&context.command) - .env_clear() - .env("PATH", &context.path) - .output() - .unwrap(); - assert!(output.status.success()); - assert_eq!(output.stdout, b"managed-node\n"); - fs::remove_file(node_bin.join("node")).unwrap(); - assert!( - crate::pi::PiContext::new(&harness, dir.path().to_str().unwrap(), &BTreeMap::new()) - .is_err() - ); -} - -#[cfg(unix)] -#[test] -fn agent_value_beats_agent_default_which_beats_build_default() { - let dir = tempfile::tempdir().unwrap(); - let bundle = bundle(dir.path()); - let key = Secret::parse(KEY, PUB).unwrap(); - let mut blank = agent(dir.path()); - blank.harness.provider.clear(); - blank.harness.model.clear(); - blank.imported["record"] - .as_object_mut() - .unwrap() - .remove("effort_level"); - let env = |agent: &Agent| { - let command = bundle - .command_with_defaults(agent, &key, &deployment_defaults()) - .unwrap(); - command - .get_envs() - .filter_map(|(k, v)| Some((k.to_str()?.to_owned(), v?.to_str()?.to_owned()))) - .collect::>() - }; - // Build floor only. - assert_eq!(env(&blank)["BUZZ_AGENT_MODEL"], "build-model"); - assert!(!env(&blank).contains_key("BUZZ_ACP_EFFORT_LEVEL")); - let defaults = crate::agent_defaults::AgentDefaults { - harness: "buzz-agent".into(), - provider: "databricks_v2".into(), - model: "global-model".into(), - effort: "medium".into(), - session_policy: crate::config::SessionPolicy::Channel, - environment: BTreeMap::from([ - ("PROVIDER_TEST_SETTING".into(), "global-value".into()), - ("GLOBAL_SETTING".into(), "global-value".into()), - ]), - }; - let inherited = env(&crate::agent_defaults::effective(&blank, &defaults)); - assert_eq!(inherited["BUZZ_AGENT_MODEL"], "global-model"); - assert_eq!(inherited["BUZZ_ACP_EFFORT_LEVEL"], "medium"); - assert_eq!(inherited["PROVIDER_TEST_SETTING"], "explicit-value"); - assert_eq!(inherited["GLOBAL_SETTING"], "global-value"); - let mut own = blank.clone(); - own.harness.model = "agent-model".into(); - own.imported["record"]["effort_level"] = json!("low"); - let own = env(&crate::agent_defaults::effective(&own, &defaults)); - assert_eq!(own["BUZZ_AGENT_MODEL"], "agent-model"); - assert_eq!(own["BUZZ_ACP_EFFORT_LEVEL"], "low"); -} - -#[test] -#[cfg(unix)] -fn inherited_default_changes_reach_restart_diff_and_the_next_start() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - let mut a = agent(dir.path()); - a.harness.model.clear(); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - let edit = |model: &str| crate::AgentDefaultsEdit { - harness: "buzz-agent".into(), - provider: String::new(), - model: model.into(), - effort: String::new(), - session_policy: Some(crate::config::SessionPolicy::Channel), - environment: BTreeMap::new(), - }; - controller.save_defaults(edit("first-default")).unwrap(); - controller.action(&a.id, Action::Start).unwrap(); - let first = wait_for_contents(&dir.path().join("starts"), |text| { - (text.lines().count() == 10).then(|| text.to_owned()) - }); - assert_eq!(first.lines().nth(3), Some("first-default")); - let before = controller.running_settings().unwrap(); - let saved = controller.save_defaults(edit("second-default")).unwrap(); - let fields: Vec<_> = saved.agents[0] - .restart_diff - .iter() - .map(|e| e.field.as_str()) - .collect(); - assert_eq!(fields, ["model"]); - assert_eq!( - saved.agents[0].launch_model.as_deref(), - Some("second-default") - ); - assert_ne!(controller.running_settings().unwrap(), before); - // An unrelated defaults edit leaves effective settings unchanged. - let before = controller.running_settings().unwrap(); - let mut other = edit("second-default"); - other.environment = BTreeMap::from([("PROVIDER_TEST_SETTING".into(), Some("hidden".into()))]); - controller.save_defaults(other).unwrap(); - assert_eq!(controller.running_settings().unwrap(), before); - // A failed Stop can persist disabled intent while its listener remains live. - // Saving defaults must not select it for a restart or re-enable it. - controller.store.enabled(&a.id, false).unwrap(); - let disabled = controller.snapshot().unwrap(); - assert!(disabled.agents[0].status == ProcessStatus::Running); - assert!(!disabled.agents[0].enabled); - assert!(controller.running_settings().unwrap().is_empty()); - controller.shutdown().unwrap(); - assert!(controller.running_settings().unwrap().is_empty()); -} - -#[test] -#[cfg(unix)] -fn saved_databricks_workspace_launches_without_inheriting_global_host_or_restart() { - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - let mut a = agent(dir.path()); - a.harness.provider = "databricks_v2".into(); - a.harness.databricks = Some(crate::connection::DatabricksSettings { - host: "https://agent.example".into(), - filter: "agent-*".into(), - }); - store.insert(vec![a.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(bundle(tools.path())), - dir.path().join("ownership"), - ); - let edit = |host: &str, filter: &str| crate::AgentDefaultsEdit { - harness: "buzz-agent".into(), - provider: "databricks_v2".into(), - model: String::new(), - effort: String::new(), - session_policy: Some(crate::config::SessionPolicy::Channel), - environment: BTreeMap::from([ - ("DATABRICKS_HOST".into(), Some(host.into())), - ("DATABRICKS_MODEL_FILTER".into(), Some(filter.into())), - ]), - }; - controller - .save_defaults(edit("https://global.example", "global-*")) - .unwrap(); - controller.action(&a.id, Action::Start).unwrap(); - let env = wait_for_contents(&dir.path().join("runtime-env"), |text| { - (text.lines().count() == 6).then(|| text.to_owned()) - }); - assert_eq!(env.lines().nth(1), Some("https://agent.example")); - assert_eq!(env.lines().nth(2), Some("agent-*")); - assert_eq!( - controller.running[&a.id].databricks_host.as_deref(), - Some("https://agent.example") - ); - let before = controller.running_settings().unwrap(); - let snapshot = controller - .save_defaults(edit("https://next-global.example", "next-global-*")) - .unwrap(); - assert_eq!(controller.running_settings().unwrap(), before); - assert!(snapshot.agents[0].restart_diff.is_empty()); - - let mut blank = a.clone(); - blank.harness.databricks = None; - let inherited = crate::agent_defaults::effective(&blank, &controller.store.defaults().unwrap()); - let settings = effective_databricks(&inherited).unwrap().unwrap(); - assert_eq!(settings.host, "https://next-global.example"); - assert_eq!(settings.filter, "next-global-*"); - controller.shutdown().unwrap(); -} - -#[test] -fn restart_comparison_ignores_overridden_selectors_without_exposing_secrets() { - let dir = tempfile::tempdir().unwrap(); - let mut a = agent(dir.path()); - a.harness.command = "/usr/local/bin/goose".into(); - a.harness.model.clear(); - a.harness.provider.clear(); - a.environment - .insert("GOOSE_MODEL".into(), "secret-model".into()); - a.environment - .insert("GOOSE_PROVIDER".into(), "secret-provider".into()); - let defaults = |model: &str, provider: &str| crate::agent_defaults::AgentDefaults { - harness: "goose".into(), - provider: provider.into(), - model: model.into(), - ..Default::default() - }; - let first = crate::restart::spawn_config(&crate::agent_defaults::effective( - &a, - &defaults("global-one", "provider-one"), - )); - let second = crate::restart::spawn_config(&crate::agent_defaults::effective( - &a, - &defaults("global-two", "provider-two"), - )); - assert_eq!( - first, second, - "overridden global selectors must not restart" - ); - assert!(crate::restart::diff(&first, &second).is_empty()); - a.environment - .insert("GOOSE_MODEL".into(), "other-secret".into()); - let changed = crate::restart::spawn_config(&crate::agent_defaults::effective( - &a, - &defaults("global-two", "provider-two"), - )); - let diff = serde_json::to_string(&crate::restart::diff(&first, &changed)).unwrap(); - assert!(diff.contains("GOOSE_MODEL")); - assert!(!diff.contains("secret-model") && !diff.contains("other-secret")); -} - -#[test] -fn databricks_environment_override_is_not_projected_as_a_restart_selector() { - let dir = tempfile::tempdir().unwrap(); - let mut a = agent(dir.path()); - a.harness.provider = "databricks_v2".into(); - a.harness.model.clear(); - a.environment - .insert("DATABRICKS_MODEL".into(), "secret-model".into()); - a.environment - .insert("DATABRICKS_HOST".into(), "https://private.example".into()); - a.harness.databricks = Some(crate::connection::DatabricksSettings { - host: "https://old.example".into(), - filter: String::new(), - }); - // Pin the policy explicitly so the raw and effective configs agree and only the model is under test. - a.session_policy = Some(crate::config::SessionPolicy::Channel); - let first = crate::restart::spawn_config(&a); - let matching_default = crate::agent_defaults::AgentDefaults { - harness: "buzz-agent".into(), - model: "secret-model".into(), - ..Default::default() - }; - assert_eq!( - first, - crate::restart::spawn_config(&crate::agent_defaults::effective(&a, &matching_default)), - "setting the same model must not restart even if the source changes" - ); - a.harness.databricks.as_mut().unwrap().host = "https://new.example".into(); - let second = crate::restart::spawn_config(&a); - assert_eq!(first, second, "env overrides the saved workspace host"); - a.environment - .insert("DATABRICKS_MODEL".into(), "new-secret".into()); - let third = crate::restart::spawn_config(&a); - let entries = crate::restart::diff(&first, &third); - assert!(matches!( - entries.iter().find(|e| e.field == "model").unwrap().change, - crate::restart::RestartChange::Masked { .. } - )); - let wire = serde_json::to_string(&entries).unwrap(); - assert!(wire.contains("DATABRICKS_MODEL")); - assert!(!wire.contains("secret-model") && !wire.contains("new-secret")); -} - -#[test] -fn retained_import_cannot_enable_or_open_credentials_until_explicit_setup() { - let root = tempfile::tempdir().unwrap(); - let mut imported = agent(root.path()); - imported.extra.insert("configured".into(), json!(false)); - let id = imported.id.clone(); - let mut store = Store::open(root.path().into()).unwrap(); - store.insert(vec![imported]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Err("No runtime".into()), - root.path().join("locks"), - ); - assert!(controller.action(&id, Action::Start).unwrap().agents[0] - .error - .as_ref() - .unwrap() - .contains("Use here")); - assert!(controller.credential_request(&id).is_err()); - assert!(controller.launch_ids().unwrap().is_empty()); - assert!(!controller.restore().unwrap().agents[0].enabled); - let resolution = crate::community::tests::resolution("wss://relay.example"); - let snapshot = controller.use_here(&id, resolution).unwrap(); - assert!(snapshot.agents[0].configured); - assert!(!snapshot.agents[0].enabled); - assert_eq!(snapshot.agents[0].pubkey, PUB); -} -#[test] -fn use_here_clears_retained_startup_intent_until_an_explicit_start() { - // (destination, explicit startup preference, legacy enabled) - for (relay, explicit, legacy) in [ - ("wss://relay.example", Some(true), false), - ("wss://other.example", Some(true), false), - ("wss://relay.example", None, true), - ] { - let root = tempfile::tempdir().unwrap(); - let mut imported = agent(root.path()); - imported.extra.insert("configured".into(), json!(false)); - imported.start_on_app_launch = explicit; - imported.enabled = legacy; - let id = imported.id.clone(); - let mut store = Store::open(root.path().into()).unwrap(); - store.insert(vec![imported]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Err("No runtime".into()), - root.path().join("locks"), - ); - assert!(controller.launch_ids().unwrap().is_empty(), "{relay}"); - let snapshot = controller - .use_here(&id, crate::community::tests::resolution(relay)) - .unwrap(); - assert!(controller.launch_ids().unwrap().is_empty(), "{relay}"); - let target = agent_id(PUB, relay); - let saved = snapshot.agents.iter().find(|a| a.id == target).unwrap(); - assert!(saved.configured); - assert!(!saved.enabled); - assert!(!saved.start_on_app_launch); - // Reopening the saved state must not restore a running agent. - drop(controller); - let reopened = Controller::new( - Store::open(root.path().into()).unwrap(), - Arc::new(Memory), - Err("No runtime".into()), - root.path().join("locks"), - ); - assert!(reopened.launch_ids().unwrap().is_empty(), "{relay}"); - } -} - -#[test] -fn use_here_retry_keeps_a_configured_agent_startup_preference() { - let root = tempfile::tempdir().unwrap(); - let mut configured = agent(root.path()); - configured.enabled = true; - configured.start_on_app_launch = Some(true); - let id = configured.id.clone(); - let mut store = Store::open(root.path().into()).unwrap(); - store.insert(vec![configured]).unwrap(); - let path = root.path().join("agents.json"); - let before = fs::read(&path).unwrap(); - store - .use_here( - &id, - crate::community::tests::resolution("wss://relay.example"), - ) - .unwrap(); - assert_eq!(fs::read(&path).unwrap(), before); -} - -#[test] -fn use_here_rejects_configured_other_community_without_writes() { - let root = tempfile::tempdir().unwrap(); - let mut original = agent(root.path()); - original.enabled = true; - let id = original.id.clone(); - let mut store = Store::open(root.path().into()).unwrap(); - store.insert(vec![original]).unwrap(); - let path = root.path().join("agents.json"); - let before = fs::read(&path).unwrap(); - let error = store - .use_here( - &id, - crate::community::tests::resolution("wss://other.example"), - ) - .unwrap_err(); - assert!(error.contains("Clone")); - assert_eq!(fs::read(&path).unwrap(), before); - assert_eq!( - store.local_clone_settings(&id).unwrap().system_prompt, - "test prompt" - ); -} - -#[test] -fn use_here_recovers_incomplete_import_but_cannot_add_a_third_community() { - let root = tempfile::tempdir().unwrap(); - let mut imported = agent(root.path()); - imported.extra.insert("configured".into(), json!(false)); - let id = imported.id.clone(); - let mut store = Store::open(root.path().into()).unwrap(); - store.insert(vec![imported]).unwrap(); - let path = root.path().join("agents.json"); - let before = fs::read(&path).unwrap(); - let mut forged = crate::community::tests::resolution("wss://other.example"); - forged.relay_url = "wss://attacker.example".into(); - assert!(store.use_here(&id, forged).is_err()); - assert_eq!(fs::read(&path).unwrap(), before); - store - .use_here( - &id, - crate::community::tests::resolution("wss://other.example"), - ) - .unwrap(); - let agents = store.agents().unwrap(); - assert!(!agents[0].configured()); - assert!(agents[1].configured()); - assert!(!agents[1].enabled); - assert_eq!(agents[1].pubkey, agents[0].pubkey); - assert_eq!(agents[1].credential_id, agents[0].credential_id); - let after = fs::read(&path).unwrap(); - // A delayed retry of the completed recovery is harmless. - store - .use_here( - &id, - crate::community::tests::resolution("wss://other.example"), - ) - .unwrap(); - assert_eq!(fs::read(&path).unwrap(), after); - assert!(store - .use_here( - &id, - crate::community::tests::resolution("wss://third.example") - ) - .is_err()); - assert_eq!(fs::read(&path).unwrap(), after); -} - -#[test] -fn use_here_exhausted_revision_preserves_the_saved_import() { - let root = tempfile::tempdir().unwrap(); - let mut imported = agent(root.path()); - imported.revision = 9_007_199_254_740_991; - imported.extra.insert("configured".into(), json!(false)); - let id = imported.id.clone(); - let mut store = Store::open(root.path().into()).unwrap(); - store.insert(vec![imported]).unwrap(); - let path = root.path().join("agents.json"); - let before = fs::read(&path).unwrap(); - assert_eq!( - store - .use_here( - &id, - crate::community::tests::resolution("wss://relay.example") - ) - .unwrap_err(), - "Agent revision exhausted" - ); - assert_eq!(fs::read(path).unwrap(), before); - assert!(!store.snapshot().unwrap().agents[0].configured); -} - -#[test] -fn protection_revision_limit_preserves_saved_agent() { - use crate::security::Request; - let root = tempfile::tempdir().unwrap(); - let mut saved = agent(root.path()); - saved.revision = 9_007_199_254_740_990; - let mut store = Store::open(root.path().join("config")).unwrap(); - store.insert(vec![saved.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Err("No fixture runtime".into()), - root.path().join("ownership"), - ); - controller - .security(Request::Agent { - id: saved.id.clone(), - revision: saved.revision, - binding: None, - }) - .unwrap(); - let saved = controller.store.agents().unwrap().remove(0); - assert_eq!(saved.revision, 9_007_199_254_740_991); - let path = root.path().join("config/agents.json"); - let before = fs::read(&path).unwrap(); - assert_eq!( - controller - .security(Request::Agent { - id: saved.id, - revision: saved.revision, - binding: None, - }) - .unwrap_err(), - "Agent revision exhausted" - ); - assert_eq!(fs::read(path).unwrap(), before); -} - -#[cfg(unix)] -struct ProtectionWorkerFixture { - executable: &'static str, - args: &'static [&'static str], - bundled: bool, -} - -// Shell fixtures cover bundled and external worker routing, not real agent behavior. -// Add cases here without adding harness-specific branches to the lifecycle checks. -#[cfg(unix)] -const PROTECTION_WORKERS: &[ProtectionWorkerFixture] = &[ - ProtectionWorkerFixture { - executable: "buzz-agent", - args: &[], - bundled: true, - }, - ProtectionWorkerFixture { - executable: "external-acp-worker", - args: &["acp"], - bundled: false, - }, -]; - -#[test] -#[cfg(unix)] -fn plugin_protection_copies_defaults_and_fails_closed_when_provider_retires() { - use crate::security::{Binding, Request}; - let dir = tempfile::tempdir().unwrap(); - let mut controller = Controller::new( - Store::open(dir.path().join("config")).unwrap(), - Arc::new(Memory), - Err("No fixture runtime".into()), - dir.path().join("ownership"), - ); - let executable = dir.path().join("protection"); - crate::test_executable::write_executable(&executable, "#!/bin/sh\nexit 0\n"); - let register = || Request::Register { - provider: "test.security".into(), - executable: executable.clone(), - }; - let lease = controller.security(register()).unwrap()["lease"] - .as_str() - .unwrap() - .to_owned(); - let binding = Binding { - provider: "test.security".into(), - policy: json!({"network":"selected"}), - }; - controller - .security(Request::Defaults { - revision: 0, - binding: Some(binding.clone()), - }) - .unwrap(); - assert!(controller - .security(Request::Defaults { - revision: 0, - binding: None - }) - .is_err()); - let a = agent(dir.path()); - controller.store.insert(vec![a.clone()]).unwrap(); - controller - .security(Request::Defaults { - revision: 1, - binding: None, - }) - .unwrap(); - let saved = controller.store.agents().unwrap().remove(0); - assert_eq!( - saved.extra["launchProtection"], - serde_json::to_value(&binding).unwrap() - ); - let mut cmd = Command::new("/bin/true"); - cmd.env("BUZZ_ACP_AGENT_COMMAND", "/runtime/buzz-agent"); - let control = controller - .wrap_protected_worker(&saved, &mut cmd) - .unwrap() - .unwrap(); - let launch: serde_json::Value = - serde_json::from_slice(&fs::read(control.path().join("launch-protection.json")).unwrap()) - .unwrap(); - assert_eq!(launch["policy"], binding.policy); - assert_eq!(launch["relayUrl"], saved.relay_url); - assert!(cmd.get_envs().any(|(k, v)| k == "BUZZ_ACP_AGENT_COMMAND" - && v == Some(std::ffi::OsStr::new("/runtime/buzz-agent")))); - // A late disposal cannot remove a replacement provider. - let next = controller.security(register()).unwrap(); - controller - .security(Request::Unregister { - provider: binding.provider.clone(), - lease, - }) - .unwrap(); - assert_eq!( - controller.security(Request::Snapshot).unwrap()["availableProviders"], - json!(["test.security"]) - ); - controller - .security(Request::Unregister { - provider: binding.provider.clone(), - lease: next["lease"].as_str().unwrap().into(), - }) - .unwrap(); - let error = controller - .wrap_protected_worker(&saved, &mut cmd) - .unwrap_err(); - assert!(error.contains("unavailable")); - controller.security(register()).unwrap(); - fs::write(&executable, "#!/bin/sh\nexit 1\n").unwrap(); - assert!(controller - .wrap_protected_worker(&saved, &mut cmd) - .unwrap_err() - .contains("changed")); - assert!(controller - .security(Request::Agent { - id: saved.id.clone(), - revision: saved.revision + 1, - binding: None - }) - .is_err()); - controller - .security(Request::Agent { - id: saved.id.clone(), - revision: saved.revision, - binding: None, - }) - .unwrap(); - let cleared = controller.store.agents().unwrap().remove(0); - assert_eq!(cleared.revision, saved.revision + 1); - controller - .wrap_protected_worker(&cleared, &mut cmd) - .unwrap(); -} - -#[test] -#[cfg(unix)] -fn provider_runs_through_controller_start_restart_and_missing_provider_fails_closed() { - for worker in PROTECTION_WORKERS { - protected_worker_lifecycle(worker); - } -} - -#[cfg(unix)] -fn protected_worker_lifecycle(worker: &ProtectionWorkerFixture) { - use crate::security::{Binding, Request}; - let dir = tempfile::tempdir().unwrap(); - let tools = tempfile::tempdir().unwrap(); - let mut saved = agent(dir.path()); - let runtime = bundle(tools.path()); - let worker_path = tools.path().join(worker.executable); - saved.harness.command = if worker.bundled { - worker.executable.into() - } else { - // A generic external harness owns its provider/model configuration. - saved.harness.model.clear(); - saved.harness.provider.clear(); - crate::test_executable::copy_executable(&tools.path().join("buzz-agent"), &worker_path); - worker_path.display().to_string() - }; - saved.harness.args = worker.args.iter().map(|arg| (*arg).into()).collect(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![saved.clone()]).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(runtime), - dir.path().join("ownership"), - ); - // Plugin configuration errors must not affect unprotected agents. - controller.protect_control_paths(Err("Invalid plugin profile".into())); - let started = controller.action(&saved.id, Action::Start).unwrap(); - assert!( - matches!(started.agents[0].status, ProcessStatus::Running), - "{}: {:?}", - worker.executable, - started.agents[0].error - ); - wait_for_contents(&dir.path().join("starts"), |s| { - (s.lines().count() == 10).then_some(()) - }); - controller.action(&saved.id, Action::Stop).unwrap(); - let provider_dir = dir.path().join("provider"); - fs::create_dir(&provider_dir).unwrap(); - let provider = provider_dir.join("launcher"); - // Exercise the real launch boundary without requiring a plugin: - // check the --launch protocol, record each context, and stay alive for restart/stop. - crate::test_executable::write_executable( - &provider, - r#"#!/bin/sh -[ "$1" = --launch ] || exit 2 -/usr/bin/env python3 -c 'import json,os,sys; c=json.load(open(sys.argv[2])); c.update(worker=sys.argv[4], args=sys.argv[5:], tmpdir=os.environ["TMPDIR"]); print(json.dumps(c))' "$@" >> launches -trap 'exit 0' TERM INT -while :; do /bin/sleep 0.1; done -"#, - ); - let lease = controller - .security(Request::Register { - provider: "test.provider".into(), - executable: provider, - }) - .unwrap()["lease"] - .as_str() - .unwrap() - .to_owned(); - controller - .security(Request::Agent { - id: saved.id.clone(), - revision: saved.revision, - binding: Some(Binding { - provider: "test.provider".into(), - policy: json!({"synthetic":true}), - }), - }) - .unwrap(); - assert!( - controller.action(&saved.id, Action::Start).unwrap().agents[0] - .error - .as_deref() - .unwrap() - .contains("Invalid plugin profile") - ); - assert!(!dir.path().join("launches").exists()); - assert_eq!( - fs::read_dir(dir.path().join("config/run-controls")) - .unwrap() - .count(), - 0 - ); - let plugin_root = dir.path().join("plugins"); - controller.protect_control_paths(Ok(vec![plugin_root.clone()])); - for (index, action) in [Action::Start, Action::Restart].into_iter().enumerate() { - let snapshot = controller.action(&saved.id, action).unwrap(); - assert!( - matches!(snapshot.agents[0].status, ProcessStatus::Running), - "{:?}", - snapshot.agents[0].error - ); - let launches: Vec = - wait_for_contents(&dir.path().join("launches"), |s| { - let values = s - .lines() - .map(serde_json::from_str) - .collect::, _>>() - .ok()?; - (values.len() == index + 1).then_some(values) - }); - // Restart replaces the old snapshot; it must not leak prior controls. - assert_eq!( - fs::read_dir(dir.path().join("config/run-controls")) - .unwrap() - .count(), - 1 - ); - let context = &launches[index]; - assert_eq!(context["worker"], worker_path.to_str().unwrap()); - assert_eq!(context["args"], json!(worker.args)); - assert_eq!(context["policy"], json!({"synthetic":true})); - assert_eq!(context["workspace"], saved.workspace); - let paths = context["protectedPaths"].as_array().unwrap(); - for path in [ - &plugin_root, - &dir.path().join("config/agents.json"), - &dir.path().join("config/run-controls"), - &tools.path().to_path_buf(), - ] { - assert!(paths.contains(&json!(path)), "{paths:?}"); - } - for writable in [ - dir.path().join("config/buzz-agent/oauth"), - PathBuf::from(context["tmpdir"].as_str().unwrap()), - ] { - for protected in paths { - let protected = std::path::Path::new(protected.as_str().unwrap()); - assert!( - !writable.starts_with(protected), - "writable path {} is covered by protected path {}", - writable.display(), - protected.display(), - ); - } - } - } - controller.action(&saved.id, Action::Stop).unwrap(); - assert_eq!( - fs::read_dir(dir.path().join("config/run-controls")) - .unwrap() - .count(), - 0 - ); - controller - .security(Request::Unregister { - provider: "test.provider".into(), - lease, - }) - .unwrap(); - let before = fs::read(dir.path().join("launches")).unwrap(); - let refused = controller.action(&saved.id, Action::Start).unwrap(); - assert!(refused.agents[0] - .error - .as_deref() - .unwrap() - .contains("unavailable")); - assert!(controller.running.is_empty()); - assert_eq!(fs::read(dir.path().join("launches")).unwrap(), before); - assert_eq!( - fs::read_to_string(dir.path().join("starts")) - .unwrap() - .lines() - .count(), - 10 - ); -} - -#[cfg(target_os = "macos")] -mod protection_integration; - -#[test] -#[cfg(unix)] -fn launch_discovers_login_shell_tools_without_inheriting_credentials() { - const FIXTURE: &str = "BUZZ_TOOL_PATH_FIXTURE"; - let Some(root) = std::env::var_os(FIXTURE) else { - let dir = tempfile::Builder::new() - .prefix("Installed tools ") - .tempdir() - .unwrap(); - let tools = dir.path().join("custom tools"); - fs::create_dir_all(&tools).unwrap(); - crate::test_executable::write_executable( - &tools.join("installed-helper"), - "#!/bin/sh\nprintf 'discovered'\n", - ); - let shell = dir.path().join("login-shell"); - crate::test_executable::write_executable(&shell, format!( - "#!/bin/sh\n[ \"${{OPENAI_API_KEY-unset}}\" = unset ] || exit 1\n[ \"${{BUZZ_PRIVATE_KEY-unset}}\" = unset ] || exit 1\nexport SHELL_EXPORTED_SECRET=from-shell\nexport PATH='{}:/usr/bin:/bin'\n/bin/sh -c \"$2\"\n", tools.display())); - let output = Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "runtime::tests::launch_discovers_login_shell_tools_without_inheriting_credentials", - "--nocapture", - ]) - .env(FIXTURE, dir.path()) - .env("SHELL", shell) - .env("PATH", "/usr/bin:/bin") - .env("BUZZ_PRIVATE_KEY", "parent-key") - .env("NOSTR_PRIVATE_KEY", "parent-key") - .env("BUZZ_RELAY_URL", "wss://parent.example") - .env("OPENAI_API_KEY", "parent-provider-secret") - .env("BUZZ_ACP_REPLAY_FLOOR", "parent-replay") - .env("GIT_CONFIG_COUNT", "1") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - return; - }; - let root = PathBuf::from(root); - crate::prepare_tools_path(); - let runtime = bundle(&root); - let mut saved = agent(&root); - // An explicit tool path adds tools without displacing the bundled runtime. - saved.environment.insert( - "PATH".into(), - root.join("owner tools").display().to_string(), - ); - let command = runtime - .command(&saved, &Secret::parse(KEY, PUB).unwrap()) - .unwrap(); - let paths = command - .get_envs() - .find(|(name, _)| *name == "PATH") - .unwrap() - .1 - .unwrap(); - let paths = std::env::split_paths(paths).collect::>(); - assert_eq!(paths[0], root); - assert!(paths.contains(&root.join("owner tools"))); - assert_eq!( - installed("installed-helper"), - Some(root.join("custom tools/installed-helper")) - ); - // Exercise the resulting environment, including env_clear, in a real child. - let mut child = command; - crate::test_executable::write_executable( - &root.join("buzz-acp"), - r#"#!/bin/sh -installed-helper || exit 1 -printf '\n%s\n%s\n%s\n' "$BUZZ_PRIVATE_KEY" "$NOSTR_PRIVATE_KEY" "$BUZZ_RELAY_URL" -for name in OPENAI_API_KEY SHELL_EXPORTED_SECRET BUZZ_ACP_REPLAY_FLOOR GIT_CONFIG_COUNT; do - eval "value=\${$name-unset}" - [ "$value" = unset ] || exit 2 -done -"#, - ); - child.stdout(Stdio::piped()).stderr(Stdio::piped()); - let output = child.output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - String::from_utf8_lossy(&output.stdout), - format!("discovered\n{KEY}\n{KEY}\nwss://relay.example\n") - ); -} - -#[test] -#[cfg(unix)] -fn pi_saved_tool_path_is_shared_by_context_and_launch() { - let dir = tempfile::tempdir().unwrap(); - let tools = dir.path().join("saved tools"); - fs::create_dir(&tools).unwrap(); - crate::test_executable::write_executable( - &tools.join("saved-path-helper"), - "#!/bin/sh\nprintf '0.99.1\\n'\n", - ); - let runtime = bundle(dir.path()); - for name in ["node", "buzz-pi-acp"] { - crate::test_executable::write_executable(&dir.path().join(name), "#!/bin/sh\nexit 0\n"); - } - crate::test_executable::write_executable( - &dir.path().join("pi"), - "#!/bin/sh\nsaved-path-helper\n", - ); - let mut selected = agent(dir.path()); - selected.harness.command = dir.path().join("buzz-pi-acp").display().to_string(); - selected - .environment - .insert("PATH".into(), tools.display().to_string()); - let context = crate::pi::PiContext::new( - &selected.harness, - &selected.workspace, - &selected.environment, - ) - .unwrap(); - let paths = std::env::split_paths(&context.path).collect::>(); - assert_eq!(paths[0], dir.path()); - assert!(paths.contains(&tools), "Pi context omitted saved PATH"); - let version = context.version_command().output().unwrap(); - assert!(version.status.success()); - assert_eq!(String::from_utf8_lossy(&version.stdout), "0.99.1\n"); - let mut command = runtime - .command(&selected, &Secret::parse(KEY, PUB).unwrap()) - .unwrap(); - crate::test_executable::write_executable( - &dir.path().join("buzz-acp"), - "#!/bin/sh\nsaved-path-helper\n", - ); - let result = command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .unwrap(); - assert!( - result.status.success(), - "{}", - String::from_utf8_lossy(&result.stderr) - ); - assert_eq!(String::from_utf8_lossy(&result.stdout), "0.99.1\n"); -} - -#[test] -#[cfg(unix)] -fn codex_saved_adapter_survives_global_install_without_silent_fallback() { - use std::os::unix::fs::PermissionsExt; - const CHILD: &str = "BUZZ_TEST_SAVED_CODEX_ADAPTER"; - if std::env::var_os(CHILD).is_none() { - let home = tempfile::tempdir().unwrap(); - let output = Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "runtime::tests::codex_saved_adapter_survives_global_install_without_silent_fallback", "--nocapture"]) - .env_clear() - .env("HOME", home.path()) - .env("PATH", "/usr/bin:/bin") - .env(CHILD, "1") - .output().unwrap(); - assert!( - output.status.success(), - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - return; - } - let dir = tempfile::tempdir().unwrap(); - let user = PathBuf::from(std::env::var_os("HOME").unwrap()).join(".local/bin"); - let app_data = dir.path().join("app-data"); - let adapter = app_data.join("codex-tools/bin/codex-acp"); - let tool = |path: &Path, body: &str| { - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, body).unwrap(); - fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); - }; - tool(&adapter, "#!/usr/bin/env node\n"); - tool(&user.join("codex"), "#!/bin/sh\nexit 0\n"); - for platform in ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"] { - tool( - &app_data - .join("runtimes/node/v24.18.0") - .join(platform) - .join("bin/node"), - "#!/bin/sh\nexit 0\n", - ); - } - let (mut saved, _) = codex_fixture(dir.path()); - saved.harness.command = adapter.to_string_lossy().into_owned(); - let mut store = Store::open(dir.path().join("config")).unwrap(); - store.insert(vec![saved.clone()]).unwrap(); - let controller = Controller::new( - store, - Arc::new(Memory), - Err("No runtime".into()), - dir.path().join("ownership"), - ); - let edit: AgentEdit = serde_json::from_value(json!({ - "name": saved.name, "systemPrompt": saved.system_prompt, "workspace": saved.workspace, - "harness": saved.harness, "environment": {} - })) - .unwrap(); - let before = controller - .codex_model_context(&saved.id, saved.revision, edit.clone()) - .unwrap(); - assert_eq!(before.adapter, adapter.canonicalize().unwrap()); - assert_eq!( - before.interpreter, - Some( - managed_tool(&app_data, "node") - .unwrap() - .canonicalize() - .unwrap() - ) - ); - - let global = user.join("codex-acp"); - tool(&global, "#!/bin/sh\nexit 0\n"); - assert_eq!( - crate::codex::installed_adapter(Some(&app_data)), - Some(global) - ); - assert!( - controller - .codex_model_context(&saved.id, saved.revision, edit.clone()) - .unwrap() - == before - ); - fs::remove_file(&adapter).unwrap(); - assert!(controller - .codex_model_context(&saved.id, saved.revision, edit) - .is_err()); -} diff --git a/crates/agent-controller/src/runtime/tests/protection_integration.rs b/crates/agent-controller/src/runtime/tests/protection_integration.rs deleted file mode 100644 index 09c920689..000000000 --- a/crates/agent-controller/src/runtime/tests/protection_integration.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! Opt-in real listeners + enforcing provider. Disposable relay and synthetic workers. -use super::*; -use crate::security::{Binding, Request}; -#[path = "protection_relay.rs"] -mod relay; - -struct LogsOnFailure(PathBuf); -impl Drop for LogsOnFailure { - fn drop(&mut self) { - if std::thread::panicking() { - for entry in fs::read_dir(self.0.join("logs")) - .into_iter() - .flatten() - .flatten() - { - eprintln!("{}", fs::read_to_string(entry.path()).unwrap_or_default()); - } - } - } -} -// Real engine bootstrap and ACP's retry backoff are bounded by this integration -// deadline; each step still waits for its explicit completion signal. -fn wait(path: &Path, parse: impl Fn(&str) -> Option) -> T { - let deadline = Instant::now() + Duration::from_secs(30); - loop { - if let Some(value) = fs::read_to_string(path).ok().and_then(|text| parse(&text)) { - return value; - } - assert!(Instant::now() < deadline, "fixture did not reach {path:?}"); - std::thread::sleep(Duration::from_millis(10)); - } -} -struct Gate { - token: PathBuf, - pid: i32, -} -impl Gate { - fn accept(work: &Path, previous: Option<&Gate>) -> Self { - wait(&work.join("ready.json"), |text| { - let value: serde_json::Value = serde_json::from_str(text).ok()?; - let token = work.join(value["token"].as_str()?); - if previous.is_some_and(|p| p.token == token) { - return None; - } - Some(Self { - token, - pid: value["pid"].as_i64()? as i32, - }) - }) - } - fn check(&self) { - fs::write(self.token.with_extension("probe"), "").unwrap(); - wait(&self.token.with_extension("checked"), |_| Some(())); - } - fn finish(&self) { - fs::write(self.token.with_extension("reply"), "").unwrap(); - } -} -struct LiveRun { - process: Supervised, - control: PathBuf, - scratch: PathBuf, - log: PathBuf, -} -impl LiveRun { - fn completed(&self, count: usize) { - wait(&self.log, |text| { - let text = strip_ansi_escapes::strip_str(text); - (text - .lines() - .filter(|line| line.contains("agent_returned") && line.contains("outcome=\"ok\"")) - .count() - == count) - .then_some(()) - }); - } -} -fn start(controller: &Controller, agent: &Agent, secret: &Secret, relay: &str) -> LiveRun { - let config = controller.store.root(); - crate::connection::oauth_root(config).unwrap(); - let runs = config.join("runs"); - crate::connection::private_directory(&runs).unwrap(); - let temporary = tempfile::tempdir_in(&runs).unwrap(); - let scratch = temporary.path().join("tmp"); - fs::create_dir(&scratch).unwrap(); - let mut command = controller - .bundle - .as_ref() - .unwrap() - .command(agent, secret) - .unwrap(); - // App records require secure origins. Only this disposable fixture uses a - // loopback plaintext transport; the real ACP binary/launch path is unchanged. - command - .env("RUST_LOG", "buzz_acp=debug") - .env("BUZZ_RELAY_URL", relay) - .env("BUZZ_AGENT_CONFIG_DIR", config) - .env("TMPDIR", &scratch) - .env("TMP", &scratch) - .env("TEMP", &scratch); - let control = controller - .wrap_protected_worker(agent, &mut command) - .unwrap() - .unwrap(); - let log = crate::logs::path(config, &agent.id).unwrap(); - let scratch = temporary.keep(); - let control = control.keep(); - let process = Supervised::spawn( - &command, - &controller.ownership_root, - &agent.id, - &scratch, - Some(&control), - &log, - ) - .unwrap(); - LiveRun { - process, - control, - scratch, - log, - } -} -fn probe( - work: &Path, - config: &Path, - scratch: &Path, - own: &Path, - peer: &Path, - controller: &Controller, -) { - let mut files = vec![]; - for run in [own, peer] { - files.extend([run.join("launch-protection.json"), run.join("launcher")]); - } - files.extend( - controller - .store - .protected_control_paths() - .into_iter() - .filter(|p| !p.is_dir()), - ); - // Missing store controls must be protected too; existing files exercise chmod/unlink. - let directories = vec![ - controller.ownership_root.clone(), - own.to_path_buf(), - peer.to_path_buf(), - config.join("run-controls"), - config.join("control-write"), - ]; - fs::write( - work.join("probe.json"), - serde_json::to_vec( - &json!({"config":config,"scratch":scratch,"files":files,"directories":directories}), - ) - .unwrap(), - ) - .unwrap(); -} - -#[test] -#[ignore = "requires prepared runtime and BUZZ_TEST_PROTECTION_LAUNCHER/POLICY"] -fn real_listeners_protect_future_run_controls_and_recover_worker_crashes() { - let resources = - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../src-tauri/resources/agent-runtime"); - let runtime = RuntimeBundle::new(resources.canonicalize().unwrap()).unwrap(); - let launcher = PathBuf::from(std::env::var_os("BUZZ_TEST_PROTECTION_LAUNCHER").unwrap()); - let policy: serde_json::Value = - serde_json::from_str(&std::env::var("BUZZ_TEST_PROTECTION_POLICY").unwrap()).unwrap(); - let provider = tempfile::tempdir().unwrap(); - for entry in fs::read_dir(launcher.parent().unwrap()).unwrap() { - let entry = entry.unwrap(); - if entry.file_type().unwrap().is_file() { - crate::test_executable::copy_executable( - &entry.path(), - &provider.path().join(entry.file_name()), - ); - } - } - let launcher = provider.path().join(launcher.file_name().unwrap()); - let root = tempfile::tempdir().unwrap(); - let public_b = relay::public_key(3); - let relay = relay::Relay::new(&[PUB.into(), public_b.clone()]); - let config = root.path().join("config"); - let _logs = LogsOnFailure(config.clone()); - let mut store = Store::open(config.clone()).unwrap(); - let work_a = root.path().join("a"); - let work_b = root.path().join("b"); - let mut agents = Vec::new(); - for (work, public) in [(&work_a, PUB), (&work_b, &public_b)] { - fs::create_dir(work).unwrap(); - let worker = work.join("goose"); - crate::test_executable::write_executable(&worker, include_str!("protection_worker.py")); - fs::write(work.join("probe.json"), json!({}).to_string()).unwrap(); - let mut saved = agent(work); - saved.pubkey = public.into(); - saved.relay_url = relay.url.replacen("ws://", "wss://", 1); - saved.id = agent_id(public, &saved.relay_url); - saved.auth_tag = Some(crate::secret::test_attestation(public)); - saved.harness.command = worker.to_str().unwrap().into(); - saved.harness.args.clear(); - saved.harness.model.clear(); - saved.harness.provider.clear(); - saved.imported = json!({"record":{"respond_to":"owner-only","parallelism":1}}); - saved.extra.insert( - "launchProtection".into(), - serde_json::to_value(Binding { - provider: "test.enforcing".into(), - policy: policy.clone(), - }) - .unwrap(), - ); - agents.push(saved); - } - store.insert(agents.clone()).unwrap(); - let mut controller = Controller::new( - store, - Arc::new(Memory), - Ok(runtime), - root.path().join("ownership"), - ); - controller - .security(Request::Register { - provider: "test.enforcing".into(), - executable: launcher.clone(), - }) - .unwrap(); - let mut run_a = start( - &controller, - &agents[0], - &Secret::parse(KEY, PUB).unwrap(), - &relay.url, - ); - relay.online(PUB); - assert!(!work_a.join("initialized").exists()); - relay.mention(PUB, "first probe"); - let a = Gate::accept(&work_a, None); // A's enforcing policy is active BEFORE B exists. - let control_a = run_a.control.clone(); - assert_eq!( - fs::read_dir(config.join("run-controls")).unwrap().count(), - 1 - ); - let secret_b = Secret::parse(&format!("{:064x}", 3), &public_b).unwrap(); - let mut run_b = start(&controller, &agents[1], &secret_b, &relay.url); - relay.online(&public_b); - assert!(!work_b.join("initialized").exists()); - let control_b = run_b.control.clone(); - let scratch_a = run_a.scratch.clone(); - let scratch_b = run_b.scratch.clone(); - let snapshots: Vec<_> = [&control_a, &control_b] - .into_iter() - .flat_map(|dir| [dir.join("launcher"), dir.join("launch-protection.json")]) - .map(|path| { - let bytes = fs::read(&path).unwrap(); - (path, bytes) - }) - .collect(); - // Both listeners are alive. B has not launched a worker. The mutable source - // now fails, so first startup and crash replacement must use staged bytes. - fs::write(&launcher, "#!/bin/sh\nexit 99\n").unwrap(); - probe( - &work_a, - &config, - &scratch_a.join("tmp"), - &control_a, - &control_b, - &controller, - ); - probe( - &work_b, - &config, - &scratch_b.join("tmp"), - &control_b, - &control_a, - &controller, - ); - a.check(); - a.finish(); - wait(&work_a.join("completed"), |s| (!s.is_empty()).then_some(())); - run_a.completed(1); - relay.mention(&public_b, "crash probe"); - let b = Gate::accept(&work_b, None); - b.check(); - // Kill the real worker during an outstanding prompt, keeping its listener. - assert_eq!(unsafe { libc::kill(b.pid, libc::SIGKILL) }, 0); - let recovered = Gate::accept(&work_b, Some(&b)); - assert_ne!(recovered.pid, b.pid); - recovered.check(); - recovered.finish(); - wait(&work_b.join("completed"), |s| { - (s.lines().count() == 1).then_some(()) - }); - run_b.completed(1); - relay.mention(&public_b, "post recovery probe"); - let later = Gate::accept(&work_b, Some(&recovered)); - later.check(); - later.finish(); - wait(&work_b.join("completed"), |s| { - (s.lines().count() == 2).then_some(()) - }); - run_b.completed(2); - for (path, bytes) in snapshots { - assert_eq!(fs::read(path).unwrap(), bytes); - } - controller - .store - .enabled(&agents[0].id, agents[0].enabled) - .unwrap(); - run_a.process.stop().unwrap(); - assert!(!control_a.exists() && !scratch_a.exists()); - assert!(control_b.exists() && scratch_b.exists()); - run_b.process.stop().unwrap(); - assert!(!control_b.exists() && !scratch_b.exists()); - for work in [work_a, work_b] { - assert!(config - .join("buzz-agent/oauth/databricks") - .join(work.file_name().unwrap()) - .with_extension("json") - .exists()); - } -} diff --git a/crates/agent-controller/src/runtime/tests/protection_relay.rs b/crates/agent-controller/src/runtime/tests/protection_relay.rs deleted file mode 100644 index 3faa3add5..000000000 --- a/crates/agent-controller/src/runtime/tests/protection_relay.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! Disposable protocol fixture for real ACP listeners; no external services. -use super::*; -use std::io::{BufRead, BufReader, Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - mpsc, Mutex, -}; -use tungstenite::Message; - -const CHANNEL: &str = "e1becfb2-7e7f-441d-b079-bcb4f06ed39b"; -pub(super) fn public_key(secret: u8) -> String { - pair(secret).x_only_public_key().0.to_string() -} -fn pair(secret: u8) -> secp256k1::Keypair { - let mut bytes = [0; 32]; - bytes[31] = secret; - secp256k1::Keypair::from_secret_key( - &secp256k1::Secp256k1::new(), - &secp256k1::SecretKey::from_byte_array(bytes).unwrap(), - ) -} -fn event(kind: u64, tags: serde_json::Value, content: &str) -> serde_json::Value { - use sha2::{Digest, Sha256}; - let pair = pair(2); // owner used by test_attestation - let public = pair.x_only_public_key().0.to_string(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let digest = - Sha256::digest(serde_json::to_vec(&json!([0, public, now, kind, tags, content])).unwrap()); - let id: String = digest.iter().map(|b| format!("{b:02x}")).collect(); - let sig = secp256k1::Secp256k1::new() - .sign_schnorr_no_aux_rand(&digest, &pair) - .to_string(); - json!({"id":id,"pubkey":public,"created_at":now,"kind":kind,"tags":tags,"content":content,"sig":sig}) -} - -pub(super) struct Relay { - pub url: String, - clients: Arc>>>, - published: mpsc::Receiver, - stop: Arc, - thread: Option>, -} -impl Relay { - pub fn new(agents: &[String]) -> Self { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(true).unwrap(); - let url = format!("ws://{}", listener.local_addr().unwrap()); - let clients = Arc::new(Mutex::new(Vec::new())); - let stop = Arc::new(AtomicBool::new(false)); - let (send, published) = mpsc::channel(); - let mut members = vec![json!(["d", CHANNEL])]; - members.extend(agents.iter().map(|agent| json!(["p", agent]))); - let membership = event(39002, json!(members), ""); - let metadata = event( - 39000, - json!([ - ["d", CHANNEL], - ["name", "Protection fixture"], - ["t", "stream"] - ]), - "", - ); - let thread = { - let clients = clients.clone(); - let stop = stop.clone(); - std::thread::spawn(move || { - let mut connections = Vec::new(); - while !stop.load(Ordering::SeqCst) { - match listener.accept() { - Ok((stream, _)) => { - let (tx, rx) = mpsc::channel(); - clients.lock().unwrap().push(tx); - let (stop, send, membership, metadata) = ( - stop.clone(), - send.clone(), - membership.clone(), - metadata.clone(), - ); - connections.push(std::thread::spawn(move || { - connection(stream, rx, send, stop, membership, metadata) - })); - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(10)) - } - Err(e) => panic!("fixture accept: {e}"), - } - } - for connection in connections { - connection.join().unwrap(); - } - }) - }; - Self { - url, - clients, - published, - stop, - thread: Some(thread), - } - } - pub fn mention(&self, target: &str, content: &str) { - let event = event(9, json!([["h", CHANNEL], ["p", target]]), content); - self.clients - .lock() - .unwrap() - .retain(|client| client.send(event.clone()).is_ok()); - } - pub fn online(&self, agent: &str) { - let deadline = Instant::now() + Duration::from_secs(30); - let (mut online, mut subscribed) = (false, false); - loop { - let event = self - .published - .recv_timeout(deadline.saturating_duration_since(Instant::now())) - .expect("listener did not publish online presence"); - online |= - event["kind"] == 20001 && event["content"] == "online" && event["pubkey"] == agent; - subscribed |= event["subscribed"] == agent; - if online && subscribed { - return; - } - } - } -} -impl Drop for Relay { - fn drop(&mut self) { - self.stop.store(true, Ordering::SeqCst); - self.thread.take().unwrap().join().unwrap(); - } -} -fn connection( - mut stream: TcpStream, - incoming: mpsc::Receiver, - published: mpsc::Sender, - stop: Arc, - membership: serde_json::Value, - metadata: serde_json::Value, -) { - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - stream - .set_write_timeout(Some(Duration::from_secs(2))) - .unwrap(); - // Peek headers without consuming a WebSocket handshake. - let mut peek = [0; 8192]; - let header = loop { - let Ok(n) = stream.peek(&mut peek) else { - return; - }; - if n == 0 { - return; - } - if let Some(end) = peek[..n].windows(4).position(|w| w == b"\r\n\r\n") { - break String::from_utf8_lossy(&peek[..end + 4]).into_owned(); - } - if stop.load(Ordering::SeqCst) { - return; - } - std::thread::sleep(Duration::from_millis(1)); - }; - if !header.to_ascii_lowercase().contains("upgrade: websocket") { - let mut reader = BufReader::new(&mut stream); - let mut line = String::new(); - let mut length = 0; - loop { - line.clear(); - if reader.read_line(&mut line).unwrap_or(0) == 0 { - return; - } - if line == "\r\n" { - break; - } - if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { - length = value.trim().parse().unwrap(); - } - } - let mut body = vec![0; length]; - if reader.read_exact(&mut body).is_err() { - return; - } - let response = if header.starts_with("POST /query ") { - let filters: serde_json::Value = serde_json::from_slice(&body).unwrap(); - if filters.as_array().unwrap().iter().any(|f| { - f["kinds"] - .as_array() - .is_some_and(|k| k.contains(&json!(39002))) - }) { - json!([membership]) - } else if filters.as_array().unwrap().iter().any(|f| { - f["kinds"] - .as_array() - .is_some_and(|k| k.contains(&json!(39000))) - }) { - json!([metadata]) - } else { - json!([]) - } - } else if header.starts_with("POST /events ") { - let _ = published.send(serde_json::from_slice(&body).unwrap()); - json!({}) - } else if header.starts_with("GET / ") { - json!({"self":public_key(2)}) - } else { - json!({"count":0}) - }; - let body = response.to_string(); - let _ = write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); - return; - } - let Ok(mut ws) = tungstenite::accept(stream) else { - return; - }; - ws.get_mut() - .set_read_timeout(Some(Duration::from_millis(30))) - .unwrap(); - let mut subscriptions = Vec::<(String, String)>::new(); - if ws - .send(Message::text(json!(["AUTH", "fixture"]).to_string())) - .is_err() - { - return; - } - while !stop.load(Ordering::SeqCst) { - for event in incoming.try_iter() { - for (id, target) in &subscriptions { - if event["tags"] - .as_array() - .unwrap() - .contains(&json!(["p", target])) - && ws - .send(Message::text(json!(["EVENT", id, event]).to_string())) - .is_err() - { - return; - } - } - } - let text = match ws.read() { - Ok(Message::Text(text)) => text, - Ok(_) => continue, - Err(tungstenite::Error::Io(e)) - if matches!( - e.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => - { - continue - } - Err(_) => return, - }; - let value: serde_json::Value = serde_json::from_str(&text).unwrap(); - let response = match value[0].as_str() { - Some("AUTH" | "EVENT") => { - let _ = published.send(value[1].clone()); - json!(["OK", value[1]["id"], true, ""]) - } - Some("REQ") => { - for filter in value.as_array().unwrap().iter().skip(2) { - if filter["#h"] - .as_array() - .is_some_and(|h| h.contains(&json!(CHANNEL))) - { - for target in filter["#p"].as_array().into_iter().flatten() { - let _ = published.send(json!({"subscribed":target})); - subscriptions.push(( - value[1].as_str().unwrap().into(), - target.as_str().unwrap().into(), - )); - } - } - } - json!(["EOSE", value[1]]) - } - _ => continue, - }; - if ws.send(Message::text(response.to_string())).is_err() { - return; - } - } -} diff --git a/crates/agent-controller/src/runtime/tests/protection_worker.py b/crates/agent-controller/src/runtime/tests/protection_worker.py deleted file mode 100644 index 1b4ef7049..000000000 --- a/crates/agent-controller/src/runtime/tests/protection_worker.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -"""Synthetic ACP worker. Gates expose real sandbox and listener lifecycle ordering.""" -import fcntl -import json -import os -from pathlib import Path -import sys -import tempfile -import time - -assert sys.argv[1:] == ['acp'], sys.argv - -def denied(operation): - try: - operation() - except PermissionError: - return - raise AssertionError('protected operation succeeded') - -def probe(p): - # Broad profile writes must actually be allowed; a restrictive workspace - # grant would mask the cross-run defect and make this test a false positive. - with tempfile.TemporaryFile(dir=p['config']) as ordinary: - ordinary.write(b'ordinary profile writes are allowed') - oauth = Path(p['config']) / 'buzz-agent/oauth/databricks' - with (oauth / 'fixture.lock').open('a') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - with tempfile.NamedTemporaryFile(dir=oauth, delete=False) as tmp: - tmp.write(b'synthetic cache') - os.replace(tmp.name, oauth / (Path.cwd().name + '.json')) - for directory in [p['scratch'], os.environ['TMPDIR']]: - with tempfile.TemporaryFile(dir=directory) as tmp: - tmp.write(b'temp works') - for target in map(Path, p['files']): - if target.exists(): - denied(lambda: target.chmod(0o700)) - denied(target.unlink) - denied(lambda: target.open('wb')) - source = Path(p['scratch']) / 'replacement' - source.write_text('must not replace control') - denied(lambda: os.replace(source, target)) - source.unlink() - for directory in map(Path, p['directories']): - denied(lambda: (directory / 'injected').write_text('bad')) - denied(lambda: directory.rename(str(directory) + '-moved')) - denied(lambda: Path(p['config']).rename(p['config'] + '-moved')) - -def wait(path): - deadline = time.monotonic() + 30 - while not path.exists(): - assert time.monotonic() < deadline, 'gate not released' - time.sleep(.01) - -sequence = 0 -for line in sys.stdin: - msg = json.loads(line) - if 'id' not in msg: - continue - result = {} - if msg.get('method') == 'initialize': - with open('initialized', 'a') as log: - log.write(str(os.getpid()) + '\n') - result = {'protocolVersion': 1, 'agentCapabilities': {}} - elif msg.get('method') == 'session/new': - result = {'sessionId': 'probe'} - elif msg.get('method') == 'session/prompt': - sequence += 1 - token = f'gate-{os.getpid()}-{sequence}' - Path('ready.tmp').write_text(json.dumps({'pid':os.getpid(), 'token':token})) - os.replace('ready.tmp', 'ready.json') - wait(Path(token + '.probe')) - probe(json.loads(Path('probe.json').read_text())) - Path(token + '.checked').touch() - wait(Path(token + '.reply')) - with open('completed', 'a') as log: - log.write(str(os.getpid()) + '\n') - print(json.dumps({'jsonrpc':'2.0','method':'session/update','params':{ - 'sessionId':'probe','update':{'sessionUpdate':'agent_message_chunk', - 'content':{'type':'text','text':'fixture complete'}}}}), flush=True) - result = {'stopReason': 'end_turn'} - print(json.dumps({'jsonrpc':'2.0','id':msg['id'],'result':result}), flush=True) diff --git a/crates/agent-controller/src/security.rs b/crates/agent-controller/src/security.rs deleted file mode 100644 index 87c30d702..000000000 --- a/crates/agent-controller/src/security.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! Generic, native-owned launch protection. Provider code and policy schemas are external. -pub use crate::store::Binding; -use crate::{config::Agent, runtime::Controller, store::PROTECTION_KEY as KEY, Result}; -use serde::Deserialize; -use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; -use std::{ - path::{Path, PathBuf}, - process::Command, -}; - -#[derive(Clone)] -pub(crate) struct Provider { - executable: PathBuf, - digest: Vec, - lease: String, -} -#[derive(Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] -pub enum Request { - Snapshot, - Register { - provider: String, - executable: PathBuf, - }, - Unregister { - provider: String, - lease: String, - }, - Defaults { - revision: u64, - binding: Option, - }, - Agent { - id: String, - revision: u64, - binding: Option, - }, -} -fn executable_bytes(path: &Path) -> Result> { - if !path.is_absolute() { - return Err("Choose an absolute protection launcher path".into()); - } - let mut options = std::fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); - } - let file = options - .open(path) - .map_err(|_| "Protection launcher is unavailable")?; - let meta = file - .metadata() - .map_err(|_| "Cannot inspect protection launcher")?; - if !meta.is_file() || meta.len() > 256 * 1024 * 1024 { - return Err("Protection launcher must be a bounded regular file".into()); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if meta.permissions().mode() & 0o111 == 0 { - return Err("Protection launcher is not executable".into()); - } - } - use std::io::Read; - let mut bytes = Vec::new(); - file.take(256 * 1024 * 1024 + 1) - .read_to_end(&mut bytes) - .map_err(|_| "Cannot read protection launcher")?; - if bytes.len() > 256 * 1024 * 1024 { - return Err("Protection launcher exceeds size limit".into()); - } - Ok(bytes) -} -impl Controller { - pub fn protect_control_paths(&mut self, paths: Result>) { - self.protection_paths = paths; - } - - pub fn security(&mut self, request: Request) -> Result { - match request { - Request::Snapshot => self.security_snapshot(), - Request::Register { - provider, - executable, - } => { - if !Binding::valid_provider(&provider) { - return Err("Invalid protection provider".into()); - } - let digest = Sha256::digest(executable_bytes(&executable)?).to_vec(); - let mut bytes = [0u8; 24]; - getrandom::fill(&mut bytes).map_err(|_| "Cannot create provider lease")?; - let lease: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); - self.security_providers.insert( - provider, - Provider { - executable, - digest, - lease: lease.clone(), - }, - ); - Ok(json!({"lease": lease})) - } - Request::Unregister { provider, lease } => { - if self - .security_providers - .get(&provider) - .is_some_and(|p| p.lease == lease) - { - self.security_providers.remove(&provider); - } - self.security_snapshot() - } - Request::Defaults { revision, binding } => { - self.require_provider(&binding)?; - self.store - .set_launch_protection_defaults(revision, binding)?; - self.security_snapshot() - } - Request::Agent { - id, - revision: expected, - binding, - } => { - self.require_provider(&binding)?; - self.store.set_launch_protection(&id, expected, binding)?; - self.security_snapshot() - } - } - } - fn require_provider(&self, binding: &Option) -> Result<()> { - if binding - .as_ref() - .is_some_and(|b| !self.security_providers.contains_key(&b.provider)) - { - return Err("Enable the protection provider before saving".into()); - } - Ok(()) - } - fn security_snapshot(&self) -> Result { - let mut snapshot = self.store.launch_protection_snapshot()?; - snapshot["availableProviders"] = json!(self.security_providers.keys().collect::>()); - Ok(snapshot) - } - pub fn security_restore_ids(&mut self, provider: &str) -> Result> { - let running: Vec<_> = self - .snapshot()? - .agents - .into_iter() - .filter(|a| a.status == crate::ProcessStatus::Running) - .map(|a| a.id) - .collect(); - Ok(self - .store - .agents()? - .iter() - .filter(|a| a.starts_on_launch() && !running.contains(&a.id)) - .filter_map(|a| { - Binding::decode(a.extra.get(KEY)) - .ok() - .flatten() - .filter(|b| b.provider == provider) - .map(|_| a.id.clone()) - }) - .collect()) - } - pub(crate) fn wrap_protected_worker( - &self, - agent: &Agent, - command: &mut Command, - ) -> Result> { - let Some(binding) = Binding::decode(agent.extra.get(KEY))? else { - return Ok(None); - }; - let provider = self - .security_providers - .get(&binding.provider) - .ok_or("Required security plugin is unavailable; agent was not started")?; - if !cfg!(unix) { - return Err("Protected worker launch requires Unix process containment".into()); - } - // Hash and stage the SAME bytes: delayed starts and worker replacement - // must never execute the mutable registration path. - let bytes = executable_bytes(&provider.executable)?; - if Sha256::digest(&bytes).as_slice() != provider.digest { - return Err("Protection launcher changed; re-enable the plugin before starting".into()); - } - // Every policy covers this namespace, including runs created later. - let controls = self.store.root().join("run-controls"); - crate::connection::private_directory(&controls)?; - let snapshot = tempfile::Builder::new() - .prefix("agent-") - .tempdir_in(&controls) - .map_err(|_| "Cannot create private protection directory")?; - let control = snapshot.path(); - let directory = provider - .executable - .parent() - .ok_or("Invalid protection launcher")?; - let mut protected_paths = self.protection_paths.clone()?; - protected_paths.extend(self.store.protected_control_paths()); - protected_paths.push(self.ownership_root.clone()); - protected_paths.push(directory.to_path_buf()); - protected_paths.push(controls); - if let Ok(bundle) = &self.bundle { - protected_paths.push(bundle.directory.clone()); - } - protected_paths.push(std::env::current_exe().map_err(|_| "Cannot locate host executable")?); - let context = json!({"version":2, "providerDirectory":directory, - "policy":binding.policy,"relayUrl":agent.relay_url,"workspace":agent.workspace, - "protectedPaths":protected_paths}); - let path = control.join("launch-protection.json"); - let launcher = control.join("launcher"); - write_snapshot(&launcher, &bytes, 0o500)?; - write_snapshot( - &path, - &serde_json::to_vec(&context).map_err(|_| "Invalid launch context")?, - 0o400, - )?; - command.env( - "BUZZ_ACP_LAUNCH_PREFIX", - serde_json::to_string(&json!([launcher, "--launch", path, "--"])) - .map_err(|_| "Invalid launch prefix")?, - ); - Ok(Some(snapshot)) - } -} - -fn write_snapshot(path: &Path, bytes: &[u8], mode: u32) -> Result<()> { - use std::io::Write; - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(mode); - } - #[cfg(not(unix))] - let _ = mode; - options - .open(path) - .and_then(|mut file| { - file.write_all(bytes)?; - file.sync_all() - }) - .map_err(|_| "Cannot create private protection launch snapshot".into()) -} - -#[cfg(all(test, unix))] -mod tests { - use super::*; - #[test] - fn nonregular_launchers_are_rejected_without_waiting_for_a_writer() { - let root = tempfile::tempdir().unwrap(); - let fifo = root.path().join("launcher"); - assert!(Command::new("mkfifo") - .arg(&fifo) - .status() - .unwrap() - .success()); - assert!(executable_bytes(&fifo) - .unwrap_err() - .contains("regular file")); - let link = root.path().join("link"); - std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap(); - assert!(executable_bytes(&link).is_err()); - } -} diff --git a/crates/agent-controller/src/skills.rs b/crates/agent-controller/src/skills.rs deleted file mode 100644 index 520488250..000000000 --- a/crates/agent-controller/src/skills.rs +++ /dev/null @@ -1,177 +0,0 @@ -use crate::{connection::private_directory, Result}; -use std::fs; -use std::io::Write; -use std::path::Path; - -// Copied from desktop/src-tauri/src/managed_agents/nest_skill.md at the Buzz -// revision in runtime/agent-runtime.json. -const CLI_SKILL: &str = include_str!("buzz_cli_skill.md"); -// Shares the old desktop's marker. Bump only with a newer template; never -// downgrade an installation refreshed by a newer old desktop. -const CLI_SKILL_VERSION: u32 = 6; -#[cfg(unix)] -const PROVIDERS: &[&str] = &[".claude", ".codex", ".goose"]; - -/// Install the bundled CLI guidance in the desktop's default workspace. -/// Like the old desktop, custom content survives until a template upgrade. -pub fn ensure_buzz_cli_skill(workspace: &Path) -> Result<()> { - if !workspace.is_absolute() { - return Err("Buzz skill workspace must be absolute".into()); - } - private_directory(workspace)?; - let agents = workspace.join(".agents"); - private_directory(&agents)?; - let skills = agents.join("skills"); - private_directory(&skills)?; - let canonical = skills.join("buzz-cli"); - #[cfg(unix)] - migrate_claude_skill(workspace, &canonical)?; - private_directory(&canonical)?; - let skill = canonical.join("SKILL.md"); - let version = canonical.join(".skill-version"); - let skill_exists = regular_file(&skill)?; - let installed_version = read_version(&version)?; - if !skill_exists || installed_version < CLI_SKILL_VERSION { - atomic_write(&skill, CLI_SKILL)?; - } - // Publish the marker only after the content succeeds; interrupted installs - // retry next startup. Missing content is repaired even with a newer marker. - if installed_version < CLI_SKILL_VERSION { - atomic_write(&version, &format!("{CLI_SKILL_VERSION}\n"))?; - } - #[cfg(unix)] - for provider in PROVIDERS { - let parent = workspace.join(provider); - if let Err(error) = link_provider(&parent) { - eprintln!( - "buzz: skipped CLI skill link at {}: {error}", - parent.display() - ); - } - } - Ok(()) -} - -#[cfg(unix)] -fn link_provider(parent: &Path) -> Result<()> { - private_directory(parent)?; - let skills = parent.join("skills"); - private_directory(&skills)?; - let link = skills.join("buzz-cli"); - match fs::symlink_metadata(&link) { - Ok(meta) if meta.file_type().is_symlink() && !link.exists() => { - fs::remove_file(&link).map_err(|_| "Could not repair Buzz skill link")?; - } - Ok(_) => return Ok(()), // Preserve existing links and custom real directories. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err("Could not inspect Buzz skill link".into()), - } - std::os::unix::fs::symlink("../../.agents/skills/buzz-cli", &link) - .map_err(|_| "Could not create Buzz skill link".into()) -} - -#[cfg(unix)] -fn migrate_claude_skill(workspace: &Path, canonical: &Path) -> Result<()> { - let canonical_exists = match fs::symlink_metadata(canonical) { - Ok(_) => { - private_directory(canonical)?; - if read_version(&canonical.join(".skill-version"))? >= CLI_SKILL_VERSION { - return Ok(()); - } - true - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, - Err(_) => return Err("Could not inspect canonical Buzz skill directory".into()), - }; - let provider = workspace.join(".claude"); - let skills = provider.join("skills"); - // Migrate only from the workspace's own directories. A missing or - // redirected ancestor holds no legacy layout to move, and must not stop - // the canonical install. - let real_dir = |path: &Path| fs::symlink_metadata(path).is_ok_and(|meta| meta.is_dir()); - if !real_dir(&provider) || !real_dir(&skills) { - return Ok(()); - } - let legacy = skills.join("buzz-cli"); - match fs::symlink_metadata(&legacy) { - Ok(meta) if meta.is_dir() && !meta.file_type().is_symlink() => { - let legacy_skill_exists = regular_file(&legacy.join("SKILL.md"))?; - let version = legacy.join(".skill-version"); - let legacy_version = read_version(&version)?; - if canonical_exists { - // Old startup created the canonical template before migrating. - // Merge that partial directory into the legacy one, preserving - // resources from both. Refuse name collisions before any moves. - let entries = fs::read_dir(canonical) - .map_err(|_| "Could not inspect incomplete Buzz skill")? - .collect::>>() - .map_err(|_| "Could not inspect incomplete Buzz skill")?; - for entry in &entries { - if entry.file_name() == "SKILL.md" || entry.file_name() == ".skill-version" { - regular_file(&entry.path())?; - } else if fs::symlink_metadata(legacy.join(entry.file_name())).is_ok() { - return Err( - "Legacy and canonical Buzz skill resources conflict; left unchanged" - .into(), - ); - } - } - for entry in entries { - if entry.file_name() == "SKILL.md" || entry.file_name() == ".skill-version" { - fs::remove_file(entry.path()) - .map_err(|_| "Could not replace incomplete Buzz skill")?; - } else { - fs::rename(entry.path(), legacy.join(entry.file_name())) - .map_err(|_| "Could not preserve incomplete Buzz skill resources")?; - } - } - fs::remove_dir(canonical) - .map_err(|_| "Could not replace incomplete Buzz skill directory")?; - } - // Mark preserved legacy content before moving it. If startup stops - // after the rename, the next launch still preserves those user edits. - if legacy_skill_exists && legacy_version < CLI_SKILL_VERSION { - atomic_write(&version, &format!("{CLI_SKILL_VERSION}\n"))?; - } - // Move the whole directory so supporting files and user edits survive. - fs::rename(legacy, canonical).map_err(|_| "Could not migrate legacy Buzz skill")?; - Ok(()) - } - Ok(_) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(_) => Err("Could not inspect legacy Buzz skill".into()), - } -} - -fn read_version(path: &Path) -> Result { - if !regular_file(path)? { - return Ok(0); - } - Ok(fs::read_to_string(path) - .map_err(|_| "Could not read Buzz skill version")? - .trim() - .parse() - .unwrap_or(0)) -} - -fn regular_file(path: &Path) -> Result { - match fs::symlink_metadata(path) { - Ok(meta) if meta.is_file() && !meta.file_type().is_symlink() => Ok(true), - Ok(_) => Err("Buzz skill content and version must be regular files".into()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(_) => Err("Could not inspect Buzz skill file".into()), - } -} - -fn atomic_write(path: &Path, content: &str) -> Result<()> { - let mut file = tempfile::NamedTempFile::new_in(path.parent().ok_or("Missing skill directory")?) - .map_err(|_| "Could not prepare Buzz skill write")?; - file.write_all(content.as_bytes()) - .map_err(|_| "Could not write Buzz skill")?; - file.persist(path) - .map_err(|_| "Could not install Buzz skill")?; - Ok(()) -} - -#[cfg(test)] -mod tests; diff --git a/crates/agent-controller/src/skills/tests.rs b/crates/agent-controller/src/skills/tests.rs deleted file mode 100644 index 587e92a0d..000000000 --- a/crates/agent-controller/src/skills/tests.rs +++ /dev/null @@ -1,243 +0,0 @@ -use super::*; -use std::fs; - -#[test] -fn fresh_workspace_installs_discoverable_cli_guidance() { - let dir = tempfile::tempdir().unwrap(); - let workspace = dir.path().join(".buzz"); - ensure_buzz_cli_skill(&workspace).unwrap(); - let skill = workspace.join(".agents/skills/buzz-cli/SKILL.md"); - let text = fs::read_to_string(&skill).expect("fresh installs must provide the CLI skill"); - assert!(text.starts_with("---\nname: buzz-cli\n")); - assert!(text.contains("buzz messages send")); - assert!(text.contains("Never read or echo the value")); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - fs::metadata(&skill).unwrap().permissions().mode() & 0o777, - 0o600 - ); - for provider in [".claude", ".codex", ".goose"] { - let discovered = workspace.join(provider).join("skills/buzz-cli/SKILL.md"); - assert_eq!( - fs::canonicalize(discovered).unwrap(), - fs::canonicalize(&skill).unwrap() - ); - } - } - ensure_buzz_cli_skill(&workspace).unwrap(); - assert_eq!(fs::read_to_string(skill).unwrap(), text); -} - -#[test] -fn refresh_preserves_current_or_newer_edits_and_repairs_missing_content() { - let dir = tempfile::tempdir().unwrap(); - let workspace = dir.path().join(".buzz"); - ensure_buzz_cli_skill(&workspace).unwrap(); - let skill = workspace.join(".agents/skills/buzz-cli/SKILL.md"); - let version = skill.with_file_name(".skill-version"); - let installed = fs::read_to_string(&skill).unwrap(); - let current = fs::read_to_string(&version).unwrap(); - for marker in [current.as_str(), "999\n"] { - fs::write(&skill, "user edits").unwrap(); - fs::write(&version, marker).unwrap(); - ensure_buzz_cli_skill(&workspace).unwrap(); - assert_eq!(fs::read_to_string(&skill).unwrap(), "user edits"); - assert_eq!(fs::read_to_string(&version).unwrap(), marker); - } - fs::write(&version, "1\n").unwrap(); - ensure_buzz_cli_skill(&workspace).unwrap(); - assert_eq!(fs::read_to_string(&skill).unwrap(), installed); - fs::write(&version, "999\n").unwrap(); - fs::remove_file(&skill).unwrap(); - ensure_buzz_cli_skill(&workspace).unwrap(); - assert_eq!(fs::read_to_string(&skill).unwrap(), installed); - assert_eq!(fs::read_to_string(&version).unwrap(), "999\n"); -} - -#[cfg(unix)] -#[test] -fn legacy_claude_layout_migrates_edits_and_supporting_files() { - for partial in ["absent", "empty", "unversioned"] { - let dir = tempfile::tempdir().unwrap(); - let workspace = dir.path().join(".buzz"); - let legacy = workspace.join(".claude/skills/buzz-cli"); - fs::create_dir_all(&legacy).unwrap(); - fs::write(legacy.join("SKILL.md"), "legacy user edits").unwrap(); - fs::write(legacy.join("reference.md"), "supporting file").unwrap(); - let canonical = workspace.join(".agents/skills/buzz-cli"); - if partial != "absent" { - fs::create_dir_all(&canonical).unwrap(); - if partial == "unversioned" { - fs::write(canonical.join("SKILL.md"), "unfinished generated template").unwrap(); - fs::write(canonical.join("other.md"), "canonical resource").unwrap(); - } - } - ensure_buzz_cli_skill(&workspace).unwrap(); - assert!(fs::symlink_metadata(&legacy) - .unwrap() - .file_type() - .is_symlink()); - assert_eq!( - fs::read_to_string(canonical.join("SKILL.md")).unwrap(), - "legacy user edits" - ); - assert_eq!( - fs::read_to_string(legacy.join("reference.md")).unwrap(), - "supporting file" - ); - if partial == "unversioned" { - assert_eq!( - fs::read_to_string(canonical.join("other.md")).unwrap(), - "canonical resource" - ); - } - ensure_buzz_cli_skill(&workspace).unwrap(); - assert_eq!( - fs::read_to_string(legacy.join("SKILL.md")).unwrap(), - "legacy user edits" - ); - } -} - -#[cfg(unix)] -#[test] -fn migration_refuses_conflicts_or_redirected_legacy_content_before_removing_files() { - use std::os::unix::fs::symlink; - for problem in ["resource-conflict", "SKILL.md", ".skill-version"] { - let dir = tempfile::tempdir().unwrap(); - let workspace = dir.path().join(".buzz"); - let canonical = workspace.join(".agents/skills/buzz-cli"); - let legacy = workspace.join(".claude/skills/buzz-cli"); - fs::create_dir_all(&canonical).unwrap(); - fs::create_dir_all(&legacy).unwrap(); - fs::write(canonical.join("SKILL.md"), "canonical content").unwrap(); - fs::write(legacy.join("SKILL.md"), "legacy edits").unwrap(); - if problem == "resource-conflict" { - fs::write(canonical.join("reference.md"), "canonical reference").unwrap(); - fs::write(legacy.join("reference.md"), "legacy reference").unwrap(); - } else { - let outside = dir.path().join("outside"); - fs::write(&outside, "untouched").unwrap(); - let link = legacy.join(problem); - if link.exists() { - fs::remove_file(&link).unwrap(); - } - symlink(outside, link).unwrap(); - } - assert!(ensure_buzz_cli_skill(&workspace).is_err()); - assert_eq!( - fs::read_to_string(canonical.join("SKILL.md")).unwrap(), - "canonical content" - ); - if problem == "resource-conflict" { - assert_eq!( - fs::read_to_string(legacy.join("reference.md")).unwrap(), - "legacy reference" - ); - } - } -} - -#[cfg(unix)] -#[test] -fn provider_customizations_survive_and_dangling_links_are_repaired() { - use std::os::unix::fs::symlink; - let dir = tempfile::tempdir().unwrap(); - let workspace = dir.path().join(".buzz"); - let custom = workspace.join(".goose/skills/buzz-cli"); - fs::create_dir_all(&custom).unwrap(); - fs::write(custom.join("SKILL.md"), "custom Goose skill").unwrap(); - let codex = workspace.join(".codex/skills"); - fs::create_dir_all(&codex).unwrap(); - symlink("missing", codex.join("buzz-cli")).unwrap(); - ensure_buzz_cli_skill(&workspace).unwrap(); - assert_eq!( - fs::read_to_string(custom.join("SKILL.md")).unwrap(), - "custom Goose skill" - ); - assert!(codex.join("buzz-cli/SKILL.md").is_file()); -} - -#[cfg(unix)] -#[test] -fn redirected_directories_and_skill_files_are_rejected_without_external_writes() { - use std::os::unix::fs::symlink; - for redirected in ["", ".agents", ".agents/skills", ".agents/skills/buzz-cli"] { - let dir = tempfile::tempdir().unwrap(); - let workspace = dir.path().join(".buzz"); - let outside = dir.path().join("outside"); - fs::create_dir(&outside).unwrap(); - let link = if redirected.is_empty() { - workspace.clone() - } else { - workspace.join(redirected) - }; - fs::create_dir_all(link.parent().unwrap()).unwrap(); - symlink(&outside, &link).unwrap(); - assert!(ensure_buzz_cli_skill(&workspace).is_err(), "{redirected}"); - assert_eq!(fs::read_dir(&outside).unwrap().count(), 0); - } - for redirected in ["SKILL.md", ".skill-version"] { - let dir = tempfile::tempdir().unwrap(); - let workspace = dir.path().join(".buzz"); - ensure_buzz_cli_skill(&workspace).unwrap(); - let file = workspace.join(".agents/skills/buzz-cli").join(redirected); - let outside = dir.path().join("outside"); - fs::write(&outside, "untouched").unwrap(); - fs::remove_file(&file).unwrap(); - symlink(&outside, &file).unwrap(); - assert!(ensure_buzz_cli_skill(&workspace).is_err()); - assert_eq!(fs::read_to_string(outside).unwrap(), "untouched"); - } -} - -#[cfg(unix)] -#[test] -fn redirected_provider_skips_only_its_link_and_preserves_external_content() { - use std::os::unix::fs::{symlink, PermissionsExt}; - for provider in [".claude", ".codex", ".goose"] { - for child in ["", "skills"] { - let dir = tempfile::tempdir().unwrap(); - let workspace = dir.path().join(".buzz"); - let outside = dir.path().join("outside"); - fs::create_dir(&outside).unwrap(); - fs::write(outside.join("SKILL.md"), "external skill").unwrap(); - fs::set_permissions(&outside, fs::Permissions::from_mode(0o750)).unwrap(); - let parent = workspace.join(provider); - let link = if child.is_empty() { - parent - } else { - parent.join(child) - }; - fs::create_dir_all(link.parent().unwrap()).unwrap(); - symlink(&outside, &link).unwrap(); - for _ in 0..2 { - ensure_buzz_cli_skill(&workspace).unwrap(); - let canonical = workspace.join(".agents/skills/buzz-cli/SKILL.md"); - let content = fs::read_to_string(&canonical).unwrap(); - assert!(content.starts_with("---\nname: buzz-cli\n")); - for other in [".claude", ".codex", ".goose"] { - if other != provider { - let discovered = workspace.join(other).join("skills/buzz-cli/SKILL.md"); - assert_eq!( - fs::canonicalize(discovered).unwrap(), - fs::canonicalize(&canonical).unwrap() - ); - } - } - assert_eq!( - fs::read_to_string(outside.join("SKILL.md")).unwrap(), - "external skill" - ); - assert_eq!(fs::read_dir(&outside).unwrap().count(), 1); - assert_eq!( - fs::metadata(&outside).unwrap().permissions().mode() & 0o777, - 0o750 - ); - assert_eq!(fs::read_link(&link).unwrap(), outside); - } - } - } -} diff --git a/crates/agent-controller/src/store.rs b/crates/agent-controller/src/store.rs deleted file mode 100644 index d579c2a90..000000000 --- a/crates/agent-controller/src/store.rs +++ /dev/null @@ -1,861 +0,0 @@ -use crate::agent_defaults::AgentDefaults; -use crate::config::{Agent, AgentEdit, MAX_AGENTS, MAX_BYTES}; -use crate::Result; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::{BTreeMap, BTreeSet}; -use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -/// Marks a beta import whose prompt no longer needs the baked-team cut. -pub(crate) const CLEANED: &str = "teamSuffixCleaned"; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; - -const MAX_DEFAULTS_BYTES: usize = 1024 * 1024; - -pub(crate) const PROTECTION_KEY: &str = "launchProtection"; -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Binding { - pub provider: String, - pub policy: Value, -} -impl Binding { - pub(crate) fn valid_provider(id: &str) -> bool { - !id.is_empty() - && id.len() <= 128 - && id - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b)) - } - fn validate(&self) -> Result<()> { - if !Self::valid_provider(&self.provider) - || !self.policy.is_object() - || serde_json::to_vec(&self.policy) - .map_err(|_| "Invalid protection policy")? - .len() - > 32 * 1024 - { - return Err("Invalid or oversized protection policy".into()); - } - Ok(()) - } - pub(crate) fn decode(value: Option<&Value>) -> Result> { - let binding: Option = serde_json::from_value(value.cloned().unwrap_or(Value::Null)) - .map_err(|_| "Saved protection is malformed")?; - if let Some(binding) = &binding { - binding.validate()?; - } - Ok(binding) - } -} - -#[derive(Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct Document { - version: u32, - agents: Vec, - #[serde(default)] - parked: BTreeMap, - #[serde(flatten)] - extra: BTreeMap, -} -impl Document { - fn protection_revision(&self) -> Result { - match self.extra.get("launchProtectionRevision") { - None => Ok(0), - Some(value) => value - .as_u64() - .filter(|n| *n <= 9_007_199_254_740_991) - .ok_or("Invalid protection revision".into()), - } - } -} -/// Keyless inventory. Provenance is not proof of present key custody or membership. -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ParkedIdentity { - pub pubkey: String, - pub name: String, - pub sources: Vec, -} -/// One native host owns this profile for its entire lifetime. A corrupt store is -/// an error, never a fresh library; there is no auto-reset or legacy write path. -pub struct Store { - root: PathBuf, - _lock: File, - importing: Arc, -} -// Hold across unlocked credential I/O and the final store write. Dropping any -// intermediate import value releases the reservation, including on failure. -pub(crate) struct ImportReservation(Arc); -impl ImportReservation { - pub(crate) fn belongs_to(&self, store: &Store) -> bool { - Arc::ptr_eq(&self.0, &store.importing) - } -} -impl Drop for ImportReservation { - fn drop(&mut self) { - self.0.store(false, Ordering::Release); - } -} -impl Store { - pub fn open(root: PathBuf) -> Result { - if !root.is_absolute() { - return Err("Agent storage requires an absolute directory".into()); - } - fs::create_dir_all(&root).map_err(|_| "Could not create agent storage")?; - if fs::symlink_metadata(&root) - .map_err(|_| "Could not inspect agent storage")? - .file_type() - .is_symlink() - { - return Err("Agent storage cannot be a symbolic link".into()); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) - .map_err(|_| "Could not protect agent storage")?; - } - let mut options = OpenOptions::new(); - options.read(true).write(true).create(true).truncate(false); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600).custom_flags(libc::O_NOFOLLOW); - } - let lock = options - .open(root.join("controller.lock")) - .map_err(|_| "Could not open agent storage lock")?; - lock.try_lock() - .map_err(|_| "Another Buzz app owns this agent storage")?; - let store = Self { - root, - _lock: lock, - importing: Arc::default(), - }; - store.read()?; - Ok(store) - } - /// Imports made before the import-time cut kept old Buzz's baked team - /// section in their saved prompt. Only beta imports carry `imported.global`, - /// so prompts written in this app are never touched, and a prompt is cut - /// only while it still equals the retained original. Each agent is cleaned - /// once: `imported.teamSuffixCleaned` is saved in the same write, and new - /// imports carry it already. Unreadable storage stays fatal; a failed write - /// only returns a warning, so one prompt cannot disable the controller. - pub fn clean_imported_prompts(&mut self) -> Result> { - let mut doc = self.read()?; - let mut changed = false; - for agent in &mut doc.agents { - if agent.imported.get("global").is_none() - || agent.imported.get(CLEANED) == Some(&Value::Bool(true)) - { - continue; - } - // Cut only a prompt still exactly as imported; an owner edit made - // in this app, or an unreadable original, is left untouched. - let original = match agent.imported.get("definition") { - Some(definition) if !definition.is_null() => definition, - _ => &agent.imported["record"], - }; - let unedited = original.get("system_prompt").and_then(Value::as_str) - == Some(agent.system_prompt.as_str()); - let prompt = crate::import::imported_prompt(&agent.system_prompt); - if unedited && prompt.len() != agent.system_prompt.len() { - agent.system_prompt = prompt.to_owned(); - agent.revision = agent - .revision - .checked_add(1) - .ok_or("Agent revision exhausted")?; - } - agent.imported[CLEANED] = Value::Bool(true); - changed = true; - } - if !changed { - return Ok(None); - } - let Err(error) = self.write(&doc) else { - return Ok(None); - }; - eprintln!("buzz: could not clean imported agent prompts: {error}"); - // The new file may have landed before a later step such as the - // directory sync failed. Either way the saved file must still read - // back cleanly, and the error (which says which case) is shown. - self.read()?; - Ok(Some(format!( - "Could not finish removing old Buzz team text from imported agent prompts: {error}" - ))) - } - pub(crate) fn reserve_import(&self) -> Result { - self.importing - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .map_err(|_| "Another import is in progress; wait for it to finish")?; - Ok(ImportReservation(self.importing.clone())) - } - pub fn root(&self) -> &Path { - &self.root - } - pub(crate) fn protected_control_paths(&self) -> Vec { - [ - "agents.json", - "agents.previous.json", - "defaults.json", - "controller.lock", - "control-write", - ] - .map(|name| self.root.join(name)) - .into() - } - fn path(&self) -> PathBuf { - self.root.join("agents.json") - } - fn read(&self) -> Result { - let path = self.path(); - match fs::symlink_metadata(&path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(Document { - version: 1, - ..Document::default() - }); - } - Err(_) => return Err("Could not inspect saved agents".into()), - Ok(meta) if !meta.is_file() || meta.len() > MAX_BYTES as u64 => { - return Err("Saved agents must be a bounded regular file; left unchanged".into()); - } - Ok(_) => {} - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let file = options - .open(path) - .map_err(|_| "Could not read saved agents")?; - let mut bytes = Vec::new(); - file.take((MAX_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|_| "Could not read saved agents")?; - if bytes.len() > MAX_BYTES { - return Err("Saved agents exceed the size limit; left unchanged".into()); - } - let doc: Document = serde_json::from_slice(&bytes) - .map_err(|_| "Saved agents are malformed; left unchanged")?; - validate(&doc)?; - Ok(doc) - } - fn write(&self, doc: &Document) -> Result<()> { - self.write_with_backup(doc, true) - } - fn write_with_backup(&self, doc: &Document, backup: bool) -> Result<()> { - validate(doc)?; - let bytes = - serde_json::to_vec_pretty(doc).map_err(|_| "Could not encode agent settings")?; - if bytes.len() > MAX_BYTES { - return Err("Agent settings exceed the size limit".into()); - } - // Validate/read first: never replace a newly corrupted file on a later save. - let old = self.read()?; - if backup && self.path().exists() { - let backup = - serde_json::to_vec_pretty(&old).map_err(|_| "Could not back up agent settings")?; - atomic_write(&self.root.join("agents.previous.json"), &backup)?; - } else if !backup { - // A successful delete must not leave the removed settings in the - // previous-version file. Clear it before replacing the live file. - match fs::remove_file(self.root.join("agents.previous.json")) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err("Could not clear previous agent settings".into()), - } - } - atomic_write(&self.path(), &bytes) - } - /// Saved agents and parked identities from one read of the document. - pub(crate) fn inventory(&self) -> Result<(Vec, Vec)> { - let doc = self.read()?; - Ok((doc.agents, doc.parked.into_values().collect())) - } - pub(crate) fn agents(&self) -> Result> { - Ok(self.read()?.agents) - } - /// Device-wide defaults; absent means the built-in Buzz Agent defaults. - pub(crate) fn defaults(&self) -> Result { - let path = self.root.join("defaults.json"); - match fs::symlink_metadata(&path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(AgentDefaults::default()) - } - Err(_) => return Err("Could not inspect agent defaults".into()), - Ok(meta) if !meta.is_file() || meta.len() > MAX_DEFAULTS_BYTES as u64 => { - return Err("Agent defaults must be a bounded regular file; left unchanged".into()) - } - Ok(_) => {} - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let mut bytes = Vec::new(); - options - .open(path) - .and_then(|file| { - file.take((MAX_DEFAULTS_BYTES + 1) as u64) - .read_to_end(&mut bytes) - }) - .map_err(|_| "Could not read agent defaults")?; - if bytes.len() > MAX_DEFAULTS_BYTES { - return Err("Agent defaults exceed the size limit; left unchanged".into()); - } - let defaults: AgentDefaults = serde_json::from_slice(&bytes) - .map_err(|_| "Agent defaults are malformed; left unchanged")?; - defaults.validate()?; - Ok(defaults) - } - /// Owner-only (0600) atomic replacement, like saved agents. - pub(crate) fn save_defaults(&self, defaults: &AgentDefaults) -> Result<()> { - defaults.validate()?; - self.defaults()?; - let bytes = - serde_json::to_vec_pretty(defaults).map_err(|_| "Could not encode agent defaults")?; - if bytes.len() > MAX_DEFAULTS_BYTES { - return Err("Agent defaults exceed the size limit".into()); - } - atomic_write(&self.root.join("defaults.json"), &bytes) - } - pub fn snapshot(&self) -> Result { - let defaults = self.defaults()?; - let doc = self.read()?; - Ok(crate::ControlSnapshot { - agents: doc.agents.iter().map(|a| a.view(&defaults)).collect(), - parked: doc.parked.into_values().collect(), - runtime_available: false, - runtime_message: Some("Native runtime has not been connected".into()), - default_settings: defaults.view(), - }) - } - /// Mirror only safe metadata, independently per source. Each readable source - /// replaces its own entries, and a missing source no longer lists anything; an - /// identity with no remaining source leaves the inventory. A damaged source - /// keeps its previous entries and never prevents managing existing agents. - pub fn migrate_legacy(&mut self, parent: &Path) -> Vec { - let mut warnings = Vec::new(); - for source in [ - crate::LegacySource::Installed, - crate::LegacySource::Development, - ] { - let path = parent - .join(source.app_directory()) - .join("agents/managed-agents.json"); - let result = (|| -> Result<()> { - let candidates = match fs::symlink_metadata(&path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), - _ => { - crate::Imports::default() - .preview(source, parent.into(), self.root.clone(), "")? - .candidates - } - }; - let mut doc = self.read()?; - let before = doc.parked.clone(); - let listed: std::collections::BTreeSet<_> = - candidates.iter().map(|c| c.pubkey.clone()).collect(); - for row in doc.parked.values_mut() { - if !listed.contains(&row.pubkey) { - row.sources.retain(|listed| *listed != source); - } - } - for candidate in candidates { - let row = doc - .parked - .entry(candidate.pubkey.clone()) - .or_insert_with(|| ParkedIdentity { - pubkey: candidate.pubkey, - name: candidate.name, - sources: Vec::new(), - }); - if !row.sources.contains(&source) { - row.sources.push(source); - } - } - doc.parked.retain(|_, row| !row.sources.is_empty()); - if doc.parked != before { - self.write(&doc)?; - } - Ok(()) - })(); - if let Err(error) = result { - warnings.push(format!( - "Could not update inventory from {}: {error}", - source.app_directory() - )); - } - } - warnings - } - /// Configure retained custody, never reread the old installation or move it. - pub fn use_here(&mut self, id: &str, resolution: crate::CommunityResolution) -> Result<()> { - let mut doc = self.read()?; - let source = doc - .agents - .iter() - .find(|agent| agent.id == id) - .cloned() - .ok_or("Imported identity no longer exists")?; - resolution.verify(source.auth_tag.as_deref().unwrap_or(""))?; - if resolution.pubkey != source.pubkey { - return Err("Use here must preserve the imported identity".into()); - } - let target_id = crate::config::agent_id(&source.pubkey, &resolution.relay_url); - if doc.agents.iter().any(|agent| { - agent.pubkey == source.pubkey && agent.configured() && agent.id != target_id - }) { - return Err("This identity is already configured in another community. Clone it to create a new identity here.".into()); - } - if let Some(target) = doc.agents.iter_mut().find(|agent| agent.id == target_id) { - resolution.verify(target.auth_tag.as_deref().unwrap_or(""))?; - if !target.configured() { - // Setup completes custody only; starting remains a separate - // explicit action, so drop any retained startup intent. - target.extra.insert("configured".into(), Value::Bool(true)); - target.enabled = false; - target.start_on_app_launch = Some(false); - target.revision = target - .revision - .checked_add(1) - .filter(|n| *n <= 9_007_199_254_740_991) - .ok_or("Agent revision exhausted")?; - } - } else { - // Explicit owner-signed setup of an existing identity/community pair. - // Keep the source setup and credential reference; the new pair starts stopped. - let mut target = source.clone(); - target.id = target_id; - target.relay_url = resolution.relay_url; - target.enabled = false; - target.start_on_app_launch = Some(false); - target.revision = 1; - target.extra.insert("configured".into(), Value::Bool(true)); - doc.agents.push(target); - } - self.write(&doc) - } - pub fn local_clone_settings(&self, id: &str) -> Result { - let agent = self - .agents()? - .into_iter() - .find(|agent| agent.id == id) - .ok_or("Local identity no longer exists")?; - Ok(crate::CloneSettings { - name: agent.name, - system_prompt: agent.system_prompt, - }) - } - /// Copy each team's current text to the owner's agents in `relay`. A team - /// with text binds the agents its roster lists; a team without text never - /// counts. Teams absent from `texts` are unreadable, not removed, so their - /// bindings and the copied text stay. Refuses, without writing, when one - /// agent would receive two different texts. - pub(crate) fn sync_team_instructions( - &mut self, - relay: &str, - owner: &str, - heads: &std::collections::BTreeMap, - texts: &std::collections::BTreeMap, - ) -> Result<()> { - for raw in texts.values() { - crate::import::team_text(&json!(raw))?; - } - let mut doc = self.read()?; - // Teams the app could not read keep their bindings and copied text. - let mut changed = reconcile_bindings(&mut doc, relay, owner, heads, |team| { - texts.contains_key(team) - })?; - for agent in &mut doc.agents { - let authorized = agent - .auth_tag - .as_deref() - .and_then(|tag| serde_json::from_str::>(tag).ok()) - .is_some_and(|tag| tag.get(1).map(String::as_str) == Some(owner)); - if agent.relay_url != relay || !authorized { - continue; - } - let saved: Vec = match agent.imported.get("teamBindings") { - Some(raw) => serde_json::from_value(raw.clone()) - .map_err(|_| "Invalid saved team bindings")?, - None => Vec::new(), - }; - let current = crate::import::team_text(&agent.imported["teamInstructions"])?.to_owned(); - let mut bindings: Vec = saved - .iter() - .filter(|team| !texts.contains_key(*team) || !heads.contains_key(*team)) - .cloned() - .collect(); - let unknown = !bindings.is_empty(); - let mut text: Option<(&str, &str)> = None; - for (team, raw) in texts { - let team_text = raw.trim(); - if team_text.is_empty() - || !heads - .get(team) - .is_some_and(|head| head.members.contains(&agent.pubkey)) - { - continue; - } - match text { - Some((other, chosen)) if chosen != team_text => { - return Err(format!( - "{} is on teams \"{other}\" and \"{team}\", which have different instructions. Give both teams the same instructions or remove the agent from one", - agent.name - )); - } - _ => text = Some((team, team_text)), - } - bindings.push(team.clone()); - } - // Text imported from old Buzz has no team binding. It stays until a - // team with text claims the agent; that team's text then replaces it. - let legacy = agent.imported.get("teamBindings").is_none(); - let next = match text { - Some((_, chosen)) => chosen.to_owned(), - None if unknown || legacy => current.clone(), - None => String::new(), - }; - if bindings.len() > 100 { - return Err("Too many team bindings for this agent".into()); - } - if bindings == saved && next == current { - continue; - } - if agent.imported.is_null() { - agent.imported = json!({}); - } - agent.imported["teamBindings"] = json!(bindings); - if next != current { - agent.imported["teamInstructions"] = json!(next); - agent.revision = agent - .revision - .checked_add(1) - .ok_or("Agent revision exhausted")?; - } - changed = true; - } - if changed { - self.write(&doc)?; - } - Ok(()) - } - pub fn save(&mut self, id: &str, revision: u64, edit: AgentEdit) -> Result<()> { - let mut doc = self.read()?; - let agent = doc - .agents - .iter_mut() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - if agent.revision != revision { - return Err( - "Agent settings changed. Reload before saving; your draft was not applied".into(), - ); - } - agent.apply(edit)?; - self.write(&doc) - } - pub(crate) fn set_launch_protection_defaults( - &mut self, - revision: u64, - binding: Option, - ) -> Result<()> { - if let Some(binding) = &binding { - binding.validate()?; - } - let mut doc = self.read()?; - if doc.protection_revision()? != revision { - return Err("Protection defaults changed; reload before saving".into()); - } - let next = revision - .checked_add(1) - .filter(|n| *n <= 9_007_199_254_740_991) - .ok_or("Protection defaults revision exhausted")?; - doc.extra.insert( - PROTECTION_KEY.into(), - serde_json::to_value(binding).map_err(|_| "Invalid defaults")?, - ); - doc.extra - .insert("launchProtectionRevision".into(), json!(next)); - self.write(&doc) - } - pub(crate) fn set_launch_protection( - &mut self, - id: &str, - revision: u64, - binding: Option, - ) -> Result<()> { - if let Some(binding) = &binding { - binding.validate()?; - } - let mut doc = self.read()?; - let agent = doc - .agents - .iter_mut() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - if agent.revision != revision { - return Err("Agent changed; reload before saving protection".into()); - } - agent.revision = agent - .revision - .checked_add(1) - .filter(|n| *n <= 9_007_199_254_740_991) - .ok_or("Agent revision exhausted")?; - agent.extra.insert( - PROTECTION_KEY.into(), - serde_json::to_value(binding).map_err(|_| "Invalid protection")?, - ); - self.write(&doc) - } - pub(crate) fn launch_protection_snapshot(&self) -> Result { - let doc = self.read()?; - let agents = doc - .agents - .iter() - .map(|a| { - Ok(json!({"id": a.id, "binding": Binding::decode(a.extra.get(PROTECTION_KEY))?})) - }) - .collect::>>()?; - Ok(json!({"revision": doc.protection_revision()?, - "defaults": Binding::decode(doc.extra.get(PROTECTION_KEY))?, "agents": agents})) - } - pub(crate) fn remove(&mut self, id: &str, revision: u64) -> Result<()> { - let mut doc = self.read()?; - let index = doc - .agents - .iter() - .position(|agent| agent.id == id) - .ok_or("Agent no longer exists")?; - if doc.agents[index].revision != revision { - return Err("Agent settings changed. Reload before deleting".into()); - } - doc.agents.remove(index); - self.write_with_backup(&doc, false) - } - pub(crate) fn enabled(&mut self, id: &str, enabled: bool) -> Result<()> { - let mut doc = self.read()?; - let agent = doc - .agents - .iter_mut() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - if enabled && !agent.configured() { - return Err("Choose Use here before starting this imported identity".into()); - } - agent.enabled = enabled; - self.write(&doc) - } - /// A launch preference, not a harness setting: the revision is unchanged. - pub(crate) fn start_on_app_launch(&mut self, id: &str, value: bool) -> Result<()> { - let mut doc = self.read()?; - let agent = doc - .agents - .iter_mut() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - agent.start_on_app_launch = Some(value); - self.write(&doc) - } - pub(crate) fn profile_published(&mut self, id: &str, revision: u64) -> Result<()> { - let mut doc = self.read()?; - let agent = doc - .agents - .iter_mut() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - if agent.revision != revision { - return Err("Saved settings changed; retry the profile".into()); - } - agent.extra.remove("profilePending"); - agent.extra.remove("profileNamePending"); - agent.extra.remove("importAboutPending"); - self.write(&doc) - } - /// One atomic import batch; repairs add only the missing team snapshot. - pub(crate) fn import( - &mut self, - mut agents: Vec, - repairs: Vec<(String, u64, String)>, - ) -> Result<()> { - let mut doc = self.read()?; - let defaults = Binding::decode(doc.extra.get(PROTECTION_KEY))?; - for agent in &mut agents { - if defaults.is_some() && !agent.extra.contains_key(PROTECTION_KEY) { - agent.extra.insert( - PROTECTION_KEY.into(), - serde_json::to_value(&defaults) - .map_err(|_| "Could not encode protection defaults")?, - ); - } - } - for (id, revision, instructions) in repairs { - let agent = doc - .agents - .iter_mut() - .find(|agent| agent.id == id) - .ok_or("Agent no longer exists; preview again")?; - if agent.revision != revision || !agent.needs_team_import() { - return Err("Agent settings changed; preview the team import again".into()); - } - agent.imported["teamInstructions"] = Value::String(instructions); - agent.revision = agent - .revision - .checked_add(1) - .ok_or("Agent revision exhausted")?; - } - doc.agents.extend(agents); - self.write(&doc) - } - pub(crate) fn insert(&mut self, agents: Vec) -> Result<()> { - self.import(agents, Vec::new()) - } -} -impl Drop for Store { - fn drop(&mut self) { - // fork/dup shares the lock's open-file description until exec/close. - // Releasing only our descriptor can leave the profile spuriously owned. - let _ = self._lock.unlock(); - } -} -fn validate(doc: &Document) -> Result<()> { - if doc.version != 1 || doc.agents.len() > MAX_AGENTS { - return Err("Unsupported agent storage version or size; left unchanged".into()); - } - if doc.parked.len() > MAX_AGENTS - || doc.parked.iter().any(|(key, row)| { - key != &row.pubkey || !crate::config::canonical_key(key) || row.sources.is_empty() - }) - { - return Err("Invalid parked identity inventory; left unchanged".into()); - } - let mut ids = BTreeSet::new(); - for agent in &doc.agents { - agent.validate()?; - if !ids.insert(&agent.id) { - return Err("Duplicate saved agent identity; left unchanged".into()); - } - } - Ok(()) -} -fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { - let parent = path.parent().ok_or("Missing agent storage directory")?; - let staging = parent.join("control-write"); - crate::connection::private_directory(&staging)?; - let mut temp = tempfile::NamedTempFile::new_in(staging) - .map_err(|_| "Could not prepare agent settings write")?; - temp.write_all(bytes) - .map_err(|_| "Could not write agent settings")?; - temp.as_file() - .sync_all() - .map_err(|_| "Could not sync agent settings")?; - temp.persist(path) - .map_err(|_| "Could not replace agent settings")?; - // A successful rename is visible even if directory fsync fails. Surface that - // uncertainty, do not claim that a failed return means nothing was persisted. - #[cfg(unix)] - File::open(parent) - .and_then(|dir| dir.sync_all()) - .map_err(|_| { - "Settings were replaced but durability is uncertain; reload before retrying" - })?; - Ok(()) -} - -/// Stage catalog heads and release obsolete bindings in `doc`. `releasable` -/// limits which teams may release a binding. Returns whether `doc` changed. -fn reconcile_bindings( - doc: &mut Document, - relay: &str, - owner: &str, - teams: &std::collections::BTreeMap, - releasable: impl Fn(&str) -> bool, -) -> Result { - let scope = format!("{owner}@{relay}"); - let mut catalogs: std::collections::BTreeMap< - String, - std::collections::BTreeMap, - > = doc - .extra - .get("teamCatalogHeads") - .map(|value| serde_json::from_value(value.clone())) - .transpose() - .map_err(|_| "Invalid saved team catalog heads")? - .unwrap_or_default(); - let heads = catalogs.entry(scope).or_default(); - // Validate all incoming heads before releasing any binding. Reads happen - // outside controller admission and can complete in the opposite order. - for (team, incoming) in teams { - if let Some(saved) = heads.get(team) { - if incoming.created_at < saved.created_at - || (incoming.created_at == saved.created_at && incoming.event_id > saved.event_id) - || (incoming.created_at == saved.created_at - && incoming.event_id == saved.event_id - && incoming.members != saved.members) - { - return Err("Team catalog changed; refresh before deploying".into()); - } - } - } - let mut changed = false; - for (team, incoming) in teams { - if heads.get(team) != Some(incoming) { - heads.insert(team.clone(), incoming.clone()); - changed = true; - } - } - for agent in &mut doc.agents { - if agent.relay_url != relay { - continue; - } - let authorized = agent - .auth_tag - .as_deref() - .and_then(|tag| serde_json::from_str::>(tag).ok()) - .is_some_and(|tag| tag.get(1).map(String::as_str) == Some(owner)); - if !authorized { - continue; - } - let Some(raw) = agent.imported.get("teamBindings") else { - continue; - }; - let bindings: Vec = - serde_json::from_value(raw.clone()).map_err(|_| "Invalid saved team bindings")?; - let retained: Vec<_> = bindings - .iter() - .filter(|team| match teams.get(*team) { - Some(head) if releasable(team) => head.members.contains(&agent.pubkey), - _ => true, - }) - .cloned() - .collect(); - if bindings != retained { - agent.imported["teamBindings"] = json!(retained); - changed = true; - } - } - if changed { - doc.extra.insert( - "teamCatalogHeads".into(), - serde_json::to_value(catalogs).map_err(|_| "Invalid team catalog heads")?, - ); - } - Ok(changed) -} -#[cfg(test)] -pub(crate) mod tests; diff --git a/crates/agent-controller/src/store/tests.rs b/crates/agent-controller/src/store/tests.rs deleted file mode 100644 index a6cc81941..000000000 --- a/crates/agent-controller/src/store/tests.rs +++ /dev/null @@ -1,1110 +0,0 @@ -use super::*; -use crate::config::{agent_id, HarnessEdit}; -use serde_json::json; - -pub(crate) fn fixture() -> Agent { - let pubkey = "ab".repeat(32); - let relay_url = "wss://relay.example".to_owned(); - Agent { - picture: None, - id: agent_id(&pubkey, &relay_url), - pubkey, - relay_url, - name: "Test Brain".into(), - system_prompt: "Take over the test world".into(), - session_policy: None, - session_policy_inherit: false, - // Only validated/serialized here; never used to launch a harness. - workspace: std::env::current_dir().unwrap().to_str().unwrap().into(), - harness: HarnessEdit { - integration: None, - databricks: None, - command: "buzz-agent".into(), - args: vec![], - model: "test-model".into(), - configuration: None, - provider: "test-provider".into(), - }, - environment: BTreeMap::from([("TEST_TOKEN".into(), "secret-env-value".into())]), - revision: 1, - enabled: false, - start_on_app_launch: None, - credential_id: "test-credential".into(), - auth_tag: Some("private-attestation".into()), - imported: json!({"futureSetting": {"opaque": "preserve-me"}}), - extra: BTreeMap::from([("futureTopLevel".into(), json!([1, 2, 3]))]), - } -} -fn edit() -> AgentEdit { - AgentEdit { - effort: None, - picture: None, - name: "Edited Brain".into(), - system_prompt: "New prompt".into(), - session_policy: Some(None), - // Only validated/serialized here; never used to launch a harness. - workspace: std::env::current_dir().unwrap().to_str().unwrap().into(), - harness: fixture().harness, - environment: BTreeMap::new(), - } -} - -#[test] -fn native_codex_configuration_round_trips_through_revision_checked_store() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let mut agent = fixture(); - agent.harness.integration = Some(crate::HarnessIntegration::Codex); - agent.harness.command = "/tools/codex-acp".into(); - agent.harness.model.clear(); - agent.harness.provider.clear(); - agent.harness.configuration = Some(crate::AiConfiguration::Default); - agent.environment.clear(); - store.insert(vec![agent.clone()]).unwrap(); - drop(store); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - - let snapshot = store.snapshot().unwrap(); - assert_eq!( - snapshot.agents[0].harness.integration, - Some(crate::HarnessIntegration::Codex) - ); - assert_eq!( - snapshot.agents[0].harness.configuration, - Some(crate::AiConfiguration::Default) - ); - - let mut advanced = edit(); - advanced.harness = agent.harness.clone(); - advanced.harness.model = "gpt-6".into(); - advanced.harness.configuration = Some(crate::AiConfiguration::Advanced { - effort: crate::EffortSelection::Value { - value: "high".into(), - }, - }); - store.save(&agent.id, agent.revision, advanced).unwrap(); - let saved = store.agents().unwrap().remove(0); - assert_eq!(saved.revision, 2); - assert!(matches!( - saved.harness.configuration, - Some(crate::AiConfiguration::Advanced { - effort: crate::EffortSelection::Value { ref value } - }) if value == "high" - )); -} - -#[test] -fn native_codex_agent_can_switch_to_another_harness() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let mut agent = fixture(); - agent.harness.integration = Some(crate::HarnessIntegration::Codex); - agent.harness.command = "/tools/codex-acp".into(); - agent.harness.model = "gpt-6".into(); - agent.harness.provider.clear(); - agent.harness.configuration = Some(crate::AiConfiguration::Advanced { - effort: crate::EffortSelection::Unsupported, - }); - agent.environment.clear(); - store.insert(vec![agent.clone()]).unwrap(); - - // Leaving Codex must drop its managed configuration with the marker. - let mut leaking = edit(); - leaking.harness.configuration = agent.harness.configuration.clone(); - assert!(store - .save(&agent.id, agent.revision, leaking) - .unwrap_err() - .contains("requires a native integration")); - - store.save(&agent.id, agent.revision, edit()).unwrap(); - let saved = store.agents().unwrap().remove(0); - assert_eq!(saved.harness.integration, None); - assert_eq!(saved.harness.configuration, None); - assert_eq!(saved.harness.command, "buzz-agent"); - assert_eq!(saved.harness.model, "test-model"); -} - -#[test] -fn native_codex_structure_rejects_inheritance_custom_args_and_foreign_environment() { - let mut agent = fixture(); - agent.harness.integration = Some(crate::HarnessIntegration::Codex); - agent.harness.command = "/tools/codex-acp".into(); - agent.harness.model.clear(); - agent.harness.provider.clear(); - agent.harness.configuration = None; - agent.environment.clear(); - assert!(agent - .validate() - .unwrap_err() - .contains("Default or Advanced")); - - agent.harness.configuration = Some(crate::AiConfiguration::Default); - agent.harness.args = vec!["--config".into()]; - assert!(agent - .validate() - .unwrap_err() - .contains("custom adapter arguments")); - agent.harness.args.clear(); - agent - .environment - .insert("OPENAI_API_KEY".into(), "secret".into()); - assert!(agent.validate().unwrap_err().contains("does not permit")); -} -#[test] -fn snapshot_withholds_model_and_provider_environment_values() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let agent = |key: &str, command: &str, provider: &str, env: &[(&str, &str)]| { - let mut agent = fixture(); - agent.pubkey = key.repeat(32); - agent.id = agent_id(&agent.pubkey, &agent.relay_url); - agent.harness.command = command.into(); - agent.harness.model.clear(); - agent.harness.provider = provider.into(); - agent.environment = env - .iter() - .map(|(k, v)| ((*k).into(), (*v).into())) - .collect(); - agent - }; - store - .insert(vec![ - agent( - "a1", - "buzz-agent", - "", - &[ - ("BUZZ_AGENT_MODEL", "synthetic-buzz-model"), - ("BUZZ_AGENT_PROVIDER", "synthetic-buzz-provider"), - ], - ), - agent( - "b2", - "goose", - "", - &[ - ("GOOSE_MODEL", "synthetic-goose-model"), - ("GOOSE_PROVIDER", "synthetic-goose-provider"), - ], - ), - // A blank model on a Databricks provider falls back to this key. - agent( - "c3", - "buzz-agent", - "databricks", - &[("DATABRICKS_MODEL", "synthetic-databricks-model")], - ), - // An empty override is still an override. - agent("d4", "buzz-agent", "", &[("BUZZ_AGENT_PROVIDER", "")]), - // A hidden provider decides a blank model before the Databricks fallback. - agent( - "e5", - "buzz-agent", - "databricks", - &[ - ("BUZZ_AGENT_PROVIDER", "synthetic-combined-provider"), - ("DATABRICKS_MODEL", "synthetic-combined-model"), - ], - ), - agent( - "f6", - "/opt/tools/buzz-pi-acp", - "custom", - &[("BUZZ_ACP_MODEL", "synthetic-acp-pi-model")], - ), - agent( - "a7", - "goose-acp", - "custom", - &[ - ("BUZZ_ACP_MODEL", "synthetic-acp-goose-model"), - ("GOOSE_MODEL", "synthetic-worker-goose-model"), - ], - ), - ]) - .unwrap(); - let snapshot = store.snapshot().unwrap(); - let wire = serde_json::to_string(&snapshot).unwrap(); - for value in [ - "synthetic-buzz-model", - "synthetic-buzz-provider", - "synthetic-goose-model", - "synthetic-goose-provider", - "synthetic-databricks-model", - "synthetic-combined-provider", - "synthetic-combined-model", - "synthetic-acp-pi-model", - "synthetic-acp-goose-model", - "synthetic-worker-goose-model", - ] { - assert!(!wire.contains(value), "projected {value}"); - } - for (key, model, provider) in [ - ("a1", Some("BUZZ_AGENT_MODEL"), Some("BUZZ_AGENT_PROVIDER")), - ("b2", Some("GOOSE_MODEL"), Some("GOOSE_PROVIDER")), - ("c3", Some("DATABRICKS_MODEL"), None), - ( - "d4", - Some("BUZZ_AGENT_PROVIDER"), - Some("BUZZ_AGENT_PROVIDER"), - ), - ( - "e5", - Some("BUZZ_AGENT_PROVIDER"), - Some("BUZZ_AGENT_PROVIDER"), - ), - ("f6", Some("BUZZ_ACP_MODEL"), None), - ("a7", Some("BUZZ_ACP_MODEL"), None), - ] { - let view = snapshot - .agents - .iter() - .find(|a| a.pubkey.starts_with(key)) - .unwrap(); - assert_eq!( - (view.launch_model_env, view.launch_provider_env), - (model, provider) - ); - assert!(view.launch_model.is_none(), "{key} model value"); - assert_eq!(view.launch_provider.is_none(), provider.is_some(), "{key}"); - } -} -#[test] -fn real_store_save_cas_unknown_fields_secret_projection_and_reopen() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let agent = fixture(); - store.insert(vec![agent.clone()]).unwrap(); - let mut update = edit(); - update.session_policy = Some(Some(crate::config::SessionPolicy::Thread)); - update.name = "Edited Brain".into(); - store.save(&agent.id, 1, update).unwrap(); - let stale = store.save(&agent.id, 1, edit()).unwrap_err(); - assert!(stale.contains("Reload")); - let view = serde_json::to_string(&store.snapshot().unwrap()).unwrap(); - for secret in [ - "secret-env-value", - "private-attestation", - "preserve-me", - "test-credential", - "futureTopLevel", - ] { - assert!(!view.contains(secret), "projected {secret}"); - } - assert!(view.contains("TEST_TOKEN")); - assert!(view.contains("Edited Brain")); - assert!(view.contains("\"sessionPolicy\":\"thread\"")); - assert_eq!(store.agents().unwrap()[0].revision, 2); - let before = fs::read(store.path()).unwrap(); - assert!(Store::open(dir.path().to_owned()).is_err()); - drop(store); - let store = Store::open(dir.path().to_owned()).unwrap(); - assert_eq!(fs::read(store.path()).unwrap(), before); - let saved = &store.agents().unwrap()[0]; - assert_eq!(saved.environment, agent.environment); - assert_eq!(saved.imported, agent.imported); - let mut expected_extra = agent.extra.clone(); - expected_extra.insert("profileNamePending".into(), json!(true)); - assert_eq!(saved.extra, expected_extra); - assert_eq!(saved.auth_tag, agent.auth_tag); - assert_eq!(saved.credential_id, agent.credential_id); - assert_eq!( - saved.session_policy, - Some(crate::config::SessionPolicy::Thread) - ); - let backup: Value = - serde_json::from_slice(&fs::read(dir.path().join("agents.previous.json")).unwrap()) - .unwrap(); - assert_eq!(backup["agents"][0]["revision"], 1); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - fs::metadata(store.path()).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } -} -#[test] -fn explicitly_inheriting_context_can_replace_an_imported_policy() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let mut agent = fixture(); - agent.imported = json!({"record": {"session_policy": "thread"}}); - store.insert(vec![agent.clone()]).unwrap(); - assert_eq!( - store.snapshot().unwrap().agents[0].session_policy, - Some(crate::config::SessionPolicy::Thread) - ); - store.save(&agent.id, agent.revision, edit()).unwrap(); - assert_eq!(store.snapshot().unwrap().agents[0].session_policy, None); - drop(store); - let saved = Store::open(dir.path().to_owned()) - .unwrap() - .agents() - .unwrap() - .remove(0); - assert!(saved.session_policy_inherit); - assert_eq!(saved.selected_session_policy(), None); - assert_eq!(saved.imported, agent.imported); -} - -#[test] -fn an_omitted_ipc_policy_preserves_the_imported_choice_but_null_inherits() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let mut agent = fixture(); - agent.imported = json!({"record": {"session_policy": "thread"}}); - store.insert(vec![agent.clone()]).unwrap(); - let mut payload = json!({ - "name": "Renamed Brain", - "systemPrompt": agent.system_prompt, - "workspace": agent.workspace, - "harness": agent.harness, - "environment": {} - }); - let omitted: AgentEdit = serde_json::from_value(payload.clone()).unwrap(); - store.save(&agent.id, agent.revision, omitted).unwrap(); - assert_eq!( - store.snapshot().unwrap().agents[0].session_policy, - Some(crate::config::SessionPolicy::Thread) - ); - payload["sessionPolicy"] = Value::Null; - let inherit: AgentEdit = serde_json::from_value(payload).unwrap(); - store.save(&agent.id, agent.revision + 1, inherit).unwrap(); - assert_eq!(store.snapshot().unwrap().agents[0].session_policy, None); -} - -#[test] -fn remove_requires_current_revision_and_persists_absence() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let agent = fixture(); - store.insert(vec![agent.clone()]).unwrap(); - store.enabled(&agent.id, false).unwrap(); - assert!(dir.path().join("agents.previous.json").exists()); - assert!(store.remove(&agent.id, agent.revision + 1).is_err()); - assert_eq!(store.agents().unwrap().len(), 1); - store.remove(&agent.id, agent.revision).unwrap(); - assert!(store.agents().unwrap().is_empty()); - assert!(!dir.path().join("agents.previous.json").exists()); - drop(store); - assert!(Store::open(dir.path().to_owned()) - .unwrap() - .agents() - .unwrap() - .is_empty()); -} -#[test] -fn environment_patch_preserves_deletes_and_rejects_host_overrides_without_writing() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let a = fixture(); - store.insert(vec![a.clone()]).unwrap(); - let mut update = edit(); - update.environment.insert("TEST_TOKEN".into(), None); - update - .environment - .insert("PROVIDER_TOKEN".into(), Some("new-secret".into())); - store.save(&a.id, 1, update).unwrap(); - assert_eq!( - store.agents().unwrap()[0].environment, - BTreeMap::from([("PROVIDER_TOKEN".into(), "new-secret".into())]) - ); - let before = fs::read(store.path()).unwrap(); - for key in [ - "BUZZ_PRIVATE_KEY", - "buzz_auth_tag", - "BUZZ_ACP_AGENT_COMMAND", - "BUZZ_ACP_AGENT_ARGS", - "BUZZ_ACP_MCP_COMMAND", - "buzz_acp_launch_prefix", - "BUZZ_ACP_RESPOND_TO", - "BUZZ_ACP_RESPOND_TO_ALLOWLIST", - "BUZZ_ACP_ALLOWED_RESPOND_TO", - "BUZZ_ACP_AGENT_OWNER", - "BUZZ_ACP_DISPLAY_NAME", - "BUZZ_ACP_TEAM_INSTRUCTIONS", - "BUZZ_ACP_PRIVATE_KEY", - "BUZZ_ACP_API_TOKEN", - "BUZZ_RELAY_URL", - "BUZZ_ACP_SESSION_POLICY", - "BUZZ_ACP_IDLE_POOL_SLEEP", - "BUZZ_ACP_EXIT_AFTER_INACTIVITY", - "BUZZ_ACP_NO_PRESENCE", - "BUZZ_ACP_SETUP_PAYLOAD", - "BUZZ_ACP_REPLAY_FLOOR", - "buzz_acp_agents", - "PI_ACP_PI_COMMAND", - "BUZZ_AGENT_CONFIG_DIR", - "BUZZ_MANAGED_AGENT", - "buzz_managed_agent_start_nonce", - "BUZZ_APP_PROFILE", - "GIT_CONFIG_COUNT", - "NOSTR_PRIVATE_KEY", - "bad=key", - ] { - for command in ["buzz-agent", "goose-acp", "/opt/tools/buzz-pi-acp"] { - let mut update = edit(); - update.harness.command = command.into(); - update - .environment - .insert(key.into(), Some("do-not-echo".into())); - let error = store.save(&a.id, 2, update).unwrap_err(); - assert!(!error.contains("do-not-echo")); - assert_eq!(fs::read(store.path()).unwrap(), before); - } - } - // The broader policy is scoped to Pi/Goose; Buzz Agent is unchanged. - for key in [ - "BUZZ_ACP_MODEL", - "BUZZ_ACP_SYSTEM_PROMPT", - "BUZZ_ACP_LAZY_POOL", - "BUZZ_MANAGED_CUSTOM", - ] { - let mut update = edit(); - update - .environment - .insert(key.into(), Some("do-not-echo".into())); - assert!(store.save(&a.id, 2, update).is_err()); - assert_eq!(fs::read(store.path()).unwrap(), before); - } -} -#[test] -fn environment_override_removal_allows_a_harness_switch_without_weakening_validation() { - for command in ["goose-acp", "/opt/tools/buzz-pi-acp"] { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let mut a = fixture(); - a.harness.command = command.into(); - a.environment - .insert("BUZZ_ACP_MODEL".into(), "private-model".into()); - store.insert(vec![a.clone()]).unwrap(); - let before = fs::read(store.path()).unwrap(); - - // Keeping or replacing the override is invalid for Buzz Agent. - for value in [None, Some("replacement")] { - let mut update = edit(); - if let Some(value) = value { - update - .environment - .insert("BUZZ_ACP_MODEL".into(), Some(value.into())); - } - assert!(store.save(&a.id, a.revision, update).is_err()); - assert_eq!(fs::read(store.path()).unwrap(), before); - } - let mut update = edit(); - update.environment.insert("BUZZ_ACP_MODEL".into(), None); - store.save(&a.id, a.revision, update).unwrap(); - drop(store); - let saved = Store::open(dir.path().to_owned()) - .unwrap() - .agents() - .unwrap() - .remove(0); - assert_eq!(saved.harness.command, "buzz-agent"); - assert!(!saved.environment.contains_key("BUZZ_ACP_MODEL")); - assert_eq!(saved.environment["TEST_TOKEN"], "secret-env-value"); - } -} - -#[test] -fn worker_count_override_persists_only_within_runtime_limits() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let a = fixture(); - store.insert(vec![a.clone()]).unwrap(); - for count in ["1", "10", "32"] { - let revision = store.agents().unwrap()[0].revision; - let mut update = edit(); - update - .environment - .insert("BUZZ_ACP_AGENTS".into(), Some(count.into())); - store.save(&a.id, revision, update).unwrap(); - assert_eq!( - store.agents().unwrap()[0].environment["BUZZ_ACP_AGENTS"], - count - ); - } - let revision = store.agents().unwrap()[0].revision; - let before = fs::read(store.path()).unwrap(); - for count in ["", "0", "33", "-1", "1.5", "invalid", "4294967296"] { - let mut update = edit(); - update - .environment - .insert("BUZZ_ACP_AGENTS".into(), Some(count.into())); - assert!(store.save(&a.id, revision, update).is_err()); - assert_eq!(fs::read(store.path()).unwrap(), before); - } - drop(store); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - assert_eq!( - store.agents().unwrap()[0].environment["BUZZ_ACP_AGENTS"], - "32" - ); - let mut update = edit(); - update.environment.insert("BUZZ_ACP_AGENTS".into(), None); - store.save(&a.id, revision, update).unwrap(); - assert!(!store.agents().unwrap()[0] - .environment - .contains_key("BUZZ_ACP_AGENTS")); -} - -#[test] -fn malformed_store_never_becomes_empty_or_overwritten() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let a = fixture(); - store.insert(vec![a.clone()]).unwrap(); - fs::write(store.path(), b"{broken-secret").unwrap(); - assert!(store.save(&a.id, 1, edit()).is_err()); - assert!(store.snapshot().is_err()); - assert_eq!(fs::read(store.path()).unwrap(), b"{broken-secret"); - drop(store); - assert!(Store::open(dir.path().to_owned()).is_err()); -} -#[test] -fn durable_enablement_is_not_a_config_revision() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let a = fixture(); - store.insert(vec![a.clone()]).unwrap(); - store.enabled(&a.id, true).unwrap(); - store.enabled(&a.id, false).unwrap(); - drop(store); - let store = Store::open(dir.path().to_owned()).unwrap(); - assert!(!store.agents().unwrap()[0].enabled); - assert_eq!(store.agents().unwrap()[0].revision, 1); -} -#[test] -fn launch_preference_persists_without_a_config_revision_and_overrides_enablement() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let a = fixture(); - store.insert(vec![a.clone()]).unwrap(); - assert!(!store.agents().unwrap()[0].starts_on_launch()); - store.enabled(&a.id, true).unwrap(); - assert!(store.agents().unwrap()[0].starts_on_launch()); - store.start_on_app_launch(&a.id, false).unwrap(); - assert!(store.start_on_app_launch("missing", true).is_err()); - drop(store); - let store = Store::open(dir.path().to_owned()).unwrap(); - let saved = &store.agents().unwrap()[0]; - assert_eq!(saved.start_on_app_launch, Some(false)); - assert!(saved.enabled && !saved.starts_on_launch()); - assert_eq!(saved.revision, 1); -} -#[test] -fn identity_and_transport_validation_rejects_duplicates_and_argument_loss() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let a = fixture(); - assert!(store.insert(vec![a.clone(), a.clone()]).is_err()); - store.insert(vec![a.clone()]).unwrap(); - for argument in ["one,two", "", "a\0b"] { - let mut update = edit(); - update.harness.args = vec![argument.into()]; - assert!(store.save(&a.id, 1, update).is_err()); - } - assert_eq!(store.agents().unwrap()[0].revision, 1); -} -#[cfg(unix)] -#[test] -fn symlink_store_and_lock_are_refused() { - use std::os::unix::fs::symlink; - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("target"); - fs::write(&target, "untouched").unwrap(); - symlink(&target, dir.path().join("controller.lock")).unwrap(); - assert!(Store::open(dir.path().to_owned()).is_err()); - fs::remove_file(dir.path().join("controller.lock")).unwrap(); - symlink(&target, dir.path().join("agents.json")).unwrap(); - assert!(Store::open(dir.path().to_owned()).is_err()); - assert_eq!(fs::read(target).unwrap(), b"untouched"); -} - -#[test] -fn closing_store_releases_lock_even_with_inherited_file_description() { - let dir = tempfile::tempdir().unwrap(); - let store = Store::open(dir.path().to_owned()).unwrap(); - // dup/fork share the flock's open-file description. A concurrently spawning - // child can retain it until exec despite the parent's close-on-exec flag. - let inherited = store._lock.try_clone().unwrap(); - assert!(Store::open(dir.path().to_owned()).is_err()); - drop(store); - let reopened = Store::open(dir.path().to_owned()).unwrap(); - drop(inherited); - assert!(Store::open(dir.path().to_owned()).is_err()); - drop(reopened); - Store::open(dir.path().to_owned()).unwrap(); -} - -#[test] -fn avatar_save_preserve_clear_pending_cas_and_reopen() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let a = fixture(); - store.insert(vec![a.clone()]).unwrap(); - let mut update = edit(); - update.picture = Some("https://images.example/brain.png".into()); - store.save(&a.id, 1, update).unwrap(); - assert_eq!( - store.snapshot().unwrap().agents[0].picture.as_deref(), - Some("https://images.example/brain.png") - ); - assert!(store.snapshot().unwrap().agents[0].profile_pending); - drop(store); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - assert!(store.snapshot().unwrap().agents[0].profile_pending); - store.save(&a.id, 2, edit()).unwrap(); // Omitted picture preserves the managed override. - assert_eq!( - store.agents().unwrap()[0].picture.as_deref(), - Some("https://images.example/brain.png") - ); - assert!(store.profile_published(&a.id, 2).is_err()); - assert!(store.snapshot().unwrap().agents[0].profile_pending); - store.profile_published(&a.id, 3).unwrap(); - assert!(!store.snapshot().unwrap().agents[0].profile_pending); - let mut update = edit(); - update.picture = Some("https://images.example/brain.png".into()); - store.save(&a.id, 3, update).unwrap(); - assert!(!store.snapshot().unwrap().agents[0].profile_pending); // Unchanged does not republish. - let mut update = edit(); - update.picture = Some(String::new()); - store.save(&a.id, 4, update).unwrap(); - assert!(store.snapshot().unwrap().agents[0].profile_pending); - drop(store); - let store = Store::open(dir.path().to_owned()).unwrap(); - assert_eq!(store.agents().unwrap()[0].picture.as_deref(), Some("")); - assert_eq!(store.agents().unwrap()[0].imported, a.imported); -} - -#[test] -fn rename_save_persists_profile_name_pending_until_confirmed_publication() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let agent = fixture(); - store.insert(vec![agent.clone()]).unwrap(); - - let mut update = edit(); - update.name = "Luna".into(); - store.save(&agent.id, agent.revision, update).unwrap(); - - let saved = &store.agents().unwrap()[0]; - assert_eq!(saved.name, "Luna"); - assert_eq!(saved.extra.get("profileNamePending"), Some(&json!(true))); - assert!(store.snapshot().unwrap().agents[0].profile_pending); - drop(store); - - let mut store = Store::open(dir.path().to_owned()).unwrap(); - assert_eq!(store.agents().unwrap()[0].name, "Luna"); - assert!(store.snapshot().unwrap().agents[0].profile_pending); - assert!(store.profile_published(&agent.id, agent.revision).is_err()); - assert!(store.snapshot().unwrap().agents[0].profile_pending); - - store - .profile_published(&agent.id, agent.revision + 1) - .unwrap(); - assert!(!store.snapshot().unwrap().agents[0].profile_pending); - assert!(!store.agents().unwrap()[0] - .extra - .contains_key("profileNamePending")); -} - -#[test] -fn invalid_avatar_and_stale_save_leave_persistent_bytes_unchanged() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let a = fixture(); - store.insert(vec![a.clone()]).unwrap(); - let before = fs::read(store.path()).unwrap(); - for value in [ - "http://images.example/a.png", - "data:image/png;base64,AA==", - "https://user:secret@images.example/a.png", - "not a URL", - ] { - let mut update = edit(); - update.picture = Some(value.into()); - assert!(store.save(&a.id, 1, update).is_err()); - assert_eq!(fs::read(store.path()).unwrap(), before); - } - let mut update = edit(); - update.picture = Some("https://images.example/new.png".into()); - assert!(store.save(&a.id, 0, update).is_err()); - assert_eq!(fs::read(store.path()).unwrap(), before); -} - -#[test] -fn agent_defaults_persist_owner_only_and_never_project_values() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("agent-controller"); - let store = Store::open(root.clone()).unwrap(); - assert_eq!(store.defaults().unwrap().harness, "buzz-agent"); - let mut defaults = store.defaults().unwrap(); - defaults.harness = "goose".into(); - defaults.model = "global-model".into(); - defaults.session_policy = crate::config::SessionPolicy::Thread; - defaults - .environment - .insert("API_TOKEN".into(), "secret-env-value".into()); - store.save_defaults(&defaults).unwrap(); - drop(store); - let store = Store::open(root.clone()).unwrap(); - assert!(store.defaults().unwrap() == defaults); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(root.join("defaults.json")) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o600); - } - let snapshot = serde_json::to_string(&store.snapshot().unwrap()).unwrap(); - assert!(snapshot.contains("\"sessionPolicy\":\"thread\"")); - assert!(snapshot.contains("\"environmentKeys\":[\"API_TOKEN\"]")); - assert!(!snapshot.contains("secret-env-value")); - fs::write(root.join("defaults.json"), b"{not json").unwrap(); - assert!(store.defaults().is_err()); -} - -#[test] -fn oversized_defaults_are_rejected_before_replacing_the_usable_record() { - let dir = tempfile::tempdir().unwrap(); - let store = Store::open(dir.path().to_owned()).unwrap(); - let mut defaults = store.defaults().unwrap(); - defaults.model = "working-model".into(); - store.save_defaults(&defaults).unwrap(); - let original = fs::read(dir.path().join("defaults.json")).unwrap(); - - // Individual values are valid, but their combined JSON exceeds the read limit. - for index in 0..40 { - defaults - .environment - .insert(format!("TOKEN_{index}"), "x".repeat(32 * 1024)); - } - assert_eq!( - store.save_defaults(&defaults).unwrap_err(), - "Agent defaults exceed the size limit" - ); - assert_eq!( - fs::read(dir.path().join("defaults.json")).unwrap(), - original - ); - assert_eq!(store.defaults().unwrap().model, "working-model"); -} - -#[test] -fn protection_defaults_preserve_explicit_bindings_and_persist_copies() { - let root = tempfile::tempdir().unwrap(); - let mut store = Store::open(root.path().into()).unwrap(); - let defaults = Binding { - provider: "default.provider".into(), - policy: json!({"network":"selected"}), - }; - store - .set_launch_protection_defaults(0, Some(defaults.clone())) - .unwrap(); - let mut agents: Vec<_> = ["a1", "b2", "c3"] - .into_iter() - .map(|key| { - let mut agent = fixture(); - agent.pubkey = key.repeat(32); - agent.id = agent_id(&agent.pubkey, &agent.relay_url); - agent - }) - .collect(); - agents[1].extra.insert(PROTECTION_KEY.into(), Value::Null); - let override_binding = json!({"provider":"agent.provider","policy":{"network":"deny_all"}}); - agents[2] - .extra - .insert(PROTECTION_KEY.into(), override_binding.clone()); - store.insert(agents.clone()).unwrap(); - let before = fs::read(store.path()).unwrap(); - assert_eq!( - store.set_launch_protection_defaults(0, None).unwrap_err(), - "Protection defaults changed; reload before saving" - ); - assert_eq!(fs::read(store.path()).unwrap(), before); - store.set_launch_protection_defaults(1, None).unwrap(); - drop(store); - let store = Store::open(root.path().into()).unwrap(); - let saved = store.agents().unwrap(); - agents[0].extra.insert( - PROTECTION_KEY.into(), - serde_json::to_value(defaults).unwrap(), - ); - assert_eq!( - serde_json::to_value(saved).unwrap(), - serde_json::to_value(agents).unwrap() - ); - let snapshot = store.launch_protection_snapshot().unwrap(); - assert_eq!(snapshot["revision"], 2); - assert!(snapshot["defaults"].is_null()); -} - -#[test] -fn protection_defaults_and_team_repair_commit_as_one_batch() { - let root = tempfile::tempdir().unwrap(); - let mut store = Store::open(root.path().into()).unwrap(); - let mut repaired = fixture(); - repaired.imported["record"] = json!({"team_id":"fixture-team"}); - store.insert(vec![repaired.clone()]).unwrap(); - let defaults = Binding { - provider: "default.provider".into(), - policy: json!({"network":"selected"}), - }; - store - .set_launch_protection_defaults(0, Some(defaults.clone())) - .unwrap(); - let mut incoming = fixture(); - incoming.pubkey = "cd".repeat(32); - incoming.id = agent_id(&incoming.pubkey, &incoming.relay_url); - let before = fs::read(store.path()).unwrap(); - assert_eq!( - store - .import( - vec![incoming.clone()], - vec![( - repaired.id.clone(), - repaired.revision + 1, - "Team instructions".into() - )] - ) - .unwrap_err(), - "Agent settings changed; preview the team import again" - ); - assert_eq!(fs::read(store.path()).unwrap(), before); - store - .import( - vec![incoming.clone()], - vec![( - repaired.id.clone(), - repaired.revision, - "Team instructions".into(), - )], - ) - .unwrap(); - repaired.imported["teamInstructions"] = json!("Team instructions"); - repaired.revision += 1; - incoming.extra.insert( - PROTECTION_KEY.into(), - serde_json::to_value(defaults).unwrap(), - ); - drop(store); - let store = Store::open(root.path().into()).unwrap(); - assert_eq!( - serde_json::to_value(store.agents().unwrap()).unwrap(), - serde_json::to_value(vec![repaired, incoming]).unwrap() - ); -} - -#[test] -fn protection_defaults_reject_malformed_values_without_inserting_agents() { - let root = tempfile::tempdir().unwrap(); - let mut store = Store::open(root.path().into()).unwrap(); - let mut doc = store.read().unwrap(); - doc.extra - .insert(PROTECTION_KEY.into(), json!("malformed binding")); - store.write(&doc).unwrap(); - let before = fs::read(store.path()).unwrap(); - assert_eq!( - store.insert(vec![fixture()]).unwrap_err(), - "Saved protection is malformed" - ); - assert_eq!(fs::read(store.path()).unwrap(), before); -} - -#[test] -fn protection_defaults_revision_limit_preserves_saved_bytes() { - let root = tempfile::tempdir().unwrap(); - let mut store = Store::open(root.path().into()).unwrap(); - let mut doc = store.read().unwrap(); - doc.extra.insert( - "launchProtectionRevision".into(), - json!(9_007_199_254_740_990u64), - ); - store.write(&doc).unwrap(); - store - .set_launch_protection_defaults(9_007_199_254_740_990, None) - .unwrap(); - assert_eq!( - store.launch_protection_snapshot().unwrap()["revision"], - 9_007_199_254_740_991u64 - ); - let before = fs::read(store.path()).unwrap(); - assert_eq!( - store - .set_launch_protection_defaults(9_007_199_254_740_991, None) - .unwrap_err(), - "Protection defaults revision exhausted" - ); - assert_eq!(fs::read(store.path()).unwrap(), before); - let mut doc = store.read().unwrap(); - doc.extra.insert( - "launchProtectionRevision".into(), - json!(9_007_199_254_740_992u64), - ); - store.write(&doc).unwrap(); - assert_eq!( - store.launch_protection_snapshot().unwrap_err(), - "Invalid protection revision" - ); -} - -#[test] -fn existing_agent_save_accepts_international_text_and_crlf_but_rejects_hidden_controls() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let agent = fixture(); - store.insert(vec![agent.clone()]).unwrap(); - let mut update = edit(); - update.name = "زبان فارسی".into(); - update.system_prompt = "فارسی‌زبان\r\nनमस्ते\u{200d}दुनिया 👩‍💻 “അവന്‍” അവന്‍। വന്നു".into(); - store.save(&agent.id, agent.revision, update).unwrap(); - let saved = store.agents().unwrap().into_iter().next().unwrap(); - assert_eq!( - saved.system_prompt, - "فارسی‌زبان\r\nनमस्ते\u{200d}दुनिया 👩‍💻 “അവന്‍” അവന്‍। വന്നു" - ); - let mut invalid = edit(); - invalid.system_prompt = "hidden\u{202e} instructions".into(); - assert!(store.save(&agent.id, saved.revision, invalid).is_err()); -} -fn beta_agent(key: &str, prompt: &str, imported: serde_json::Value) -> Agent { - let mut agent = fixture(); - agent.pubkey = key.repeat(32); - agent.id = agent_id(&agent.pubkey, &agent.relay_url); - agent.system_prompt = prompt.into(); - agent.imported = imported; - agent -} -const D: &str = "\n\n---\n# Team Instructions\n"; -fn cleaned(dir: &Path) -> (Vec, Option) { - let mut store = Store::open(dir.to_owned()).unwrap(); - let warning = store.clean_imported_prompts().unwrap(); - (store.agents().unwrap(), warning) -} -#[test] -fn cleanup_cuts_beta_imports_once_at_the_last_delimiter() { - let dir = tempfile::tempdir().unwrap(); - let twice = format!("role{D}old{D}team"); - let beta = serde_json::json!({"record": {"system_prompt": twice}, "global": {}}); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - store - .insert(vec![ - beta_agent("01", &twice, beta), - beta_agent( - "02", - &twice, - serde_json::json!({"record": {"system_prompt": twice}}), - ), - beta_agent( - "03", - "role only", - serde_json::json!({"record": {"system_prompt": "role only"}, "global": {}}), - ), - ]) - .unwrap(); - let before = store.agents().unwrap(); - drop(store); - let (after, warning) = cleaned(dir.path()); - assert_eq!(warning, None); - assert_eq!(after[0].system_prompt, format!("role{D}old")); - assert_eq!(after[0].revision, before[0].revision + 1); - for i in [1, 2] { - assert_eq!(after[i].system_prompt, before[i].system_prompt); - assert_eq!(after[i].revision, before[i].revision); - } - let (again, _) = cleaned(dir.path()); - assert_eq!(again[0].system_prompt, format!("role{D}old")); - assert_eq!(again[0].revision, after[0].revision); -} -#[test] -fn cleanup_leaves_later_owner_edits_alone() { - let dir = tempfile::tempdir().unwrap(); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - store - .insert(vec![beta_agent( - "01", - "role", - serde_json::json!({"global": {}}), - )]) - .unwrap(); - drop(store); - cleaned(dir.path()); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - let saved = store.agents().unwrap()[0].clone(); - assert_eq!(saved.imported[CLEANED], true); - let mut update = edit(); - update.system_prompt = format!("mine{D}also mine"); - store.save(&saved.id, saved.revision, update).unwrap(); - drop(store); - let (after, _) = cleaned(dir.path()); - assert_eq!(after[0].system_prompt, format!("mine{D}also mine")); -} -#[test] -fn cleanup_keeps_prompts_edited_before_upgrade_or_without_an_original() { - let dir = tempfile::tempdir().unwrap(); - let edited = format!("role{D}my own team notes"); - let original = format!("role{D}baked team"); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - store - .insert(vec![ - beta_agent( - "01", - &edited, - serde_json::json!({"record": {"system_prompt": original}, "global": {}}), - ), - beta_agent( - "02", - &edited, - serde_json::json!({ - "record": {"system_prompt": edited}, - "definition": {"system_prompt": original}, - "global": {}, - }), - ), - beta_agent( - "03", - &original, - serde_json::json!({"record": {}, "global": {}}), - ), - ]) - .unwrap(); - let before = store.agents().unwrap(); - drop(store); - let (after, warning) = cleaned(dir.path()); - assert_eq!(warning, None); - for (agent, prior) in after.iter().zip(&before) { - assert_eq!(agent.system_prompt, prior.system_prompt); - assert_eq!(agent.revision, prior.revision); - assert_eq!(agent.imported[CLEANED], true); - } -} -#[test] -fn failed_cleanup_write_warns_and_sets_no_mark() { - let dir = tempfile::tempdir().unwrap(); - let baked = format!("role{D}team"); - let mut store = Store::open(dir.path().to_owned()).unwrap(); - store - .insert(vec![beta_agent( - "01", - &baked, - serde_json::json!({"record": {"system_prompt": baked}, "global": {}}), - )]) - .unwrap(); - drop(store); - fs::remove_file(dir.path().join("agents.previous.json")).ok(); - fs::create_dir(dir.path().join("agents.previous.json")).unwrap(); - let (agents, warning) = cleaned(dir.path()); - assert!(warning - .unwrap() - .contains("Could not finish removing old Buzz team text")); - assert_eq!(agents[0].system_prompt, baked); - assert!(agents[0].imported.get(CLEANED).is_none()); - fs::remove_dir(dir.path().join("agents.previous.json")).unwrap(); - let (agents, warning) = cleaned(dir.path()); - assert_eq!((warning, agents[0].system_prompt.as_str()), (None, "role")); -} diff --git a/crates/agent-controller/src/supervisor.rs b/crates/agent-controller/src/supervisor.rs deleted file mode 100644 index 7405b059d..000000000 --- a/crates/agent-controller/src/supervisor.rs +++ /dev/null @@ -1,898 +0,0 @@ -//! One private, execed process owns the identity lock and entire listener session -//! through confirmed cleanup, including after the GUI exits abruptly. -use crate::ownership::Ownership; -use crate::process::Process; -use crate::Result; -use std::io::{Read, Write}; -use std::path::Path; -use std::process::{Child, Command, Stdio}; -use std::time::Duration; -#[cfg(windows)] -mod pipe; -#[cfg(windows)] -use pipe::{channel, inherited, log_channel, peek, Channel}; -#[cfg(unix)] -use unix::{channel, inherited, log_channel, peek, Channel}; - -pub const MODE: &str = "--buzz-agent-session-supervisor"; - -/// Dispatch before Tauri is constructed. Credentials are never passed on argv. -pub fn dispatch() -> bool { - let mut args = std::env::args_os(); - args.next(); - if args.next().as_deref() != Some(std::ffi::OsStr::new(MODE)) { - return false; - } - let (Some(root), Some(id), Some(temp), Some(control), Some(runtime), Some(log), None) = ( - args.next(), - args.next(), - args.next(), - args.next(), - args.next(), - args.next(), - args.next(), - ) else { - std::process::exit(1); - }; - let control = (!control.is_empty()).then(|| Path::new(&control)); - // The connected channel occupies stdin; stdout/stderr stay closed. - let Ok(socket) = inherited() else { - let _ = remove_runtime_dirs(Path::new(&temp), control); - std::process::exit(1); - }; - let result = serve( - socket, - Path::new(&root), - &id.to_string_lossy(), - Path::new(&temp), - control, - Path::new(&runtime), - Path::new(&log), - ); - std::process::exit(if result.is_ok() { 0 } else { 1 }); -} - -pub(crate) fn serve( - mut socket: Channel, - root: &Path, - id: &str, - temp: &Path, - control: Option<&Path>, - runtime: &Path, - log: &Path, -) -> Result<()> { - let _ownership = match Ownership::acquire(root, id) { - Ok(lock) => lock, - Err(error) => { - let _ = remove_runtime_dirs(temp, control); - let _ = socket.write_all(if error.starts_with("Another buzz-app profile") { - b"O" - } else { - b"E" - }); - return Err(error); - } - }; - if socket - .set_read_timeout(Some(Duration::from_millis(100))) - .is_err() - { - let _ = remove_runtime_dirs(temp, control); - let _ = socket.write_all(b"E"); - return Err("Could not watch app lifetime".into()); - } - let mut spawned = false; - let result = (|| { - let mut writer = crate::logs::Writer::new(log)?; - let (mut reader, stdout, stderr) = - log_channel().map_err(|_| "Could not capture harness log")?; - let mut command = Command::new(runtime); - command.stdin(Stdio::null()).stdout(stdout).stderr(stderr); - // Windows cannot run the sh listener fixture; its stand-in is a copy of - // the test binary running only runtime::tests::windows_listener. - #[cfg(all(test, windows))] - command.args(["--exact", "runtime::tests::windows_listener", "--nocapture"]); - let mut process = match Process::spawn(&mut command) { - Ok(process) => process, - Err(error) => { - let _ = remove_runtime_dirs(temp, control); - return Err(error); - } - }; - drop(command); - spawned = true; - // Arming boundary: lock and listener exist before Start is acknowledged. - let _ = socket.write_all(b"R"); - let mut input = [0u8; 1]; - loop { - // A logging failure must not strand the owned listener. Teardown - // still runs on the same path as Stop and app death. - if drain_log(&mut reader, &mut writer, true).is_err() { - break; - } - match socket.read(&mut input) { - Ok(0 | 1) => break, // explicit Stop or kernel EOF on app death - Ok(_) => unreachable!(), - Err(e) - if matches!( - e.kind(), - std::io::ErrorKind::TimedOut - | std::io::ErrorKind::WouldBlock - | std::io::ErrorKind::Interrupted - ) => - { - match process.alive() { - Ok(true) => {} - Ok(false) => break, - Err(_) => { - if process.stop().is_err() { - let _ = socket.write_all(b"F"); - loop { - std::thread::park(); - } - } - break; - } - } - } - Err(_) => break, - } - } - if process.stop().is_err() { - // Neither lock nor signing directory may be released on failed cleanup. - let _ = socket.write_all(b"F"); - loop { - std::thread::park(); - } - } - // The listener session is confirmed stopped. Empty the finite kernel - // buffer, not just one live-loop budget, before acknowledging Stop. - let _ = drain_log(&mut reader, &mut writer, false); - // A confirmed failure to delete the private dir can be reported and - // retried manually without claiming the old execution is still running. - remove_runtime_dirs(temp, control) - .map_err(|_| "Could not remove agent runtime directory")?; - Ok(()) - })(); - if !spawned { - let _ = remove_runtime_dirs(temp, control); - } - // Cleanup failures after confirmed exit retain the private directory but - // allow another execution. Keep the lock through the completion handshake. - let _ = socket.write_all(if result.is_ok() { - b"S" - } else if spawned { - b"E" - } else { - b"X" - }); - result -} - -// Both directories belong to the same session. Attempt both removals even if -// one fails, and report failure without releasing a still-running session. -fn remove_runtime_dirs(temp: &Path, control: Option<&Path>) -> std::io::Result<()> { - let scratch = std::fs::remove_dir_all(temp); - let snapshot = control.map(std::fs::remove_dir_all).unwrap_or(Ok(())); - scratch.and(snapshot) -} - -// A noisy child cannot starve Stop or app-death detection. -fn drain_log(reader: &mut Channel, writer: &mut crate::logs::Writer, live: bool) -> Result<()> { - let mut bytes = [0u8; 8192]; - let mut chunks = 0; - loop { - if live && chunks == 32 { - break; - } - match reader.read(&mut bytes) { - Ok(0) => break, - Ok(n) => { - writer.append(&bytes[..n])?; - chunks += 1; - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, - Err(_) => return Err("Could not capture harness log".into()), - } - } - Ok(()) -} - -// A failed Start cannot wait indefinitely for an unconfirmed guardian. Keep -// Child owned until its guardian finishes the same cleanup used after app death. -fn reap_failed_start(mut child: Child) { - std::thread::spawn(move || { - let _ = child.wait(); - }); -} - -fn confirm_start(mut socket: Channel, child: Child) -> Result<(Channel, Child)> { - let mut state = [0u8]; - let read = socket.read_exact(&mut state); - if read.is_err() || state != *b"R" { - // Confirmed pre-listener replies exit immediately. An unconfirmed - // read may still own a listener; close the channel so its guardian - // cleans up, and reap it without blocking Start indefinitely. - drop(socket); - if read.is_ok() && matches!(state, [b'O' | b'X' | b'E']) { - let mut child = child; - let _ = child.wait(); - } else { - reap_failed_start(child); - } - return Err(if state == *b"O" { - "Another buzz-app profile is already running this exact agent/community" - } else { - "Could not start agent listener or acquire ownership" - } - .into()); - } - Ok((socket, child)) -} - -pub(crate) struct Supervised { - socket: Channel, - child: Child, - stopped: bool, - cleanup_failed: bool, - shutdown_unconfirmed: bool, -} -impl Supervised { - pub(crate) fn spawn( - command: &Command, - root: &Path, - id: &str, - temp: &Path, - control: Option<&Path>, - log: &Path, - ) -> Result { - let preflight = (|| { - let pair = channel().map_err(|_| "Could not create agent supervision channel")?; - let program = - std::env::current_exe().map_err(|_| "Could not locate agent supervisor")?; - let cwd = command.get_current_dir().ok_or("Missing agent workspace")?; - Ok::<_, &str>((pair, program, cwd)) - })(); - let ((socket, other), program, cwd) = preflight.inspect_err(|_| { - let _ = remove_runtime_dirs(temp, control); // no guardian was spawned - })?; - let mut guardian = Command::new(program); - #[cfg(not(test))] - guardian.args([ - MODE.into(), - root.as_os_str().to_owned(), - id.into(), - temp.as_os_str().to_owned(), - control - .map(|path| path.as_os_str().to_owned()) - .unwrap_or_default(), - command.get_program().to_owned(), - log.as_os_str().to_owned(), - ]); - #[cfg(test)] - guardian.args(["--exact", "supervisor::tests::entrypoint", "--nocapture"]); - guardian.env_clear().envs( - command - .get_envs() - .filter_map(|(key, value)| value.map(|value| (key.to_owned(), value.to_owned()))), - ); - #[cfg(test)] - guardian.env( - "BUZZ_SUPERVISOR_TEST_ARGS", - serde_json::to_string(&[ - root.display().to_string(), - id.to_owned(), - temp.display().to_string(), - control - .map(|path| path.display().to_string()) - .unwrap_or_default(), - command.get_program().to_string_lossy().into_owned(), - log.display().to_string(), - ]) - .map_err(|_| { - let _ = remove_runtime_dirs(temp, control); - "Could not prepare test supervisor" - })?, - ); - guardian - .current_dir(cwd) - .stdin(other) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - // A separate session keeps terminal/launcher group signals from killing - // this lock owner, and is distinct from the listener's session. - #[cfg(unix)] - unsafe { - use std::os::unix::process::CommandExt; - guardian.pre_exec(|| { - if libc::setsid() == -1 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - // Windows: its own hidden console, outside a terminal's Ctrl+C group. - // An enclosing kill-on-close launcher job can still end the guardian. - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - guardian.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW); - } - let child = guardian.spawn().map_err(|_| { - let _ = remove_runtime_dirs(temp, control); // no guardian can use it - "Could not start agent supervisor" - })?; - // After spawn, only the guardian can decide when temp storage is safe to remove. - if socket - .set_read_timeout(Some(Duration::from_secs(10))) - .is_err() - { - drop(socket); - reap_failed_start(child); - return Err("Could not confirm agent supervisor".into()); - } - let (socket, child) = confirm_start(socket, child)?; - Ok(Self { - socket, - child, - stopped: false, - cleanup_failed: false, - shutdown_unconfirmed: false, - }) - } - pub(crate) fn stopped(&self) -> bool { - self.stopped - } - pub(crate) fn alive(&mut self) -> Result { - if self.stopped { - return Ok(false); - } - if self.cleanup_failed || self.failed()? { - return Err("Agent session cleanup failed; ownership remains held".into()); - } - if self.shutdown_unconfirmed { - return Err("Agent session shutdown could not be confirmed".into()); - } - if self - .child - .try_wait() - .map_err(|_| "Could not inspect agent supervisor")? - .is_some() - { - // Read S/E from the completed guardian; exit status alone cannot - // distinguish a clean stop from failed private-directory removal. - self.stop()?; - return Ok(false); - } - Ok(true) - } - fn failed(&self) -> Result { - let byte = peek(&self.socket).map_err(|_| "Could not inspect agent supervisor")?; - Ok(byte == Some(b'F')) - } - pub(crate) fn stop(&mut self) -> Result<()> { - if self.stopped { - return Ok(()); - } - if self.cleanup_failed || self.failed()? { - return Err("Agent session cleanup failed; ownership remains held".into()); - } - if !self.shutdown_unconfirmed { - self.shutdown_unconfirmed = true; - let _ = self.socket.write_all(b"S"); - } - let _ = self.socket.set_read_timeout(Some(Duration::from_secs(8))); - let mut result = [0u8]; - if self.socket.read_exact(&mut result).is_err() { - return Err("Agent session shutdown could not be confirmed".into()); - } - if result == *b"F" { - self.cleanup_failed = true; - return Err("Agent session cleanup failed; ownership remains held".into()); - } - if !matches!(result, [b'S' | b'E']) { - return Err("Agent session shutdown could not be confirmed".into()); - } - self.child - .wait() - .map_err(|_| "Could not reap agent supervisor")?; - self.stopped = true; - if result == *b"E" { - Err("Agent stopped, but its private runtime directory could not be removed".into()) - } else { - Ok(()) - } - } -} -impl Drop for Supervised { - fn drop(&mut self) { - let _ = self.stop(); - } -} - -/// Unix transport: a socketpair on the guardian's stdin. -#[cfg(unix)] -mod unix { - use std::io; - use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; - pub(super) use std::os::unix::net::UnixStream as Channel; - use std::process::Stdio; - - pub(super) fn channel() -> io::Result<(Channel, Stdio)> { - let (ours, theirs) = Channel::pair()?; - Ok((ours, OwnedFd::from(theirs).into())) - } - - pub(super) fn inherited() -> io::Result { - Ok(unsafe { Channel::from_raw_fd(0) }) - } - - pub(super) fn log_channel() -> io::Result<(Channel, Stdio, Stdio)> { - let (reader, output) = Channel::pair()?; - reader.set_nonblocking(true)?; - let stderr = output.try_clone()?; - Ok(( - reader, - OwnedFd::from(output).into(), - OwnedFd::from(stderr).into(), - )) - } - - pub(super) fn peek(channel: &Channel) -> io::Result> { - let mut byte = [0u8]; - let n = unsafe { - libc::recv( - channel.as_raw_fd(), - byte.as_mut_ptr().cast(), - 1, - libc::MSG_PEEK | libc::MSG_DONTWAIT, - ) - }; - if n < 0 { - let error = io::Error::last_os_error(); - if error.kind() == io::ErrorKind::WouldBlock { - return Ok(None); - } - return Err(error); - } - Ok((n == 1).then_some(byte[0])) - } -} - -#[cfg(test)] -mod tests { - #[test] - fn entrypoint() { - let Some(args) = std::env::var_os("BUZZ_SUPERVISOR_TEST_ARGS") else { - return; - }; - let args: Vec = serde_json::from_str(&args.to_string_lossy()).unwrap(); - let socket = super::inherited().unwrap(); - super::serve( - socket, - std::path::Path::new(&args[0]), - &args[1], - std::path::Path::new(&args[2]), - (!args[3].is_empty()).then(|| std::path::Path::new(&args[3])), - std::path::Path::new(&args[4]), - std::path::Path::new(&args[5]), - ) - .unwrap(); - } -} - -#[cfg(all(test, windows))] -mod windows_tests; - -#[cfg(all(test, unix))] -mod lifecycle_tests { - use super::*; - use std::fs; - use std::os::unix::net::UnixStream; - use std::time::Instant; - - const ID: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798-79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - - #[test] - fn parent_entrypoint() { - let Some(dir) = std::env::var_os("BUZZ_SUPERVISOR_PARENT_TEST") else { - return; - }; - let dir = Path::new(&dir); - let mut command = Command::new(dir.join("listener")); - command.current_dir(dir); - fs::create_dir(dir.join("control")).unwrap(); - let run = Supervised::spawn( - &command, - &dir.join("locks"), - ID, - &dir.join("temp"), - Some(&dir.join("control")), - &dir.join("harness.log"), - ) - .unwrap(); - fs::write(dir.join("ready"), run.child.id().to_string()).unwrap(); - loop { - std::thread::park(); - } - } - - fn wait(path: &Path) { - let deadline = Instant::now() + Duration::from_secs(8); - while !path.exists() { - assert!(Instant::now() < deadline, "fixture did not reach {path:?}"); - std::thread::sleep(Duration::from_millis(10)); - } - } - - struct FixtureCleanup { - parent: i32, - guardian: i32, - leader: i32, - worker: i32, - root: std::path::PathBuf, - } - impl Drop for FixtureCleanup { - fn drop(&mut self) { - if self.guardian <= 1 { - self.guardian = fs::read_to_string(self.root.join("ready")) - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - } - if self.leader <= 1 || self.worker <= 1 { - if let Ok(text) = fs::read_to_string(self.root.join("worker")) { - if let Some((leader, worker)) = text.split_once(' ') { - self.leader = leader.parse().unwrap_or(0); - self.worker = worker.parse().unwrap_or(0); - } - } - } - unsafe { - if self.parent > 1 { - libc::kill(self.parent, libc::SIGKILL); - } - if self.worker > 1 && libc::getsid(self.worker) == self.leader { - libc::kill(self.worker, libc::SIGKILL); - } - if self.leader > 1 && libc::getsid(self.leader) == self.leader { - libc::kill(self.leader, libc::SIGKILL); - } - if self.guardian > 1 && libc::getsid(self.guardian) == self.guardian { - libc::kill(self.guardian, libc::SIGCONT); - libc::kill(self.guardian, libc::SIGKILL); - } - } - } - } - - #[test] - fn confirmed_failure_waits_and_uncertain_handshake_reaps_later() { - for &response in b"EOX" { - let child = Command::new("/usr/bin/true").spawn().unwrap(); - let pid = child.id() as i32; - let (parent, mut guardian) = UnixStream::pair().unwrap(); - guardian.write_all(&[response]).unwrap(); - let error = confirm_start(parent, child).err().unwrap(); - assert_eq!( - error, - if response == b'O' { - "Another buzz-app profile is already running this exact agent/community" - } else { - "Could not start agent listener or acquire ownership" - } - ); - assert_eq!( - unsafe { libc::kill(pid, 0) }, - -1, - "terminal guardian was not reaped" - ); - } - // No handshake: Start returns without waiting for a still-live child, - // but its socket closes and its exit is eventually reaped. - let child = Command::new("/bin/sleep").arg("60").spawn().unwrap(); - let pid = child.id() as i32; - // An assertion before the explicit kill must not leave /bin/sleep running. - struct KillOnFailure(i32); - impl Drop for KillOnFailure { - fn drop(&mut self) { - if self.0 > 1 { - unsafe { libc::kill(self.0, libc::SIGKILL) }; - } - } - } - let mut cleanup = KillOnFailure(pid); - let (parent, mut guardian) = UnixStream::pair().unwrap(); - parent - .set_read_timeout(Some(Duration::from_millis(30))) - .unwrap(); - // Model a peer reference that outlives confirm_start's socket drop. - let delayed_peer = parent.try_clone().unwrap(); - assert_eq!( - confirm_start(parent, child).err().unwrap(), - "Could not start agent listener or acquire ownership" - ); - assert_eq!(unsafe { libc::kill(pid, 0) }, 0); - guardian.set_nonblocking(true).unwrap(); - let mut byte = [0]; - let deadline = Instant::now() + Duration::from_secs(5); - // Verify the race path: the extra reference prevents immediate EOF. - assert_eq!( - guardian.read(&mut byte).unwrap_err().kind(), - std::io::ErrorKind::WouldBlock - ); - drop(delayed_peer); - loop { - match guardian.read(&mut byte) { - Ok(0) => break, // peer socket closed - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - assert!(Instant::now() < deadline, "peer socket did not close"); - std::thread::sleep(Duration::from_millis(10)); - } - result => panic!("unexpected peer read: {result:?}"), - } - } - assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); - cleanup.0 = 0; - let deadline = Instant::now() + Duration::from_secs(5); - while unsafe { libc::kill(pid, 0) } == 0 { - assert!( - Instant::now() < deadline, - "failed-start child was not reaped" - ); - std::thread::sleep(Duration::from_millis(10)); - } - } - - #[test] - fn cleanup_attempts_controls_even_if_scratch_removal_fails() { - let root = tempfile::tempdir().unwrap(); - let scratch = root.path().join("scratch"); - let control = root.path().join("control"); - fs::write(&scratch, "not a directory").unwrap(); - fs::create_dir(&control).unwrap(); - assert!(remove_runtime_dirs(&scratch, Some(&control)).is_err()); - assert!(!control.exists()); - assert!(scratch.exists()); - } - - #[test] - fn startup_abort_releases_ownership_and_private_directory() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let temp = root.join("temp"); - fs::create_dir(&temp).unwrap(); - let control = root.join("control"); - fs::create_dir(&control).unwrap(); - let mut command = Command::new(root.join("missing-listener")); - command.current_dir(root); - assert!(Supervised::spawn( - &command, - &root.join("locks"), - ID, - &temp, - Some(&control), - &root.join("harness.log") - ) - .is_err()); - assert!(!temp.exists()); - assert!(!control.exists()); - let _lock = Ownership::acquire(&root.join("locks"), ID).unwrap(); - } - - #[test] - fn listener_self_exit_reaps_guardian_and_allows_retry() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let temp = root.join("temp"); - fs::create_dir(&temp).unwrap(); - let control = root.join("control"); - fs::create_dir(&control).unwrap(); - let listener = root.join("listener"); - // Keep the listener alive until Start's handshake has completed. - // Then explicitly permit it to exit, without sending Stop to guardian. - let gate = root.join("exit"); - crate::test_executable::write_executable( - &listener, - format!( - "#!/bin/sh\nwhile [ ! -f '{}' ]; do /bin/sleep .01; done\n", - gate.display() - ), - ); - let mut command = Command::new(&listener); - command.current_dir(root); - let mut run = Supervised::spawn( - &command, - &root.join("locks"), - ID, - &temp, - Some(&control), - &root.join("harness.log"), - ) - .unwrap(); - fs::write(&gate, b"exit").unwrap(); - let deadline = Instant::now() + Duration::from_secs(8); - loop { - if !run.alive().unwrap() { - break; - } - assert!(Instant::now() < deadline, "exited listener was not reaped"); - std::thread::sleep(Duration::from_millis(10)); - } - assert!(!temp.exists()); - assert!(!control.exists()); - let _lock = Ownership::acquire(&root.join("locks"), ID).unwrap(); - } - - #[test] - fn stop_timeout_cannot_report_running_and_explicit_stop_confirms_completion() { - let (parent, mut guardian) = UnixStream::pair().unwrap(); - let child = Command::new("/bin/sleep").arg("60").spawn().unwrap(); - let mut run = Supervised { - socket: parent, - child, - stopped: false, - cleanup_failed: false, - shutdown_unconfirmed: false, - }; - // The guardian waits on an explicit gate after receiving Stop. - let (accepted, accepted_rx) = std::sync::mpsc::channel(); - let (release, released) = std::sync::mpsc::channel(); - let sender = std::thread::spawn(move || { - let mut byte = [0]; - guardian.read_exact(&mut byte).unwrap(); - accepted.send(()).unwrap(); - released.recv().unwrap(); - guardian.write_all(b"S").unwrap(); - }); - // Exercise the real eight-second read timeout while the gate is held; - // not a sleep racing with process teardown. - assert_eq!( - run.stop().unwrap_err(), - "Agent session shutdown could not be confirmed" - ); - accepted_rx.recv().unwrap(); - assert_eq!( - run.alive().unwrap_err(), - "Agent session shutdown could not be confirmed" - ); - release.send(()).unwrap(); - sender.join().unwrap(); - run.child.kill().unwrap(); - assert!(run.stop().is_ok()); - assert!(!run.alive().unwrap()); - } - - #[test] - fn failed_session_cleanup_remains_failed_after_stop_consumes_the_signal() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let temp = root.join("temp"); - fs::create_dir(&temp).unwrap(); - let mut command = Command::new("/usr/bin/true"); - command.current_dir(root); - // A test-only guardian fixture sends F without exiting. It models an - // incomplete session teardown, not a private-directory deletion error. - let (parent, mut guardian) = UnixStream::pair().unwrap(); - let child = Command::new("/bin/sleep").arg("60").spawn().unwrap(); - let mut run = Supervised { - socket: parent, - child, - stopped: false, - cleanup_failed: false, - shutdown_unconfirmed: false, - }; - let sender = std::thread::spawn(move || { - let mut byte = [0]; - guardian.read_exact(&mut byte).unwrap(); - guardian.write_all(b"F").unwrap(); - }); - assert_eq!( - run.stop().unwrap_err(), - "Agent session cleanup failed; ownership remains held" - ); - sender.join().unwrap(); - assert_eq!( - run.alive().unwrap_err(), - "Agent session cleanup failed; ownership remains held" - ); - assert_eq!( - run.stop().unwrap_err(), - "Agent session cleanup failed; ownership remains held" - ); - run.child.kill().unwrap(); - run.child.wait().unwrap(); - } - - #[test] - fn app_death_keeps_lock_through_worker_session_cleanup() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::create_dir(root.join("temp")).unwrap(); - // No real credentials or relay. This listener spawns a worker in a - // different process group and ignores TERM; session cleanup must kill it. - crate::test_executable::write_executable( - &root.join("listener"), - format!( - r##"#!/usr/bin/python3 -import os, signal, subprocess, time -signal.signal(signal.SIGTERM, signal.SIG_IGN) -signal.signal(signal.SIGHUP, signal.SIG_IGN) -child=subprocess.Popen(['/bin/sleep','60'], preexec_fn=os.setpgrp) -with open('{}/worker.tmp', 'w') as f: f.write(str(os.getpid())+' '+str(child.pid)) -os.rename('{}/worker.tmp', '{}/worker') -while True: time.sleep(.1) -"##, - root.display(), - root.display(), - root.display() - ), - ); - let mut parent = Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "supervisor::lifecycle_tests::parent_entrypoint", - "--nocapture", - ]) - .env("BUZZ_SUPERVISOR_PARENT_TEST", root) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - let mut cleanup = FixtureCleanup { - parent: parent.id() as i32, - guardian: 0, - leader: 0, - worker: 0, - root: root.into(), - }; - wait(&root.join("ready")); - wait(&root.join("worker")); - let pids = fs::read_to_string(root.join("worker")).unwrap(); - let (leader, worker) = pids.split_once(' ').unwrap(); - let leader: i32 = leader.parse().unwrap(); - let worker: i32 = worker.parse().unwrap(); - let guardian_pid: i32 = fs::read_to_string(root.join("ready")) - .unwrap() - .parse() - .unwrap(); - cleanup.guardian = guardian_pid; - cleanup.leader = leader; - cleanup.worker = worker; - assert!(leader > 1 && worker > 1); - assert_eq!(unsafe { libc::getsid(worker) }, leader); - assert_eq!(unsafe { libc::getpgid(worker) }, worker); - assert_ne!(guardian_pid, leader); - // Freeze the guardian: a fast cleanup cannot make this race pass by luck. - assert_eq!(unsafe { libc::kill(guardian_pid, libc::SIGSTOP) }, 0); - unsafe { libc::kill(parent.id() as i32, libc::SIGKILL) }; - parent.wait().unwrap(); - assert!(Ownership::acquire(&root.join("locks"), ID).is_err()); - assert!(root.join("temp").exists() && root.join("control").exists()); - assert_eq!(unsafe { libc::kill(guardian_pid, libc::SIGCONT) }, 0); - let deadline = Instant::now() + Duration::from_secs(8); - loop { - if Ownership::acquire(&root.join("locks"), ID).is_ok() { - break; - } - assert!( - Instant::now() < deadline, - "guardian did not release after cleanup" - ); - std::thread::sleep(Duration::from_millis(10)); - } - assert!(!root.join("temp").exists()); - assert!(!root.join("control").exists()); - let state = Command::new("/bin/ps") - .args(["-p", &worker.to_string(), "-o", "stat="]) - .output() - .unwrap(); - let text = String::from_utf8_lossy(&state.stdout); - assert!( - text.trim().is_empty() || text.trim().starts_with('Z'), - "worker still live: {text}" - ); - } -} diff --git a/crates/agent-controller/src/supervisor/pipe.rs b/crates/agent-controller/src/supervisor/pipe.rs deleted file mode 100644 index 6dc05bcb5..000000000 --- a/crates/agent-controller/src/supervisor/pipe.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Windows stand-in for the Unix socketpair: one private duplex named-pipe -//! instance per guardian. Reads poll `PeekNamedPipe`, so a timeout never -//! leaves a blocking read queued ahead of a write on the synchronous handle. -use std::cell::Cell; -use std::fs::File; -use std::io::{self, Read, Write}; -use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; -use std::process::Stdio; -use std::time::{Duration, Instant}; -use windows_sys::Win32::Foundation::{ - SetHandleInformation, ERROR_BROKEN_PIPE, ERROR_PIPE_NOT_CONNECTED, GENERIC_READ, GENERIC_WRITE, - HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, -}; -use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_FLAG_FIRST_PIPE_INSTANCE, OPEN_EXISTING, PIPE_ACCESS_DUPLEX, -}; -use windows_sys::Win32::System::Pipes::{ - CreateNamedPipeW, PeekNamedPipe, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, -}; - -pub(crate) struct Channel { - file: File, - timeout: Cell>, -} - -impl Channel { - fn new(handle: OwnedHandle) -> Self { - Self { - file: File::from(handle), - timeout: Cell::new(None), - } - } - - pub(crate) fn set_read_timeout(&self, timeout: Option) -> io::Result<()> { - self.timeout.set(timeout); - Ok(()) - } - - pub(crate) fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { - self.timeout.set(nonblocking.then_some(Duration::ZERO)); - Ok(()) - } - - /// Readable byte count and the next byte, unconsumed. `None` once the peer - /// has closed and buffered bytes are drained. - fn poll(&self) -> io::Result> { - let (mut byte, mut read, mut available) = (0u8, 0u32, 0u32); - let peeked = unsafe { - PeekNamedPipe( - self.file.as_raw_handle(), - (&mut byte as *mut u8).cast(), - 1, - &mut read, - &mut available, - std::ptr::null_mut(), - ) - }; - if peeked == 0 { - let error = io::Error::last_os_error(); - return match error.raw_os_error().map(|code| code as u32) { - Some(ERROR_BROKEN_PIPE | ERROR_PIPE_NOT_CONNECTED) => Ok(None), - _ => Err(error), - }; - } - Ok(Some((available, byte))) - } -} - -impl Read for Channel { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - let deadline = self.timeout.get().map(|timeout| Instant::now() + timeout); - loop { - match self.poll()? { - None => return Ok(0), - Some((0, _)) => {} - Some((available, _)) => { - let n = buf.len().min(available as usize); - return self.file.read(&mut buf[..n]); - } - } - if deadline.is_some_and(|deadline| Instant::now() >= deadline) { - return Err(io::ErrorKind::WouldBlock.into()); - } - std::thread::sleep(Duration::from_millis(10)); - } - } -} - -impl Write for Channel { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.file.write(buf) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -/// Both ends start non-inheritable; std duplicates the guardian's end only -/// for the duration of that one spawn. -pub(crate) fn channel() -> io::Result<(Channel, Stdio)> { - let mut nonce = [0u8; 16]; - getrandom::fill(&mut nonce).map_err(|_| io::Error::other("no randomness"))?; - let name = format!( - r"\\.\pipe\buzz-agent-supervisor-{:032x}", - u128::from_ne_bytes(nonce) - ); - let name: Vec = name.encode_utf16().chain([0]).collect(); - // A single first instance: a squatter or competing client fails closed. - let server = unsafe { - CreateNamedPipeW( - name.as_ptr(), - PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, - PIPE_TYPE_BYTE | PIPE_REJECT_REMOTE_CLIENTS, - 1, - 4096, - 4096, - 0, - std::ptr::null(), - ) - }; - if server == INVALID_HANDLE_VALUE { - return Err(io::Error::last_os_error()); - } - let server = unsafe { OwnedHandle::from_raw_handle(server) }; - let client = unsafe { - CreateFileW( - name.as_ptr(), - GENERIC_READ | GENERIC_WRITE, - 0, - std::ptr::null(), - OPEN_EXISTING, - 0, - std::ptr::null_mut(), - ) - }; - if client == INVALID_HANDLE_VALUE { - return Err(io::Error::last_os_error()); - } - let client = unsafe { OwnedHandle::from_raw_handle(client) }; - Ok((Channel::new(server), Stdio::from(client))) -} - -/// The guardian's stdin. Clear inheritance before anything is spawned so the -/// listener's tree cannot hold the app channel open past app death. -pub(crate) fn inherited() -> io::Result { - let handle = io::stdin().as_raw_handle(); - if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) } == 0 { - return Err(io::Error::last_os_error()); - } - Ok(Channel::new(unsafe { - OwnedHandle::from_raw_handle(handle) - })) -} - -/// Listener stdout/stderr; the guardian-private read end never blocks. -pub(crate) fn log_channel() -> io::Result<(Channel, Stdio, Stdio)> { - let (reader, writer) = io::pipe()?; - let stderr = writer.try_clone()?; - let reader = Channel::new(reader.into()); - reader.set_nonblocking(true)?; - Ok((reader, writer.into(), stderr.into())) -} - -/// The next unread byte without consuming it; `None` when empty or closed. -pub(crate) fn peek(channel: &Channel) -> io::Result> { - Ok(channel - .poll()? - .filter(|(n, _)| *n > 0) - .map(|(_, byte)| byte)) -} diff --git a/crates/agent-controller/src/supervisor/windows_tests.rs b/crates/agent-controller/src/supervisor/windows_tests.rs deleted file mode 100644 index de37703a2..000000000 --- a/crates/agent-controller/src/supervisor/windows_tests.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Native lifecycle: the listener tree lives in the guardian's job, and the -//! guardian keeps ownership and temp storage until that job is empty. -use super::*; -use std::fs; -use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; -use std::time::Instant; -use windows_sys::Win32::Foundation::{INVALID_HANDLE_VALUE, WAIT_OBJECT_0}; -use windows_sys::Win32::System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, -}; -use windows_sys::Win32::System::Threading::{ - GetExitCodeProcess, OpenProcess, OpenThread, ResumeThread, SuspendThread, TerminateProcess, - WaitForSingleObject, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, - THREAD_SUSPEND_RESUME, -}; - -const ID: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798-79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - -/// cmd root -> PowerShell listener -> ping worker. The root first runs short -/// cmd children whose handles it closes as they exit, so Stop must have opened -/// them as they joined. The listener logs a line, records both IDs, and exits -/// only once `exit` appears; the worker outlives it. -fn listener(root: &Path) -> Command { - fs::write( - root.join("listener.ps1"), - r#"[Console]::Out.WriteLine('listener-ready'); [Console]::Out.Flush() -$worker = Start-Process -PassThru -NoNewWindow "$env:SystemRoot\System32\PING.EXE" '-n 600 127.0.0.1' -Set-Content -LiteralPath "$PSScriptRoot\worker.tmp" -Value "$PID $($worker.Id)" -Rename-Item -LiteralPath "$PSScriptRoot\worker.tmp" -NewName worker -while (-not (Test-Path -LiteralPath "$PSScriptRoot\exit")) { Start-Sleep -Milliseconds 20 } -"#, - ) - .unwrap(); - fs::write( - root.join("listener.cmd"), - "@for /l %%i in (1,1,5) do @\"%SystemRoot%\\System32\\cmd.exe\" /d /c exit\r\n@\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"%~dp0listener.ps1\"\r\n", - ) - .unwrap(); - let mut command = Command::new(root.join("listener.cmd")); - command.current_dir(root).envs(std::env::vars_os()); - command -} - -/// A process handle; dropping it terminates the process so failures never leak. -struct Proc(OwnedHandle); -impl Proc { - fn open(pid: u32) -> Self { - let access = PROCESS_SYNCHRONIZE | PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION; - let handle = unsafe { OpenProcess(access, 0, pid) }; - assert!(!handle.is_null(), "process {pid} is not running"); - Self(unsafe { OwnedHandle::from_raw_handle(handle) }) - } - fn exited(&self) -> bool { - unsafe { WaitForSingleObject(self.0.as_raw_handle(), 0) == WAIT_OBJECT_0 } - } - fn exit_code(&self) -> u32 { - let waited = unsafe { WaitForSingleObject(self.0.as_raw_handle(), 30_000) }; - assert_eq!(waited, WAIT_OBJECT_0, "process did not exit"); - let mut code = 0; - assert_ne!( - unsafe { GetExitCodeProcess(self.0.as_raw_handle(), &mut code) }, - 0 - ); - code - } -} -impl Drop for Proc { - fn drop(&mut self) { - unsafe { TerminateProcess(self.0.as_raw_handle(), 1) }; - } -} - -fn read(path: &Path) -> String { - let deadline = Instant::now() + Duration::from_secs(30); - loop { - if let Ok(text) = fs::read_to_string(path) { - return text; - } - assert!(Instant::now() < deadline, "fixture did not reach {path:?}"); - std::thread::sleep(Duration::from_millis(10)); - } -} - -fn tree(root: &Path) -> (Proc, Proc) { - let text = read(&root.join("worker")); - let (listener, worker) = text.trim().split_once(' ').unwrap(); - ( - Proc::open(listener.parse().unwrap()), - Proc::open(worker.parse().unwrap()), - ) -} - -/// Suspend every guardian thread so fast cleanup cannot pass the order check by luck. -fn freeze(pid: u32) -> Vec { - let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; - assert_ne!(snapshot, INVALID_HANDLE_VALUE); - let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot) }; - let mut entry = THREADENTRY32 { - dwSize: std::mem::size_of::() as u32, - ..Default::default() - }; - let mut threads = Vec::new(); - let mut more = unsafe { Thread32First(snapshot.as_raw_handle(), &mut entry) } != 0; - while more { - if entry.th32OwnerProcessID == pid { - let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; - assert!(!thread.is_null()); - let thread = unsafe { OwnedHandle::from_raw_handle(thread) }; - assert_ne!(unsafe { SuspendThread(thread.as_raw_handle()) }, u32::MAX); - threads.push(thread); - } - more = unsafe { Thread32Next(snapshot.as_raw_handle(), &mut entry) } != 0; - } - assert!(!threads.is_empty()); - threads -} - -#[test] -fn parent_entrypoint() { - let Some(dir) = std::env::var_os("BUZZ_SUPERVISOR_PARENT_TEST") else { - return; - }; - let dir = Path::new(&dir); - let run = Supervised::spawn( - &listener(dir), - &dir.join("locks"), - ID, - &dir.join("temp"), - None, - &dir.join("harness.log"), - ) - .unwrap(); - fs::write(dir.join("ready.tmp"), run.child.id().to_string()).unwrap(); - fs::rename(dir.join("ready.tmp"), dir.join("ready")).unwrap(); - loop { - std::thread::park(); - } -} - -#[test] -fn app_death_keeps_lock_through_listener_tree_cleanup() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::create_dir(root.join("temp")).unwrap(); - let mut parent = Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "supervisor::windows_tests::parent_entrypoint", - "--nocapture", - ]) - .env("BUZZ_SUPERVISOR_PARENT_TEST", root) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - let app = Proc::open(parent.id()); - let guardian_pid: u32 = read(&root.join("ready")).parse().unwrap(); - let guardian = Proc::open(guardian_pid); - let (listener, worker) = tree(root); - let frozen = freeze(guardian_pid); - drop(app); // TerminateProcess: the app dies without Stop. - parent.wait().unwrap(); - assert!(Ownership::acquire(&root.join("locks"), ID).is_err()); - assert!( - !guardian.exited(), - "guardian died with the tested launch context" - ); - assert!( - !worker.exited(), - "listener tree did not belong to the guardian" - ); - for thread in frozen { - assert_ne!(unsafe { ResumeThread(thread.as_raw_handle()) }, u32::MAX); - } - // Exit 0 means serve() confirmed an empty job and removed temp storage. - assert_eq!(guardian.exit_code(), 0, "guardian did not complete cleanup"); - assert!(listener.exited() && worker.exited()); - assert!(!root.join("temp").exists()); - let _lock = Ownership::acquire(&root.join("locks"), ID).unwrap(); -} - -#[test] -fn stop_and_root_exit_end_the_listener_tree_before_release() { - for stop in [true, false] { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let temp = root.join("temp"); - fs::create_dir(&temp).unwrap(); - let mut run = Supervised::spawn( - &listener(root), - &root.join("locks"), - ID, - &temp, - None, - &root.join("harness.log"), - ) - .unwrap(); - let (listener, worker) = tree(root); - if stop { - run.stop().unwrap(); - } else { - fs::write(root.join("exit"), b"").unwrap(); - let deadline = Instant::now() + Duration::from_secs(30); - while run.alive().unwrap() { - assert!(Instant::now() < deadline, "exited listener was not reaped"); - std::thread::sleep(Duration::from_millis(10)); - } - } - assert!(listener.exited() && worker.exited(), "descendant survived"); - assert!(!temp.exists()); - let log = fs::read_to_string(root.join("harness.log")).unwrap(); - assert!( - log.contains("listener-ready"), - "listener output was not captured" - ); - let _lock = Ownership::acquire(&root.join("locks"), ID).unwrap(); - } -} diff --git a/crates/agent-controller/src/teams.rs b/crates/agent-controller/src/teams.rs deleted file mode 100644 index af5674e32..000000000 --- a/crates/agent-controller/src/teams.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! Portable definitions only. Native identities, credentials and local paths never travel. -use crate::{ - config::{Agent, SessionPolicy}, - Controller, Result, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TeamMeta { - pub name: String, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub instructions: Option, -} -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Definition { - pub name: String, - #[serde(default)] - pub source_is_builtin: bool, - #[serde(default)] - pub name_pool: Vec, - #[serde(default)] - pub system_prompt: Option, - #[serde(default)] - pub runtime: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub provider: Option, - #[serde(default = "channel_policy")] - pub session_policy: SessionPolicy, - #[serde(default)] - pub respond_to: Option, - #[serde(default)] - pub respond_to_allowlist: Vec, - #[serde(default)] - pub parallelism: Option, - #[serde(default)] - pub idle_timeout_seconds: Option, - #[serde(default)] - pub max_turn_duration_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effort: Option, -} -fn channel_policy() -> SessionPolicy { - SessionPolicy::Channel -} -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Profile { - pub display_name: String, - #[serde(default)] - pub about: Option, - #[serde(default)] - pub avatar_data_url: Option, - #[serde(default)] - pub avatar_url: Option, -} -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MemoryEntry { - pub slug: String, - pub body: String, -} -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Memory { - pub level: String, - #[serde(default)] - pub entries: Vec, -} -impl Memory { - pub fn validate(&self) -> Result<()> { - let mut slugs = std::collections::BTreeSet::new(); - let mut bytes = 0usize; - if !matches!(self.level.as_str(), "none" | "core" | "everything") - || self.entries.len() > 256 - || (self.level == "none" && !self.entries.is_empty()) - { - return Err("Invalid snapshot memory".into()); - } - for entry in &self.entries { - let valid_slug = entry.slug == "core" - || entry.slug.strip_prefix("mem/").is_some_and(|path| { - path.split('/').all(|segment| { - !segment.is_empty() - && segment.len() <= 64 - && segment.as_bytes()[0].is_ascii_alphanumeric() - && segment.bytes().all(|b| { - b.is_ascii_lowercase() || b.is_ascii_digit() || b"_-".contains(&b) - }) - }) - }); - bytes = bytes.saturating_add(entry.body.len() + entry.slug.len()); - if !valid_slug - || entry.slug.len() > 255 - || !slugs.insert(&entry.slug) - || (self.level == "core" && entry.slug != "core") - || bytes > 1024 * 1024 - { - return Err("Invalid snapshot memory entry".into()); - } - } - Ok(()) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MemberSnapshot { - pub format: String, - pub version: u32, - pub definition: Definition, - pub profile: Profile, - pub memory: Memory, -} -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TeamSnapshot { - pub format: String, - pub version: u32, - pub team: TeamMeta, - pub members: Vec, -} -impl TeamSnapshot { - pub fn decode(bytes: &[u8]) -> Result { - if bytes.len() > crate::config::MAX_BYTES { - return Err("Team snapshot exceeds the size limit".into()); - } - let value: Self = - serde_json::from_slice(bytes).map_err(|_| "Invalid team snapshot JSON")?; - value.validate()?; - Ok(value) - } - pub fn validate(&self) -> Result<()> { - if self.format != "buzz-team-snapshot" || self.version != 1 { - return Err("Unsupported team snapshot format or version".into()); - } - if self.team.name.trim().is_empty() - || self.team.name.len() > 256 - || self - .team - .description - .as_ref() - .is_some_and(|s| s.len() > 4096) - || self.members.is_empty() - || self.members.len() > 32 - { - return Err("Invalid team snapshot name or members".into()); - } - crate::import::team_text(&json!(self.team.instructions))?; - if serde_json::to_vec(self) - .map_err(|_| "Invalid team snapshot")? - .len() - > crate::config::MAX_BYTES - { - return Err("Team snapshot exceeds the size limit".into()); - } - for member in &self.members { - member.memory.validate()?; - let d = &member.definition; - if member.format != "buzz-agent-snapshot" - || member.version != 1 - || d.name.trim().is_empty() - || d.name.len() > 256 - || member.profile.display_name.trim().is_empty() - || member.profile.display_name.len() > 256 - || d.runtime.as_ref().is_some_and(|s| { - !matches!( - s.as_str(), - "buzz-agent" | "goose" | "pi" | "hermes" | "claude" | "codex" - ) - }) - || d.name_pool.len() > 256 - || d.name_pool - .iter() - .any(|s| s.is_empty() || s.len() > 256 || s.contains('\0')) - || d.model.as_ref().is_some_and(|s| s.len() > 512) - || d.provider.as_ref().is_some_and(|s| s.len() > 128) - || d.parallelism.is_some_and(|n| !(1..=32).contains(&n)) - || d.respond_to - .as_ref() - .is_some_and(|s| !matches!(s.as_str(), "owner-only" | "allowlist" | "anyone")) - || d.respond_to_allowlist.len() > 2000 - || d.respond_to_allowlist - .iter() - .any(|k| !crate::config::canonical_key(k)) - || [d.idle_timeout_seconds, d.max_turn_duration_seconds] - .into_iter() - .flatten() - .any(|n| n == 0 || n > 86400) - || !matches!(member.memory.level.as_str(), "none" | "core" | "everything") - || (member.memory.level == "none" && !member.memory.entries.is_empty()) - { - return Err("Invalid team member snapshot".into()); - } - if let Some(effort) = &d.effort { - crate::config::validate_effort(effort)?; - } - crate::import::team_text(&json!(d.system_prompt))?; - for picture in [&member.profile.avatar_data_url, &member.profile.avatar_url] - .into_iter() - .flatten() - { - crate::config::validate_picture(picture)?; - } - // Import prefers embedded artwork for team members, then a URL. - let picture = member - .profile - .avatar_data_url - .as_deref() - .or(member.profile.avatar_url.as_deref()); - crate::profile::initial_content( - &member.profile.display_name, - member.profile.about.as_deref(), - picture, - )?; - } - Ok(()) - } -} -/// Signed private catalog head, including NIP-01 replaceable-event ordering. -#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct TeamCatalogEntry { - pub created_at: u64, - pub event_id: String, - pub members: Vec, -} -impl Controller { - fn validate_team_catalog( - &self, - owner: &str, - teams: &std::collections::BTreeMap, - ) -> Result<()> { - if !crate::config::canonical_key(owner) - || teams.len() > 500 - || teams.values().any(|head| { - !crate::config::canonical_key(&head.event_id) - || head - .members - .iter() - .any(|member| !crate::config::canonical_key(member)) - }) - { - return Err("Invalid team catalog".into()); - } - Ok(()) - } - /// Save and app-start sync: release obsolete bindings, then copy each - /// readable team's current text to its members. Never restarts an agent. - pub fn sync_team_instructions( - &mut self, - community: &str, - owner: &str, - heads: &std::collections::BTreeMap, - texts: &std::collections::BTreeMap, - ) -> Result<()> { - if texts.len() > 500 || texts.keys().any(|team| team.is_empty() || team.len() > 120) { - return Err("Invalid team instructions".into()); - } - self.validate_team_catalog(owner, heads)?; - let relay = crate::config::canonical_relay(community)?; - self.store - .sync_team_instructions(&relay, owner, heads, texts) - } - pub fn team_member_authorization(&self, id: &str, owner: &str) -> Result { - self.verify_team_member_owner(id, owner)?; - let agent = self - .store - .agents()? - .into_iter() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - let auth = agent.auth_tag.ok_or("Missing owner authorization")?; - crate::secret::validate_attestation(&auth, &agent.pubkey)?; - Ok(auth) - } - pub fn verify_team_member_owner(&self, id: &str, owner: &str) -> Result<()> { - let agent = self - .store - .agents()? - .into_iter() - .find(|a| a.id == id) - .ok_or("Agent no longer exists")?; - let tag: Vec = serde_json::from_str( - agent - .auth_tag - .as_deref() - .ok_or("Missing owner authorization")?, - ) - .map_err(|_| "Invalid owner authorization")?; - if tag.get(1).map(String::as_str) != Some(owner) { - return Err("Agent belongs to another owner".into()); - } - Ok(()) - } - pub fn export_team( - &self, - team: TeamMeta, - members: &[String], - relay: &str, - ) -> Result { - let agents = self.store.agents()?; - let defaults = self.store.defaults()?; - let members = members - .iter() - .map(|id| { - let agent = agents - .iter() - .find(|a| &a.pubkey == id && a.relay_url == relay) - .ok_or("A team member is unavailable")?; - snapshot_member(agent, &defaults) - }) - .collect::>()?; - let snapshot = TeamSnapshot { - format: "buzz-team-snapshot".into(), - version: 1, - team, - members, - }; - snapshot.validate()?; - Ok(snapshot) - } -} -fn snapshot_member( - agent: &Agent, - defaults: &crate::agent_defaults::AgentDefaults, -) -> Result { - let view = agent.view(defaults); - let workers = view.launch_parallelism; - let effective = crate::agent_defaults::effective(agent, defaults); - let runtime = crate::agent_defaults::harness_kind(&effective.harness.command) - .ok_or("Team member harness is not portable")?; - if view.launch_model_env.is_some() || view.launch_provider_env.is_some() { - return Err("Team member environment-selected model or provider is not portable".into()); - } - if crate::agent_defaults::effort_from_env(&effective) { - return Err("Team member environment-selected effort is not portable".into()); - } - let agent = &effective; - let record = &agent.imported["record"]; - Ok(MemberSnapshot { - format: "buzz-agent-snapshot".into(), - version: 1, - definition: Definition { - name: agent.name.clone(), - source_is_builtin: record["source_is_builtin"].as_bool().unwrap_or(false), - name_pool: serde_json::from_value(record["name_pool"].clone()).unwrap_or_default(), - system_prompt: Some(agent.system_prompt.clone()), - runtime: Some(runtime.to_owned()), - model: Some(agent.harness.model.clone()), - provider: Some(agent.harness.provider.clone()), - session_policy: agent.selected_session_policy().unwrap_or_default(), - respond_to: Some(agent.respond_to(false)?.into()), - respond_to_allowlist: serde_json::from_value(record["respond_to_allowlist"].clone()) - .unwrap_or_default(), - parallelism: workers, - idle_timeout_seconds: record["idle_timeout_seconds"].as_u64(), - max_turn_duration_seconds: record["max_turn_duration_seconds"].as_u64(), - effort: crate::agent_defaults::launch_effort(&effective).map(str::to_owned), - }, - profile: Profile { - display_name: agent.name.clone(), - about: record["profile"]["about"].as_str().map(str::to_owned), - avatar_data_url: agent.picture.clone().filter(|p| p.starts_with("data:")), - avatar_url: agent.picture.clone().filter(|p| !p.starts_with("data:")), - }, - memory: Memory { - level: "none".into(), - entries: vec![], - }, - }) -} - -#[derive(Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct BundleMember { - pub team: String, - pub member: MemberSnapshot, - pub instructions: String, - pub keep_allowlist: bool, -} -impl BundleMember { - pub fn validate(&self) -> Result<()> { - if self.team.is_empty() || self.team.len() > 120 { - return Err("Invalid team binding".into()); - } - let snapshot = TeamSnapshot { - format: "buzz-team-snapshot".into(), - version: 1, - team: TeamMeta { - name: "Import".into(), - description: None, - instructions: Some(self.instructions.clone()), - }, - members: vec![self.member.clone()], - }; - snapshot.validate()?; - if !self.member.memory.entries.is_empty() { - return Err("Memory import is unavailable; export Team only".into()); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/agent-controller/src/teams/tests.rs b/crates/agent-controller/src/teams/tests.rs deleted file mode 100644 index fc0299d29..000000000 --- a/crates/agent-controller/src/teams/tests.rs +++ /dev/null @@ -1,1134 +0,0 @@ -use super::*; -use crate::{AgentEdit, Credentials, LegacySource, NewAgent, Secret, Store}; -use std::{collections::BTreeMap, sync::Arc}; - -struct NoCredentials; -impl Credentials for NoCredentials { - fn read(&self, _: &str, _: &str) -> Result> { - panic!("preview/export must not read keys") - } - fn read_legacy(&self, _: LegacySource, _: &str) -> Result { - panic!("bundles must not import legacy identities") - } - fn add(&self, _: &str, _: &Secret) -> Result<()> { - panic!("controller tests do not persist keys") - } - fn delete(&self, _: &str, _: &str) -> Result<()> { - panic!("bundles must not delete keys") - } -} -fn controller(root: &std::path::Path) -> Controller { - Controller::new( - Store::open(root.join("store")).unwrap(), - Arc::new(NoCredentials), - Err("No fixture runtime".into()), - root.join("ownership"), - ) -} -fn member() -> MemberSnapshot { - snapshot_member( - &crate::store::tests::fixture(), - &crate::agent_defaults::AgentDefaults::default(), - ) - .unwrap() -} -fn edit(root: &std::path::Path) -> AgentEdit { - AgentEdit { - effort: None, - name: "Fixture".into(), - system_prompt: "INDIVIDUAL_MARKER".into(), - picture: None, - session_policy: Some(Some(SessionPolicy::Thread)), - workspace: root.display().to_string(), - harness: crate::store::tests::fixture().harness, - environment: BTreeMap::new(), - } -} -fn owner() -> String { - let tag: Vec = - serde_json::from_str(&crate::secret::test_attestation(&"ab".repeat(32))).unwrap(); - tag[1].clone() -} -#[test] -fn independent_imports_keep_prompts_separate_and_receipts_survive_reload() { - let root = tempfile::tempdir().unwrap(); - let mut control = controller(root.path()); - let owner = owner(); - let first = NewAgent::prepare("https://relay.example/", &owner).unwrap(); - let second = NewAgent::prepare("wss://relay.example", &owner).unwrap(); - let mut snapshot = member(); - snapshot.definition.source_is_builtin = true; - snapshot.profile.about = Some("Fixture profile".into()); - snapshot.definition.respond_to = Some("allowlist".into()); - snapshot.definition.respond_to_allowlist = vec!["ab".repeat(32)]; - snapshot.definition.effort = Some("high".into()); - for (request, prepared) in [("request-one", &first), ("request-two", &second)] { - control - .create_bundle_member( - prepared, - AgentEdit { - effort: snapshot.definition.effort.clone(), - ..edit(root.path()) - }, - &crate::secret::test_attestation(prepared.key.pubkey()), - request, - &BundleMember { - team: "team-a".into(), - member: snapshot.clone(), - instructions: "TEAM_MARKER".into(), - keep_allowlist: false, - }, - ) - .unwrap(); - } - assert_ne!(first.key.pubkey(), second.key.pubkey()); - let agents = control.store.agents().unwrap(); - assert_eq!(agents.len(), 2); - for agent in agents { - assert_eq!(agent.system_prompt, "INDIVIDUAL_MARKER"); - assert_eq!(agent.imported["teamInstructions"], "TEAM_MARKER"); - assert_eq!(agent.respond_to(false).unwrap(), "owner-only"); - assert_eq!(agent.imported["record"]["respond_to_allowlist"], json!([])); - assert!(!agent.enabled); - assert!(!agent.starts_on_launch()); - assert_eq!(agent.selected_session_policy(), Some(SessionPolicy::Thread)); - } - let exported = control - .export_team( - TeamMeta { - name: "Roundtrip".into(), - description: None, - instructions: None, - }, - &[first.key.pubkey().to_owned()], - "wss://relay.example", - ) - .unwrap(); - assert!(exported.members[0].definition.source_is_builtin); - assert_eq!( - exported.members[0].definition.effort.as_deref(), - Some("high") - ); - let target = control.creation_profile(&first.id).unwrap(); - let initial = target.event(&first.key, &[]).unwrap(); - assert_eq!(initial["kind"], 0); - let initial_content: serde_json::Value = - serde_json::from_str(initial["content"].as_str().unwrap()).unwrap(); - assert_eq!(initial_content["about"], "Fixture profile"); - let prior = first - .key - .profile( - "Fixture", - Some("https://example.test/avatar.png"), - false, - Some("stale about"), - &target.auth, - &[], - ) - .unwrap(); - let retried = target.event(&first.key, &[prior]).unwrap(); - let retried_content: serde_json::Value = - serde_json::from_str(retried["content"].as_str().unwrap()).unwrap(); - assert_eq!(retried_content["about"], "Fixture profile"); - assert_eq!( - retried_content["picture"], - "https://example.test/avatar.png" - ); - control - .profile_published(&first.id, target.revision) - .unwrap(); - // Subsequent avatar writes do not restore the original import description. - let avatar = control.memory_target(&first.id).unwrap(); - assert!(avatar.about.is_none()); - assert!(control.creation_profile(&first.id).is_err()); - let external = first - .key - .profile( - "Fixture", - None, - false, - Some("Updated elsewhere"), - &target.auth, - &[retried], - ) - .unwrap(); - let avatar_event = avatar.event(&first.key, &[external]).unwrap(); - let avatar_content: serde_json::Value = - serde_json::from_str(avatar_event["content"].as_str().unwrap()).unwrap(); - assert_eq!(avatar_content["about"], "Updated elsewhere"); - assert_eq!( - exported.members[0].profile.about.as_deref(), - Some("Fixture profile") - ); - drop(control); - let mut control = controller(root.path()); - assert_eq!( - control - .created_request("request-one", "https://RELAY.example/", &owner) - .unwrap() - .unwrap() - .id, - first.id - ); - assert!(control - .created_request("request-one", "wss://other.example", &owner) - .is_err()); - assert!(control - .created_request("request-one", "wss://relay.example", &"ab".repeat(32)) - .is_err()); - // A retry with a new prepared identity still recovers the original saved receipt. - control - .create_bundle_member( - &second, - edit(root.path()), - &crate::secret::test_attestation(second.key.pubkey()), - "request-one", - &BundleMember { - team: "team-a".into(), - member: snapshot.clone(), - instructions: "TEAM_MARKER".into(), - keep_allowlist: false, - }, - ) - .unwrap(); - assert_eq!(control.store.agents().unwrap().len(), 2); -} -#[test] -fn rename_during_pending_team_import_preserves_profile_intents() { - let root = tempfile::tempdir().unwrap(); - let mut control = controller(root.path()); - let prepared = NewAgent::prepare("wss://relay.example", &owner()).unwrap(); - let mut snapshot = member(); - snapshot.profile.about = Some("Imported description".into()); - control - .create_bundle_member( - &prepared, - edit(root.path()), - &crate::secret::test_attestation(prepared.key.pubkey()), - "rename-pending-import", - &BundleMember { - team: "rename-pending-import".into(), - member: snapshot, - instructions: String::new(), - keep_allowlist: false, - }, - ) - .unwrap(); - let initial = control.creation_profile(&prepared.id).unwrap(); - let mut rename = edit(root.path()); - rename.name = "Renamed member".into(); - control - .save(&prepared.id, initial.revision, rename) - .unwrap(); - // An earlier publication receipt cannot clear either the import or rename. - assert!(control - .profile_published(&prepared.id, initial.revision) - .is_err()); - let target = control.creation_profile(&prepared.id).unwrap(); - assert!(target.name_pending); - assert_eq!(target.about.as_deref(), Some("Imported description")); - let existing = prepared - .key - .profile( - "External name", - Some("https://example.test/current.png"), - false, - Some("External description"), - &target.auth, - &[], - ) - .unwrap(); - let event = target.event(&prepared.key, &[existing]).unwrap(); - let content: serde_json::Value = - serde_json::from_str(event["content"].as_str().unwrap()).unwrap(); - assert_eq!(content["name"], "Renamed member"); - assert_eq!(content["display_name"], "Renamed member"); - assert_eq!(content["about"], "Imported description"); - assert_eq!(content["picture"], "https://example.test/current.png"); - target - .confirm(std::slice::from_ref(&event), event["id"].as_str().unwrap()) - .unwrap(); - control - .profile_published(&prepared.id, target.revision) - .unwrap(); - assert!(control.creation_profile(&prepared.id).is_err()); - let memory = control.memory_target(&prepared.id).unwrap(); - assert!(!memory.name_pending); - assert!(memory.about.is_none()); - assert!(memory.picture.is_none()); -} -#[test] -fn export_uses_effective_workers_for_native_and_edited_imported_agents() { - let root = tempfile::tempdir().unwrap(); - let mut control = controller(root.path()); - let mut native = crate::store::tests::fixture(); - native.harness.model.clear(); - native.harness.provider.clear(); - let defaults = crate::agent_defaults::AgentDefaults { - model: "inherited-model".into(), - provider: "inherited-provider".into(), - session_policy: SessionPolicy::Channel, - ..Default::default() - }; - control.store.save_defaults(&defaults).unwrap(); - let mut imported = native.clone(); - imported.pubkey = "cd".repeat(32); - imported.id = crate::config::agent_id(&imported.pubkey, &imported.relay_url); - imported.imported = json!({"record": {"parallelism": 3}}); - control - .store - .insert(vec![native.clone(), imported.clone()]) - .unwrap(); - let export = |control: &Controller, key: &str| { - control - .export_team( - TeamMeta { - name: "Workers".into(), - description: None, - instructions: None, - }, - &[key.to_owned()], - "wss://relay.example", - ) - .unwrap() - .members - .remove(0) - .definition - .parallelism - }; - assert_eq!(export(&control, &native.pubkey), Some(1)); - assert_eq!(export(&control, &imported.pubkey), Some(3)); - let inherited = control - .export_team( - TeamMeta { - name: "Inherited".into(), - description: None, - instructions: None, - }, - &[native.pubkey.clone()], - &native.relay_url, - ) - .unwrap(); - assert_eq!( - inherited.members[0].definition.model.as_deref(), - Some("inherited-model") - ); - assert_eq!( - inherited.members[0].definition.provider.as_deref(), - Some("inherited-provider") - ); - assert_eq!( - inherited.members[0].definition.session_policy, - SessionPolicy::Channel - ); - native - .environment - .insert("BUZZ_ACP_AGENTS".into(), "7".into()); - imported - .environment - .insert("BUZZ_ACP_AGENTS".into(), "5".into()); - control.store.remove(&native.id, native.revision).unwrap(); - control - .store - .remove(&imported.id, imported.revision) - .unwrap(); - control - .store - .insert(vec![native.clone(), imported.clone()]) - .unwrap(); - assert_eq!(export(&control, &native.pubkey), Some(7)); - assert_eq!(export(&control, &imported.pubkey), Some(5)); -} - -#[test] -fn export_is_portable_and_preview_does_not_create_or_start_agents() { - let root = tempfile::tempdir().unwrap(); - let mut control = controller(root.path()); - let source = crate::store::tests::fixture(); - control.store.insert(vec![source.clone()]).unwrap(); - let exported = control - .export_team( - TeamMeta { - name: "Fixture team".into(), - description: Some("Safe fixtures".into()), - instructions: Some("TEAM_MARKER".into()), - }, - std::slice::from_ref(&source.pubkey), - &source.relay_url, - ) - .unwrap(); - let bytes = serde_json::to_vec(&exported).unwrap(); - let text = String::from_utf8(bytes.clone()).unwrap(); - for private in [ - &source.pubkey, - &source.credential_id, - &source.workspace, - "secret-env-value", - "private-attestation", - "futureTopLevel", - ] { - assert!(!text.contains(private), "leaked {private}"); - } - let decoded = TeamSnapshot::decode(&bytes).unwrap(); - assert_eq!(decoded.team.instructions.as_deref(), Some("TEAM_MARKER")); - assert_eq!( - decoded.members[0].definition.system_prompt.as_deref(), - Some(source.system_prompt.as_str()) - ); - assert_eq!(decoded.members[0].memory.level, "none"); - assert_eq!(control.store.agents().unwrap().len(), 1); - assert!(!control.store.agents().unwrap()[0].enabled); -} -#[test] -fn memory_preview_bounds_slugs_duplicates_and_selected_level() { - let mut memory = Memory { - level: "core".into(), - entries: vec![MemoryEntry { - slug: "core".into(), - body: "safe fixture".into(), - }], - }; - memory.validate().unwrap(); - memory.entries.push(memory.entries[0].clone()); - assert!(memory.validate().is_err()); - memory.entries.pop(); - memory.entries[0].slug = "mem/note".into(); - assert!(memory.validate().is_err()); - memory.level = "everything".into(); - memory.validate().unwrap(); - for slug in ["mem/../note", "mem/Note", "mem/", "mem/a//b", "private"] { - memory.entries[0].slug = slug.into(); - assert!(memory.validate().is_err(), "{slug}"); - } - memory.entries[0].slug = "core".into(); - memory.entries[0].body = "x".repeat(1024 * 1024); - assert!(memory.validate().is_err()); -} - -#[test] -fn malformed_snapshots_are_rejected_before_creation() { - let mut snapshot = TeamSnapshot { - format: "buzz-team-snapshot".into(), - version: 1, - team: TeamMeta { - name: "Fixture".into(), - description: None, - instructions: None, - }, - members: vec![member()], - }; - snapshot.validate().unwrap(); - snapshot.members[0].memory.entries.push(MemoryEntry { - slug: "core".into(), - body: "Fixture".into(), - }); - assert!(snapshot.validate().is_err()); - snapshot.members[0].memory.entries.clear(); - snapshot.members[0].definition.runtime = Some("/private/local/executable".into()); - assert!(snapshot.validate().is_err()); - snapshot.members[0].definition.runtime = None; - snapshot.team.instructions = Some("bad\0instructions".into()); - assert!(snapshot.validate().is_err()); -} - -#[test] -fn memory_authorization_requires_exact_saved_owner_and_valid_member_attestation() { - let root = tempfile::tempdir().unwrap(); - let mut control = controller(root.path()); - let prepared = NewAgent::prepare("https://relay.example/", &owner()).unwrap(); - let auth = crate::secret::test_attestation(prepared.key.pubkey()); - control - .create_bundle_member( - &prepared, - edit(root.path()), - &auth, - "memory-owner", - &BundleMember { - team: "team-a".into(), - member: member(), - instructions: "TEAM".into(), - keep_allowlist: false, - }, - ) - .unwrap(); - assert_eq!( - control - .team_member_authorization(&prepared.id, &owner()) - .unwrap(), - auth - ); - assert!(control - .verify_team_member_owner(&prepared.id, &"ab".repeat(32)) - .is_err()); - assert!(control - .team_member_authorization(&prepared.id, &"ab".repeat(32)) - .is_err()); - assert!(control - .team_member_authorization("missing", &owner()) - .is_err()); - let mut saved = control.store.agents().unwrap(); - saved[0].auth_tag = Some(json!(["auth", owner(), "", "invalid-signature"]).to_string()); - std::fs::write( - root.path().join("store/agents.json"), - serde_json::to_vec(&json!({"version": 1, "agents": saved})).unwrap(), - ) - .unwrap(); - drop(control); - let control = controller(root.path()); - assert!(control - .team_member_authorization(&prepared.id, &owner()) - .is_err()); -} - -fn catalog(created_at: u64, members: Vec) -> crate::TeamCatalogEntry { - crate::TeamCatalogEntry { - created_at, - event_id: format!("{created_at:064x}"), - members, - } -} - -#[test] -fn late_catalog_read_cannot_release_a_newer_binding_even_after_reopen() { - let root = tempfile::tempdir().unwrap(); - let (mut control, agent) = synced_agent(root.path(), json!({})); - let me = vec![agent.pubkey.clone()]; - sync( - &mut control, - &[("team-a", 2, me.clone())], - &[("team-a", "SHARED")], - ) - .unwrap(); - drop(control); - let mut control = controller(root.path()); - // An older roster read that finishes late is refused and changes nothing. - let late = sync( - &mut control, - &[("team-a", 1, vec![])], - &[("team-a", "SHARED")], - ); - assert!(late.is_err_and(|error| error.contains("Team catalog changed"))); - let saved = control.store.agents().unwrap().remove(0); - assert_eq!(saved.imported["teamBindings"], json!(["team-a"])); - assert_eq!(saved.imported["teamInstructions"], "SHARED"); - // Same timestamp uses the NIP-01 lower-event-ID winner; a late loser is refused. - let texts = std::collections::BTreeMap::from([("team-a".to_string(), "SHARED".to_string())]); - let mut winner = catalog(2, me.clone()); - winner.event_id = "00".repeat(32); - let winner = std::collections::BTreeMap::from([("team-a".to_string(), winner)]); - control - .sync_team_instructions("wss://relay.example", &owner(), &winner, &texts) - .unwrap(); - assert!(sync(&mut control, &[("team-a", 2, me)], &[("team-a", "SHARED")]).is_err()); - // Another owner's sync never touches this owner's agent. - let other = std::collections::BTreeMap::from([("team-a".to_string(), "OTHER".to_string())]); - control - .sync_team_instructions("wss://relay.example", &"ab".repeat(32), &winner, &other) - .unwrap(); - assert_eq!(control.store.agents().unwrap()[0].imported, saved.imported); -} - -#[test] -fn maximal_native_member_settings_survive_the_creation_receipt_and_reopen() { - let root = tempfile::tempdir().unwrap(); - let mut control = controller(root.path()); - let prepared = NewAgent::prepare("https://relay.example", &owner()).unwrap(); - let mut snapshot = member(); - snapshot.definition.name = "N".repeat(256); - snapshot.profile.display_name = snapshot.definition.name.clone(); - snapshot.profile.about = Some("é\"\\\n".repeat(1024)); - snapshot.definition.system_prompt = Some("é".repeat(64 * 1024)); - snapshot.definition.respond_to = Some("allowlist".into()); - snapshot.definition.respond_to_allowlist = (0..2000).map(|n| format!("{n:064x}")).collect(); - snapshot.definition.name_pool = vec!["N".repeat(256); 256]; - snapshot.definition.parallelism = Some(32); - snapshot.definition.idle_timeout_seconds = Some(86400); - snapshot.definition.max_turn_duration_seconds = Some(86400); - snapshot.definition.model = Some("M".repeat(512)); - snapshot.definition.provider = Some("P".repeat(128)); - snapshot.profile.avatar_url = Some("https://example.test/picture?size=2#avatar".into()); - let bundle = BundleMember { - team: "maximal-team".into(), - member: snapshot.clone(), - instructions: "S".repeat(128 * 1024), - keep_allowlist: true, - }; - let mut mapped = edit(root.path()); - mapped.name = snapshot.profile.display_name.clone(); - mapped.system_prompt = snapshot.definition.system_prompt.clone().unwrap(); - mapped.harness.model = snapshot.definition.model.clone().unwrap(); - mapped.harness.provider = snapshot.definition.provider.clone().unwrap(); - mapped.picture = snapshot.profile.avatar_url.clone(); - control - .create_bundle_member( - &prepared, - mapped, - &crate::secret::test_attestation(prepared.key.pubkey()), - "maximal-request", - &bundle, - ) - .unwrap(); - drop(control); - let control = controller(root.path()); - let agent = control.store.agents().unwrap().remove(0); - assert_eq!( - agent.system_prompt, - snapshot.definition.system_prompt.unwrap() - ); - assert_eq!(agent.harness.model, snapshot.definition.model.unwrap()); - assert_eq!( - agent.harness.provider, - snapshot.definition.provider.unwrap() - ); - assert_eq!(agent.picture, snapshot.profile.avatar_url); - assert_eq!(agent.imported["teamInstructions"], bundle.instructions); - let record = &agent.imported["record"]; - assert_eq!(record["respond_to"], "allowlist"); - assert_eq!( - record["respond_to_allowlist"], - json!(snapshot.definition.respond_to_allowlist) - ); - assert_eq!(record["name_pool"], json!(snapshot.definition.name_pool)); - assert_eq!(record["parallelism"], 32); - assert_eq!(record["idle_timeout_seconds"], 86400); - assert_eq!(record["max_turn_duration_seconds"], 86400); - assert_eq!(record["profile"]["about"], json!(snapshot.profile.about)); - assert_eq!(agent.extra["bundleRequest"], "maximal-request"); - assert!(!agent.enabled); - assert!(!agent.starts_on_launch()); -} - -#[test] -fn profile_content_bound_preflights_every_member_and_roundtrips_at_boundary() { - let root = tempfile::tempdir().unwrap(); - let mut control = controller(root.path()); - let mut snapshot = TeamSnapshot { - format: "buzz-team-snapshot".into(), - version: 1, - team: TeamMeta { - name: "Boundary".into(), - description: None, - instructions: None, - }, - members: vec![member(), member()], - }; - let name = snapshot.members[1].profile.display_name.clone(); - let empty_bytes = crate::profile::initial_content(&name, None, None) - .unwrap() - .len(); - // Escaped quotes cost two bytes each, unlike the raw about length. - let about = format!( - "{}a", - "\"".repeat((crate::profile::MAX_PROFILE_CONTENT_BYTES - empty_bytes - 11) / 2) - ); - snapshot.members[1].profile.about = Some(about); - let content = - crate::profile::initial_content(&name, snapshot.members[1].profile.about.as_deref(), None) - .unwrap(); - assert_eq!(content.len(), crate::profile::MAX_PROFILE_CONTENT_BYTES); - let bytes = serde_json::to_vec(&snapshot).unwrap(); - let decoded = TeamSnapshot::decode(&bytes).unwrap(); - let prepared = NewAgent::prepare("https://relay.example", &owner()).unwrap(); - let mut mapped = edit(root.path()); - mapped.name = name.clone(); - control - .create_bundle_member( - &prepared, - mapped, - &crate::secret::test_attestation(prepared.key.pubkey()), - "boundary", - &BundleMember { - team: "boundary".into(), - member: decoded.members[1].clone(), - instructions: String::new(), - keep_allowlist: false, - }, - ) - .unwrap(); - let target = control.creation_profile(&prepared.id).unwrap(); - let event = target.event(&prepared.key, &[]).unwrap(); - assert_eq!( - event["content"].as_str().unwrap().len(), - crate::profile::MAX_PROFILE_CONTENT_BYTES - ); - target - .confirm(std::slice::from_ref(&event), event["id"].as_str().unwrap()) - .unwrap(); - control - .profile_published(&prepared.id, target.revision) - .unwrap(); - assert!(control.creation_profile(&prepared.id).is_err()); - - snapshot.members[1] - .profile - .about - .as_mut() - .unwrap() - .push('"'); - assert!(TeamSnapshot::decode(&serde_json::to_vec(&snapshot).unwrap()).is_err()); - let clean_root = tempfile::tempdir().unwrap(); - assert!(controller(clean_root.path()) - .store - .agents() - .unwrap() - .is_empty()); - // Even a direct bundle create refuses the oversized member before insertion. - let mut clean_control = controller(clean_root.path()); - let prepared = NewAgent::prepare("https://relay.example", &owner()).unwrap(); - assert!(clean_control - .create_bundle_member( - &prepared, - edit(clean_root.path()), - &crate::secret::test_attestation(prepared.key.pubkey()), - "rejected", - &BundleMember { - team: "boundary".into(), - member: snapshot.members[1].clone(), - instructions: String::new(), - keep_allowlist: false, - }, - ) - .is_err()); - assert!(clean_control.store.agents().unwrap().is_empty()); -} - -#[test] -fn native_export_rejects_unrepresentable_runtime_settings_without_leaking_values() { - let mut agent = crate::store::tests::fixture(); - let defaults = crate::agent_defaults::AgentDefaults::default(); - let exported = snapshot_member(&agent, &defaults).unwrap(); - let decoded = TeamSnapshot::decode( - &serde_json::to_vec(&TeamSnapshot { - format: "buzz-team-snapshot".into(), - version: 1, - team: TeamMeta { - name: "Safe".into(), - description: None, - instructions: None, - }, - members: vec![exported.clone()], - }) - .unwrap(), - ) - .unwrap(); - assert_eq!( - decoded.members[0].definition.runtime.as_deref(), - Some("buzz-agent") - ); - assert_eq!( - decoded.members[0].definition.model.as_deref(), - Some("test-model") - ); - agent.harness.command = "/opt/private/buzz-agent.exe".into(); - assert_eq!( - snapshot_member(&agent, &defaults) - .unwrap() - .definition - .runtime - .as_deref(), - Some("buzz-agent") - ); - agent.harness.command = "/opt/private/local-worker".into(); - assert!(snapshot_member(&agent, &defaults) - .unwrap_err() - .contains("harness")); - agent.harness.command = "buzz-agent".into(); - let effort = |agent: &Agent, defaults: &crate::agent_defaults::AgentDefaults| { - snapshot_member(agent, defaults).unwrap().definition.effort - }; - assert_eq!(effort(&agent, &defaults), None); - agent.imported["record"]["effort_level"] = json!("high"); - assert_eq!(effort(&agent, &defaults).as_deref(), Some("high")); - agent.imported["record"]["effort_level"] = serde_json::Value::Null; - let mut inherited = defaults.clone(); - inherited.effort = "medium".into(); - assert_eq!(effort(&agent, &inherited).as_deref(), Some("medium")); - agent - .environment - .insert("BUZZ_AGENT_MODEL".into(), "synthetic-secret-model".into()); - let error = snapshot_member(&agent, &defaults).err().unwrap(); - assert!(error.contains("environment-selected")); - assert!(!error.contains("synthetic-secret-model")); - agent.environment.clear(); - agent.environment.insert( - "BUZZ_AGENT_PROVIDER".into(), - "synthetic-secret-provider".into(), - ); - assert!(snapshot_member(&agent, &defaults) - .unwrap_err() - .contains("environment-selected")); -} - -#[test] -fn native_export_refuses_and_hides_pi_goose_effort_overrides() { - let mut agent = crate::store::tests::fixture(); - let mut defaults = crate::agent_defaults::AgentDefaults::default(); - for (harness, command) in [("pi", "buzz-pi-acp"), ("goose", "goose")] { - agent.harness.command = command.into(); - agent.harness.model.clear(); - agent.harness.provider.clear(); - agent.environment.clear(); - defaults.environment.clear(); - let exported = snapshot_member(&agent, &defaults).unwrap(); - assert_eq!(exported.definition.runtime.as_deref(), Some(harness)); - - agent.extra.insert("effort".into(), "high".into()); - assert_eq!( - snapshot_member(&agent, &defaults) - .unwrap() - .definition - .effort - .as_deref(), - Some("high") - ); - for (own, value) in [ - (true, "private-effort"), - (false, "private-effort"), - (true, ""), - (false, ""), - ] { - agent.environment.clear(); - defaults.environment.clear(); - let environment = if own { - &mut agent.environment - } else { - &mut defaults.environment - }; - environment.insert("BUZZ_ACP_EFFORT_LEVEL".into(), value.into()); - let error = snapshot_member(&agent, &defaults).err().unwrap(); - assert!(error.contains("environment-selected effort")); - assert!(!error.contains("private-effort")); - assert_eq!(agent.view(&defaults).launch_effort, None); - } - agent.extra.remove("effort"); - } -} - -#[test] -fn native_export_refuses_effort_the_app_import_parser_rejects() { - let mut agent = crate::store::tests::fixture(); - let mut defaults = crate::agent_defaults::AgentDefaults::default(); - let snapshot = |agent: &Agent, defaults: &crate::agent_defaults::AgentDefaults| TeamSnapshot { - format: "buzz-team-snapshot".into(), - version: 1, - team: TeamMeta { - name: "Fixture".into(), - description: None, - instructions: None, - }, - members: vec![snapshot_member(agent, defaults).unwrap()], - }; - for effort in ["low", "high", "xhigh", "max", "off"] { - agent.extra.insert("effort".into(), effort.into()); - snapshot(&agent, &defaults).validate().unwrap(); - } - // Saved before the effort rule matched the app's parser, which rejects these. - for effort in ["high\u{200b}low", "\u{d15}\u{d4d}\u{200d}$"] { - agent.extra.insert("effort".into(), effort.into()); - assert!(snapshot(&agent, &defaults).validate().is_err()); - } - // A device default that breaks the rule is refused the same way. - agent.extra.remove("effort"); - snapshot(&agent, &defaults).validate().unwrap(); - defaults.harness = crate::agent_defaults::harness_kind(&agent.harness.command) - .unwrap() - .into(); - defaults.effort = "\u{d15}\u{d4d}\u{200d}$".into(); - assert!(snapshot(&agent, &defaults).validate().is_err()); -} - -#[test] -fn merged_existing_profile_overflow_keeps_import_about_pending() { - let root = tempfile::tempdir().unwrap(); - let mut control = controller(root.path()); - let prepared = NewAgent::prepare("https://relay.example", &owner()).unwrap(); - let mut snapshot = member(); - snapshot.profile.about = Some("new about".into()); - control - .create_bundle_member( - &prepared, - edit(root.path()), - &crate::secret::test_attestation(prepared.key.pubkey()), - "merged", - &BundleMember { - team: "merged".into(), - member: snapshot, - instructions: String::new(), - keep_allowlist: false, - }, - ) - .unwrap(); - let target = control.creation_profile(&prepared.id).unwrap(); - let existing = prepared - .key - .profile( - "Fixture", - None, - false, - Some(&"x".repeat(crate::profile::MAX_PROFILE_CONTENT_BYTES - 160)), - &target.auth, - &[], - ) - .unwrap(); - // Updating about with escaped characters grows the full merged map while - // retaining the verified existing profile's other fields. - let target = crate::CreationProfile { - about: Some("\"".repeat(crate::profile::MAX_PROFILE_CONTENT_BYTES / 2)), - ..target - }; - assert!(target - .event(&prepared.key, &[existing]) - .unwrap_err() - .contains("readable limit")); - assert!(control.creation_profile(&prepared.id).is_ok()); -} - -fn synced_agent( - root: &std::path::Path, - imported: serde_json::Value, -) -> (Controller, crate::config::Agent) { - let mut control = controller(root); - let mut agent = crate::store::tests::fixture(); - agent.auth_tag = Some(crate::secret::test_attestation(&agent.pubkey)); - agent.imported = imported; - control.store.insert(vec![agent.clone()]).unwrap(); - (control, agent) -} -fn sync( - control: &mut Controller, - rosters: &[(&str, u64, Vec)], - texts: &[(&str, &str)], -) -> Result { - let heads = rosters - .iter() - .map(|(team, at, members)| (team.to_string(), catalog(*at, members.clone()))) - .collect(); - let texts = texts - .iter() - .map(|(team, text)| (team.to_string(), text.to_string())) - .collect(); - control.sync_team_instructions("wss://relay.example", &owner(), &heads, &texts)?; - Ok(control.store.agents().unwrap().remove(0)) -} - -#[test] -fn team_sync_binds_writes_and_clears_text_without_counting_empty_teams() { - let root = tempfile::tempdir().unwrap(); - let (mut control, agent) = synced_agent(root.path(), serde_json::Value::Null); - let me = vec![agent.pubkey.clone()]; - // Link repair: the only team with text that lists the agent binds it. - let saved = sync( - &mut control, - &[("crew", 1, me.clone()), ("mentions", 1, me.clone())], - &[("crew", " SHARED\n"), ("mentions", "")], - ) - .unwrap(); - assert_eq!(saved.imported["teamBindings"], serde_json::json!(["crew"])); - assert_eq!(saved.imported["teamInstructions"], "SHARED"); - assert_eq!(saved.revision, agent.revision + 1); - // Unchanged text and a second team with identical text write no revision. - let same = sync( - &mut control, - &[("crew", 1, me.clone()), ("pair", 1, me.clone())], - &[("crew", "SHARED"), ("pair", "SHARED")], - ) - .unwrap(); - assert_eq!( - same.imported["teamBindings"], - serde_json::json!(["crew", "pair"]) - ); - assert_eq!(same.revision, saved.revision); - // Removal from the last team with text clears the copy and the binding. - let cleared = sync( - &mut control, - &[ - ("crew", 2, vec![]), - ("pair", 2, vec![]), - ("mentions", 1, me), - ], - &[("crew", "SHARED"), ("pair", "SHARED"), ("mentions", "")], - ) - .unwrap(); - assert_eq!(cleared.imported["teamBindings"], serde_json::json!([])); - assert_eq!(cleared.imported["teamInstructions"], ""); - assert_eq!(cleared.revision, same.revision + 1); -} - -#[test] -fn team_sync_releases_deleted_teams_sent_as_empty_text() { - let root = tempfile::tempdir().unwrap(); - let (mut control, agent) = synced_agent(root.path(), serde_json::Value::Null); - let me = vec![agent.pubkey.clone()]; - sync( - &mut control, - &[("crew", 1, me.clone()), ("pair", 1, me.clone())], - &[("crew", "SHARED"), ("pair", "SHARED")], - ) - .unwrap(); - // A deleted team arrives with the empty roster of its tombstone and no - // text. The surviving team with identical text keeps the copy. - let kept = sync( - &mut control, - &[("crew", 2, vec![]), ("pair", 1, me)], - &[("crew", ""), ("pair", "SHARED")], - ) - .unwrap(); - assert_eq!(kept.imported["teamBindings"], serde_json::json!(["pair"])); - assert_eq!(kept.imported["teamInstructions"], "SHARED"); - // Deleting the last team with text clears it. - let cleared = sync( - &mut control, - &[("crew", 2, vec![]), ("pair", 2, vec![])], - &[("crew", ""), ("pair", "")], - ) - .unwrap(); - assert_eq!(cleared.imported["teamBindings"], serde_json::json!([])); - assert_eq!(cleared.imported["teamInstructions"], ""); -} - -#[test] -fn team_sync_refuses_conflicts_and_keeps_unreadable_teams() { - let root = tempfile::tempdir().unwrap(); - let (mut control, agent) = synced_agent(root.path(), serde_json::Value::Null); - let me = vec![agent.pubkey.clone()]; - let saved = sync( - &mut control, - &[("crew", 1, me.clone())], - &[("crew", "SHARED")], - ) - .unwrap(); - let error = sync( - &mut control, - &[("crew", 1, me.clone()), ("other", 1, me.clone())], - &[("crew", "SHARED"), ("other", "DIFFERENT")], - ) - .err() - .unwrap(); - assert!(error.contains("crew") && error.contains("other"), "{error}"); - assert_eq!(control.store.agents().unwrap()[0].revision, saved.revision); - // A team the app could not read is left out: binding and text survive. - let kept = sync(&mut control, &[("crew", 1, me)], &[]).unwrap(); - assert_eq!(kept.imported["teamBindings"], serde_json::json!(["crew"])); - assert_eq!(kept.imported["teamInstructions"], "SHARED"); -} - -#[test] -fn team_sync_keeps_unbound_legacy_text_until_a_team_with_text_claims_it() { - let root = tempfile::tempdir().unwrap(); - let (mut control, agent) = synced_agent( - root.path(), - serde_json::json!({"teamInstructions": "LEGACY"}), - ); - let me = vec![agent.pubkey.clone()]; - let untouched = sync( - &mut control, - &[("mentions", 1, me.clone())], - &[("mentions", "")], - ) - .unwrap(); - assert_eq!(untouched.imported, agent.imported); - let adopted = sync( - &mut control, - &[("crew", 1, me.clone())], - &[("crew", " LEGACY ")], - ) - .unwrap(); - assert_eq!( - adopted.imported["teamBindings"], - serde_json::json!(["crew"]) - ); - assert_eq!(adopted.revision, agent.revision); - // A team with different text replaces the imported copy. - let other = tempfile::tempdir().unwrap(); - let (mut control, agent) = synced_agent( - other.path(), - serde_json::json!({"teamInstructions": "LEGACY"}), - ); - let replaced = sync(&mut control, &[("crew", 1, me)], &[("crew", "NEW")]).unwrap(); - assert_eq!(replaced.imported["teamInstructions"], "NEW"); - assert_eq!(replaced.revision, agent.revision + 1); -} - -#[test] -fn refused_team_sync_leaves_the_saved_document_byte_for_byte() { - let root = tempfile::tempdir().unwrap(); - let (mut control, agent) = synced_agent(root.path(), serde_json::Value::Null); - let me = vec![agent.pubkey.clone()]; - sync( - &mut control, - &[("crew", 1, me.clone()), ("old", 1, me.clone())], - &[("crew", "SHARED"), ("old", "SHARED")], - ) - .unwrap(); - let saved = root.path().join("store/agents.json"); - let before = std::fs::read(&saved).unwrap(); - // A newer roster that would drop "old" and new heads would be staged, but - // the conflict refuses the whole sync. - assert!(sync( - &mut control, - &[ - ("crew", 2, me.clone()), - ("old", 2, vec![]), - ("other", 1, me) - ], - &[ - ("crew", "SHARED"), - ("old", "SHARED"), - ("other", "DIFFERENT") - ], - ) - .is_err()); - assert_eq!(std::fs::read(&saved).unwrap(), before); -} - -#[test] -fn unreadable_team_keeps_link_and_text_when_its_roster_drops_the_agent() { - let root = tempfile::tempdir().unwrap(); - let (mut control, agent) = synced_agent(root.path(), serde_json::Value::Null); - let me = vec![agent.pubkey.clone()]; - sync(&mut control, &[("crew", 1, me)], &[("crew", "SHARED")]).unwrap(); - let kept = sync(&mut control, &[("crew", 2, vec![])], &[]).unwrap(); - assert_eq!(kept.imported["teamBindings"], serde_json::json!(["crew"])); - assert_eq!(kept.imported["teamInstructions"], "SHARED"); -} - -#[test] -fn team_sync_instruction_change_preserves_identity_and_individual_settings() { - let root = tempfile::tempdir().unwrap(); - let mut control = controller(root.path()); - let prepared = NewAgent::prepare("https://relay.example/", &owner()).unwrap(); - control - .create_bundle_member( - &prepared, - edit(root.path()), - &crate::secret::test_attestation(prepared.key.pubkey()), - "request", - &BundleMember { - team: "team-a".into(), - member: member(), - instructions: "OLD_TEAM".into(), - keep_allowlist: false, - }, - ) - .unwrap(); - let before = control.store.agents().unwrap().remove(0); - let me = vec![before.pubkey.clone()]; - let after = sync( - &mut control, - &[("team-a", 1, me)], - &[("team-a", "NEW_TEAM")], - ) - .unwrap(); - assert_eq!(after.imported["teamInstructions"], "NEW_TEAM"); - assert_eq!(after.revision, before.revision + 1); - assert_eq!(after.id, before.id); - assert_eq!(after.pubkey, before.pubkey); - assert_eq!(after.credential_id, before.credential_id); - assert_eq!(after.auth_tag, before.auth_tag); - assert_eq!(after.system_prompt, "INDIVIDUAL_MARKER"); - assert_eq!(after.system_prompt, before.system_prompt); - assert_eq!(after.harness.command, before.harness.command); - assert_eq!(after.environment, before.environment); - assert_eq!(after.imported["record"], before.imported["record"]); - assert_eq!(after.enabled, before.enabled); -} diff --git a/crates/agent-controller/src/test_executable.rs b/crates/agent-controller/src/test_executable.rs deleted file mode 100644 index 29e767137..000000000 --- a/crates/agent-controller/src/test_executable.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Test fixtures that create executable scripts. - -use std::path::Path; - -/// Creates an owner-only executable at `path` that holds `contents`. -/// -/// Tests run in parallel threads, and other threads fork child processes. A -/// fork can copy this process's open write descriptor for a new script. Until -/// that child execs, Linux refuses to execute the script with ETXTBSY. This -/// process therefore never opens `path` for writing: it writes a sibling -/// source file, and a single-threaded `cp` process creates the executable. -#[cfg(unix)] -pub(crate) fn write_executable(path: &Path, contents: impl AsRef<[u8]>) { - use std::os::unix::fs::PermissionsExt; - let mut source = path.as_os_str().to_owned(); - source.push(".source"); - let source = Path::new(&source); - std::fs::write(source, contents).unwrap(); - let status = std::process::Command::new("/bin/cp") - .arg(source) - .arg(path) - .status() - .unwrap(); - assert!(status.success(), "could not copy {}", path.display()); - std::fs::remove_file(source).unwrap(); - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); -} - -/// Windows has no fork, so a plain write cannot leak a descriptor. -#[cfg(not(unix))] -pub(crate) fn write_executable(path: &Path, contents: impl AsRef<[u8]>) { - std::fs::write(path, contents).unwrap(); -} - -/// Copies the executable fixture at `from` to `path` without this process -/// opening `path` for writing. `fs::copy` would hold that write descriptor. -#[cfg(unix)] -pub(crate) fn copy_executable(from: &Path, path: &Path) { - write_executable(path, std::fs::read(from).unwrap()); -} diff --git a/crates/agent-controller/tests/build_config.rs b/crates/agent-controller/tests/build_config.rs deleted file mode 100644 index 9d10ce332..000000000 --- a/crates/agent-controller/tests/build_config.rs +++ /dev/null @@ -1,254 +0,0 @@ -#[path = "../build_config.rs"] -mod build_config; -use build_config::{load, parse, selector}; - -#[test] -fn local_multiline_defaults_are_allowlisted_and_process_wins_including_empty() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - assert!(load(&path, |_| None).unwrap().is_empty()); - std::fs::write(&path, "BUZZ_BUILD_AGENT_ENV='DATABRICKS_HOST=https://workspace.example.com\nDATABRICKS_MODEL=custom-model\nDATABRICKS_MODEL_FILTER=team-*'\nBUZZ_BUILD_BUZZ_AGENT_PROVIDER=databricks_v2\nUNRELATED_SECRET=NEVER_PRINT\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=\n").unwrap(); - let values = load(&path, |_| None).unwrap(); - assert_eq!(values.len(), 3); - assert_eq!( - parse(&values["BUZZ_BUILD_AGENT_ENV"]).unwrap(), - ( - "https://workspace.example.com".into(), - "team-*".into(), - "custom-model".into() - ) - ); - assert_eq!(values["BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"], ""); - for override_value in ["", "DATABRICKS_HOST=https://ci.example.com"] { - let values = load(&path, |key| { - (key == "BUZZ_BUILD_AGENT_ENV").then(|| override_value.into()) - }) - .unwrap(); - assert_eq!(values["BUZZ_BUILD_AGENT_ENV"], override_value); - assert_eq!(values["BUZZ_BUILD_BUZZ_AGENT_PROVIDER"], "databricks_v2"); - } - std::fs::remove_file(&path).unwrap(); - assert!(load(&path, |_| None).unwrap().is_empty()); -} - -#[test] -fn errors_never_echo_local_values_and_unknown_agent_keys_fail_closed() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - std::fs::write(&path, "BUZZ_BUILD_AGENT_ENV='NEVER_PRINT").unwrap(); - assert!(!load(&path, |_| None).unwrap_err().contains("NEVER_PRINT")); - // A fully supplied CI environment does not depend on an unrelated local file. - assert_eq!(load(&path, |_| Some(String::new())).unwrap().len(), 3); - for raw in [ - "DATABRICKS_TOKEN=NEVER_PRINT", - "OTHER=NEVER_PRINT", - "BUZZ_PRIVATE_KEY=NEVER_PRINT", - "DATABRICKS_HOST=x\nDATABRICKS_HOST=y", - "malformed", - "DATABRICKS_HOST=https://user:NEVER_PRINT@example.com", - "DATABRICKS_HOST=https://example.com/path", - "DATABRICKS_HOST=https://example.com?NEVER_PRINT", - "DATABRICKS_MODEL=a\tNEVER_PRINT", - ] { - assert!(!parse(raw).unwrap_err().contains("NEVER_PRINT")); - } - assert!(selector("bad\nvalue", 128).is_err()); - assert!(selector(&"a".repeat(129), 128).is_err()); - assert_eq!( - parse("").unwrap(), - (String::new(), String::new(), String::new()) - ); -} - -#[test] -fn unrelated_dev_syntax_is_ignored_without_promoting_multiline_contents() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - std::fs::write(&path, "# ignored='quote in comment\nFOO=bar baz\nBROKEN=\nnot an assignment\nOTHER='multiline\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=true\n'\nexport BUZZ_BUILD_BUZZ_AGENT_PROVIDER=databricks_v2\nBUZZ_BUILD_AGENT_ENV='DATABRICKS_MODEL=first\nDATABRICKS_MODEL_FILTER=team-*'\nTRAILING='unfinished\n").unwrap(); - let values = load(&path, |_| None).unwrap(); - assert_eq!(values.len(), 2); - assert_eq!(values["BUZZ_BUILD_BUZZ_AGENT_PROVIDER"], "databricks_v2"); - assert_eq!(parse(&values["BUZZ_BUILD_AGENT_ENV"]).unwrap().1, "team-*"); - for invalid in [ - "BUZZ_BUILD_BUZZ_AGENT_PROVIDER=bad value", - "BUZZ_BUILD_AGENT_ENV='unfinished", - "BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY malformed", - ] { - std::fs::write(&path, invalid).unwrap(); - assert!(load(&path, |_| None).is_err(), "{invalid}"); - } - // Explicit process values make even malformed same-key file values irrelevant. - assert_eq!( - load(&path, |key| (key == "BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY") - .then(String::new)) - .unwrap()["BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"], - "" - ); -} - -#[test] -fn lexical_selection_handles_bom_crlf_exports_comments_and_escaped_quotes() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - std::fs::write(&path, "\u{feff}# ignored=\"unclosed comment\r\nOTHER=\"a \\\"quote\r\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=true\r\n\"\r\nexport\tBUZZ_BUILD_BUZZ_AGENT_PROVIDER=databricks-v2\r\nBUZZ_BUILD_AGENT_ENV='DATABRICKS_MODEL=first\r\nDATABRICKS_MODEL_FILTER=team-*'\r\n").unwrap(); - let values = load(&path, |_| None).unwrap(); - assert_eq!(values.len(), 2); - assert_eq!(values["BUZZ_BUILD_BUZZ_AGENT_PROVIDER"], "databricks-v2"); - assert_eq!(parse(&values["BUZZ_BUILD_AGENT_ENV"]).unwrap().2, "first"); -} - -#[test] -fn unclosed_unrelated_quotes_never_promote_embedded_settings() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - for quote in ["'", "\"", "`"] { - std::fs::write( - &path, - format!("OTHER={quote}unterminated\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=true\n"), - ) - .unwrap(); - if quote == "`" { - assert_eq!( - load(&path, |_| None).unwrap_err(), - "Invalid native build .env.local" - ); - } else { - assert!(load(&path, |_| None).unwrap().is_empty()); - } - } -} - -#[test] -fn whole_dotenv_records_never_promote_embedded_build_keys() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - for value in [ - "\"literal ` inside quote\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n\"", - "`foo\"second`\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n\"", - "\"first\"\"second\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n\"", - "prefix\"second\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n\"", - "foo\\ #\"more\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n\"", - "'first'\"second\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n\"", - "prefix'second\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n'", - "\"first \"#\"second\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n\"", - ] { - let record = format!("OTHER={value}\n"); - // Pin the lexical contract to the actual decoder, not our scanner. - let decoded: Vec<_> = dotenvy::from_read_iter(record.as_bytes()) - .collect::>() - .unwrap(); - assert_eq!(decoded.len(), 1); - assert_eq!(decoded[0].0, "OTHER"); - std::fs::write( - &path, - format!("{record}INVALID=bar baz\nBUZZ_BUILD_BUZZ_AGENT_PROVIDER=databricks_v2\n"), - ) - .unwrap(); - let values = load(&path, |_| None).unwrap(); - assert_eq!(values.len(), 1, "{record}"); - assert_eq!(values["BUZZ_BUILD_BUZZ_AGENT_PROVIDER"], "databricks_v2"); - let values = load(&path, |key| { - (key == "BUZZ_BUILD_BUZZ_AGENT_PROVIDER").then(String::new) - }) - .unwrap(); - assert_eq!(values.len(), 1, "{record}"); - assert_eq!(values["BUZZ_BUILD_BUZZ_AGENT_PROVIDER"], ""); - } -} - -#[test] -fn selected_adjacent_segments_and_unrelated_comments_keep_record_boundaries() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - std::fs::write(&path, "OTHER=value # ignored \"quote\nBUZZ_BUILD_AGENT_ENV=DATABRICKS_MODEL='build-model'\"\nDATABRICKS_MODEL_FILTER=team-*\"\nBUZZ_BUILD_BUZZ_AGENT_PROVIDER=data\"bricks\"_v2\n").unwrap(); - let values = load(&path, |_| None).unwrap(); - assert_eq!(values.len(), 2); - assert_eq!(values["BUZZ_BUILD_BUZZ_AGENT_PROVIDER"], "databricks_v2"); - assert_eq!( - parse(&values["BUZZ_BUILD_AGENT_ENV"]).unwrap(), - (String::new(), "team-*".into(), "build-model".into()) - ); -} - -#[test] -fn literal_backticks_do_not_hide_following_build_settings() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - for record in [ - "OTHER=prefix`literal", - "OTHER=prefix`one`two`three", - "export OTHER = prefix`literal", - "OTHER=\"quoted ` literal\"", - "OTHER='quoted ` literal'", - "OTHER=\"prefix\"`literal", - ] { - let text = format!("{record}\nBUZZ_BUILD_BUZZ_AGENT_PROVIDER=databricks_v2\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n"); - let decoded: Vec<_> = dotenvy::from_read_iter(text.as_bytes()) - .collect::>() - .unwrap(); - assert_eq!(decoded.len(), 3, "{record}"); - std::fs::write(&path, text).unwrap(); - let values = load(&path, |_| None).unwrap(); - assert_eq!(values.len(), 2, "{record}"); - assert_eq!(values["BUZZ_BUILD_BUZZ_AGENT_PROVIDER"], "databricks_v2"); - assert_eq!(values["BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"], "1"); - } -} - -#[test] -fn backtick_delimited_values_contain_multiline_text_and_reject_unclosed_framing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - for prefix in ["OTHER=", " export OTHER = \t"] { - std::fs::write(&path, format!("{prefix}`multiline\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n`\nBUZZ_BUILD_BUZZ_AGENT_PROVIDER=databricks_v2\n")).unwrap(); - let values = load(&path, |_| None).unwrap(); - assert_eq!(values.len(), 1); - assert_eq!(values["BUZZ_BUILD_BUZZ_AGENT_PROVIDER"], "databricks_v2"); - std::fs::write( - &path, - format!("{prefix}`NEVER_PRINT\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n"), - ) - .unwrap(); - assert_eq!( - load(&path, |_| None).unwrap_err(), - "Invalid native build .env.local" - ); - assert_eq!(load(&path, |_| Some(String::new())).unwrap().len(), 3); - } -} - -#[test] -fn backtick_comments_do_not_reject_a_closed_value_at_end_of_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - for record in [ - "OTHER=`note # literal`", - " export OTHER = `first\nnext # literal`", - "OTHER=value # `not an opening delimiter", - ] { - std::fs::write(&path, format!("{record}\nBUZZ_BUILD_AGENT_ENV='DATABRICKS_MODEL=following-model'\nBUZZ_BUILD_BUZZ_AGENT_PROVIDER=databricks_v2\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\n")).unwrap(); - let values = load(&path, |_| None).unwrap(); - assert_eq!(values.len(), 3, "{record}"); - assert_eq!( - values["BUZZ_BUILD_AGENT_ENV"], - "DATABRICKS_MODEL=following-model" - ); - assert_eq!(values["BUZZ_BUILD_BUZZ_AGENT_PROVIDER"], "databricks_v2"); - assert_eq!(values["BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"], "1"); - } -} - -#[test] -fn backtick_comments_do_not_absorb_defaults_before_a_later_literal_backtick() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(".env.local"); - std::fs::write(&path, "OTHER=`note # literal`\nBUZZ_BUILD_AGENT_ENV='DATABRICKS_MODEL=following-model'\nBUZZ_BUILD_BUZZ_AGENT_PROVIDER=databricks_v2\nBUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1\nAFTER=prefix`literal\n").unwrap(); - let values = load(&path, |_| None).unwrap(); - assert_eq!(values.len(), 3); - assert_eq!( - values["BUZZ_BUILD_AGENT_ENV"], - "DATABRICKS_MODEL=following-model" - ); - assert_eq!(values["BUZZ_BUILD_BUZZ_AGENT_PROVIDER"], "databricks_v2"); - assert_eq!(values["BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"], "1"); -} diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 7d24553a0..6b4ee0e3d 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -172,10 +172,6 @@ pub fn bundled_manifests() -> Vec { .expect("todos manifest"), serde_json::from_str(include_str!("../../../src/bundled/diffs/manifest.json")) .expect("bundled diffs manifest"), - serde_json::from_str(include_str!( - "../../../src/bundled/channel-templates/manifest.json" - )) - .expect("channel templates manifest"), serde_json::from_str(include_str!( "../../../src/bundled/identity-naming/manifest.json" )) @@ -216,8 +212,6 @@ pub fn bundled_manifests() -> Vec { .expect("bestie manifest"), serde_json::from_str(include_str!("../../../src/bundled/projects/manifest.json")) .expect("projects manifest"), - serde_json::from_str(include_str!("../../../src/bundled/agents/manifest.json")) - .expect("agents manifest"), serde_json::from_str(include_str!("../../../src/bundled/agents2/manifest.json")) .expect("agents2 manifest"), serde_json::from_str(include_str!( @@ -264,7 +258,6 @@ fn enabled_by_default(id: &str) -> bool { | "buzz.inbox" | "buzz.reminders" | "buzz.projects" - | "buzz.agents" | "buzz.agents2" | "buzz.claude-code" | "buzz.codex" @@ -1546,26 +1539,25 @@ mod tests { #[test] fn optional_plugins_default_off_and_preserve_explicit_overrides() { - for id in ["buzz.todos", "buzz.channel-templates"] { - let temp = tempfile::tempdir().unwrap(); - let manager = Manager::open(Some(temp.path().into()), "optional-test", false).unwrap(); - let enabled = |manager: &Manager| { - manager - .catalog() - .unwrap() - .plugins - .into_iter() - .find(|plugin| plugin.manifest.id == id) - .unwrap() - .enabled - }; - assert!(!enabled(&manager), "{id} defaults off"); - manager.change("enable", id).unwrap(); - let reopened = Manager::open(Some(temp.path().into()), "optional-test", false).unwrap(); - assert!(enabled(&reopened), "{id} retains enable after reopening"); - reopened.change("disable", id).unwrap(); - assert!(!enabled(&manager), "{id} exposes persisted disable"); - } + let id = "buzz.todos"; + let temp = tempfile::tempdir().unwrap(); + let manager = Manager::open(Some(temp.path().into()), "optional-test", false).unwrap(); + let enabled = |manager: &Manager| { + manager + .catalog() + .unwrap() + .plugins + .into_iter() + .find(|plugin| plugin.manifest.id == id) + .unwrap() + .enabled + }; + assert!(!enabled(&manager), "{id} defaults off"); + manager.change("enable", id).unwrap(); + let reopened = Manager::open(Some(temp.path().into()), "optional-test", false).unwrap(); + assert!(enabled(&reopened), "{id} retains enable after reopening"); + reopened.change("disable", id).unwrap(); + assert!(!enabled(&manager), "{id} exposes persisted disable"); } #[test] diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 7ee667e92..d85b19ba3 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -534,7 +534,6 @@ fn bundled_plugins_have_independent_flags_and_all_ids_are_reserved() { "buzz.sessions", "buzz.inbox", "buzz.projects", - "buzz.agents", "buzz.emoji", "buzz.mentions", "buzz.voice-notes", @@ -671,7 +670,7 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() { .any(|plugin| plugin.manifest.id == "buzz.me")); for plugin in &catalog.plugins { let id = plugin.manifest.id.as_str(); - let default = !matches!(id, "buzz.bestie" | "buzz.todos" | "buzz.channel-templates"); + let default = !matches!(id, "buzz.bestie" | "buzz.todos"); assert_eq!(plugin.enabled, default, "{id}"); if id == "buzz.channels" { assert!(manager.change("disable", id).is_err()); diff --git a/docs/agent-control.md b/docs/agent-control.md deleted file mode 100644 index bab2118c6..000000000 --- a/docs/agent-control.md +++ /dev/null @@ -1,1168 +0,0 @@ -# Local agent controls - -The Agents page uses one app-owned native controller for creating, importing, -editing and running local agents. Inventory cards join records by exact public key; -managed actions remain keyed by native identity/community record. Browser-only access keeps the read-only old library; it cannot run -agents. The only product entry point is ordinary desktop startup. - -## Normal desktop workflow - -Run from the feature worktree with `bin/just desktop`, not a management-only -launcher. The command prepares the pinned agent runtime before starting Tauri; -the first build may take several minutes. Later launches verify and reuse matching -resources, rebuilding missing, stale or corrupt ones. Preparation failure stops -launch rather than opening a desktop that cannot run agents. This opens **Buzz Foundation** using the ordinary live-development -configuration and persistent native settings. Coordinate the native rebuild/relaunch; -quit other Foundation copies first. Saved enabled agents can restore on startup. -Keep imported agents disabled and old Buzz running until an attended handover. - -Open **Agents** for discovered and imported identities grouped by known community -associations. Compact individual-agent cards open **View profile** on click, without -expanding the grid. The three-dot menu keeps **Manage agent**, **Edit**, **Duplicate**, -and **Delete** separate. **Manage agent** opens the configured destinations’ -**Start / Stop**, recovery, and identity/source controls in a dialog; it is also -the card-click fallback when no profile panel is available. Closing Manage does not -cancel Start/Stop or discard its failure reason. Reopening retains that result -until another action from Manage or Edit, or an observed process-status transition, -supersedes it. Retained failures expose **Review agent status** on the card. -Duplicate seeds Create with editable settings and a fresh identity; write-only -environment values require re-entry. Delete stops the local process and removes -this app's settings and Keychain key after confirmation. The key remains while -another setup of the same identity still uses it. Delete does not archive the -relay identity or erase messages. Deployed remote records are refused. Actions -use native ID/revision, never the display name. Older hosts without parked -inventory retain the **Individual agents** and collapsed read-only **Other agents** sections. Managed -controls remain available when discovery is disconnected, unavailable or archived. - -**Create agent** shares the Edit fields and model browser. In the development desktop, -Create generates a native key, obtains the captured viewer's owner authorization, -saves the agent, starts it, then publishes its profile. A failed Start or profile -publication retains the saved identity and offers a retry for that step; it never -creates another identity. A native Start response can confirm a saved agent while -reporting that its process could not run. During Create, Start, or profile setup, -**Close** leaves the operation running and keeps recovery **Stop** available through **Manage agent**. Late completion never closes a subsequently opened dialog. If an operation -cannot be confirmed, refresh status before repeating it. - -Create is blocked with an explanation if this app’s runtime is unavailable; -existing agents and profile retry remain intact. -Without the dev broker, the native identity signs the owner authorization only -for the key this host prepared for the pending Create. Native owner-scoped -community resolution is also available; other broker-only helpers remain -unavailable. Packaged support still requires attended native acceptance. - -New agents, including clones and duplicates, start with `BUZZ_ACP_AGENTS=10` in -the existing **Environment** overrides. Replace or remove it there to choose the -worker count. With **Each thread** conversation context, separate threads can -use different workers while retaining separate histories. Existing saved agents -keep their settings; add the variable and restart them to enable more workers. - -Pi and Goose apply permitted Environment overrides after saved fields and -imported settings. For example, `BUZZ_ACP_MODEL`, `BUZZ_ACP_SYSTEM_PROMPT`, and -`BUZZ_ACP_AGENTS` take precedence at launch; worker counts must be from 1 to 32. -Removing an override restores the saved/default setting. Identity keys, the relay -URL, executable commands, saved response policy, and team instructions remain -protected, along with specific host controls: session policy, presence, inactivity -exit, idle-pool sleep, setup payload, and replay floor. Pi and Goose still permit -subscription, relay observer, deduplication, and event-handling overrides, as old -Buzz did. Device-wide overrides are inherited only by harnesses that permit them; -`BUZZ_ACP_MODEL` also requires the same default harness. Buzz Agent keeps its key -restrictions; its worker override takes precedence over imported parallelism. -Pi model browsing and connection tests use the -Provider/Model fields; ACP overrides apply to the listener's sessions at launch. - -**Hermes Agent** is an externally installed ACP harness. Settings → Agents → -Harnesses → **Add harness** shows executable availability and a manual setup -guide; **Check again** refreshes discovery. Buzz uses `hermes-acp` with no default arguments and does not -install Hermes or manage its provider credentials. Configure its default model -and sign-in with `hermes model` in your terminal. The agent form uses those -defaults and offers no Provider, Browse models or Test connection controls for -Hermes yet. Existing model/provider values remain visible for recovery: select -**Use Hermes Agent defaults**, then Save, before starting. Native launch rejects those -selectors, and Hermes does not permit `BUZZ_ACP_MODEL` environment overrides. -Saved executable paths survive detection refresh; choose the newly discovered -Hermes option explicitly to replace an old path. Device defaults and old-agent -import do not offer Hermes in this slice. - -**Clone to this community** opens the existing creation dialog with only the old -agent’s name and resolved instructions. Review that text for embedded secrets. -Runtime settings and workspace use this app’s defaults and remain editable. -Clone generates a new native identity; it does not copy identity keys, environment -values, command arguments, paths, history or membership. Saving leaves the new -agent stopped. The source is read-only and no legacy credential access occurs. - -**Import** preserves the selected old-installation identity and private key. It -requires an explicit destination and a fresh source/destination-bound preview. -Successful import saves a configured, stopped setup and moves keyboard focus to -the persistent card’s **Review agent status** button (or Actions for a row). It does not start a listener, -invite an agent, or modify the source installation. An identity already held -locally cannot be imported again into another community; use **Clone** instead. -The native prepare and commit boundaries both enforce that exact-key rule. - -The unified inventory offers Import only under **Available to import**, once per -exact public key. Multiple old installations require an explicit source choice. -The selected row opens the import review. Without a selected community, such -as in Personal space, the review asks for the destination before it loads a -fresh preview. Each startup reads every old installation again: an identity -deleted from all of them, or whose installation is removed, leaves the list. A -damaged source keeps its previous entries. Source read failures remain visible. -Older hosts retain the separate installation browser as a compatibility path. - -**Use here** is recovery for older incomplete local imports, not a normal next -step after Import. It retains the identity/key, requires owner-authorized community -confirmation, and leaves the recovered setup stopped with app-launch start off. -The development broker and the packaged desktop app both provide that -confirmation; the desktop app signs it natively (`relay_agent_resolve`). Native -code refuses a new -community when that identity already has a configured setup elsewhere. A retry -for the already recovered destination is harmless. Existing historical setups -remain visible and controllable; this rule does not move or delete them. - -Local team-linked imports snapshot the deployment team's instructions from the -chosen library's `agents/teams.json`, alongside the resolved persona prompt. -The existing ACP team-instructions input receives that snapshot; later edits in -old Buzz are not synchronized. Teams saved in this app deliver their own text -through the same input (see **Team instructions** in `docs/agents.md`). As in old Buzz, a deleted team or a directory-only -legacy binding without a deployment team ID contributes no team instructions. -Remote backends and relay mesh remain unsupported. - -Older imports missing this snapshot show **Import or repair from old Buzz**. -Choose their original source library and destination, then **Repair team import**. -This adds only the missing team instructions and advances the saved revision; -it preserves the identity, credentials, prompt, model, environment and enabled -intent, and never starts/restarts an agent. Source changes or changed saved -settings reject the repair. No Keychain read is needed for repair. Before Start, -perform the same attended old-Buzz handover as for a fresh import. - -To use an agent, open a channel and select it from **@ mentions**. Both mention -menus include the selected community’s people directory alongside channel members -and configured managed agents. Directory reads are bounded; narrow the search for more people. -A nonmember is labeled **Not in channel · Choose whether to add when you send**. -Selection alone does nothing. Send asks, as block/buzz desktop does: **Invite** -or **Do nothing**. Without add permission, the only action is **Send anyway**. -Invite uses the existing durable member-add operation and confirms membership -before addressed delivery. It does not start an agent before the outgoing message. -Do nothing and Send anyway send nonmembers as reference mentions, without granting -access or notifying them; channel-member mentions remain addressed. Close or -Escape keeps the draft. -Failed additions keep the draft and allow retry of the same pending operation. -A definitively failed addition older than 15 minutes must be dismissed in Outbox -before a new add; unknown outcomes are never silently replaced. Channel, thread, -and forum-channel composers share this behavior. DM participants and session -admission rules are unchanged. - -Once an identity is configured by Import or legacy **Use here** recovery, a confirmed outgoing channel or thread mention starts that exact local -agent (public key + community), without a separate Start click. Import itself -remains non-starting. Stop cancels earlier pending mention wakes and active work; -a later deliberate mention can start the agent again. Plain name text without -recipient selection, received history and unconfirmed sends do not start agents. -Start failures appear separately as “Message sent, but…”; do not resend merely -because execution failed. The old-Buzz ownership guard remains in force. - -Mention startup carries the earliest relevant pending send timestamp into the -bundled runner's existing replay input (bounded by its 15-minute catch-up limit). -Already-running agents are not restarted. Mentions during queued or pending startup -attach their earliest timestamp to that same launch, without another credential -read or listener. Stop and changed saved revisions retire that input. If launch -has already finished or the host cannot attach it, the send shows a separate -replay warning instead of silently treating Waiting as Running. Attachment is not -proof of delivery; a later credential/launch failure remains visible in Agents. -Process state is not proof of a live reply; imported identities require old Buzz -stopped before handover. - -The focused Add/Edit dialog contains Name and Agent instructions, followed by -**AI configuration** in dependency order: **Harness → Provider → Model**. Provider -choices come from the selected harness; model discovery uses the current draft. -Existing/custom values remain intact when another field changes. Workspace, -arguments and write-only environment patches remain under **Advanced**; -Start/Stop/Restart and exact identity are under **Runtime and identity**. Save uses -native ID/revision and restarts the agent only if it is running and its effective -settings changed (see [saving](#global-agent-defaults-and-saving)). Dirty -drafts resist backdrop/Escape; explicit Cancel/Close discards. Page -navigation/reload still discards page-local drafts. - -For Buzz Agent, **Browse models** requests the current Databricks catalog on explicit button -activation, including when typing has already opened the local popup. Typing, -focus and ArrowDown navigation never start a model-host request. Existing -app-isolated credentials are used/refreshed first; only an authentication failure -can open browser sign-in. No separate Connect button is required. Errors/cancellation -need explicit Retry; Refresh in **Advanced model settings** stays headless. -Choose a result or enter a custom ID (blank is allowed); Enter or leaving the field -commits typed text, Escape abandons the query. Save, close and reopen to check it. -If no workspace is configured, edit the agent and set **Databricks workspace (HTTPS origin)** -under **Advanced → Model**. App maintainers can instead supply the nonsecret -`DATABRICKS_HOST` build default below and rebuild the app. - -The native model-request service also has a Codex discovery path keyed by the -stable integration ID. It reads the selected Codex CLI's `codex debug models` -catalog, offers only the models Codex lists in its own picker, and reports effort -choices for only the selected model. It opens no ACP session, sends no prompt, -and persists no selection. -See [Codex model and effort discovery](codex-model-discovery.md). - -### Nonsecret build defaults - -Native builds read these inputs from the repository-root, ignored `.env.local`, -or from the build process environment. A process value wins **by presence**, -including an empty value. Both `just desktop` and release Cargo/Tauri builds use -the same controller-owned configuration. For example: - -```dotenv -BUZZ_BUILD_BUZZ_AGENT_PROVIDER=databricks_v2 -BUZZ_BUILD_AGENT_ENV='DATABRICKS_HOST=https://workspace.example.com -DATABRICKS_MODEL=your-model-id -DATABRICKS_MODEL_FILTER=team-*' -# Optional capability: any present value, even empty/0/false, enables it. -BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 -``` - -`BUZZ_BUILD_AGENT_ENV` is multiline `KEY=value` content, not a file path. Only -`DATABRICKS_HOST`, `DATABRICKS_MODEL` and `DATABRICKS_MODEL_FILTER` are accepted; -tokens, unknown/duplicate keys and malformed HTTPS origins fail the build without -echoing values. These are **public, nonsecret defaults** compiled into the binary -and available in the native editor snapshot. Never put credentials here. No -organization-specific host, provider or model is supplied by the app. - -- Blank saved provider/model selectors inherit the build floor for `buzz-agent` - only (including its absolute executable path); other harnesses do not. Saved - selectors win over the floor, and saved `BUZZ_AGENT_PROVIDER`/`BUZZ_AGENT_MODEL` - environment overrides win over selectors, including explicit empty strings. - `DATABRICKS_MODEL` is a provider fallback below an explicit Model selector. -- An absent saved Databricks pair inherits the build host/filter; an explicit - pair, including blanks, wins. Saved `DATABRICKS_HOST`/`DATABRICKS_MODEL_FILTER` - environment overrides win over either. The same resolution supplies model - discovery, credential requests and worker startup. Untouched Create or - name-only Edit/Save never copies build defaults into storage. Editing either - workspace/filter field intentionally saves **both displayed values**. -- Presence of `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY` clamps the local listener to - owner-only after saved settings/environment and removes its response allowlist. - This includes the runner's verified same-owner agent semantics. It does not - rewrite imported policy or saved records; remove the flag from both file and - process to disable the clamp. Empty/`0`/`false` do not disable it. - -Changing, adding or removing native inputs requires a **native rebuild and app -restart**; Cargo tracks the file and all three process keys, including removal. -Running agents are not hot-reconfigured. Explicit empty process values clear the -provider or environment floor; boolean capabilities must be absent to disable. -Runtime resources remain separately built from pinned sources with private build -settings stripped. The app-owned relay/key/OAuth boundaries are unchanged: -`BUZZ_RELAY_URL` is not an agent destination, saved destinations remain explicit, -and `DATABRICKS_TOKEN` still conflicts with app-isolated persistent OAuth. - -See [configuration parity](configuration.md) for development routing, release -flag exclusions and the supported deployment boundary. - -## Installed machine tools - -Local agents retain access to installed tools without inheriting the desktop's -identity or provider-credential environment. On Unix the controller warms the -user's interactive login-shell PATH in the background with a cleared environment -and a fifteen-second bound. Discovery and Start wait outside the native operation -queue; Stop remains available during shell startup. Only successful probes are -cached for the app process; **Check again** can retry a failed probe. Restart the -app after changing an already-discovered shell PATH. An unavailable or failed -probe falls back to inherited machine directories, `~/.local/bin`, Homebrew, and -system directories. Startup helpers are retired with the probe's owned session, -including separate job-control groups. Windows retains its native tool PATH. - -Bundled Buzz tools and harness-owned pinned runtimes remain first. Explicit -agent PATH directories are also included, but cannot displace those tools. -Empty, relative, and duplicate Unix directories are omitted. Only PATH comes -back from the shell: other variables exported by startup files are not copied -into the agent. Agent launches use the environment allowlist, explicit saved -provider settings, and managed identity overrides. This is tool discovery, not -an OS sandbox or a restriction on access to files on the machine. - -## Harnesses and agent defaults - -Individual-agent configuration stays on the Agents page; Settings → Agents owns -installation guidance and device-wide defaults. - -### Shared configuration policy - -The native `agent-controller::HarnessConfigurationPolicy` is projected through -each `harnessOptions[].configurationPolicy`. Create/Edit and Agent defaults -consume that policy for provider discovery, authentication ownership, model -requirements, and selector environment keys. Provider-specific credential fields -remain with their provider owners; authentication ownership is not a claim that -every provider requires an API key. - -| Harness | Authentication owner | Provider configuration | Model selection | -| --- | --- | --- | --- | -| Buzz Agent | Selected provider | Scalar selector | Existing defaults and overrides | -| Goose | Harness, with provider-specific overrides | Scalar selector | Existing defaults and overrides | -| Pi | Harness, with provider-specific overrides | Discovered provider selector | A selected provider requires a model | -| Codex | Codex CLI | External Codex configuration | Default, or Advanced discovered model and model-specific effort | -| Custom executable | External executable | External configuration | Existing saved value | - -Policy does not migrate saved records or change validation timing. Pi selection -checks stay at discovery/launch and default-save admission. Custom provider -values remain readable and editable, but an unmapped provider is still refused -at launch. Worker selector keys are shared with native launch resolution; -environment values never appear in the policy. Older hosts without the policy -retain the existing editor behavior. - -`supportedModes` is empty for every integration except Codex, which supports -Default and Advanced. Legacy blank-field inheritance is not managed Default -intent. Likewise, `effortDiscovery: "unknown"` means no model-specific capability -evidence is available; it does not mean effort is unsupported. Codex reports -`modelSpecific` effort discovery. The existing Agent defaults -effort suggestions remain editable suggestions, not allowed-value validation. - -Settings lists Codex with a stable native integration identity. New selection -requires installed CLI and adapter presence. Once both are found, Settings reads -the existing CLI login with a bounded `codex login status` on opening, Check -again, and install completion, as it does for Claude Code; ordinary control -snapshots do not launch it. Start launches the saved binding without a separate -probe; adapter and login failures appear in the agent log, as for Claude Code. -Codex discovery and execution resolve their saved context for each operation. **Adapter needed** offers an app-owned Install of -the ACP adapter only, following Claude Code's setup. See -[Codex binding readiness](codex-binding-readiness.md). - -Create and Save use ordinary persistence without inference validation. Existing -agents retain their saved adapter. Advanced model/effort selection remains; see -[Codex persistence and execution](codex-validation-execution.md). - -For native acceptance, use the Buzz community in the ordinary development app. -Open Create, Edit, and Agent defaults for Buzz Agent, Goose, and Pi. Check -provider/setup fields and harness switching, preserve saved/custom values, and -save/reopen a disposable configuration. Existing agent settings must not change -merely from opening the forms. Mounted form tests cover these controls; -controller tests cover stored-record preservation, selector precedence, and -launch rejection; a Tauri IPC test checks the actual serialized snapshot. - -### Harnesses - -The **Harnesses** card always lists **Buzz Agent**, **Goose**, **Pi**, and -**Claude Code**. Claude Code is also a choice in Create/Edit once its tools are -installed. **Add harness** opens the Tier 2 Hermes chooser and setup details. -Hermes also appears in the main list once its executable is detected. - -Buzz supplies `buzz-dev-mcp` to **Buzz Agent** for developer tools and **Hermes -Agent** for its authenticated shell path: Hermes's native terminal can strip the -Buzz signing key. Launch and runtime snapshots use the same native harness -policy's `include_buzz_dev_mcp` flag. Every supported harness declares its choice -explicitly. The shared preset definition requires `includeBuzzDevMcp`; omission -is invalid. Unknown custom harnesses fall back to false. Available tools depend -on the harness and its configuration. - -| Harness | `include_buzz_dev_mcp` | -| --- | --- | -| Buzz Agent | `true` | -| Hermes Agent | `true` | -| Goose | `false` | -| Pi | `false` | -| Claude Code | `false` | -| Codex | `false` | -| Unknown custom harness | `false` (fallback) | - -Saved absolute paths follow the same policy when the executable retains its -recognized basename (`buzz-agent` or `hermes-acp`, including supported suffixes). -A differently named wrapper or symlink is treated as a custom harness. - -- **Buzz Agent** is bundled and shows **Ready**. -- **Goose** is bundled and always shows **Ready**. Buzz launches `goose-acp` - directly, with no CLI installation or ACP subcommand. Provider credentials - and inference readiness remain separate from executable availability. - Buzz Agent remains the default; existing selections are preserved. -- **Pi** shows **Ready**, **CLI needed**, or **Adapter needed**. On macOS/Linux, - **Install** downloads checksum-verified Node v24.18.0 into app-data, then uses - that Node/npm to install Pi and `buzz-pi-acp` into an app-owned npm prefix. - Installation has a private log, re-detects the executables, and restarts only - enabled Pi agents that previously failed because their executable was missing. - User-global Pi installations remain untouched. Windows and unsupported - architectures retain manual setup. Settings explains that **Install** supplies - Node.js, Pi, and the adapter automatically. Copyable terminal commands are - collapsed under **Manual setup** within the Pi row (Node.js 22.19 or newer required). - Failed installs show the failed step and a bounded npm error excerpt above - the expandable private install log; errors do not assume a registry problem. - The manual fallback commands are: - - ```sh - npm install -g '@earendil-works/pi-coding-agent@>=0.99.0' - npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#72015de' - ``` - -- **Hermes Agent** shows **Ready** or **CLI needed** in **Add harness**, with a - manual setup guide and no Install/Update action. **Check again** updates the - chooser and main list; removing its executable hides the main row again. - Discovery searches for the exact `hermes-acp` launcher name. Windows - `.exe`/`.cmd`/`.bat` launchers are a known discovery limitation in this slice; - saved absolute paths remain recognizable/editable. - -- **Claude Code** shows **CLI needed** or **Adapter needed** for missing tools, - **Sign-in needed** when its CLI reports signed out, and **Ready** once tools - and sign-in are confirmed. A failed auth check shows **Sign-in unconfirmed**. - On macOS/Linux x64 and arm64, **Install** reuses the checksum-verified managed - Node and installs `@anthropic-ai/claude-code@2.1.289` and - `@agentclientprotocol/claude-agent-acp@0.85.1` into a new app-owned - `claude-tools/releases` directory. Both launchers must pass `--version` before - activation; a failed install preserves the previous release. Pi and Claude - use separate release storage and share the native install/quit owner, so only - one installation runs at a time. Stop remains available for running agents. - The result and private log survive leaving Settings, and completion refreshes - native detection. Complete external installations take precedence and remain - untouched. Windows and unsupported architectures retain manual setup with - Node.js 22 or newer. The fallback commands are: - - ```sh - npm install -g @anthropic-ai/claude-code@2.1.289 - npm install -g @agentclientprotocol/claude-agent-acp@0.85.1 - ``` - - Windows discovery resolves `node.exe` and native/Windows npm launchers; - the PowerShell sign-in command uses the selected `.exe`/`.cmd`/`.bat` path. - While installation runs, Install keeps keyboard focus but blocks activation. - Failure leaves it focused for retry. Success moves focus to the status if - Install still owns focus, without taking it from another control. - - **Sign in to Claude Code** shows the resolved CLI's `auth login` command, - including managed Node on PATH when needed. Settings checks the selected CLI - with a bounded, read-only `auth status` command on opening, Check again, and - after installation. Only its `loggedIn` boolean is exposed; account metadata - is discarded. Ready rows hide setup guidance; failed installs retain their - error and log. This confirms local sign-in, not inference. The ACP adapter bundles its own Claude - runtime; the separate CLI provides the sign-in command. Native retains - `claudeSetup` for Settings and reports the selected adapter in `harnessOptions` - for Create/Edit. Picker availability confirms installed tools, not sign-in or - inference. Claude is not a device-wide default-harness choice yet. - - **Create agent → Harness → Claude Code** uses Claude's own model and sign-in. - Provider, model browsing and Test connection are not offered in this slice. - Saved model/provider fields remain visible for recovery; choose **Use Claude - Code defaults** before saving or starting those agents. Start uses the saved - absolute adapter path with empty default arguments. Managed adapters use the - pinned managed Node; external adapters prefer a runnable Node beside the adapter, - then native discovery. Node and adapter directories precede the existing - controlled tools PATH, after bundled Buzz tools. Shell provider credentials - are not inherited. Explicit Advanced environment values remain write-only. - Buzz points `CLAUDE_CODE_EXECUTABLE` at the selected runnable CLI unless Advanced - environment explicitly overrides it. An explicit override skips CLI discovery; - Node is still required. This avoids relying on the SDK's optional - native-binary download. Windows `.cmd`/`.bat` login launchers cannot be used by - the JavaScript SDK, so those installations use the SDK's bundled native runtime - and must include its platform optional dependency. - The pinned `buzz-acp` owns Claude system-prompt append, channel/thread sessions, - permissions, cancellation and cleanup. Create, Start/Stop/Restart and retry use - the existing native controller, without another identity or lifecycle owner. - See [View a Claude Code session](view-claude-session.md) for local transcripts - and tool activity. - -Tier 2 definitions live in [`harness-presets.json`](../crates/agent-controller/src/harness-presets.json), -owned by the controller and read by both Rust and TypeScript. Native discovery -reports executable presence and editing suggestions through `harnessOptions`. -Settings and create/edit identify presets by executable name using the shared -JSON; setup metadata is not duplicated in IPC. Frontend lookup preserves -saved-path identity on older hosts and does not infer installation. -All presets currently use the harness's own model/provider defaults. Runtime -quirks remain in the pinned `buzz-acp`; Goose and Pi retain their specialized -setup and model integrations. Adding a preset definition still requires a real -ACP compatibility check; this registry does not implement model browsing. - -**Check again** re-detects installed Harnesses without reopening Buzz. Status -is executable detection, not a guarantee of sign-in, ACP readiness or inference. -The reviewed Pi adapter revision supports native steering, which needs Pi 0.99.0 -or later. Buzz checks the selected Pi CLI's version before agent startup or -model browsing. An older or unreadable version fails with an error naming the -selected CLI path and the required update. Verification has a five-second limit -and receives only basic system environment values, never inherited Buzz or -provider credentials. Model Cancel retires the probe and its helpers; probing -runs outside the agent controller lock so Stop and status remain available. -Start rechecks its ticket and effective Pi settings after verification. - -Each app-owned install goes into a new release under -`node-tools/releases/.