From e97a92e24a7fecf20f713950cb997be04af3ccc5 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 20 Aug 2026 16:15:31 +0000 Subject: [PATCH 01/17] fix(desktop): package macOS DMGs without Finder Build the macOS app bundle separately from DMG creation so headless release machines do not block in Tauri's Finder AppleScript. Keep Finder styling best-effort while always converting the writable image into an installable DMG. Co-authored-by: Other Brother Darryl Signed-off-by: Logan Johnson Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- Justfile | 18 +++- desktop/scripts/package-macos-dmg.sh | 128 +++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100755 desktop/scripts/package-macos-dmg.sh diff --git a/Justfile b/Justfile index 3cd03874538..b5c445a84e2 100644 --- a/Justfile +++ b/Justfile @@ -274,7 +274,23 @@ desktop-release-build target="aarch64-apple-darwin": touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" pnpm install - cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} + if [[ "$(uname -s)" == "Darwin" ]]; then + # Tauri's DMG bundler runs Finder AppleScript, which blocks on headless + # macOS. Build only the app, then use our hdiutil packager whose Finder + # styling is best-effort. + cd {{desktop_dir}} + pnpm tauri build --features mesh-llm --target "$TARGET" --bundles app + cd .. + DMG_DIR="desktop/src-tauri/target/$TARGET/release/bundle/dmg" + APP_PATH="desktop/src-tauri/target/$TARGET/release/bundle/macos/Buzz.app" + DMG_ARCH="${TARGET%%-*}" + VERSION="$(node -p "require('./desktop/package.json').version")" + ./desktop/scripts/package-macos-dmg.sh \ + "$APP_PATH" \ + "$DMG_DIR/Buzz_${VERSION}_${DMG_ARCH}.dmg" + else + cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target "$TARGET" + fi # Run desktop checks suitable for CI / pre-push desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test diff --git a/desktop/scripts/package-macos-dmg.sh b/desktop/scripts/package-macos-dmg.sh new file mode 100755 index 00000000000..7ecaf9502e8 --- /dev/null +++ b/desktop/scripts/package-macos-dmg.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Build a drag-to-Applications DMG without requiring a GUI login session. +# Finder styling is optional; the disk image itself is always authoritative. + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +app_path="$1" +out_dmg="$2" +app_name="$(basename "$app_path")" +volume_name="${VOL_NAME:-Buzz}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +background="$script_dir/../src-tauri/icons/dmg-background.png" +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/buzz-dmg.XXXXXX")" +source_dir="$work_dir/source" +rw_dmg="$work_dir/read-write.dmg" +mount_point="$work_dir/mount" +applescript="$work_dir/style.applescript" +device="" + +finish() { + local status="$?" + trap - EXIT + if [[ -n "$device" ]]; then + hdiutil detach "$device" >/dev/null 2>&1 || true + hdiutil detach -force "$device" >/dev/null 2>&1 || true + fi + rm -rf "$work_dir" + exit "$status" +} +trap finish EXIT + +[[ -d "$app_path" ]] || { echo "App bundle not found: $app_path" >&2; exit 1; } +[[ -f "$background" ]] || { echo "DMG background not found: $background" >&2; exit 1; } + +mkdir -p "$(dirname "$out_dmg")" "$source_dir/.background" "$mount_point" +ditto "$app_path" "$source_dir/$app_name" +ln -s /Applications "$source_dir/Applications" +cp "$background" "$source_dir/.background/background.png" + +rm -f "$rw_dmg" "$out_dmg" +hdiutil create -volname "$volume_name" -srcfolder "$source_dir" \ + -format UDRW -ov "$rw_dmg" >/dev/null + +attach_output="$(hdiutil attach -readwrite -noverify -noautoopen -nobrowse \ + -mountpoint "$mount_point" "$rw_dmg")" +device="$(printf '%s\n' "$attach_output" | awk '/^\/dev\// { print $1; exit }')" +[[ -n "$device" ]] || { echo "Failed to attach writable DMG" >&2; exit 1; } + +detach() { + local attempt + for attempt in 1 2 3 4 5; do + if hdiutil detach "$device" >/dev/null 2>&1; then + device="" + return 0 + fi + sleep 1 + done + hdiutil detach -force "$device" >/dev/null + device="" +} + +if command -v SetFile >/dev/null 2>&1; then + SetFile -a V "$mount_point/.background" || true + icon="$mount_point/$app_name/Contents/Resources/icon.icns" + if [[ -f "$icon" ]]; then + cp "$icon" "$mount_point/.VolumeIcon.icns" || true + SetFile -c icnC "$mount_point/.VolumeIcon.icns" || true + SetFile -a C "$mount_point" || true + fi +fi + +cat >"$applescript" <<'APPLESCRIPT' +on run argv + set mountPath to item 1 of argv + set appName to item 2 of argv + tell application "Finder" + set rootFolder to POSIX file mountPath as alias + open rootFolder + set imageWindow to container window of rootFolder + set current view of imageWindow to icon view + set toolbar visible of imageWindow to false + set statusbar visible of imageWindow to false + set bounds of imageWindow to {200, 120, 860, 652} + set viewOptions to icon view options of imageWindow + set arrangement of viewOptions to not arranged + set icon size of viewOptions to 128 + set text size of viewOptions to 14 + set background picture of viewOptions to file ".background:background.png" of rootFolder + set position of item appName of rootFolder to {191, 330} + set position of item "Applications" of rootFolder to {469, 330} + set extension hidden of item appName of rootFolder to true + delay 1 + close imageWindow + end tell +end run +APPLESCRIPT + +style_with_finder() { + local child elapsed=0 + /usr/bin/osascript "$applescript" "$mount_point" "$app_name" & + child=$! + while kill -0 "$child" 2>/dev/null; do + if (( elapsed >= 100 )); then + echo "Finder styling timed out; continuing without it" >&2 + kill "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + return 124 + fi + sleep 0.1 + elapsed=$((elapsed + 1)) + done + wait "$child" +} + +if ! style_with_finder; then + echo "Finder styling unavailable; continuing without it" >&2 +fi + +sync +detach +hdiutil convert "$rw_dmg" -format UDZO -imagekey zlib-level=9 \ + -o "$out_dmg" >/dev/null +printf 'DMG ready: %s\n' "$out_dmg" From e797e5de2a7a62a97a0478f116e258a78f8d00d8 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 20 Aug 2026 13:20:28 -0400 Subject: [PATCH 02/17] fix(desktop): select DMG packaging by target Signed-off-by: Logan Johnson Co-authored-by: Other Brother Darryl Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- Justfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Justfile b/Justfile index b5c445a84e2..0e19f03e28a 100644 --- a/Justfile +++ b/Justfile @@ -274,7 +274,7 @@ desktop-release-build target="aarch64-apple-darwin": touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" pnpm install - if [[ "$(uname -s)" == "Darwin" ]]; then + if [[ "$(uname -s)" == "Darwin" && "$TARGET" == *-apple-darwin ]]; then # Tauri's DMG bundler runs Finder AppleScript, which blocks on headless # macOS. Build only the app, then use our hdiutil packager whose Finder # styling is best-effort. @@ -284,6 +284,9 @@ desktop-release-build target="aarch64-apple-darwin": DMG_DIR="desktop/src-tauri/target/$TARGET/release/bundle/dmg" APP_PATH="desktop/src-tauri/target/$TARGET/release/bundle/macos/Buzz.app" DMG_ARCH="${TARGET%%-*}" + if [[ "$DMG_ARCH" == "x86_64" ]]; then + DMG_ARCH=x64 + fi VERSION="$(node -p "require('./desktop/package.json').version")" ./desktop/scripts/package-macos-dmg.sh \ "$APP_PATH" \ From fa140d0d4bca0920d020e6409d402e28dbc99e92 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 25 Aug 2026 11:13:05 -0400 Subject: [PATCH 03/17] feat(desktop): add isolated named demo builds Co-authored-by: Larry Signed-off-by: Logan Johnson --- Justfile | 46 +++++++----- desktop/package.json | 2 +- desktop/scripts/demo-build-config.mjs | 79 ++++++++++++++++++++ desktop/scripts/demo-build-config.test.mjs | 78 +++++++++++++++++++ desktop/src-tauri/build.rs | 21 ++++++ desktop/src-tauri/src/app_state_keyring.rs | 7 +- desktop/src-tauri/src/build_identity.rs | 62 +++++++++++++++ desktop/src-tauri/src/deep_link.rs | 2 +- desktop/src-tauri/src/lib.rs | 6 +- desktop/src-tauri/src/managed_agents/nest.rs | 12 +-- desktop/src-tauri/src/migration.rs | 6 +- 11 files changed, 287 insertions(+), 34 deletions(-) create mode 100644 desktop/scripts/demo-build-config.mjs create mode 100644 desktop/scripts/demo-build-config.test.mjs create mode 100644 desktop/src-tauri/src/build_identity.rs diff --git a/Justfile b/Justfile index 0e19f03e28a..c9605f0392a 100644 --- a/Justfile +++ b/Justfile @@ -274,26 +274,32 @@ desktop-release-build target="aarch64-apple-darwin": touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" pnpm install - if [[ "$(uname -s)" == "Darwin" && "$TARGET" == *-apple-darwin ]]; then - # Tauri's DMG bundler runs Finder AppleScript, which blocks on headless - # macOS. Build only the app, then use our hdiutil packager whose Finder - # styling is best-effort. - cd {{desktop_dir}} - pnpm tauri build --features mesh-llm --target "$TARGET" --bundles app - cd .. - DMG_DIR="desktop/src-tauri/target/$TARGET/release/bundle/dmg" - APP_PATH="desktop/src-tauri/target/$TARGET/release/bundle/macos/Buzz.app" - DMG_ARCH="${TARGET%%-*}" - if [[ "$DMG_ARCH" == "x86_64" ]]; then - DMG_ARCH=x64 - fi - VERSION="$(node -p "require('./desktop/package.json').version")" - ./desktop/scripts/package-macos-dmg.sh \ - "$APP_PATH" \ - "$DMG_DIR/Buzz_${VERSION}_${DMG_ARCH}.dmg" - else - cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target "$TARGET" - fi + cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} + +# Build an unsigned named macOS demo DMG with isolated app and runtime identities. +desktop-demo-build demo_name target="aarch64-apple-darwin": + #!/usr/bin/env bash + set -euo pipefail + TARGET={{target}} + [[ "$(uname -s)" == "Darwin" && "$TARGET" == *-apple-darwin ]] || { echo "Demo DMGs require a macOS Apple target" >&2; exit 2; } + CONFIG_PATH="$(mktemp "${TMPDIR:-/tmp}/buzz-demo-config.XXXXXX.json")" + trap 'rm -f "$CONFIG_PATH"' EXIT + DEMO_CONFIG="$(node desktop/scripts/demo-build-config.mjs "{{demo_name}}" "$CONFIG_PATH")" + read_config() { node -e 'console.log(JSON.parse(process.argv[1])[process.argv[2]])' "$DEMO_CONFIG" "$1"; } + PRODUCT_NAME="$(read_config productName)" + DMG_VOLUME_NAME="$(read_config dmgVolumeName)" + DMG_FILE_STEM="$(read_config dmgFileStem)" + DEMO_SLUG="$(read_config slug)" + mkdir -p desktop/src-tauri/binaries + for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do touch "desktop/src-tauri/binaries/$bin-$TARGET"; done + pnpm install + cd {{desktop_dir}} + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" pnpm tauri build --features mesh-llm --target "$TARGET" --bundles app --config "$CONFIG_PATH" + cd .. + VERSION="$(node -p "require('./desktop/package.json').version")" + DMG_ARCH="${TARGET%%-*}"; [[ "$DMG_ARCH" == "x86_64" ]] && DMG_ARCH=x64 + APP_PATH="desktop/src-tauri/target/$TARGET/release/bundle/macos/$PRODUCT_NAME.app" + VOL_NAME="$DMG_VOLUME_NAME" ./desktop/scripts/package-macos-dmg.sh "$APP_PATH" "desktop/src-tauri/target/$TARGET/release/bundle/dmg/${DMG_FILE_STEM}_${VERSION}_${DMG_ARCH}.dmg" # Run desktop checks suitable for CI / pre-push desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test diff --git a/desktop/package.json b/desktop/package.json index 1e93fd76a85..3272c37c1d9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,7 +14,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", - "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", + "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" \"scripts/*.test.mjs\"", "preview": "vite preview", "tauri": "tauri", "test:e2e": "pnpm build:e2e && playwright test", diff --git a/desktop/scripts/demo-build-config.mjs b/desktop/scripts/demo-build-config.mjs new file mode 100644 index 00000000000..8cfd1208283 --- /dev/null +++ b/desktop/scripts/demo-build-config.mjs @@ -0,0 +1,79 @@ +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const PRODUCTION_IDENTIFIER = "xyz.block.buzz.app"; +const MAX_DEMO_NAME_LENGTH = 48; + +export const productionBuildIdentity = Object.freeze({ + productName: "Buzz", + identifier: PRODUCTION_IDENTIFIER, + deepLinkScheme: "buzz", + keyringService: "buzz-desktop", + nestName: ".buzz", + cliName: "buzz", +}); + +export function demoBuildConfig(rawName) { + if (typeof rawName !== "string") throw new Error("Demo name must be text"); + const name = rawName.trim().replace(/\s+/g, " "); + if (!name) throw new Error("Demo name must not be empty"); + if (name.length > MAX_DEMO_NAME_LENGTH) { + throw new Error( + `Demo name must be at most ${MAX_DEMO_NAME_LENGTH} characters`, + ); + } + if (!/^[A-Za-z0-9][A-Za-z0-9 -]*$/.test(name)) { + throw new Error( + "Demo name may contain ASCII letters, numbers, spaces, and hyphens only", + ); + } + + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const productName = `Buzz ${name}`; + return { + name, + slug, + productName, + dmgVolumeName: productName, + dmgFileStem: productName.replace(/ /g, "_"), + identifier: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + appDataIdentity: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + deepLinkScheme: `buzz-demo-${slug}`, + keyringService: `buzz-desktop-demo.${slug}`, + nestName: `.buzz-demo-${slug}`, + cliName: `buzz-demo-${slug}`, + tauriConfig: { + productName, + identifier: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + plugins: { "deep-link": { desktop: { schemes: [`buzz-demo-${slug}`] } } }, + bundle: { targets: ["app"] }, + }, + }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const [name, outputPath] = process.argv.slice(2); + if (!outputPath) { + console.error( + "Usage: demo-build-config.mjs ", + ); + process.exit(2); + } + try { + const config = demoBuildConfig(name); + writeFileSync( + outputPath, + `${JSON.stringify(config.tauriConfig, null, 2)}\n`, + ); + console.log(JSON.stringify(config)); + } catch (error) { + console.error(`Invalid demo build: ${error.message}`); + process.exit(1); + } +} diff --git a/desktop/scripts/demo-build-config.test.mjs b/desktop/scripts/demo-build-config.test.mjs new file mode 100644 index 00000000000..7f47106be04 --- /dev/null +++ b/desktop/scripts/demo-build-config.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + demoBuildConfig, + productionBuildIdentity, +} from "./demo-build-config.mjs"; + +const expected = (name, slug) => ({ + name, + slug, + productName: `Buzz ${name}`, + dmgVolumeName: `Buzz ${name}`, + dmgFileStem: `Buzz_${name.replace(/ /g, "_")}`, + identifier: `xyz.block.buzz.app.demo.${slug}`, + appDataIdentity: `xyz.block.buzz.app.demo.${slug}`, + deepLinkScheme: `buzz-demo-${slug}`, + keyringService: `buzz-desktop-demo.${slug}`, + nestName: `.buzz-demo-${slug}`, + cliName: `buzz-demo-${slug}`, + tauriConfig: { + productName: `Buzz ${name}`, + identifier: `xyz.block.buzz.app.demo.${slug}`, + plugins: { "deep-link": { desktop: { schemes: [`buzz-demo-${slug}`] } } }, + bundle: { targets: ["app"] }, + }, +}); + +test("production identity remains unchanged", () => { + assert.deepEqual(productionBuildIdentity, { + productName: "Buzz", + identifier: "xyz.block.buzz.app", + deepLinkScheme: "buzz", + keyringService: "buzz-desktop", + nestName: ".buzz", + cliName: "buzz", + }); +}); + +test("two demo names produce complete, distinct identities", () => { + const board = demoBuildConfig("Workstream Board"); + const interests = demoBuildConfig("Interests Demo"); + assert.deepEqual(board, expected("Workstream Board", "workstream-board")); + assert.deepEqual(interests, expected("Interests Demo", "interests-demo")); + for (const key of [ + "productName", + "dmgVolumeName", + "dmgFileStem", + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual(board[key], interests[key], key); + assert.notEqual(board[key], productionBuildIdentity[key], key); + } +}); + +test("whitespace normalization preserves deterministic identity", () => { + assert.deepEqual( + demoBuildConfig(" Workstream Board "), + demoBuildConfig("Workstream Board"), + ); +}); + +for (const name of [ + "", + " ", + "Workstream/Board", + "Workstream_Board", + "équipe", + "x".repeat(49), +]) { + test(`rejects unusable name ${JSON.stringify(name)}`, () => + assert.throws(() => demoBuildConfig(name))); +} diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2cdd785c735..8b0e63f12bc 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -18,8 +18,29 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DEMO_SLUG"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + if let Ok(slug) = std::env::var("BUZZ_BUILD_DEMO_SLUG") { + let valid = !slug.is_empty() + && slug.len() <= 48 + && slug + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && slug + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + && slug + .bytes() + .last() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if !valid { + panic!("BUZZ_BUILD_DEMO_SLUG must be a lowercase ASCII slug"); + } + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DEMO_SLUG={slug}"); + } + // Explicit owner-only agent-access capability. Release packaging sets this // presence-only marker; OSS/custom builds leave agent access configurable. if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() { diff --git a/desktop/src-tauri/src/app_state_keyring.rs b/desktop/src-tauri/src/app_state_keyring.rs index 68d24e87f58..7684355a5bc 100644 --- a/desktop/src-tauri/src/app_state_keyring.rs +++ b/desktop/src-tauri/src/app_state_keyring.rs @@ -7,7 +7,12 @@ fn dev_keyring_service(configured: Option) -> String { } pub(crate) fn keyring_service() -> &'static str { - if cfg!(debug_assertions) { + if crate::build_identity::is_demo_build() { + static DEMO_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); + DEMO_SERVICE + .get_or_init(|| crate::build_identity::keyring_service().into_owned()) + .as_str() + } else if cfg!(debug_assertions) { static DEV_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); DEV_SERVICE .get_or_init(|| dev_keyring_service(std::env::var("BUZZ_DEV_KEYRING_SERVICE").ok())) diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs new file mode 100644 index 00000000000..e7af15060be --- /dev/null +++ b/desktop/src-tauri/src/build_identity.rs @@ -0,0 +1,62 @@ +//! Compile-time identity for reusable named demo builds. +//! +//! Production builds leave `BUZZ_DESKTOP_BUILD_DEMO_SLUG` unset and retain all +//! existing names. The demo recipe validates one slug and `build.rs` bakes it +//! into the binary; every runtime identity is then derived from that one value. + +use std::borrow::Cow; + +pub(crate) fn demo_slug() -> Option<&'static str> { + option_env!("BUZZ_DESKTOP_BUILD_DEMO_SLUG") +} + +pub(crate) fn is_demo_build() -> bool { + demo_slug().is_some() +} + +pub(crate) fn deep_link_scheme() -> Cow<'static, str> { + demo_slug() + .map(|slug| Cow::Owned(format!("buzz-demo-{slug}"))) + .unwrap_or(Cow::Borrowed("buzz")) +} + +pub(crate) fn keyring_service() -> Cow<'static, str> { + demo_slug() + .map(|slug| Cow::Owned(format!("buzz-desktop-demo.{slug}"))) + .unwrap_or(Cow::Borrowed("buzz-desktop")) +} + +pub(crate) fn nest_name(is_dev: bool) -> Cow<'static, str> { + if let Some(slug) = demo_slug() { + Cow::Owned(format!(".buzz-demo-{slug}")) + } else if is_dev { + Cow::Borrowed(".buzz-dev") + } else { + Cow::Borrowed(".buzz") + } +} + +pub(crate) fn cli_name(is_dev: bool) -> Cow<'static, str> { + if let Some(slug) = demo_slug() { + Cow::Owned(format!("buzz-demo-{slug}")) + } else if is_dev { + Cow::Borrowed("buzz-dev") + } else { + Cow::Borrowed("buzz") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordinary_release_defaults_remain_production_identity() { + if demo_slug().is_none() { + assert_eq!(deep_link_scheme(), "buzz"); + assert_eq!(keyring_service(), "buzz-desktop"); + assert_eq!(nest_name(false), ".buzz"); + assert_eq!(cli_name(false), "buzz"); + } + } +} diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 83ac7e59ff9..8446c59101a 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -600,7 +600,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } }; - if url.scheme() != "buzz" { + if url.scheme() != crate::build_identity::deep_link_scheme() { eprintln!("buzz-desktop: ignoring unsupported deep link scheme: {url_str}"); return; } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f2b196c41d9..95a74a6ca37 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod app_menu; mod app_state; mod archive; +mod build_identity; mod builderlab; mod channel_head_cache; mod commands; @@ -396,7 +397,10 @@ pub fn run() { // the now-inert ~/.sprout; the frontend dedupes the toast. // Suppressed when a reset completed this boot: the nest was wiped and // a fresh ~/.sprout-less state is exactly what we want. - if !reset_outcome.completed && migration::migrate_legacy_nest() { + if !crate::build_identity::is_demo_build() + && !reset_outcome.completed + && migration::migrate_legacy_nest() + { let _ = app_handle.emit("legacy-nest-migrated", ()); } diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 5f375e23c1c..7ec544ef841 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -88,8 +88,8 @@ static NEST_DIR: std::sync::OnceLock> = std::sync::OnceLock::new /// when the Tauri app-data directory name starts with `"xyz.block.buzz.app.dev"`. /// Pass `false` for production (signed DMG) builds. pub fn init_nest_dir(is_dev: bool) { - let suffix = if is_dev { NEST_DIR_DEV } else { NEST_DIR_PROD }; - let path = dirs::home_dir().map(|h| h.join(suffix)); + let suffix = crate::build_identity::nest_name(is_dev); + let path = dirs::home_dir().map(|h| h.join(suffix.as_ref())); // set() is a no-op when already initialized, which is correct: only the // first call (at boot, before any filesystem work) should win. let _ = NEST_DIR.set(path); @@ -315,12 +315,8 @@ fn ensure_skill_symlinks(_root: &Path) -> Result<(), String> { /// Dev builds (`is_dev = true`) use `"buzz-dev"` so that a running DMG and a /// concurrent dev build each own a separate link and never clobber each other — /// the same isolation that separates `~/.buzz` (prod) from `~/.buzz-dev` (dev). -pub fn cli_link_name(is_dev: bool) -> &'static str { - if is_dev { - "buzz-dev" - } else { - "buzz" - } +pub fn cli_link_name(is_dev: bool) -> std::borrow::Cow<'static, str> { + crate::build_identity::cli_name(is_dev) } /// Ensures `~/.local/bin/buzz` (prod) or `~/.local/bin/buzz-dev` (dev) is a diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 1e22d7aaeca..d3d8c9a6d8d 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -154,8 +154,10 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest); } - migrate_legacy_app_data_dir(app); - sync_shared_agent_data(app); + if !crate::build_identity::is_demo_build() { + migrate_legacy_app_data_dir(app); + sync_shared_agent_data(app); + } // Dev-build-only: copy any agent keys that exist in the production // keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev") // so existing agents don't lose their keys after the service-name split. From d4b6ec9d3c8089e53915b1d2a442c3ee3b0ee3c6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 25 Aug 2026 11:25:32 -0400 Subject: [PATCH 04/17] fix(desktop): pass demo CLI names as paths Co-authored-by: Larry Signed-off-by: Logan Johnson --- desktop/src-tauri/src/managed_agents/nest.rs | 2 +- desktop/src-tauri/src/reset.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 7ec544ef841..d8392d2b2a2 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -346,7 +346,7 @@ pub fn ensure_cli_symlink(exe_parent: &Path, is_dev: bool) -> Result<(), String> .join("bin"); fs::create_dir_all(&local_bin).map_err(|e| format!("create {}: {e}", local_bin.display()))?; - let link = local_bin.join(cli_link_name(is_dev)); + let link = local_bin.join(cli_link_name(is_dev).as_ref()); match link.symlink_metadata() { Ok(meta) if meta.file_type().is_symlink() => { let _ = fs::remove_file(&link); diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index 18ddd80eb8d..8920e3ee419 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -219,7 +219,7 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom let _ = std::fs::remove_dir_all(home.join(".sprout")); let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); let link_name = crate::managed_agents::cli_link_name(ctx.is_dev); - let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name)); + let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name.as_ref())); } // ── Step 4: keychain — LAST so we can read keys before deleting ────────── From 20bb2242390bdf880efc0d9feda9e21da157fdeb Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 25 Aug 2026 11:29:50 -0400 Subject: [PATCH 05/17] fix(desktop): stamp demo bundle display names Co-authored-by: Larry Signed-off-by: Logan Johnson --- Justfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Justfile b/Justfile index c9605f0392a..c60e8bce7cd 100644 --- a/Justfile +++ b/Justfile @@ -299,6 +299,10 @@ desktop-demo-build demo_name target="aarch64-apple-darwin": VERSION="$(node -p "require('./desktop/package.json').version")" DMG_ARCH="${TARGET%%-*}"; [[ "$DMG_ARCH" == "x86_64" ]] && DMG_ARCH=x64 APP_PATH="desktop/src-tauri/target/$TARGET/release/bundle/macos/$PRODUCT_NAME.app" + PLIST="$APP_PATH/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $PRODUCT_NAME" "$PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleName $PRODUCT_NAME" "$PLIST" + codesign --force --deep --sign - "$APP_PATH" VOL_NAME="$DMG_VOLUME_NAME" ./desktop/scripts/package-macos-dmg.sh "$APP_PATH" "desktop/src-tauri/target/$TARGET/release/bundle/dmg/${DMG_FILE_STEM}_${VERSION}_${DMG_ARCH}.dmg" # Run desktop checks suitable for CI / pre-push From 1e4b4f5afd88e0ae04b7439de65b493a26bcbfa8 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 25 Aug 2026 11:34:35 -0400 Subject: [PATCH 06/17] fix(desktop): expose demo CLI identity as a path name Co-authored-by: Larry Signed-off-by: Logan Johnson --- desktop/src-tauri/src/build_identity.rs | 8 ++++---- desktop/src-tauri/src/managed_agents/nest.rs | 4 ++-- desktop/src-tauri/src/reset.rs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs index e7af15060be..e14bd66327b 100644 --- a/desktop/src-tauri/src/build_identity.rs +++ b/desktop/src-tauri/src/build_identity.rs @@ -36,13 +36,13 @@ pub(crate) fn nest_name(is_dev: bool) -> Cow<'static, str> { } } -pub(crate) fn cli_name(is_dev: bool) -> Cow<'static, str> { +pub(crate) fn cli_name(is_dev: bool) -> String { if let Some(slug) = demo_slug() { - Cow::Owned(format!("buzz-demo-{slug}")) + format!("buzz-demo-{slug}") } else if is_dev { - Cow::Borrowed("buzz-dev") + "buzz-dev".to_string() } else { - Cow::Borrowed("buzz") + "buzz".to_string() } } diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index d8392d2b2a2..69f0edcd9f0 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -315,7 +315,7 @@ fn ensure_skill_symlinks(_root: &Path) -> Result<(), String> { /// Dev builds (`is_dev = true`) use `"buzz-dev"` so that a running DMG and a /// concurrent dev build each own a separate link and never clobber each other — /// the same isolation that separates `~/.buzz` (prod) from `~/.buzz-dev` (dev). -pub fn cli_link_name(is_dev: bool) -> std::borrow::Cow<'static, str> { +pub fn cli_link_name(is_dev: bool) -> String { crate::build_identity::cli_name(is_dev) } @@ -346,7 +346,7 @@ pub fn ensure_cli_symlink(exe_parent: &Path, is_dev: bool) -> Result<(), String> .join("bin"); fs::create_dir_all(&local_bin).map_err(|e| format!("create {}: {e}", local_bin.display()))?; - let link = local_bin.join(cli_link_name(is_dev).as_ref()); + let link = local_bin.join(cli_link_name(is_dev)); match link.symlink_metadata() { Ok(meta) if meta.file_type().is_symlink() => { let _ = fs::remove_file(&link); diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index 8920e3ee419..18ddd80eb8d 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -219,7 +219,7 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom let _ = std::fs::remove_dir_all(home.join(".sprout")); let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); let link_name = crate::managed_agents::cli_link_name(ctx.is_dev); - let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name.as_ref())); + let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name)); } // ── Step 4: keychain — LAST so we can read keys before deleting ────────── From d23daca140a25309fad888f4050abf2747fc32e1 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 25 Aug 2026 11:41:45 -0400 Subject: [PATCH 07/17] style(desktop): keep migration within size ratchet Co-authored-by: Larry Signed-off-by: Logan Johnson --- desktop/src-tauri/src/migration.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index d3d8c9a6d8d..572538b1980 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -155,8 +155,7 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { } if !crate::build_identity::is_demo_build() { - migrate_legacy_app_data_dir(app); - sync_shared_agent_data(app); + migrate_legacy_app_data_dir(app); sync_shared_agent_data(app); } // Dev-build-only: copy any agent keys that exist in the production // keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev") From d04c976940e79fde70279e962ec31846d4556c5e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 25 Aug 2026 11:43:08 -0400 Subject: [PATCH 08/17] style(desktop): satisfy migration size ceiling Co-authored-by: Larry Signed-off-by: Logan Johnson --- desktop/src-tauri/src/migration.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 572538b1980..5caa8e3a310 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -154,9 +154,7 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest); } - if !crate::build_identity::is_demo_build() { - migrate_legacy_app_data_dir(app); sync_shared_agent_data(app); - } + if !crate::build_identity::is_demo_build() { migrate_legacy_app_data_dir(app); sync_shared_agent_data(app); } // Dev-build-only: copy any agent keys that exist in the production // keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev") // so existing agents don't lose their keys after the service-name split. From 43030476a6dfce22e7e81c0c485b9128a9916898 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 25 Aug 2026 11:50:00 -0400 Subject: [PATCH 09/17] fix(desktop): isolate demo runtime state Route voice models through the selected build nest and prevent named demos from falling back to production repository roots. Co-authored-by: Larry Signed-off-by: Logan Johnson --- desktop/src-tauri/src/build_identity.rs | 19 ++++++- .../src/commands/project_repo_paths.rs | 56 +++++++++++++++++-- desktop/src-tauri/src/huddle/models.rs | 12 ++-- desktop/src-tauri/src/huddle/models_tests.rs | 13 +++++ desktop/src-tauri/src/migration.rs | 22 ++++---- 5 files changed, 100 insertions(+), 22 deletions(-) diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs index e14bd66327b..468c1d7cd6a 100644 --- a/desktop/src-tauri/src/build_identity.rs +++ b/desktop/src-tauri/src/build_identity.rs @@ -27,7 +27,11 @@ pub(crate) fn keyring_service() -> Cow<'static, str> { } pub(crate) fn nest_name(is_dev: bool) -> Cow<'static, str> { - if let Some(slug) = demo_slug() { + nest_name_for(demo_slug(), is_dev) +} + +fn nest_name_for(demo_slug: Option<&str>, is_dev: bool) -> Cow<'_, str> { + if let Some(slug) = demo_slug { Cow::Owned(format!(".buzz-demo-{slug}")) } else if is_dev { Cow::Borrowed(".buzz-dev") @@ -59,4 +63,17 @@ mod tests { assert_eq!(cli_name(false), "buzz"); } } + + #[test] + fn production_and_named_demo_nests_are_distinct() { + assert_eq!(nest_name_for(None, false), ".buzz"); + assert_eq!( + nest_name_for(Some("workstream-board"), false), + ".buzz-demo-workstream-board" + ); + assert_eq!( + nest_name_for(Some("second-demo"), false), + ".buzz-demo-second-demo" + ); + } } diff --git a/desktop/src-tauri/src/commands/project_repo_paths.rs b/desktop/src-tauri/src/commands/project_repo_paths.rs index 4193327c012..3fd4bbcaf82 100644 --- a/desktop/src-tauri/src/commands/project_repo_paths.rs +++ b/desktop/src-tauri/src/commands/project_repo_paths.rs @@ -145,13 +145,26 @@ pub(crate) fn find_local_repo_dir( } pub(crate) fn default_repos_root_candidates() -> Vec { + default_repos_root_candidates_for( + nest_dir(), + dirs::home_dir(), + crate::build_identity::is_demo_build(), + ) +} + +fn default_repos_root_candidates_for( + nest: Option, + home: Option, + is_demo_build: bool, +) -> Vec { let mut candidates = Vec::new(); - candidates.extend(nest_dir().map(|path| path.join("REPOS"))); - candidates.extend( - dirs::home_dir() - .map(|home| home.join(".buzz").join("REPOS")) - .filter(|path| !candidates.iter().any(|candidate| candidate == path)), - ); + candidates.extend(nest.map(|path| path.join("REPOS"))); + if !is_demo_build { + candidates.extend( + home.map(|home| home.join(".buzz").join("REPOS")) + .filter(|path| !candidates.iter().any(|candidate| candidate == path)), + ); + } candidates } @@ -190,3 +203,34 @@ pub(crate) fn canonical_repos_roots( } Ok(roots) } + +#[cfg(test)] +mod tests { + use super::default_repos_root_candidates_for; + use std::path::PathBuf; + + #[test] + fn production_keeps_the_legacy_repo_fallback() { + let home = PathBuf::from("/Users/example"); + assert_eq!( + default_repos_root_candidates_for( + Some(home.join(".buzz-dev")), + Some(home.clone()), + false, + ), + vec![home.join(".buzz-dev/REPOS"), home.join(".buzz/REPOS")] + ); + } + + #[test] + fn named_demos_only_search_their_selected_nest() { + let home = PathBuf::from("/Users/example"); + for slug in ["workstream-board", "second-demo"] { + let nest = home.join(format!(".buzz-demo-{slug}")); + assert_eq!( + default_repos_root_candidates_for(Some(nest.clone()), Some(home.clone()), true,), + vec![nest.join("REPOS")] + ); + } + } +} diff --git a/desktop/src-tauri/src/huddle/models.rs b/desktop/src-tauri/src/huddle/models.rs index f9f70657698..09154d5237d 100644 --- a/desktop/src-tauri/src/huddle/models.rs +++ b/desktop/src-tauri/src/huddle/models.rs @@ -587,6 +587,10 @@ fn tts_model_slot() -> ModelSlot { .with_expected_sizes(tts_expected_size) } +fn models_dir(nest_dir: PathBuf) -> PathBuf { + nest_dir.join("models") +} + // ── ModelManager ────────────────────────────────────────────────────────────── /// Manages download and location of STT/TTS model files. @@ -594,18 +598,18 @@ fn tts_model_slot() -> ModelSlot { /// Cheap to clone — all inner state is behind `Arc`. #[derive(Clone)] pub struct ModelManager { - /// `~/.buzz/models/` + /// Model storage under the selected build's nest. models_dir: PathBuf, stt: ModelSlot, tts: ModelSlot, } impl ModelManager { - /// Create a new `ModelManager` rooted at `~/.buzz/models/`. + /// Create a new `ModelManager` rooted in the selected build's nest. /// - /// Returns `None` if the home directory cannot be resolved. + /// Returns `None` if the nest directory cannot be resolved. pub fn new() -> Option { - let models_dir = dirs::home_dir()?.join(".buzz").join("models"); + let models_dir = models_dir(crate::managed_agents::nest_dir()?); let manager = Self { models_dir, stt: ModelSlot::new(STT_MODEL_DIR_NAME, STT_EXPECTED_FILES, STT_MODEL_VERSION), diff --git a/desktop/src-tauri/src/huddle/models_tests.rs b/desktop/src-tauri/src/huddle/models_tests.rs index 699ffbe459f..5f70b1f3f3a 100644 --- a/desktop/src-tauri/src/huddle/models_tests.rs +++ b/desktop/src-tauri/src/huddle/models_tests.rs @@ -1,5 +1,18 @@ use super::*; +#[test] +fn voice_models_follow_the_selected_build_nest() { + let home = PathBuf::from("/Users/example"); + for nest_name in [ + ".buzz", + ".buzz-demo-workstream-board", + ".buzz-demo-second-demo", + ] { + let nest = home.join(nest_name); + assert_eq!(models_dir(nest.clone()), nest.join("models")); + } +} + fn create_ready_model_dir(root: &Path) -> PathBuf { let model_dir = root.join(TTS_MODEL_DIR_NAME); std::fs::create_dir_all(&model_dir).expect("create model dir"); diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 5caa8e3a310..9b105e94d4f 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -129,10 +129,9 @@ pub fn run_boot_migrations_after_reset(app: &tauri::AppHandle) { } fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { - // Initialize the process-lifetime nest directory before any filesystem - // operation that calls nest_dir(). The discriminator matches the existing - // pattern used by reconcile_target_dir: dev instances have an app-data-dir - // name starting with CANONICAL_DEV_IDENTIFIER. + // Initialize the process-lifetime nest directory before filesystem access + // that calls nest_dir(). The discriminator matches reconcile_target_dir: + // dev instances have an app-data-dir name starting with CANONICAL_DEV_IDENTIFIER. let is_dev = if let Ok(data_dir) = app.path().app_data_dir() { let dev = data_dir .file_name() @@ -144,17 +143,18 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { false }; - // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev BEFORE - // control returns to lib.rs where resolve_repos_at_boot() reads it. This - // ensures the dev nest boots with the correct workspace on its first launch, - // matching what the prod nest had configured. Skip-if-dest-exists so it is - // idempotent and never clobbers a value the dev nest already set explicitly. - // Uses the composed helper so gate + migration share the tested code path. + // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev before + // resolve_repos_at_boot() reads it. Skip-if-dest-exists so it is idempotent + // and never clobbers a value the dev nest already set explicitly. + // The composed helper keeps gate + migration on the tested code path. if let (Some(home), Some(dev_nest)) = (dirs::home_dir(), crate::managed_agents::nest_dir()) { maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest); } - if !crate::build_identity::is_demo_build() { migrate_legacy_app_data_dir(app); sync_shared_agent_data(app); } + if !crate::build_identity::is_demo_build() { + migrate_legacy_app_data_dir(app); + sync_shared_agent_data(app); + } // Dev-build-only: copy any agent keys that exist in the production // keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev") // so existing agents don't lose their keys after the service-name split. From a9d74a1bbf06c2f59020d2f9b40a2b3e7b14b041 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 25 Aug 2026 12:26:31 -0400 Subject: [PATCH 10/17] fix(desktop): remove stale nest constant The named build identity now owns dev nest derivation, leaving the old constant unused under clippy. Co-authored-by: Larry Signed-off-by: Logan Johnson --- desktop/src-tauri/src/managed_agents/nest.rs | 6 ------ desktop/src-tauri/src/managed_agents/nest/tests.rs | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 69f0edcd9f0..46f36212cea 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -63,12 +63,6 @@ const CANONICAL_SKILL_DIR: &str = ".agents/skills/buzz-cli"; /// Nest directory name for production builds. const NEST_DIR_PROD: &str = ".buzz"; -/// Nest directory name for dev builds. Dev builds (those whose Tauri app-data -/// directory name starts with `"xyz.block.buzz.app.dev"`) use a separate nest -/// so that the DMG and dev-build instances don't clobber each other's -/// `.repos-dir` dotfile and `REPOS` symlink. -const NEST_DIR_DEV: &str = ".buzz-dev"; - /// Process-lifetime nest directory. Initialized once at startup via /// [`init_nest_dir`] before any call to [`nest_dir`]. /// diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 9aa1eeb0985..fd0f0ce7685 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -7,7 +7,7 @@ fn nest_dir_is_under_home() { // whether init_nest_dir was called before this test ran. let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or(""); assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, + name == NEST_DIR_PROD || name == crate::build_identity::nest_name(true), "nest_dir must end with .buzz or .buzz-dev, got {dir:?}" ); } @@ -23,7 +23,7 @@ fn init_nest_dir_prod_sets_buzz() { if let Some(d) = dir { let name = d.file_name().and_then(|n| n.to_str()).unwrap_or(""); assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, + name == NEST_DIR_PROD || name == crate::build_identity::nest_name(true), "nest_dir suffix must be .buzz or .buzz-dev, got {d:?}" ); } From 952e0faf5c1217c3e9e4b33e3fe19b80e33cec3f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 27 Aug 2026 17:56:22 -0400 Subject: [PATCH 11/17] fix(desktop): isolate reusable demo builds Give every invocation an independent 64-bit identity, route demo agent config into its build-owned root, scope reset to that root, and forward only the active build deep-link scheme. Co-authored-by: Larry Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- Justfile | 3 +- crates/buzz-agent/src/auth.rs | 17 ++-- desktop/scripts/demo-build-config.mjs | 19 +++- desktop/scripts/demo-build-config.test.mjs | 64 +++++++++++-- desktop/src-tauri/src/build_identity.rs | 59 ++++++++++++ desktop/src-tauri/src/lib.rs | 2 +- .../src/managed_agents/reserved_env_keys.rs | 3 + .../src-tauri/src/managed_agents/runtime.rs | 2 +- desktop/src-tauri/src/reset.rs | 91 ++++++++++++++++++- 9 files changed, 234 insertions(+), 26 deletions(-) diff --git a/Justfile b/Justfile index c60e8bce7cd..63b7888d435 100644 --- a/Justfile +++ b/Justfile @@ -284,7 +284,8 @@ desktop-demo-build demo_name target="aarch64-apple-darwin": [[ "$(uname -s)" == "Darwin" && "$TARGET" == *-apple-darwin ]] || { echo "Demo DMGs require a macOS Apple target" >&2; exit 2; } CONFIG_PATH="$(mktemp "${TMPDIR:-/tmp}/buzz-demo-config.XXXXXX.json")" trap 'rm -f "$CONFIG_PATH"' EXIT - DEMO_CONFIG="$(node desktop/scripts/demo-build-config.mjs "{{demo_name}}" "$CONFIG_PATH")" + DEMO_BUILD_ID="$(node -e 'console.log(require("node:crypto").randomBytes(8).toString("hex"))')" + DEMO_CONFIG="$(node desktop/scripts/demo-build-config.mjs {{quote(demo_name)}} "$CONFIG_PATH" "$DEMO_BUILD_ID")" read_config() { node -e 'console.log(JSON.parse(process.argv[1])[process.argv[2]])' "$DEMO_CONFIG" "$1"; } PRODUCT_NAME="$(read_config productName)" DMG_VOLUME_NAME="$(read_config dmgVolumeName)" diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index a78a499bdd1..057f3c4937c 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -93,8 +93,9 @@ impl TokenSource for StaticTokenSource { /// /// The `discovery_url` must return a JSON document with at least /// `authorization_endpoint` and `token_endpoint` (RFC 8414). The -/// `cache_namespace` is the directory under `~/.config/buzz-agent/oauth/` -/// the token JSON lives in — separates providers' caches cleanly. +/// `cache_namespace` is the directory under the platform config directory's +/// `buzz-agent/oauth/` root where the token JSON lives — separates providers' +/// caches cleanly. #[derive(Debug, Clone)] pub struct PkceOAuthConfig { pub discovery_url: String, @@ -102,7 +103,7 @@ pub struct PkceOAuthConfig { pub scopes: Vec, pub cache_namespace: String, /// When `Some`, the engine writes tokens here instead of - /// `~/.config/buzz-agent/oauth//`. Production code + /// `/buzz-agent/oauth//`. Production code /// leaves this `None`. Integration tests use it to avoid stomping on /// a shared `$HOME` when running in parallel. pub cache_dir_override: Option, @@ -455,9 +456,8 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { let dir = match &cfg.cache_dir_override { Some(p) => p.join(&cfg.cache_namespace), - None => dirs::home_dir() - .ok_or_else(|| AgentError::Llm("oauth cache: home directory not found".into()))? - .join(".config") + None => dirs::config_dir() + .ok_or_else(|| AgentError::Llm("oauth cache: config directory not found".into()))? .join("buzz-agent") .join("oauth") .join(&cfg.cache_namespace), @@ -862,7 +862,7 @@ mod tests { } #[test] - fn cache_path_uses_platform_home_directory() { + fn cache_path_uses_platform_config_directory() { let cfg = PkceOAuthConfig { discovery_url: "https://example.com/.well-known".into(), client_id: "abc".into(), @@ -871,9 +871,8 @@ mod tests { cache_dir_override: None, }; let p = cache_path_for(&cfg).unwrap(); - let expected_dir = dirs::home_dir() + let expected_dir = dirs::config_dir() .unwrap() - .join(".config") .join("buzz-agent") .join("oauth") .join("demo"); diff --git a/desktop/scripts/demo-build-config.mjs b/desktop/scripts/demo-build-config.mjs index 8cfd1208283..f3bc6f6c3f6 100644 --- a/desktop/scripts/demo-build-config.mjs +++ b/desktop/scripts/demo-build-config.mjs @@ -1,3 +1,4 @@ +import { randomBytes } from "node:crypto"; import { writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; @@ -13,7 +14,10 @@ export const productionBuildIdentity = Object.freeze({ cliName: "buzz", }); -export function demoBuildConfig(rawName) { +export function demoBuildConfig( + rawName, + buildId = randomBytes(8).toString("hex"), +) { if (typeof rawName !== "string") throw new Error("Demo name must be text"); const name = rawName.trim().replace(/\s+/g, " "); if (!name) throw new Error("Demo name must not be empty"); @@ -28,10 +32,17 @@ export function demoBuildConfig(rawName) { ); } - const slug = name + if (!/^[a-f0-9]{16}$/.test(buildId)) { + throw new Error( + "Demo build ID must be sixteen lowercase hexadecimal characters", + ); + } + + const readableSlug = name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); + const slug = `${readableSlug}-${buildId}`; const productName = `Buzz ${name}`; return { name, @@ -58,7 +69,7 @@ if ( process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href ) { - const [name, outputPath] = process.argv.slice(2); + const [name, outputPath, buildId] = process.argv.slice(2); if (!outputPath) { console.error( "Usage: demo-build-config.mjs ", @@ -66,7 +77,7 @@ if ( process.exit(2); } try { - const config = demoBuildConfig(name); + const config = demoBuildConfig(name, buildId); writeFileSync( outputPath, `${JSON.stringify(config.tauriConfig, null, 2)}\n`, diff --git a/desktop/scripts/demo-build-config.test.mjs b/desktop/scripts/demo-build-config.test.mjs index 7f47106be04..e451cff6f5e 100644 --- a/desktop/scripts/demo-build-config.test.mjs +++ b/desktop/scripts/demo-build-config.test.mjs @@ -38,10 +38,16 @@ test("production identity remains unchanged", () => { }); test("two demo names produce complete, distinct identities", () => { - const board = demoBuildConfig("Workstream Board"); - const interests = demoBuildConfig("Interests Demo"); - assert.deepEqual(board, expected("Workstream Board", "workstream-board")); - assert.deepEqual(interests, expected("Interests Demo", "interests-demo")); + const board = demoBuildConfig("Workstream Board", "27a4294c27a4294c"); + const interests = demoBuildConfig("Interests Demo", "deb5339adeb5339a"); + assert.deepEqual( + board, + expected("Workstream Board", "workstream-board-27a4294c27a4294c"), + ); + assert.deepEqual( + interests, + expected("Interests Demo", "interests-demo-deb5339adeb5339a"), + ); for (const key of [ "productName", "dmgVolumeName", @@ -58,10 +64,54 @@ test("two demo names produce complete, distinct identities", () => { } }); +test("normalized spelling aliases retain distinct runtime identities", () => { + for (const [leftName, rightName] of [ + ["A B", "A-B"], + ["Demo", "demo"], + ["Workstream Board", "WORKSTREAM BOARD"], + ]) { + const left = demoBuildConfig(leftName, "1111111111111111"); + const right = demoBuildConfig(rightName, "2222222222222222"); + assert.notEqual(left.slug, right.slug); + for (const key of [ + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual( + left[key], + right[key], + `${leftName}/${rightName}: ${key}`, + ); + } + } +}); + +test("the same display name gets a distinct identity for each build", () => { + const first = demoBuildConfig("Demo", "1111111111111111"); + const second = demoBuildConfig("Demo", "2222222222222222"); + assert.equal(first.productName, second.productName); + assert.equal(first.dmgFileStem, second.dmgFileStem); + for (const key of [ + "slug", + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual(first[key], second[key], key); + } +}); + test("whitespace normalization preserves deterministic identity", () => { assert.deepEqual( - demoBuildConfig(" Workstream Board "), - demoBuildConfig("Workstream Board"), + demoBuildConfig(" Workstream Board ", "27a4294c27a4294c"), + demoBuildConfig("Workstream Board", "27a4294c27a4294c"), ); }); @@ -74,5 +124,5 @@ for (const name of [ "x".repeat(49), ]) { test(`rejects unusable name ${JSON.stringify(name)}`, () => - assert.throws(() => demoBuildConfig(name))); + assert.throws(() => demoBuildConfig(name, "1234567812345678"))); } diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs index 468c1d7cd6a..7222e0fe026 100644 --- a/desktop/src-tauri/src/build_identity.rs +++ b/desktop/src-tauri/src/build_identity.rs @@ -14,12 +14,42 @@ pub(crate) fn is_demo_build() -> bool { demo_slug().is_some() } +pub(crate) fn demo_config_home() -> Option { + demo_config_home_for(demo_slug(), dirs::config_dir()) +} + +/// Keep child config caches inside this demo build's identity. In particular, +/// bundled buzz-agent OAuth tokens must not read or write production's root. +pub(crate) fn apply_demo_config_home(command: &mut std::process::Command) { + if let Some(config_home) = demo_config_home() { + command.env("XDG_CONFIG_HOME", config_home); + } +} + +fn demo_config_home_for( + demo_slug: Option<&str>, + config_dir: Option, +) -> Option { + demo_slug.zip(config_dir) + .map(|(slug, dir)| dir.join(format!("buzz-demo-{slug}"))) +} + pub(crate) fn deep_link_scheme() -> Cow<'static, str> { demo_slug() .map(|slug| Cow::Owned(format!("buzz-demo-{slug}"))) .unwrap_or(Cow::Borrowed("buzz")) } +pub(crate) fn is_deep_link_for_build(value: &str) -> bool { + is_deep_link_for_scheme(value, deep_link_scheme().as_ref()) +} + +fn is_deep_link_for_scheme(value: &str, scheme: &str) -> bool { + value + .strip_prefix(scheme) + .is_some_and(|suffix| suffix.starts_with("://")) +} + pub(crate) fn keyring_service() -> Cow<'static, str> { demo_slug() .map(|slug| Cow::Owned(format!("buzz-desktop-demo.{slug}"))) @@ -64,6 +94,35 @@ mod tests { } } + #[test] + fn demo_agent_config_home_is_build_scoped() { + let base = std::path::PathBuf::from("/config"); + assert_eq!(demo_config_home_for(None, Some(base.clone())), None); + assert_eq!( + demo_config_home_for(Some("board-1234567812345678"), Some(base)), + Some(std::path::PathBuf::from( + "/config/buzz-demo-board-1234567812345678" + )) + ); + } + + #[test] + fn duplicate_instance_links_follow_the_build_scheme() { + assert!(is_deep_link_for_scheme("buzz://message?id=1", "buzz")); + assert!(!is_deep_link_for_scheme( + "buzz-demo-board-1234567812345678://message?id=1", + "buzz" + )); + assert!(is_deep_link_for_scheme( + "buzz-demo-board-1234567812345678://message?id=1", + "buzz-demo-board-1234567812345678" + )); + assert!(!is_deep_link_for_scheme( + "buzz://message?id=1", + "buzz-demo-board-1234567812345678" + )); + } + #[test] fn production_and_named_demo_nests_are_distinct() { assert_eq!(nest_name_for(None, false), ".buzz"); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 95a74a6ca37..cd47c040588 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -127,7 +127,7 @@ pub fn run() { } // Forward any deep link URLs from the duplicate launch. for arg in &argv { - if arg.starts_with("buzz://") { + if crate::build_identity::is_deep_link_for_build(arg) { handle_deep_link_url(app, arg); } } diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index afaaa2b4eb3..37c551798ab 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -67,6 +67,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // ambient env var must not be able to forge setup mode (NotReady) on a // Ready agent or suppress it (empty/stale payload) on a NotReady one. "BUZZ_ACP_SETUP_PAYLOAD", + // Demo-build identity owns the child config root. A user override could + // silently reconnect a demo harness to production OAuth/config state. + "XDG_CONFIG_HOME", // Desktop ownership markers: these brand every spawned harness with the // launching Desktop instance. A user-supplied override would let a // definition masquerade as a different instance or fake the nonce used diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0ce5ca7b219..d9591814d72 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -501,7 +501,6 @@ pub fn spawn_agent_child( // The caller supplies the explicit canonical pair relay. This is the only // relay this child may connect to, regardless of the record/workspace default. let effective_relay_url = runtime_key.relay_url.clone(); - // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink // - nvm-managed node/npm (nvm initializes only in interactive shells) @@ -536,6 +535,7 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_IDLE_POOL_SLEEP", idle_pool_sleep_env(lazy)); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); + crate::build_identity::apply_demo_config_home(&mut command); match &resolved_mcp_command { Some(mcp_cmd) => { command.env("BUZZ_ACP_MCP_COMMAND", mcp_cmd); diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index 18ddd80eb8d..687989af682 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -104,6 +104,11 @@ pub(crate) struct ResetContext<'a> { pub keychain: &'a dyn ResetKeychain, pub home_dir: Option, pub is_dev: bool, + /// Build-owned config root for demos. Production leaves this unset. + pub demo_config_dir: Option, + /// Demo builds own only build-scoped state and must never delete shared + /// production or legacy agent roots. + pub is_demo: bool, } /// Entry point called from `lib.rs` setup (before migrations). @@ -133,6 +138,8 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { keychain: &store, home_dir, is_dev, + demo_config_dir: crate::build_identity::demo_config_home(), + is_demo: crate::build_identity::is_demo_build(), }; run_boot_reset_with_keychain(ctx) @@ -211,13 +218,21 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom None }; - // ── Step 3: remove nest, ~/.sprout, ~/.config/buzz-agent, CLI symlink ──── + // ── Step 3: remove build-owned nest and CLI symlink ────────────────────── + // Production and dev preserve their existing legacy/global cleanup. A demo + // never owns these shared roots, so signing out of one must leave them + // available to production and every other demo. if let Some(ref nest) = ctx.nest_dir { let _ = std::fs::remove_dir_all(nest); } + if let Some(ref demo_config_dir) = ctx.demo_config_dir { + let _ = std::fs::remove_dir_all(demo_config_dir); + } if let Some(ref home) = ctx.home_dir { - let _ = std::fs::remove_dir_all(home.join(".sprout")); - let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); + if !ctx.is_demo { + let _ = std::fs::remove_dir_all(home.join(".sprout")); + let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); + } let link_name = crate::managed_agents::cli_link_name(ctx.is_dev); let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name)); } @@ -408,6 +423,8 @@ mod tests { keychain, home_dir: None, // skip nest/sprout/CLI ops in unit tests is_dev, + demo_config_dir: None, + is_demo: false, } } @@ -451,6 +468,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -584,6 +603,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: true, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -620,6 +641,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -653,6 +676,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -736,6 +761,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: true, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.completed, "reset must complete"); @@ -807,6 +834,60 @@ mod tests { // ── Test 13: keychain-fail restores all dirs, retry cleans trash ────── + #[test] + fn test_demo_reset_preserves_shared_and_other_build_state() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let app_data = tmp + .path() + .join("Application Support") + .join("xyz.block.buzz.app.demo.current-1234567812345678"); + let demo_nest = home.join(".buzz-demo-current-1234567812345678"); + let prod_nest = home.join(".buzz"); + let other_demo_nest = home.join(".buzz-demo-other-8765432187654321"); + let shared_sprout = home.join(".sprout"); + let shared_agent = home.join(".config").join("buzz-agent"); + let demo_config = home + .join(".config") + .join("buzz-demo-current-1234567812345678"); + + for path in [ + &app_data, + &demo_nest, + &prod_nest, + &other_demo_nest, + &shared_sprout, + &shared_agent, + &demo_config, + ] { + std::fs::create_dir_all(path).unwrap(); + } + write_sentinel(&app_data).unwrap(); + + let kc = FakeKeychain::ok(); + let ctx = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: None, + nest_dir: Some(demo_nest.clone()), + keychain: &kc, + home_dir: Some(home), + is_dev: false, + demo_config_dir: Some(demo_config.clone()), + is_demo: true, + }; + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.completed, "demo reset must complete"); + assert!(!app_data.exists(), "demo app data must be wiped"); + assert!(!demo_nest.exists(), "selected demo nest must be wiped"); + assert!(!demo_config.exists(), "selected demo agent config must be wiped"); + assert!(prod_nest.exists(), "production nest must survive"); + assert!(other_demo_nest.exists(), "another demo nest must survive"); + assert!(shared_sprout.exists(), "shared legacy state must survive"); + assert!(shared_agent.exists(), "shared agent auth state must survive"); + } + #[test] fn test_keychain_fail_restores_all_then_retry_cleans() { let tmp = TempDir::new().unwrap(); @@ -830,6 +911,8 @@ mod tests { keychain: &kc1, home_dir: Some(tmp.path().to_path_buf()), is_dev: false, + demo_config_dir: None, + is_demo: false, }; let first = run_boot_reset_with_keychain(ctx1); assert!(first.failed, "first attempt must fail"); @@ -853,6 +936,8 @@ mod tests { keychain: &kc2, home_dir: Some(tmp.path().to_path_buf()), is_dev: false, + demo_config_dir: None, + is_demo: false, }; let second = run_boot_reset_with_keychain(ctx2); assert!(second.completed, "second attempt must complete"); From 36c2ea7a6500355680f07f8b7aa7b570b3d7c9bc Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 25 Aug 2026 14:09:26 -0400 Subject: [PATCH 12/17] style(desktop): format demo isolation changes Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/build_identity.rs | 3 ++- desktop/src-tauri/src/reset.rs | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs index 7222e0fe026..f37dd8dfa0e 100644 --- a/desktop/src-tauri/src/build_identity.rs +++ b/desktop/src-tauri/src/build_identity.rs @@ -30,7 +30,8 @@ fn demo_config_home_for( demo_slug: Option<&str>, config_dir: Option, ) -> Option { - demo_slug.zip(config_dir) + demo_slug + .zip(config_dir) .map(|(slug, dir)| dir.join(format!("buzz-demo-{slug}"))) } diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index 687989af682..688b65f01ad 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -881,11 +881,17 @@ mod tests { assert!(outcome.completed, "demo reset must complete"); assert!(!app_data.exists(), "demo app data must be wiped"); assert!(!demo_nest.exists(), "selected demo nest must be wiped"); - assert!(!demo_config.exists(), "selected demo agent config must be wiped"); + assert!( + !demo_config.exists(), + "selected demo agent config must be wiped" + ); assert!(prod_nest.exists(), "production nest must survive"); assert!(other_demo_nest.exists(), "another demo nest must survive"); assert!(shared_sprout.exists(), "shared legacy state must survive"); - assert!(shared_agent.exists(), "shared agent auth state must survive"); + assert!( + shared_agent.exists(), + "shared agent auth state must survive" + ); } #[test] From 9d63d86a43424b96bf412305e732beec554d1514 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 25 Aug 2026 14:34:58 -0400 Subject: [PATCH 13/17] fix(desktop): isolate demo oauth on macOS Use an explicit Buzz-owned config root for child and in-process Databricks auth while preserving production's historical cache path. Co-authored-by: Larry Signed-off-by: Logan Johnson --- crates/buzz-agent/src/auth.rs | 68 +- crates/buzz-agent/src/catalog.rs | 1543 ++--------------- crates/buzz-agent/src/lib.rs | 22 +- crates/buzz-agent/src/llm.rs | 9 +- crates/buzz-agent/src/mcp.rs | 3 + desktop/src-tauri/src/build_identity.rs | 30 +- .../src/commands/agent_model_process.rs | 2 + .../src/commands/agent_models_databricks.rs | 41 +- .../src/managed_agents/reserved_env_keys.rs | 6 +- .../src-tauri/src/managed_agents/runtime.rs | 2 +- desktop/src-tauri/src/reset.rs | 18 +- 11 files changed, 346 insertions(+), 1398 deletions(-) diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index 057f3c4937c..7ebabccbbbd 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -445,6 +445,29 @@ fn is_expired(t: &CachedToken) -> bool { now + TOKEN_REFRESH_LEEWAY.as_secs() >= exp } +const BUZZ_AGENT_CONFIG_DIR_ENV: &str = "BUZZ_AGENT_CONFIG_DIR"; + +fn oauth_cache_root_for( + config_override: Option, + home_dir: Option, +) -> Result { + if let Some(root) = config_override { + return Ok(root.join("buzz-agent").join("oauth")); + } + Ok(home_dir + .ok_or_else(|| AgentError::Llm("oauth cache: home directory not found".into()))? + .join(".config") + .join("buzz-agent") + .join("oauth")) +} + +fn default_oauth_cache_root() -> Result { + oauth_cache_root_for( + std::env::var_os(BUZZ_AGENT_CONFIG_DIR_ENV).map(PathBuf::from), + dirs::home_dir(), + ) +} + fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { let mut h = sha2::Sha256::new(); h.update(cfg.discovery_url.as_bytes()); @@ -456,11 +479,7 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { let dir = match &cfg.cache_dir_override { Some(p) => p.join(&cfg.cache_namespace), - None => dirs::config_dir() - .ok_or_else(|| AgentError::Llm("oauth cache: config directory not found".into()))? - .join("buzz-agent") - .join("oauth") - .join(&cfg.cache_namespace), + None => default_oauth_cache_root()?.join(&cfg.cache_namespace), }; Ok(dir.join(format!("{hash}.json"))) } @@ -862,7 +881,41 @@ mod tests { } #[test] - fn cache_path_uses_platform_config_directory() { + fn production_and_demo_oauth_roots_are_concrete_and_distinct() { + let home = PathBuf::from("/Users/demo"); + let production = oauth_cache_root_for(None, Some(home.clone())).unwrap(); + let first_demo_config = home + .join("Library/Application Support") + .join("buzz-demo-board-1234567812345678"); + let second_demo_config = home + .join("Library/Application Support") + .join("buzz-demo-board-8765432187654321"); + let first_demo = oauth_cache_root_for(Some(first_demo_config), Some(home.clone())).unwrap(); + let second_demo = oauth_cache_root_for(Some(second_demo_config), Some(home)).unwrap(); + + assert_eq!( + production, + PathBuf::from("/Users/demo/.config/buzz-agent/oauth") + ); + assert_eq!( + first_demo, + PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678/buzz-agent/oauth" + ) + ); + assert_eq!( + second_demo, + PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-8765432187654321/buzz-agent/oauth" + ) + ); + assert_ne!(production, first_demo); + assert_ne!(production, second_demo); + assert_ne!(first_demo, second_demo); + } + + #[test] + fn cache_path_preserves_production_home_config_directory() { let cfg = PkceOAuthConfig { discovery_url: "https://example.com/.well-known".into(), client_id: "abc".into(), @@ -871,8 +924,9 @@ mod tests { cache_dir_override: None, }; let p = cache_path_for(&cfg).unwrap(); - let expected_dir = dirs::config_dir() + let expected_dir = dirs::home_dir() .unwrap() + .join(".config") .join("buzz-agent") .join("oauth") .join("demo"); diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 82f3b086cd6..3be106fa8ef 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -12,23 +12,24 @@ //! This helper never opens a browser. Callers choose whether to reject, degrade, //! or start a separate interactive authentication flow. -use std::{collections::HashSet, sync::Arc, time::Duration}; +use std::path::Path; +use std::sync::Arc; use reqwest::Client; -use serde_json::Value; use crate::{ auth::TokenSource, - config::{Config, DatabricksModelFilter, Provider}, + config::{Config, Provider}, llm::build_token_source, types::AgentError, }; -/// A discovered model entry: `id` is the picker value (the raw endpoint id or -/// Unity Catalog model-service FQN, and the wire/config value), `name` is the -/// display label. Databricks catalog APIs do not provide a consistently useful -/// picker label, so discovery curates names from the capability manifest when -/// an exact known id exists and otherwise uses the raw id. +/// A discovered model entry: `id` is the picker value (the raw endpoint id, and +/// the wire/config value), `name` is the display label. The Databricks API has +/// no display-name field, so discovery curates `name` from the capability +/// manifest ([`model_capabilities::databricks_registry_label`]) — a known id +/// yields its curated label (e.g. `GPT-5.5`), an unknown id falls back to the +/// raw id. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelEntry { pub id: String, @@ -36,61 +37,20 @@ pub struct ModelEntry { } const AUTHENTICATED_EMPTY_CATALOG_SUFFIX: &str = " (default catalog)"; -const MAX_CATALOG_PAGES: usize = 20; -const MAX_CATALOG_ERROR_BODY_BYTES: usize = 4 * 1024; -const MAX_CATALOG_RESPONSE_BODY_BYTES: usize = 2 * 1024 * 1024; -const CATALOG_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); -const CATALOG_MAX_RETRIES: usize = 3; -const CATALOG_RETRY_BACKOFF: Duration = Duration::from_millis(100); - -#[derive(Clone, Copy)] -struct CatalogRequestPolicy { - timeout: Duration, - max_retries: usize, - retry_backoff: Duration, -} - -const DEFAULT_CATALOG_REQUEST_POLICY: CatalogRequestPolicy = CatalogRequestPolicy { - timeout: CATALOG_REQUEST_TIMEOUT, - max_retries: CATALOG_MAX_RETRIES, - retry_backoff: CATALOG_RETRY_BACKOFF, -}; -const WORKSPACE_CATALOG_QUERY: &str = "?page_size=100"; -const UNITY_CATALOG_QUERY: &str = "?page_size=100&view=FULL"; -type CatalogPage = Result<(Vec, Option), AgentError>; - -#[derive(Clone, Copy)] -struct CatalogDescriptor { - name: &'static str, - path: &'static str, - initial_query: &'static str, - parse_page: fn(&Value) -> CatalogPage, -} - -const WORKSPACE_CATALOG_DESCRIPTOR: CatalogDescriptor = CatalogDescriptor { - name: "Databricks workspace endpoint catalog", - path: "/api/ai-gateway/v2/endpoints", - initial_query: WORKSPACE_CATALOG_QUERY, - parse_page: parse_v2_endpoints_page, -}; -const UNITY_CATALOG_DESCRIPTOR: CatalogDescriptor = CatalogDescriptor { - name: "Databricks Unity Catalog model-service catalog", - path: "/api/2.1/unity-catalog/model-services", - initial_query: UNITY_CATALOG_QUERY, - parse_page: parse_uc_model_services_page, -}; -/// Curated display label for a discovered Databricks endpoint or model-service -/// id. Unknown ids deliberately pass through unchanged. +/// Curated display label for a discovered Databricks endpoint id: the manifest's +/// exact-record label when one exists, otherwise the raw id. The API returns no +/// display name, so this is the single seam that turns a raw endpoint id into a +/// human label for the picker. fn curated_model_name(id: &str) -> String { crate::model_capabilities::databricks_registry_label(id) .unwrap_or(id) .to_string() } -/// Fallback catalog used only when both authenticated Databricks v2 catalogs -/// successfully respond with no entries and no visibility filter is active. -/// The known-model ids come from the manifest, the single runtime source. +/// Fallback catalog used only when an authenticated `api/ai-gateway/v2/endpoints` +/// call succeeds with an empty list. The known-model ids come from the manifest +/// ([`model_capabilities::databricks_v2_known_models`]), the single runtime source. fn authenticated_empty_v2_catalog() -> Vec { crate::model_capabilities::databricks_v2_known_models() .iter() @@ -104,16 +64,27 @@ fn authenticated_empty_v2_catalog() -> Vec { .collect() } -/// Heuristic chat-capability filter for v2 workspace endpoints. +/// Heuristic: `true` when a v2 AI Gateway endpoint name looks like it serves +/// chat/completions traffic. /// -/// The v2 catalog omits task metadata. Known embedding endpoint families cannot -/// answer chat-completions requests, so do not offer them as selectable models. -/// Unknown names remain visible; this filter is intentionally narrow. +/// The v1 `serving-endpoints` payload carries `task`, so [`parse_v1_endpoints`] +/// can filter on it directly. The v2 `ai-gateway/v2/endpoints` payload carries +/// no task or readiness field at all, so the only signal available here is the +/// endpoint name. Embedding endpoints are the one family that reliably cannot +/// serve a chat request — they reject it with +/// `API type 'mlflow/v1/chat/completions' is not supported by ''` — so +/// they are dropped rather than offered as selectable models. +/// +/// Deliberately narrow: image-capable endpoints (e.g. +/// `databricks-gemini-3-pro-image`) do answer chat requests, so they stay. Any +/// name this heuristic does not recognise is kept — preferring to include over +/// silently dropping, matching [`parse_v1_endpoints`]. pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { let lower = name.to_ascii_lowercase(); if lower.contains("embedding") { return false; } + // Segment match so `bge`/`gte` cannot fire on a substring of a longer word. !lower .split('-') .any(|segment| matches!(segment, "bge" | "gte")) @@ -121,19 +92,33 @@ pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { /// Discover available models for a Databricks provider. /// -/// Returns an empty vector when an authenticated catalog is valid but no -/// visible entries remain after filtering. Returns `Err(AgentError::LlmAuth)` -/// when no token is available (no static token, no PKCE cache). The helper -/// itself never starts interactive authentication. -/// -/// For v2, the known-model fallback is used only when both catalog requests -/// succeed empty and no filter is active. A filter is applied to v1 results -/// after its existing endpoint capability filtering. +/// Returns a non-empty `Vec` on success. Returns +/// `Err(AgentError::LlmAuth)` when no token is available (no static token, +/// no PKCE cache). The helper itself never starts interactive authentication. /// /// # Panics /// Never panics. pub async fn discover_databricks_models(cfg: &Config) -> Result, AgentError> { - discover_databricks_models_with_token_source(cfg, build_token_source(cfg)?).await + discover_databricks_models_with_cache_dir(cfg, None).await +} + +/// Discover Databricks models while storing PKCE credentials under an explicit +/// cache root. `None` preserves buzz-agent's production cache location. +pub async fn discover_databricks_models_with_cache_dir( + cfg: &Config, + cache_dir: Option<&Path>, +) -> Result, AgentError> { + let token_source = if matches!(cfg.provider, Provider::Databricks | Provider::DatabricksV2) + && cfg.api_key.is_empty() + { + crate::auth::PkceOAuthTokenSource::new(crate::llm::databricks_pkce_config( + &cfg.base_url, + cache_dir.map(Path::to_path_buf), + ))? + } else { + build_token_source(cfg)? + }; + discover_databricks_models_with_token_source(cfg, token_source).await } async fn discover_databricks_models_with_token_source( @@ -147,19 +132,8 @@ async fn discover_databricks_models_with_token_source( loop { let result = match cfg.provider { - Provider::Databricks => fetch_v1_models(&http, host, &bearer) - .await - .map(|models| apply_model_filter(models, cfg.databricks_model_filter.as_ref())), - Provider::DatabricksV2 => { - fetch_v2_models( - &http, - host, - &bearer, - cfg.databricks_model_filter.as_ref(), - refreshed, - ) - .await - } + Provider::Databricks => fetch_v1_models(&http, host, &bearer).await, + Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await, _ => { return Err(AgentError::InvalidParams( "discover_databricks_models called for non-Databricks provider".into(), @@ -183,19 +157,6 @@ async fn discover_databricks_models_with_token_source( } } -fn apply_model_filter( - models: Vec, - filter: Option<&DatabricksModelFilter>, -) -> Vec { - match filter { - Some(filter) => models - .into_iter() - .filter(|model| filter.matches(&model.id)) - .collect(), - None => models, - } -} - // --------------------------------------------------------------------------- // v1 — api/2.0/serving-endpoints // --------------------------------------------------------------------------- @@ -206,14 +167,31 @@ async fn fetch_v1_models( bearer: &str, ) -> Result, AgentError> { let url = format!("{host}/api/2.0/serving-endpoints"); - let json = fetch_catalog_page( - http, - &url, - "Databricks serving-endpoints catalog", - bearer, - DEFAULT_CATALOG_REQUEST_POLICY, - ) - .await?; + let response = http + .get(&url) + .bearer_auth(bearer) + .send() + .await + .map_err(|e| AgentError::Llm(format!("Databricks model discovery request failed: {e}")))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + if status.as_u16() == 401 { + return Err(AgentError::LlmAuth(format!( + "Databricks model discovery HTTP {status}" + ))); + } + return Err(AgentError::Llm(format!( + "Databricks model discovery HTTP {status}: {body}" + ))); + } + + let json: serde_json::Value = response.json().await.map_err(|e| { + AgentError::Llm(format!( + "Databricks model discovery response parse failed: {e}" + )) + })?; parse_v1_endpoints(&json) } @@ -222,11 +200,11 @@ async fn fetch_v1_models( /// /// Filters to endpoints that are READY and serve an LLM chat/completions task. /// When `state.ready` or `task` is absent the endpoint is included — prefer -/// including over silently dropping, per the existing v1 contract. -pub(crate) fn parse_v1_endpoints(json: &Value) -> Result, AgentError> { +/// including over silently dropping, per spec. +pub(crate) fn parse_v1_endpoints(json: &serde_json::Value) -> Result, AgentError> { let endpoints = json .get("endpoints") - .and_then(Value::as_array) + .and_then(|v| v.as_array()) .ok_or_else(|| { AgentError::Llm( "Databricks model discovery: unexpected response (missing 'endpoints' array)" @@ -243,7 +221,7 @@ pub(crate) fn parse_v1_endpoints(json: &Value) -> Result, AgentE let state_ready = endpoint .get("state") .and_then(|s| s.get("ready")) - .and_then(Value::as_str) + .and_then(|r| r.as_str()) .map(|r| r == "READY") .unwrap_or(true); if !state_ready { @@ -253,7 +231,7 @@ pub(crate) fn parse_v1_endpoints(json: &Value) -> Result, AgentE // Require LLM chat or completions task when present. let task_ok = endpoint .get("task") - .and_then(Value::as_str) + .and_then(|t| t.as_str()) .map(|t| t == "llm/v1/chat" || t == "llm/v1/completions") .unwrap_or(true); if !task_ok { @@ -271,7 +249,7 @@ pub(crate) fn parse_v1_endpoints(json: &Value) -> Result, AgentE } // --------------------------------------------------------------------------- -// v2 — api/ai-gateway/v2/endpoints + Unity Catalog model-services +// v2 — api/ai-gateway/v2/endpoints (paginated) // --------------------------------------------------------------------------- /// Percent-encode a string for use as a URL query parameter value. @@ -287,484 +265,77 @@ fn percent_encode(s: &str) -> String { .collect() } -/// Fetch both Databricks v2 catalogs concurrently and merge them into the -/// selectable model list. One catalog may be unavailable; an empty result is -/// still authoritative and never falls through to the known-model fallback -/// when a visibility filter is active. async fn fetch_v2_models( http: &Client, host: &str, bearer: &str, - filter: Option<&DatabricksModelFilter>, - allow_partial_auth_failure: bool, -) -> Result, AgentError> { - fetch_v2_models_with_policy( - http, - host, - bearer, - filter, - allow_partial_auth_failure, - DEFAULT_CATALOG_REQUEST_POLICY, - ) - .await -} - -async fn fetch_v2_models_with_policy( - http: &Client, - host: &str, - bearer: &str, - filter: Option<&DatabricksModelFilter>, - allow_partial_auth_failure: bool, - policy: CatalogRequestPolicy, ) -> Result, AgentError> { - let workspace = - fetch_catalog_pages_with_policy(http, host, bearer, WORKSPACE_CATALOG_DESCRIPTOR, policy); - let unity_catalog = - fetch_catalog_pages_with_policy(http, host, bearer, UNITY_CATALOG_DESCRIPTOR, policy); - - let (workspace, unity_catalog) = tokio::join!(workspace, unity_catalog); - let (workspace, unity_catalog, both_succeeded) = match (workspace, unity_catalog) { - (Ok(workspace), Ok(unity_catalog)) => (workspace, unity_catalog, true), - (Ok(workspace), Err(error)) => { - if matches!(&error, AgentError::LlmAuth(_)) && !allow_partial_auth_failure { - return Err(error); - } - tracing::warn!( - catalog = "unity-catalog model-services", - error_kind = catalog_error_kind(&error), - "Databricks model discovery degraded: catalog unavailable" - ); - (workspace, Vec::new(), false) - } - (Err(error), Ok(unity_catalog)) => { - if matches!(&error, AgentError::LlmAuth(_)) && !allow_partial_auth_failure { - return Err(error); - } - tracing::warn!( - catalog = "workspace ai-gateway v2 endpoints", - error_kind = catalog_error_kind(&error), - "Databricks model discovery degraded: catalog unavailable" - ); - (Vec::new(), unity_catalog, false) - } - (Err(workspace_error), Err(unity_catalog_error)) => { - return Err(combined_catalog_error(workspace_error, unity_catalog_error)); - } - }; - - Ok(merge_v2_models( - workspace, - unity_catalog, - filter, - both_succeeded && filter.is_none(), - )) -} - -fn catalog_error_kind(error: &AgentError) -> &'static str { - match error { - AgentError::InvalidParams(_) => "invalid-params", - AgentError::Llm(_) => "llm", - AgentError::LlmAuth(_) => "auth", - AgentError::LlmModelNotFound(_) => "model-not-found", - AgentError::LlmContextExceeded(_) => "context-exceeded", - AgentError::UnsupportedImageInput(_) => "unsupported-image", - AgentError::Mcp(_) => "mcp", - AgentError::Cancelled => "cancelled", - } -} - -fn combined_catalog_error(workspace: AgentError, unity_catalog: AgentError) -> AgentError { - let auth_failure = matches!(&workspace, AgentError::LlmAuth(_)) - || matches!(&unity_catalog, AgentError::LlmAuth(_)); - let message = format!( - "Databricks v2 model discovery failed: workspace endpoint catalog: {workspace}; Unity Catalog model-service catalog: {unity_catalog}" - ); - if auth_failure { - AgentError::LlmAuth(message) - } else { - AgentError::Llm(message) - } -} - -fn merge_v2_models( - workspace: Vec, - mut unity_catalog: Vec, - filter: Option<&DatabricksModelFilter>, - allow_known_model_fallback: bool, -) -> Vec { - let mut seen_ids = HashSet::new(); - let mut merged = Vec::with_capacity(workspace.len() + unity_catalog.len()); - - // Workspace endpoints are ordered newest-first across all pages. - let mut workspace = workspace; - sort_v2_endpoints_newest_first(&mut workspace); - for endpoint in workspace { - if seen_ids.insert(endpoint.entry.id.clone()) { - merged.push(endpoint.entry); - } - } - - // UC has no user-facing recency contract. Sort by the raw FQN for stable - // picker order, then deduplicate only by raw selectable id. - unity_catalog.sort_unstable_by(|a, b| a.id.cmp(&b.id)); - for entry in unity_catalog { - if seen_ids.insert(entry.id.clone()) { - merged.push(entry); - } - } - - if merged.is_empty() && allow_known_model_fallback && filter.is_none() { - merged = authenticated_empty_v2_catalog(); - } - - apply_model_filter(merged, filter) -} - -async fn fetch_catalog_pages_with_policy( - http: &Client, - host: &str, - bearer: &str, - descriptor: CatalogDescriptor, - policy: CatalogRequestPolicy, -) -> Result, AgentError> { - let CatalogDescriptor { - name: catalog, - path, - initial_query, - parse_page, - } = descriptor; - let base_url = format!("{host}{path}"); - let mut all_items = Vec::new(); + let mut all_endpoints: Vec = Vec::new(); let mut page_token: Option = None; - let mut seen_tokens = HashSet::new(); + let base_url = format!("{host}/api/ai-gateway/v2/endpoints"); - for _page in 0..MAX_CATALOG_PAGES { + // Cap at 20 pages (2 000 endpoints) to bound execution time. + for _ in 0..20 { + // Build URL with query params manually — avoids requiring the `query` + // reqwest feature in buzz-agent's Cargo.toml. let url = match &page_token { - Some(token) => format!( - "{base_url}{initial_query}&page_token={}", - percent_encode(token) + Some(tok) => format!( + "{base_url}?page_size=100&page_token={}", + percent_encode(tok) ), - None => format!("{base_url}{initial_query}"), + None => format!("{base_url}?page_size=100"), }; - let json = fetch_catalog_page(http, &url, catalog, bearer, policy).await?; - let (items, next_token) = parse_page(&json) - .map_err(|error| catalog_context_error(catalog, error, "response parse failed"))?; - all_items.extend(items); - - match next_token { - None => return Ok(all_items), - Some(next_token) if seen_tokens.insert(next_token.clone()) => { - page_token = Some(next_token); - } - Some(next_token) => { - return Err(AgentError::Llm(format!( - "{catalog} pagination repeated page token {next_token:?}" + let response = http + .get(&url) + .bearer_auth(bearer) + .send() + .await + .map_err(|e| { + AgentError::Llm(format!("Databricks v2 model discovery request failed: {e}")) + })?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + if status.as_u16() == 401 { + return Err(AgentError::LlmAuth(format!( + "Databricks v2 model discovery HTTP {status}" ))); } + return Err(AgentError::Llm(format!( + "Databricks v2 model discovery HTTP {status}: {body}" + ))); } - } - - Err(AgentError::Llm(format!( - "{catalog} pagination exhausted after {MAX_CATALOG_PAGES} pages" - ))) -} -struct ReadResponseBody { - bytes: Vec, - truncated: bool, -} - -enum CatalogRequestError { - Auth, - Status { - status: reqwest::StatusCode, - body: String, - }, - Transport(reqwest::Error), - Body(reqwest::Error), - InvalidJson(serde_json::Error), - BodyTooLarge, -} - -async fn fetch_catalog_page( - http: &Client, - url: &str, - catalog: &str, - bearer: &str, - policy: CatalogRequestPolicy, -) -> Result { - let max_retries = policy.max_retries.max(1); - let error_body_limit = if bearer.len() > MAX_CATALOG_ERROR_BODY_BYTES { - 0 - } else { - MAX_CATALOG_ERROR_BODY_BYTES.saturating_add(bearer.len()) - }; - - for attempt in 0..max_retries { - let result = tokio::time::timeout(policy.timeout, async { - let response = http - .get(url) - .bearer_auth(bearer) - .send() - .await - .map_err(CatalogRequestError::Transport)?; - let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - // Preserve the auth contract: do not consume an auth-failure - // body because gateways may echo credential material. The - // bounded attempt ends at headers for this intentionally - // redacted branch; all other status/body paths below consume - // their response body inside the same deadline. - return Err(CatalogRequestError::Auth); - } - if !status.is_success() { - let mut response = response; - let body = read_catalog_error_body(&mut response, error_body_limit) - .await - .map_err(CatalogRequestError::Body)?; - return Err(CatalogRequestError::Status { status, body }); - } + let json: serde_json::Value = response.json().await.map_err(|e| { + AgentError::Llm(format!( + "Databricks v2 model discovery response parse failed: {e}" + )) + })?; - let mut response = response; - if response - .content_length() - .is_some_and(|length| length > MAX_CATALOG_RESPONSE_BODY_BYTES as u64) - { - return Err(CatalogRequestError::BodyTooLarge); - } - let body = read_response_body(&mut response, MAX_CATALOG_RESPONSE_BODY_BYTES) - .await - .map_err(CatalogRequestError::Body)?; - if body.truncated { - return Err(CatalogRequestError::BodyTooLarge); - } - serde_json::from_slice(&body.bytes).map_err(CatalogRequestError::InvalidJson) - }) - .await; + let (page_endpoints, next) = parse_v2_endpoints_page(&json)?; + all_endpoints.extend(page_endpoints); - match result { - Ok(Ok(json)) => return Ok(json), - Ok(Err(CatalogRequestError::Auth)) => { - return Err(AgentError::LlmAuth(format!("{catalog} HTTP 401"))); - } - Ok(Err(CatalogRequestError::Status { status, body })) => { - if (status.as_u16() == 499 || status.is_server_error()) - && retry_catalog_attempt( - catalog, - attempt, - max_retries, - policy.retry_backoff, - Some(status.as_u16()), - "transient status", - ) - .await - { - continue; - } - return Err(catalog_http_error_body(catalog, status, &body, bearer)); - } - Ok(Err(CatalogRequestError::Transport(error))) => { - if (error.is_timeout() || error.is_connect() || error.is_request()) - && retry_catalog_attempt( - catalog, - attempt, - max_retries, - policy.retry_backoff, - None, - "transport error", - ) - .await - { - continue; - } - return Err(AgentError::Llm(format!( - "{catalog} request failed: {error}" - ))); - } - Ok(Err(CatalogRequestError::Body(error))) => { - if retry_catalog_attempt( - catalog, - attempt, - max_retries, - policy.retry_backoff, - None, - "response body error", - ) - .await - { - continue; - } - return Err(AgentError::Llm(format!( - "{catalog} response body read failed: {error}" - ))); - } - Ok(Err(CatalogRequestError::InvalidJson(error))) => { - if retry_catalog_attempt( - catalog, - attempt, - max_retries, - policy.retry_backoff, - None, - "invalid JSON response", - ) - .await - { - continue; - } - return Err(AgentError::Llm(format!( - "{catalog} response parse failed: {error}" - ))); - } - Ok(Err(CatalogRequestError::BodyTooLarge)) => { - return Err(AgentError::Llm(format!( - "{catalog} response exceeded {MAX_CATALOG_RESPONSE_BODY_BYTES} bytes" - ))); - } - Err(_) => { - if retry_catalog_attempt( - catalog, - attempt, - max_retries, - policy.retry_backoff, - None, - "attempt timeout", - ) - .await - { - continue; - } - return Err(AgentError::Llm(format!( - "{catalog} request timed out after {:?}", - policy.timeout - ))); - } + match next { + Some(tok) if Some(&tok) != page_token.as_ref() => page_token = Some(tok), + _ => break, } } - Err(AgentError::Llm(format!( - "{catalog} request failed after {max_retries} attempts" - ))) -} - -async fn retry_catalog_attempt( - catalog: &str, - attempt: usize, - max_attempts: usize, - backoff: Duration, - status: Option, - reason: &'static str, -) -> bool { - if attempt + 1 >= max_attempts { - return false; - } - - tracing::warn!( - catalog, - attempt = attempt + 1, - max_attempts, - status = ?status, - reason, - "Databricks model discovery catalog request retrying" - ); - tokio::time::sleep(backoff).await; - true -} - -fn catalog_http_error_body( - catalog: &str, - status: reqwest::StatusCode, - body: &str, - bearer: &str, -) -> AgentError { - if status == reqwest::StatusCode::UNAUTHORIZED { - return AgentError::LlmAuth(format!("{catalog} HTTP {status}")); - } - - let body = if bearer.len() > MAX_CATALOG_ERROR_BODY_BYTES { - String::new() - } else if bearer.is_empty() { - body.to_string() - } else { - body.replace(bearer, "[redacted]") - }; - let body = truncate_utf8_bytes(&body, MAX_CATALOG_ERROR_BODY_BYTES); - let classification = if status.as_u16() == 499 || status.is_server_error() { - "transient" - } else { - "failed" - }; - AgentError::Llm(format!("{catalog} {classification} HTTP {status}: {body}")) -} - -async fn read_response_body( - response: &mut reqwest::Response, - limit: usize, -) -> Result { - let mut bytes = Vec::with_capacity(limit.min(16 * 1024)); - if limit == 0 { - return Ok(ReadResponseBody { - bytes, - truncated: true, - }); + // Fall back to known-model list if the API returned nothing. + if all_endpoints.is_empty() { + return Ok(authenticated_empty_v2_catalog()); } - loop { - if bytes.len() == limit { - // Probe one frame past the bound. Without this read, a chunked body - // whose first chunk lands exactly on `limit` would be accepted - // without noticing the next frame. - let truncated = response.chunk().await?.is_some(); - return Ok(ReadResponseBody { bytes, truncated }); - } - - let Some(chunk) = response.chunk().await? else { - return Ok(ReadResponseBody { - bytes, - truncated: false, - }); - }; - let remaining = limit - bytes.len(); - if chunk.len() > remaining { - bytes.extend_from_slice(&chunk[..remaining]); - return Ok(ReadResponseBody { - bytes, - truncated: true, - }); - } - bytes.extend_from_slice(&chunk); - } -} - -async fn read_catalog_error_body( - response: &mut reqwest::Response, - limit: usize, -) -> Result { - let body = read_response_body(response, limit).await?; - Ok(String::from_utf8_lossy(&body.bytes).into_owned()) -} - -fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { - if value.len() <= max_bytes { - return value.to_string(); - } - let mut end = max_bytes; - while !value.is_char_boundary(end) { - end -= 1; - } - value[..end].to_string() -} + sort_v2_endpoints_newest_first(&mut all_endpoints); -fn catalog_context_error(catalog: &str, error: AgentError, context: &str) -> AgentError { - match error { - AgentError::LlmAuth(message) => { - AgentError::LlmAuth(format!("{catalog} {context}: {message}")) - } - AgentError::Llm(message) => AgentError::Llm(format!("{catalog} {context}: {message}")), - other => AgentError::Llm(format!("{catalog} {context}: {other}")), - } + Ok(all_endpoints + .into_iter() + .map(|endpoint| endpoint.entry) + .collect()) } -/// A v2 gateway endpoint plus the key discovery order field. +/// A v2 gateway endpoint plus the key discovery orders the catalog by. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct V2Endpoint { pub(crate) entry: ModelEntry, @@ -777,15 +348,24 @@ pub(crate) struct V2Endpoint { /// /// The gateway sends epoch milliseconds as a JSON *string* /// (`"created_timestamp": "1699610000000"`); accept a bare number too, so a -/// wire-shape change does not silently drop every endpoint to the bottom. -fn endpoint_created_ms(endpoint: &Value) -> Option { +/// wire-shape change doesn't silently drop every endpoint to the bottom. +fn endpoint_created_ms(endpoint: &serde_json::Value) -> Option { let value = endpoint.get("created_timestamp")?; value .as_i64() .or_else(|| value.as_str()?.trim().parse::().ok()) } -/// Order workspace endpoints newest-first, breaking ties by name. +/// Order the catalog newest-first, breaking ties by name. +/// +/// The gateway returns endpoints in two phases — Databricks-managed first, then +/// workspace-created — each alphabetical by name, which buries a brand-new +/// frontier model deep in the list. Newest-first puts the models people are +/// reaching for at the top of the picker. +/// +/// Endpoints with no usable timestamp sort last, and the name tiebreak keeps the +/// result stable: several managed endpoints share one placeholder timestamp, so +/// without it their relative order would be arbitrary. pub(crate) fn sort_v2_endpoints_newest_first(endpoints: &mut [V2Endpoint]) { endpoints.sort_by(|a, b| { // `None` < `Some(_)`, so reversing puts timestamped endpoints first. @@ -797,20 +377,29 @@ pub(crate) fn sort_v2_endpoints_newest_first(endpoints: &mut [V2Endpoint]) { /// Parse one page of a `GET api/ai-gateway/v2/endpoints` response. /// -/// Page order is preserved here; the caller sorts once every page is in. +/// Returns `(endpoints, next_page_token)`. An empty or absent `next_page_token` +/// signals the last page. Endpoints that cannot serve chat traffic are dropped +/// (see [`is_chat_capable_endpoint`]) so the model picker only offers models the +/// agent can actually run. Page order is preserved here; the caller sorts once +/// every page is in (see [`sort_v2_endpoints_newest_first`]). pub(crate) fn parse_v2_endpoints_page( - json: &Value, + json: &serde_json::Value, ) -> Result<(Vec, Option), AgentError> { let endpoints = json .get("endpoints") - .and_then(Value::as_array) - .ok_or_else(|| AgentError::Llm("unexpected response (missing 'endpoints' array)".into()))?; + .and_then(|v| v.as_array()) + .ok_or_else(|| { + AgentError::Llm( + "Databricks v2 model discovery: unexpected response (missing 'endpoints' array)" + .into(), + ) + })?; let models = endpoints .iter() .filter_map(|endpoint| { let name = endpoint.get("name")?.as_str()?.to_string(); - if name.is_empty() || !is_chat_capable_endpoint(&name) { + if !is_chat_capable_endpoint(&name) { return None; } Some(V2Endpoint { @@ -823,64 +412,13 @@ pub(crate) fn parse_v2_endpoints_page( }) .collect(); - let next_page_token = next_page_token(json); - Ok((models, next_page_token)) -} - -/// Parse one page of a `GET api/2.1/unity-catalog/model-services` response. -/// -/// Unity Catalog resource names are returned as `model-services/..`. -/// Only the exact resource prefix, a structurally valid three-component FQN, -/// and chat-capable service metadata are selectable. Missing or empty capability -/// metadata is retained for compatibility with older Databricks workspaces; a -/// non-empty capability list must advertise the MLflow chat API used for model- -/// service inference. The positive visibility filter is applied later. -pub(crate) fn parse_uc_model_services_page( - json: &Value, -) -> Result<(Vec, Option), AgentError> { - let services = json - .get("model_services") - .and_then(Value::as_array) - .ok_or_else(|| { - AgentError::Llm("unexpected response (missing 'model_services' array)".into()) - })?; - - let models = services - .iter() - .filter_map(|service| { - let resource_name = service.get("name")?.as_str()?; - let fqn = resource_name.strip_prefix("model-services/")?; - if !crate::model_capabilities::is_databricks_model_service_fqn(fqn) - || !uc_model_service_supports_chat(service) - { - return None; - } - Some(ModelEntry { - id: fqn.to_string(), - name: curated_model_name(fqn), - }) - }) - .collect(); - - Ok((models, next_page_token(json))) -} - -fn uc_model_service_supports_chat(service: &Value) -> bool { - let Some(api_types) = service.get("supported_api_types").and_then(Value::as_array) else { - return true; - }; - - api_types.is_empty() - || api_types - .iter() - .any(|api_type| api_type.as_str() == Some("mlflow/v1/chat/completions")) -} - -fn next_page_token(json: &Value) -> Option { - json.get("next_page_token") - .and_then(Value::as_str) + let next_page_token = json + .get("next_page_token") + .and_then(|v| v.as_str()) .filter(|token| !token.is_empty()) - .map(str::to_string) + .map(str::to_string); + + Ok((models, next_page_token)) } // --------------------------------------------------------------------------- @@ -891,25 +429,8 @@ fn next_page_token(json: &Value) -> Option { mod tests { use super::*; use async_trait::async_trait; - use axum::{extract::Query, http::StatusCode, routing::get, Json, Router}; - use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; - const TEST_CATALOG_DESCRIPTOR: CatalogDescriptor = CatalogDescriptor { - name: "test catalog", - path: "/catalog", - initial_query: "?page_size=100", - parse_page: parse_v2_endpoints_page, - }; - - fn test_policy(timeout: Duration, max_retries: usize) -> CatalogRequestPolicy { - CatalogRequestPolicy { - timeout, - max_retries, - retry_backoff: Duration::ZERO, - } - } - struct RefreshingTestTokenSource { refreshes: AtomicUsize, } @@ -969,7 +490,7 @@ mod tests { let source = Arc::new(RefreshingTestTokenSource { refreshes: AtomicUsize::new(0), }); - let cfg = Config::for_discovery(Provider::DatabricksV2, String::new(), host, None); + let cfg = Config::for_discovery(Provider::DatabricksV2, String::new(), host); let models = discover_databricks_models_with_token_source(&cfg, source.clone()) .await .unwrap(); @@ -979,555 +500,6 @@ mod tests { assert_eq!(requests.load(Ordering::SeqCst), 2); } - #[tokio::test] - async fn v2_discovery_merges_workspace_and_unity_catalog_after_filtering() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let app = Router::new() - .route( - "/api/ai-gateway/v2/endpoints", - get(|Query(query): Query>| async move { - assert_eq!(query.get("page_size").map(String::as_str), Some("100")); - Json(serde_json::json!({ - "endpoints": [ - {"name": "blocked-workspace", "created_timestamp": 3}, - {"name": "allowed-workspace", "created_timestamp": 2}, - ], - "next_page_token": null, - })) - }), - ) - .route( - "/api/2.1/unity-catalog/model-services", - get(|Query(query): Query>| async move { - assert_eq!(query.get("page_size").map(String::as_str), Some("100")); - assert_eq!(query.get("view").map(String::as_str), Some("FULL")); - Json(serde_json::json!({ - "model_services": [ - {"name": "model-services/catalog.schema.blocked-service"}, - {"name": "model-services/catalog.schema.allowed-service"}, - {"name": "model-services/catalog.schema.allowed-service"}, - ], - "next_page_token": null, - })) - }), - ); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - let filter = - DatabricksModelFilter::parse(Some("allowed-*,catalog.schema.allowed-*")).unwrap(); - let cfg = Config::for_discovery(Provider::DatabricksV2, "token".into(), host, filter); - let models = discover_databricks_models(&cfg).await.unwrap(); - assert_eq!( - models - .iter() - .map(|model| model.id.as_str()) - .collect::>(), - vec!["allowed-workspace", "catalog.schema.allowed-service"] - ); - } - - #[tokio::test] - async fn v2_discovery_keeps_unity_catalog_when_workspace_catalog_fails() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let app = Router::new() - .route( - "/api/ai-gateway/v2/endpoints", - get(|| async { (StatusCode::SERVICE_UNAVAILABLE, "workspace unavailable") }), - ) - .route( - "/api/2.1/unity-catalog/model-services", - get(|| async { - Json(serde_json::json!({ - "model_services": [ - {"name": "model-services/catalog.schema.uc-service"} - ], - "next_page_token": null, - })) - }), - ); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - let cfg = Config::for_discovery(Provider::DatabricksV2, "token".into(), host, None); - let models = discover_databricks_models(&cfg).await.unwrap(); - assert_eq!( - models - .iter() - .map(|model| model.id.as_str()) - .collect::>(), - vec!["catalog.schema.uc-service"] - ); - } - - #[tokio::test] - async fn v2_empty_catalog_fallback_is_disabled_by_filter() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let app = Router::new() - .route( - "/api/ai-gateway/v2/endpoints", - get(|| async { - Json(serde_json::json!({ - "endpoints": [], - "next_page_token": null, - })) - }), - ) - .route( - "/api/2.1/unity-catalog/model-services", - get(|| async { - Json(serde_json::json!({ - "model_services": [], - "next_page_token": null, - })) - }), - ); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - let unfiltered = - Config::for_discovery(Provider::DatabricksV2, "token".into(), host.clone(), None); - let fallback = discover_databricks_models(&unfiltered).await.unwrap(); - assert_eq!( - fallback - .iter() - .map(|model| model.id.as_str()) - .collect::>(), - crate::model_capabilities::databricks_v2_known_models() - .iter() - .map(String::as_str) - .collect::>() - ); - - let filter = DatabricksModelFilter::parse(Some("no-match")).unwrap(); - let filtered = Config::for_discovery(Provider::DatabricksV2, "token".into(), host, filter); - assert!(discover_databricks_models(&filtered) - .await - .unwrap() - .is_empty()); - } - - #[tokio::test] - async fn catalog_pagination_encodes_tokens_and_rejects_repeated_tokens() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let app = Router::new().route( - "/catalog", - get(|Query(query): Query>| async move { - match query.get("page_token").map(String::as_str) { - None => Json(serde_json::json!({ - "endpoints": [{"name": "first"}], - "next_page_token": "token with/slash", - })), - Some("token with/slash") => Json(serde_json::json!({ - "endpoints": [{"name": "second"}], - })), - Some(other) => panic!("unexpected decoded page token: {other}"), - } - }), - ); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - let entries = fetch_catalog_pages_with_policy( - &Client::new(), - &host, - "token", - TEST_CATALOG_DESCRIPTOR, - DEFAULT_CATALOG_REQUEST_POLICY, - ) - .await - .unwrap(); - assert_eq!(entries.len(), 2); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let app = Router::new().route( - "/catalog", - get(|| async { - Json(serde_json::json!({ - "endpoints": [{"name": "loop"}], - "next_page_token": "same-token", - })) - }), - ); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - let error = fetch_catalog_pages_with_policy( - &Client::new(), - &host, - "token", - TEST_CATALOG_DESCRIPTOR, - DEFAULT_CATALOG_REQUEST_POLICY, - ) - .await - .unwrap_err(); - assert!(error.to_string().contains("repeated page token")); - } - - #[tokio::test] - async fn catalog_pagination_errors_after_the_finite_page_cap() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let requests = Arc::new(AtomicUsize::new(0)); - let requests_for_handler = requests.clone(); - let app = Router::new().route( - "/catalog", - get(move |Query(_query): Query>| { - let page = requests_for_handler.fetch_add(1, Ordering::SeqCst) + 1; - async move { - Json(serde_json::json!({ - "endpoints": [{"name": format!("model-{page}")}], - "next_page_token": format!("token-{page}"), - })) - } - }), - ); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - let error = fetch_catalog_pages_with_policy( - &Client::new(), - &host, - "token", - TEST_CATALOG_DESCRIPTOR, - DEFAULT_CATALOG_REQUEST_POLICY, - ) - .await - .unwrap_err(); - assert!(error - .to_string() - .contains("pagination exhausted after 20 pages")); - assert_eq!(requests.load(Ordering::SeqCst), 20); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn v2_discovery_degrades_a_stalled_secondary_catalog() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let app = Router::new() - .route( - "/api/ai-gateway/v2/endpoints", - get(|| async { - Json(serde_json::json!({ - "endpoints": [{"name": "workspace-only"}], - "next_page_token": null, - })) - }), - ) - .route( - "/api/2.1/unity-catalog/model-services", - get(|| async { - // The handler never sends headers. The catalog attempt - // deadline must still let the workspace result win. - tokio::time::sleep(Duration::from_secs(60)).await; - Json(serde_json::json!({ - "model_services": [], - "next_page_token": null, - })) - }), - ); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - let started = std::time::Instant::now(); - let models = fetch_v2_models_with_policy( - &Client::new(), - &host, - "token", - None, - false, - test_policy(Duration::from_millis(40), 1), - ) - .await - .unwrap(); - - assert!( - started.elapsed() < Duration::from_secs(1), - "stalled catalog exceeded its request deadline: {:?}", - started.elapsed() - ); - assert_eq!( - models - .iter() - .map(|model| model.id.as_str()) - .collect::>(), - vec!["workspace-only"] - ); - } - - #[tokio::test] - async fn catalog_retries_499_and_5xx_then_recovers() { - for status in [ - StatusCode::from_u16(499).unwrap(), - StatusCode::SERVICE_UNAVAILABLE, - ] { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let requests = Arc::new(AtomicUsize::new(0)); - let requests_for_route = requests.clone(); - let app = Router::new().route( - "/catalog", - get(move || { - let attempt = requests_for_route.fetch_add(1, Ordering::SeqCst); - async move { - if attempt == 0 { - Err((status, "provider body secret-token")) - } else { - Ok(Json(serde_json::json!({ - "endpoints": [{"name": "recovered"}], - "next_page_token": null, - }))) - } - } - }), - ); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - let entries = fetch_catalog_pages_with_policy( - &Client::new(), - &host, - "secret-token", - TEST_CATALOG_DESCRIPTOR, - test_policy(Duration::from_secs(1), 3), - ) - .await - .unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].entry.id, "recovered"); - assert_eq!(requests.load(Ordering::SeqCst), 2); - } - } - - #[tokio::test] - async fn catalog_retries_malformed_json_then_recovers() { - use axum::response::IntoResponse; - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let requests = Arc::new(AtomicUsize::new(0)); - let requests_for_route = requests.clone(); - let app = Router::new().route( - "/catalog", - get(move || { - let attempt = requests_for_route.fetch_add(1, Ordering::SeqCst); - async move { - if attempt == 0 { - (StatusCode::OK, "not-json").into_response() - } else { - Json(serde_json::json!({ - "endpoints": [{"name": "json-recovered"}], - "next_page_token": null, - })) - .into_response() - } - } - }), - ); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - let entries = fetch_catalog_pages_with_policy( - &Client::new(), - &host, - "token", - TEST_CATALOG_DESCRIPTOR, - test_policy(Duration::from_secs(1), 3), - ) - .await - .unwrap(); - - assert_eq!(requests.load(Ordering::SeqCst), 2); - assert_eq!(entries[0].entry.id, "json-recovered"); - } - - #[tokio::test] - async fn catalog_transient_failure_exhausts_exactly_three_attempts_without_bearer_leak() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let requests = Arc::new(AtomicUsize::new(0)); - let requests_for_route = requests.clone(); - let app = Router::new().route( - "/catalog", - get(move || { - requests_for_route.fetch_add(1, Ordering::SeqCst); - async { - ( - StatusCode::SERVICE_UNAVAILABLE, - "provider body secret-token", - ) - } - }), - ); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - let error = fetch_catalog_pages_with_policy( - &Client::new(), - &host, - "secret-token", - TEST_CATALOG_DESCRIPTOR, - test_policy(Duration::from_secs(1), 3), - ) - .await - .unwrap_err(); - - assert_eq!(requests.load(Ordering::SeqCst), 3); - let message = error.to_string(); - assert!( - message.contains("transient HTTP 503"), - "unexpected error: {message}" - ); - assert!( - message.contains("provider body"), - "body context was lost: {message}" - ); - assert!( - !message.contains("secret-token"), - "bearer leaked through catalog error: {message}" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn catalog_retries_when_headers_arrive_but_response_body_stalls() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let host = format!("http://{}", listener.local_addr().unwrap()); - let requests = Arc::new(AtomicUsize::new(0)); - let headers_sent = Arc::new(AtomicUsize::new(0)); - let requests_for_server = requests.clone(); - let headers_for_server = headers_sent.clone(); - tokio::spawn(async move { - loop { - let Ok((mut socket, _)) = listener.accept().await else { - return; - }; - let attempt = requests_for_server.fetch_add(1, Ordering::SeqCst); - let headers_sent = headers_for_server.clone(); - tokio::spawn(async move { - let mut request = Vec::new(); - let mut chunk = [0u8; 1024]; - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - match socket.read(&mut chunk).await { - Ok(0) | Err(_) => return, - Ok(read) => request.extend_from_slice(&chunk[..read]), - } - } - - if attempt == 0 { - socket - .write_all( - b"HTTP/1.1 200 OK\r\n\ - Content-Type: application/json\r\n\ - Content-Length: 64\r\n\ - Connection: close\r\n\r\n\ - {\"endpoints\": [", - ) - .await - .ok(); - headers_sent.store(1, Ordering::SeqCst); - // Keep the declared body incomplete. The outer attempt - // timeout, not reqwest::send(), must terminate this read. - tokio::time::sleep(Duration::from_secs(60)).await; - } else { - let body = - r#"{"endpoints":[{"name":"body-recovered"}],"next_page_token":null}"#; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ - Content-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - socket.write_all(response.as_bytes()).await.ok(); - } - }); - } - }); - - let entries = fetch_catalog_pages_with_policy( - &Client::new(), - &host, - "token", - TEST_CATALOG_DESCRIPTOR, - test_policy(Duration::from_millis(40), 2), - ) - .await - .unwrap(); - - assert_eq!(headers_sent.load(Ordering::SeqCst), 1); - assert_eq!(requests.load(Ordering::SeqCst), 2); - assert_eq!(entries[0].entry.id, "body-recovered"); - } - #[test] - fn v1_filter_applies_to_raw_ids_after_endpoint_filtering() { - let filter = DatabricksModelFilter::parse(Some("allowed-*")).unwrap(); - let models = apply_model_filter( - vec![ - ModelEntry { - id: "allowed-model".into(), - name: "Allowed".into(), - }, - ModelEntry { - id: "blocked-model".into(), - name: "Blocked".into(), - }, - ], - filter.as_ref(), - ); - assert_eq!(models.len(), 1); - assert_eq!(models[0].id, "allowed-model"); - } - - #[test] - fn catalog_error_body_is_bounded_and_redacts_bearer() { - let bearer = "secret-token"; - let provider_body = format!("prefix {bearer} {}", "x".repeat(8_192)); - let status = reqwest::StatusCode::SERVICE_UNAVAILABLE; - let error = catalog_http_error_body("test catalog", status, &provider_body, bearer); - let message = error.to_string(); - assert!( - message.contains("transient HTTP 503"), - "unexpected error: {message}" - ); - assert!( - message.contains("[redacted]"), - "bearer was not redacted: {message}" - ); - assert!(!message.contains(bearer), "bearer leaked: {message}"); - let prefix = format!("llm: test catalog transient HTTP {status}: "); - assert!( - message.starts_with(&prefix), - "unexpected catalog error prefix: message={message:?}, prefix={prefix:?}" - ); - let diagnostic = &message[prefix.len()..]; - assert!( - diagnostic.len() <= MAX_CATALOG_ERROR_BODY_BYTES, - "error body exceeded diagnostic bound: {}", - diagnostic.len() - ); - - // Keep the UTF-8 boundary behavior explicit as well. - let value = format!("{}é", "x".repeat(MAX_CATALOG_ERROR_BODY_BYTES)); - let truncated = truncate_utf8_bytes(&value, MAX_CATALOG_ERROR_BODY_BYTES); - assert_eq!(truncated.len(), MAX_CATALOG_ERROR_BODY_BYTES); - assert!(truncated.is_char_boundary(truncated.len())); - } - #[test] fn v1_parse_filters_ready_chat_endpoints() { let json = serde_json::json!({ @@ -1627,6 +599,9 @@ mod tests { #[test] fn v2_parse_drops_embedding_endpoints() { + // The v2 payload carries no `task`, so embedding endpoints are only + // recognisable by name. They reject chat requests, so offering them in + // the picker can only produce a 400 at send time. let json = serde_json::json!({ "endpoints": [ {"name": "databricks-bge-large-en"}, @@ -1639,172 +614,10 @@ mod tests { let (models, _) = parse_v2_endpoints_page(&json).unwrap(); let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect(); + // Image endpoints DO answer chat requests, so they are retained. assert_eq!( ids, - vec!["databricks-claude-opus-5", "databricks-gemini-3-pro-image",] - ); - } - - #[test] - fn uc_parse_requires_exact_prefix_and_structural_fqn() { - let json = serde_json::json!({ - "model_services": [ - {"name": "model-services/data_tools.goose.kimi-k3"}, - {"name": "model-services/catalog.schema.claude-gpt-5"}, - {"name": "model-services/two.parts"}, - {"name": "model-services/too.many.parts.here"}, - {"name": "Model-services/wrong.case.service"}, - {"name": "models/data_tools.goose.other"}, - {"name": "model-services/.schema.service"}, - {"name": "model-services/catalog..service"}, - {"name": "model-services/catalog.schema."}, - {"name": "model-services/catalog.schema/service"}, - ], - "next_page_token": "next token/1" - }); - - let (models, next) = parse_uc_model_services_page(&json).unwrap(); - let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect(); - assert_eq!( - ids, - vec!["data_tools.goose.kimi-k3", "catalog.schema.claude-gpt-5"] - ); - assert_eq!(next.as_deref(), Some("next token/1")); - } - - #[test] - fn uc_parse_filters_known_non_chat_services_and_preserves_unknown_capabilities() { - let json = serde_json::json!({ - "model_services": [ - { - "name": "model-services/system.ai.chat-model", - "supported_api_types": [ - "mlflow/v1/chat/completions", - "mlflow/v1/responses" - ] - }, - { - "name": "model-services/system.ai.embedding-model", - "supported_api_types": ["mlflow/v1/embeddings"] - }, - { - "name": "model-services/system.ai.responses-only-model", - "supported_api_types": ["mlflow/v1/responses"] - }, - { - "name": "model-services/catalog.schema.empty-capabilities", - "supported_api_types": [] - }, - {"name": "model-services/catalog.schema.absent-capabilities"}, - ] - }); - - let (models, _) = parse_uc_model_services_page(&json).unwrap(); - let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect(); - assert_eq!( - ids, - vec![ - "system.ai.chat-model", - "catalog.schema.empty-capabilities", - "catalog.schema.absent-capabilities", - ] - ); - } - - #[test] - fn uc_parse_requires_model_services_array() { - let err = parse_uc_model_services_page(&serde_json::json!({"data": []})).unwrap_err(); - assert!(err.to_string().contains("missing 'model_services' array")); - } - - #[test] - fn merge_deduplicates_raw_ids_and_preserves_workspace_then_lexical_uc_order() { - let workspace = vec![ - V2Endpoint { - entry: ModelEntry { - id: "workspace-new".into(), - name: "workspace-new".into(), - }, - created_ms: Some(2), - }, - V2Endpoint { - entry: ModelEntry { - id: "duplicate".into(), - name: "duplicate".into(), - }, - created_ms: Some(1), - }, - ]; - let uc = vec![ - ModelEntry { - id: "z.schema.service".into(), - name: "z.schema.service".into(), - }, - ModelEntry { - id: "a.schema.service".into(), - name: "a.schema.service".into(), - }, - ModelEntry { - id: "duplicate".into(), - name: "same leaf".into(), - }, - ModelEntry { - id: "a.other.service".into(), - name: "same leaf".into(), - }, - ]; - - let models = merge_v2_models(workspace, uc, None, false); - let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect(); - assert_eq!( - ids, - vec![ - "workspace-new", - "duplicate", - "a.other.service", - "a.schema.service", - "z.schema.service", - ] - ); - } - - #[test] - fn merge_applies_filter_after_union_and_does_not_restore_fallback() { - let filter = DatabricksModelFilter::parse(Some("allowed.*")).unwrap(); - let filter = filter.as_ref(); - let workspace = vec![V2Endpoint { - entry: ModelEntry { - id: "blocked-workspace".into(), - name: "blocked-workspace".into(), - }, - created_ms: Some(1), - }]; - let uc = vec![ModelEntry { - id: "allowed.schema.service".into(), - name: "allowed.schema.service".into(), - }]; - let models = merge_v2_models(workspace, uc, filter, false); - assert_eq!( - models.iter().map(|m| m.id.as_str()).collect::>(), - vec!["allowed.schema.service"] - ); - - let no_match = DatabricksModelFilter::parse(Some("no-match")).unwrap(); - assert!(merge_v2_models(Vec::new(), Vec::new(), no_match.as_ref(), true).is_empty()); - } - - #[test] - fn merge_uses_known_fallback_only_for_unfiltered_successful_empty_union() { - let models = merge_v2_models(Vec::new(), Vec::new(), None, true); - assert_eq!( - models - .iter() - .map(|model| model.id.as_str()) - .collect::>(), - crate::model_capabilities::databricks_v2_known_models() - .iter() - .map(String::as_str) - .collect::>() + vec!["databricks-claude-opus-5", "databricks-gemini-3-pro-image"] ); } @@ -1923,4 +736,16 @@ mod tests { "custom-unlisted-endpoint" ); } + + #[test] + fn is_chat_capable_endpoint_keeps_unrecognised_names() { + // Prefer including over silently dropping — an unknown family is kept. + assert!(is_chat_capable_endpoint("databricks-glm-5-2")); + assert!(is_chat_capable_endpoint("some-teams-custom-endpoint")); + // `bge`/`gte` match as whole segments only, never as substrings. + assert!(is_chat_capable_endpoint("databricks-budget-gtex-model")); + assert!(!is_chat_capable_endpoint("databricks-bge-large-en")); + assert!(!is_chat_capable_endpoint("databricks-gte-large-en")); + assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b")); + } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index b094a0f9fd7..3de47c82a4a 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -13,7 +13,9 @@ mod permission; pub mod types; mod wire; -pub use catalog::{discover_databricks_models, ModelEntry}; +pub use catalog::{ + discover_databricks_models, discover_databricks_models_with_cache_dir, ModelEntry, +}; pub use config::Provider; pub use types::AgentError; @@ -161,10 +163,22 @@ pub fn run() -> Result<(), Box> { Ok(()) } +/// Authenticate to Databricks and store credentials under an optional explicit +/// cache root. `None` preserves buzz-agent's production cache location. +pub async fn authenticate_databricks_with_cache_dir( + host: &str, + cache_dir: Option<&std::path::Path>, +) -> Result<(), AgentError> { + auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config( + host, + cache_dir.map(std::path::Path::to_path_buf), + ))? + .interactive_login() + .await +} + pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> { - auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config(host))? - .interactive_login() - .await + authenticate_databricks_with_cache_dir(host, None).await } /// `buzz-agent auth ` — run the interactive auth flow for a diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 55c85bb0c5a..662b8ef964d 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -2032,7 +2033,10 @@ where ))) } -pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { +pub(crate) fn databricks_pkce_config( + host: &str, + cache_dir_override: Option, +) -> PkceOAuthConfig { PkceOAuthConfig { discovery_url: format!( "{}/oidc/.well-known/oauth-authorization-server", @@ -2044,7 +2048,7 @@ pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { .map(|scope| (*scope).into()) .collect(), cache_namespace: "databricks".into(), - cache_dir_override: None, + cache_dir_override, } } @@ -2069,6 +2073,7 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A } Ok(PkceOAuthTokenSource::new(databricks_pkce_config( &cfg.base_url, + None, ))?) } } diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 42c9cc48780..a848557ae2f 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -45,6 +45,9 @@ const PASSTHROUGH_ENV: &[&str] = &[ "LC_ALL", "TMPDIR", "XDG_CONFIG_HOME", + // Explicit Buzz-owned OAuth root for named demo builds. The agent may spawn + // auth-capable child tools after clearing its ambient environment. + "BUZZ_AGENT_CONFIG_DIR", // SSH — required for git clone/push over SSH (git@github.com:...) "SSH_AUTH_SOCK", "SSH_AGENT_PID", diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs index f37dd8dfa0e..5d319d0dca9 100644 --- a/desktop/src-tauri/src/build_identity.rs +++ b/desktop/src-tauri/src/build_identity.rs @@ -14,15 +14,21 @@ pub(crate) fn is_demo_build() -> bool { demo_slug().is_some() } +pub(crate) const DEMO_AGENT_CONFIG_ENV: &str = "BUZZ_AGENT_CONFIG_DIR"; + pub(crate) fn demo_config_home() -> Option { demo_config_home_for(demo_slug(), dirs::config_dir()) } +pub(crate) fn demo_agent_oauth_cache_dir() -> Option { + demo_config_home().map(|dir| dir.join("buzz-agent").join("oauth")) +} + /// Keep child config caches inside this demo build's identity. In particular, /// bundled buzz-agent OAuth tokens must not read or write production's root. pub(crate) fn apply_demo_config_home(command: &mut std::process::Command) { if let Some(config_home) = demo_config_home() { - command.env("XDG_CONFIG_HOME", config_home); + command.env(DEMO_AGENT_CONFIG_ENV, config_home); } } @@ -96,15 +102,25 @@ mod tests { } #[test] - fn demo_agent_config_home_is_build_scoped() { - let base = std::path::PathBuf::from("/config"); + fn demo_agent_config_and_oauth_roots_are_build_scoped() { + let base = std::path::PathBuf::from("/Users/demo/Library/Application Support"); assert_eq!(demo_config_home_for(None, Some(base.clone())), None); + let first = + demo_config_home_for(Some("board-1234567812345678"), Some(base.clone())).unwrap(); + let second = demo_config_home_for(Some("board-8765432187654321"), Some(base)).unwrap(); + assert_eq!( + first, + std::path::PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678" + ) + ); assert_eq!( - demo_config_home_for(Some("board-1234567812345678"), Some(base)), - Some(std::path::PathBuf::from( - "/config/buzz-demo-board-1234567812345678" - )) + first.join("buzz-agent/oauth"), + std::path::PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678/buzz-agent/oauth" + ) ); + assert_ne!(first, second); } #[test] diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 998edeca27d..40bdacaa0e9 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -54,6 +54,8 @@ pub(super) async fn run_agent_models_command( for (k, v) in &merged_env { cmd.env(k, v); } + // Demo identity is authoritative and must win over ambient/user env. + crate::build_identity::apply_demo_config_home(&mut cmd); crate::managed_agents::configure_runtime_cli(&mut cmd, known_acp_runtime(&agent_command)); crate::util::configure_no_window(&mut cmd); cmd.stdout(std::process::Stdio::piped()) diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs index 1f66f24c6a3..8284c98ef14 100644 --- a/desktop/src-tauri/src/commands/agent_models_databricks.rs +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -178,12 +178,23 @@ pub(super) async fn discover_databricks_models( parsed_filter.clone(), ); let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &api_key); + let oauth_cache_dir = crate::build_identity::demo_agent_oauth_cache_dir(); - let entries = match buzz_agent_pkg::discover_databricks_models(&config).await { + let entries = match buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + { Ok(entries) => entries, Err(buzz_agent_pkg::AgentError::LlmAuth(_)) if should_start_interactive_auth(&api_key) => { let _auth = AUTH_GATE.lock().await; - match buzz_agent_pkg::discover_databricks_models(&config).await { + match buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + { // A peer sign-in under the gate already succeeded. Ok(entries) => entries, Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { @@ -194,22 +205,28 @@ pub(super) async fn discover_databricks_models( return Err(databricks_sign_in_required_error()); } run_interactive_databricks_auth( - buzz_agent_pkg::authenticate_databricks(&host), + buzz_agent_pkg::authenticate_databricks_with_cache_dir( + &host, + oauth_cache_dir.as_deref(), + ), AUTH_FLOW_TIMEOUT, &AUTH_COOLDOWNS, &host, &redaction_env, ) .await?; - buzz_agent_pkg::discover_databricks_models(&config) - .await - .map_err(|error| { - format_redacted_error( - "Databricks model discovery failed after sign-in", - &error, - &redaction_env, - ) - })? + buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + .map_err(|error| { + format_redacted_error( + "Databricks model discovery failed after sign-in", + &error, + &redaction_env, + ) + })? } Err(error) => { return Err(format_redacted_error( diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index 37c551798ab..e6570482afa 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -67,9 +67,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // ambient env var must not be able to forge setup mode (NotReady) on a // Ready agent or suppress it (empty/stale payload) on a NotReady one. "BUZZ_ACP_SETUP_PAYLOAD", - // Demo-build identity owns the child config root. A user override could - // silently reconnect a demo harness to production OAuth/config state. - "XDG_CONFIG_HOME", + // Demo-build identity owns the child agent config root. A user override + // could silently reconnect a demo harness to production OAuth state. + "BUZZ_AGENT_CONFIG_DIR", // Desktop ownership markers: these brand every spawned harness with the // launching Desktop instance. A user-supplied override would let a // definition masquerade as a different instance or fake the nonce used diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index d9591814d72..91d8649ebb5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -535,7 +535,6 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_IDLE_POOL_SLEEP", idle_pool_sleep_env(lazy)); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); - crate::build_identity::apply_demo_config_home(&mut command); match &resolved_mcp_command { Some(mcp_cmd) => { command.env("BUZZ_ACP_MCP_COMMAND", mcp_cmd); @@ -809,6 +808,7 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } + crate::build_identity::apply_demo_config_home(&mut command); // B5: carry persisted effort; harness resolves thought_level configId at first session. // Written AFTER descriptor.env so the canonical persisted value wins over any diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index 688b65f01ad..09d95442585 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -848,8 +848,15 @@ mod tests { let shared_sprout = home.join(".sprout"); let shared_agent = home.join(".config").join("buzz-agent"); let demo_config = home - .join(".config") + .join("Library") + .join("Application Support") .join("buzz-demo-current-1234567812345678"); + let demo_oauth = demo_config.join("buzz-agent").join("oauth"); + let other_demo_config = home + .join("Library") + .join("Application Support") + .join("buzz-demo-other-8765432187654321"); + let other_demo_oauth = other_demo_config.join("buzz-agent").join("oauth"); for path in [ &app_data, @@ -858,7 +865,8 @@ mod tests { &other_demo_nest, &shared_sprout, &shared_agent, - &demo_config, + &demo_oauth, + &other_demo_oauth, ] { std::fs::create_dir_all(path).unwrap(); } @@ -883,7 +891,11 @@ mod tests { assert!(!demo_nest.exists(), "selected demo nest must be wiped"); assert!( !demo_config.exists(), - "selected demo agent config must be wiped" + "selected demo auth root must be wiped" + ); + assert!( + other_demo_oauth.exists(), + "another demo's concrete auth root must survive" ); assert!(prod_nest.exists(), "production nest must survive"); assert!(other_demo_nest.exists(), "another demo nest must survive"); From a77b25eca6c632e46f20afef54b94a38a9fbface Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 27 Aug 2026 17:34:59 -0400 Subject: [PATCH 14/17] fix(desktop): build valid demo packages Signed-off-by: Logan Johnson Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- Justfile | 20 +- crates/buzz-agent/src/catalog.rs | 1522 +++++++++++++++++--- desktop/scripts/demo-build-config.mjs | 6 +- desktop/scripts/demo-build-config.test.mjs | 8 +- desktop/src-tauri/src/build_identity.rs | 8 + 5 files changed, 1394 insertions(+), 170 deletions(-) diff --git a/Justfile b/Justfile index 63b7888d435..a5a46b3240a 100644 --- a/Justfile +++ b/Justfile @@ -255,7 +255,17 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ cargo test compiled_policy_matches_expected -- --ignored --nocapture - echo "Both compiled states verified." + echo "=== Maximum accepted demo name reaches Rust build validation ===" + DEMO_CONFIG="$(node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..31})" /dev/null 1234567812345678)" + DEMO_SLUG="$(node -e 'console.log(JSON.parse(process.argv[1]).slug)' "$DEMO_CONFIG")" + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" \ + BUZZ_TEST_EXPECTED_DEMO_SLUG="$DEMO_SLUG" \ + cargo test compiled_demo_slug_matches_expected -- --ignored --nocapture + if node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..32})" /dev/null 1234567812345678; then + echo "A 32-character demo name unexpectedly passed JavaScript validation" >&2 + exit 1 + fi + echo "Both compiled states and the accepted/rejected demo-name boundary verified." # Build the full desktop Tauri app locally (unsigned, for testing) # Sidecar binary list must stay in sync with _ensure-sidecar-stubs above. @@ -282,7 +292,7 @@ desktop-demo-build demo_name target="aarch64-apple-darwin": set -euo pipefail TARGET={{target}} [[ "$(uname -s)" == "Darwin" && "$TARGET" == *-apple-darwin ]] || { echo "Demo DMGs require a macOS Apple target" >&2; exit 2; } - CONFIG_PATH="$(mktemp "${TMPDIR:-/tmp}/buzz-demo-config.XXXXXX.json")" + CONFIG_PATH="$(mktemp "${TMPDIR:-/tmp}/buzz-demo-config.XXXXXX")" trap 'rm -f "$CONFIG_PATH"' EXIT DEMO_BUILD_ID="$(node -e 'console.log(require("node:crypto").randomBytes(8).toString("hex"))')" DEMO_CONFIG="$(node desktop/scripts/demo-build-config.mjs {{quote(demo_name)}} "$CONFIG_PATH" "$DEMO_BUILD_ID")" @@ -291,8 +301,10 @@ desktop-demo-build demo_name target="aarch64-apple-darwin": DMG_VOLUME_NAME="$(read_config dmgVolumeName)" DMG_FILE_STEM="$(read_config dmgFileStem)" DEMO_SLUG="$(read_config slug)" - mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do touch "desktop/src-tauri/binaries/$bin-$TARGET"; done + cargo build --release --target "$TARGET" \ + -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp \ + -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" pnpm install cd {{desktop_dir}} BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" pnpm tauri build --features mesh-llm --target "$TARGET" --bundles app --config "$CONFIG_PATH" diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 3be106fa8ef..f2cda834fd1 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -12,24 +12,23 @@ //! This helper never opens a browser. Callers choose whether to reject, degrade, //! or start a separate interactive authentication flow. -use std::path::Path; -use std::sync::Arc; +use std::{collections::HashSet, path::Path, sync::Arc, time::Duration}; use reqwest::Client; +use serde_json::Value; use crate::{ auth::TokenSource, - config::{Config, Provider}, + config::{Config, DatabricksModelFilter, Provider}, llm::build_token_source, types::AgentError, }; -/// A discovered model entry: `id` is the picker value (the raw endpoint id, and -/// the wire/config value), `name` is the display label. The Databricks API has -/// no display-name field, so discovery curates `name` from the capability -/// manifest ([`model_capabilities::databricks_registry_label`]) — a known id -/// yields its curated label (e.g. `GPT-5.5`), an unknown id falls back to the -/// raw id. +/// A discovered model entry: `id` is the picker value (the raw endpoint id or +/// Unity Catalog model-service FQN, and the wire/config value), `name` is the +/// display label. Databricks catalog APIs do not provide a consistently useful +/// picker label, so discovery curates names from the capability manifest when +/// an exact known id exists and otherwise uses the raw id. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelEntry { pub id: String, @@ -37,20 +36,61 @@ pub struct ModelEntry { } const AUTHENTICATED_EMPTY_CATALOG_SUFFIX: &str = " (default catalog)"; +const MAX_CATALOG_PAGES: usize = 20; +const MAX_CATALOG_ERROR_BODY_BYTES: usize = 4 * 1024; +const MAX_CATALOG_RESPONSE_BODY_BYTES: usize = 2 * 1024 * 1024; +const CATALOG_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const CATALOG_MAX_RETRIES: usize = 3; +const CATALOG_RETRY_BACKOFF: Duration = Duration::from_millis(100); + +#[derive(Clone, Copy)] +struct CatalogRequestPolicy { + timeout: Duration, + max_retries: usize, + retry_backoff: Duration, +} + +const DEFAULT_CATALOG_REQUEST_POLICY: CatalogRequestPolicy = CatalogRequestPolicy { + timeout: CATALOG_REQUEST_TIMEOUT, + max_retries: CATALOG_MAX_RETRIES, + retry_backoff: CATALOG_RETRY_BACKOFF, +}; +const WORKSPACE_CATALOG_QUERY: &str = "?page_size=100"; +const UNITY_CATALOG_QUERY: &str = "?page_size=100&view=FULL"; +type CatalogPage = Result<(Vec, Option), AgentError>; + +#[derive(Clone, Copy)] +struct CatalogDescriptor { + name: &'static str, + path: &'static str, + initial_query: &'static str, + parse_page: fn(&Value) -> CatalogPage, +} + +const WORKSPACE_CATALOG_DESCRIPTOR: CatalogDescriptor = CatalogDescriptor { + name: "Databricks workspace endpoint catalog", + path: "/api/ai-gateway/v2/endpoints", + initial_query: WORKSPACE_CATALOG_QUERY, + parse_page: parse_v2_endpoints_page, +}; +const UNITY_CATALOG_DESCRIPTOR: CatalogDescriptor = CatalogDescriptor { + name: "Databricks Unity Catalog model-service catalog", + path: "/api/2.1/unity-catalog/model-services", + initial_query: UNITY_CATALOG_QUERY, + parse_page: parse_uc_model_services_page, +}; -/// Curated display label for a discovered Databricks endpoint id: the manifest's -/// exact-record label when one exists, otherwise the raw id. The API returns no -/// display name, so this is the single seam that turns a raw endpoint id into a -/// human label for the picker. +/// Curated display label for a discovered Databricks endpoint or model-service +/// id. Unknown ids deliberately pass through unchanged. fn curated_model_name(id: &str) -> String { crate::model_capabilities::databricks_registry_label(id) .unwrap_or(id) .to_string() } -/// Fallback catalog used only when an authenticated `api/ai-gateway/v2/endpoints` -/// call succeeds with an empty list. The known-model ids come from the manifest -/// ([`model_capabilities::databricks_v2_known_models`]), the single runtime source. +/// Fallback catalog used only when both authenticated Databricks v2 catalogs +/// successfully respond with no entries and no visibility filter is active. +/// The known-model ids come from the manifest, the single runtime source. fn authenticated_empty_v2_catalog() -> Vec { crate::model_capabilities::databricks_v2_known_models() .iter() @@ -64,27 +104,16 @@ fn authenticated_empty_v2_catalog() -> Vec { .collect() } -/// Heuristic: `true` when a v2 AI Gateway endpoint name looks like it serves -/// chat/completions traffic. +/// Heuristic chat-capability filter for v2 workspace endpoints. /// -/// The v1 `serving-endpoints` payload carries `task`, so [`parse_v1_endpoints`] -/// can filter on it directly. The v2 `ai-gateway/v2/endpoints` payload carries -/// no task or readiness field at all, so the only signal available here is the -/// endpoint name. Embedding endpoints are the one family that reliably cannot -/// serve a chat request — they reject it with -/// `API type 'mlflow/v1/chat/completions' is not supported by ''` — so -/// they are dropped rather than offered as selectable models. -/// -/// Deliberately narrow: image-capable endpoints (e.g. -/// `databricks-gemini-3-pro-image`) do answer chat requests, so they stay. Any -/// name this heuristic does not recognise is kept — preferring to include over -/// silently dropping, matching [`parse_v1_endpoints`]. +/// The v2 catalog omits task metadata. Known embedding endpoint families cannot +/// answer chat-completions requests, so do not offer them as selectable models. +/// Unknown names remain visible; this filter is intentionally narrow. pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { let lower = name.to_ascii_lowercase(); if lower.contains("embedding") { return false; } - // Segment match so `bge`/`gte` cannot fire on a substring of a longer word. !lower .split('-') .any(|segment| matches!(segment, "bge" | "gte")) @@ -92,9 +121,14 @@ pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { /// Discover available models for a Databricks provider. /// -/// Returns a non-empty `Vec` on success. Returns -/// `Err(AgentError::LlmAuth)` when no token is available (no static token, -/// no PKCE cache). The helper itself never starts interactive authentication. +/// Returns an empty vector when an authenticated catalog is valid but no +/// visible entries remain after filtering. Returns `Err(AgentError::LlmAuth)` +/// when no token is available (no static token, no PKCE cache). The helper +/// itself never starts interactive authentication. +/// +/// For v2, the known-model fallback is used only when both catalog requests +/// succeed empty and no filter is active. A filter is applied to v1 results +/// after its existing endpoint capability filtering. /// /// # Panics /// Never panics. @@ -132,8 +166,19 @@ async fn discover_databricks_models_with_token_source( loop { let result = match cfg.provider { - Provider::Databricks => fetch_v1_models(&http, host, &bearer).await, - Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await, + Provider::Databricks => fetch_v1_models(&http, host, &bearer) + .await + .map(|models| apply_model_filter(models, cfg.databricks_model_filter.as_ref())), + Provider::DatabricksV2 => { + fetch_v2_models( + &http, + host, + &bearer, + cfg.databricks_model_filter.as_ref(), + refreshed, + ) + .await + } _ => { return Err(AgentError::InvalidParams( "discover_databricks_models called for non-Databricks provider".into(), @@ -157,6 +202,19 @@ async fn discover_databricks_models_with_token_source( } } +fn apply_model_filter( + models: Vec, + filter: Option<&DatabricksModelFilter>, +) -> Vec { + match filter { + Some(filter) => models + .into_iter() + .filter(|model| filter.matches(&model.id)) + .collect(), + None => models, + } +} + // --------------------------------------------------------------------------- // v1 — api/2.0/serving-endpoints // --------------------------------------------------------------------------- @@ -167,31 +225,14 @@ async fn fetch_v1_models( bearer: &str, ) -> Result, AgentError> { let url = format!("{host}/api/2.0/serving-endpoints"); - let response = http - .get(&url) - .bearer_auth(bearer) - .send() - .await - .map_err(|e| AgentError::Llm(format!("Databricks model discovery request failed: {e}")))?; - - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - if status.as_u16() == 401 { - return Err(AgentError::LlmAuth(format!( - "Databricks model discovery HTTP {status}" - ))); - } - return Err(AgentError::Llm(format!( - "Databricks model discovery HTTP {status}: {body}" - ))); - } - - let json: serde_json::Value = response.json().await.map_err(|e| { - AgentError::Llm(format!( - "Databricks model discovery response parse failed: {e}" - )) - })?; + let json = fetch_catalog_page( + http, + &url, + "Databricks serving-endpoints catalog", + bearer, + DEFAULT_CATALOG_REQUEST_POLICY, + ) + .await?; parse_v1_endpoints(&json) } @@ -200,11 +241,11 @@ async fn fetch_v1_models( /// /// Filters to endpoints that are READY and serve an LLM chat/completions task. /// When `state.ready` or `task` is absent the endpoint is included — prefer -/// including over silently dropping, per spec. -pub(crate) fn parse_v1_endpoints(json: &serde_json::Value) -> Result, AgentError> { +/// including over silently dropping, per the existing v1 contract. +pub(crate) fn parse_v1_endpoints(json: &Value) -> Result, AgentError> { let endpoints = json .get("endpoints") - .and_then(|v| v.as_array()) + .and_then(Value::as_array) .ok_or_else(|| { AgentError::Llm( "Databricks model discovery: unexpected response (missing 'endpoints' array)" @@ -221,7 +262,7 @@ pub(crate) fn parse_v1_endpoints(json: &serde_json::Value) -> Result Result Result String { .collect() } +/// Fetch both Databricks v2 catalogs concurrently and merge them into the +/// selectable model list. One catalog may be unavailable; an empty result is +/// still authoritative and never falls through to the known-model fallback +/// when a visibility filter is active. async fn fetch_v2_models( http: &Client, host: &str, bearer: &str, + filter: Option<&DatabricksModelFilter>, + allow_partial_auth_failure: bool, +) -> Result, AgentError> { + fetch_v2_models_with_policy( + http, + host, + bearer, + filter, + allow_partial_auth_failure, + DEFAULT_CATALOG_REQUEST_POLICY, + ) + .await +} + +async fn fetch_v2_models_with_policy( + http: &Client, + host: &str, + bearer: &str, + filter: Option<&DatabricksModelFilter>, + allow_partial_auth_failure: bool, + policy: CatalogRequestPolicy, ) -> Result, AgentError> { - let mut all_endpoints: Vec = Vec::new(); + let workspace = + fetch_catalog_pages_with_policy(http, host, bearer, WORKSPACE_CATALOG_DESCRIPTOR, policy); + let unity_catalog = + fetch_catalog_pages_with_policy(http, host, bearer, UNITY_CATALOG_DESCRIPTOR, policy); + + let (workspace, unity_catalog) = tokio::join!(workspace, unity_catalog); + let (workspace, unity_catalog, both_succeeded) = match (workspace, unity_catalog) { + (Ok(workspace), Ok(unity_catalog)) => (workspace, unity_catalog, true), + (Ok(workspace), Err(error)) => { + if matches!(&error, AgentError::LlmAuth(_)) && !allow_partial_auth_failure { + return Err(error); + } + tracing::warn!( + catalog = "unity-catalog model-services", + error_kind = catalog_error_kind(&error), + "Databricks model discovery degraded: catalog unavailable" + ); + (workspace, Vec::new(), false) + } + (Err(error), Ok(unity_catalog)) => { + if matches!(&error, AgentError::LlmAuth(_)) && !allow_partial_auth_failure { + return Err(error); + } + tracing::warn!( + catalog = "workspace ai-gateway v2 endpoints", + error_kind = catalog_error_kind(&error), + "Databricks model discovery degraded: catalog unavailable" + ); + (Vec::new(), unity_catalog, false) + } + (Err(workspace_error), Err(unity_catalog_error)) => { + return Err(combined_catalog_error(workspace_error, unity_catalog_error)); + } + }; + + Ok(merge_v2_models( + workspace, + unity_catalog, + filter, + both_succeeded && filter.is_none(), + )) +} + +fn catalog_error_kind(error: &AgentError) -> &'static str { + match error { + AgentError::InvalidParams(_) => "invalid-params", + AgentError::Llm(_) => "llm", + AgentError::LlmAuth(_) => "auth", + AgentError::LlmModelNotFound(_) => "model-not-found", + AgentError::LlmContextExceeded(_) => "context-exceeded", + AgentError::UnsupportedImageInput(_) => "unsupported-image", + AgentError::Mcp(_) => "mcp", + AgentError::Cancelled => "cancelled", + } +} + +fn combined_catalog_error(workspace: AgentError, unity_catalog: AgentError) -> AgentError { + let auth_failure = matches!(&workspace, AgentError::LlmAuth(_)) + || matches!(&unity_catalog, AgentError::LlmAuth(_)); + let message = format!( + "Databricks v2 model discovery failed: workspace endpoint catalog: {workspace}; Unity Catalog model-service catalog: {unity_catalog}" + ); + if auth_failure { + AgentError::LlmAuth(message) + } else { + AgentError::Llm(message) + } +} + +fn merge_v2_models( + workspace: Vec, + mut unity_catalog: Vec, + filter: Option<&DatabricksModelFilter>, + allow_known_model_fallback: bool, +) -> Vec { + let mut seen_ids = HashSet::new(); + let mut merged = Vec::with_capacity(workspace.len() + unity_catalog.len()); + + // Workspace endpoints are ordered newest-first across all pages. + let mut workspace = workspace; + sort_v2_endpoints_newest_first(&mut workspace); + for endpoint in workspace { + if seen_ids.insert(endpoint.entry.id.clone()) { + merged.push(endpoint.entry); + } + } + + // UC has no user-facing recency contract. Sort by the raw FQN for stable + // picker order, then deduplicate only by raw selectable id. + unity_catalog.sort_unstable_by(|a, b| a.id.cmp(&b.id)); + for entry in unity_catalog { + if seen_ids.insert(entry.id.clone()) { + merged.push(entry); + } + } + + if merged.is_empty() && allow_known_model_fallback && filter.is_none() { + merged = authenticated_empty_v2_catalog(); + } + + apply_model_filter(merged, filter) +} + +async fn fetch_catalog_pages_with_policy( + http: &Client, + host: &str, + bearer: &str, + descriptor: CatalogDescriptor, + policy: CatalogRequestPolicy, +) -> Result, AgentError> { + let CatalogDescriptor { + name: catalog, + path, + initial_query, + parse_page, + } = descriptor; + let base_url = format!("{host}{path}"); + let mut all_items = Vec::new(); let mut page_token: Option = None; - let base_url = format!("{host}/api/ai-gateway/v2/endpoints"); + let mut seen_tokens = HashSet::new(); - // Cap at 20 pages (2 000 endpoints) to bound execution time. - for _ in 0..20 { - // Build URL with query params manually — avoids requiring the `query` - // reqwest feature in buzz-agent's Cargo.toml. + for _page in 0..MAX_CATALOG_PAGES { let url = match &page_token { - Some(tok) => format!( - "{base_url}?page_size=100&page_token={}", - percent_encode(tok) + Some(token) => format!( + "{base_url}{initial_query}&page_token={}", + percent_encode(token) ), - None => format!("{base_url}?page_size=100"), + None => format!("{base_url}{initial_query}"), }; - let response = http - .get(&url) - .bearer_auth(bearer) - .send() - .await - .map_err(|e| { - AgentError::Llm(format!("Databricks v2 model discovery request failed: {e}")) - })?; - - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - if status.as_u16() == 401 { - return Err(AgentError::LlmAuth(format!( - "Databricks v2 model discovery HTTP {status}" + let json = fetch_catalog_page(http, &url, catalog, bearer, policy).await?; + let (items, next_token) = parse_page(&json) + .map_err(|error| catalog_context_error(catalog, error, "response parse failed"))?; + all_items.extend(items); + + match next_token { + None => return Ok(all_items), + Some(next_token) if seen_tokens.insert(next_token.clone()) => { + page_token = Some(next_token); + } + Some(next_token) => { + return Err(AgentError::Llm(format!( + "{catalog} pagination repeated page token {next_token:?}" ))); } - return Err(AgentError::Llm(format!( - "Databricks v2 model discovery HTTP {status}: {body}" - ))); } + } - let json: serde_json::Value = response.json().await.map_err(|e| { - AgentError::Llm(format!( - "Databricks v2 model discovery response parse failed: {e}" - )) - })?; + Err(AgentError::Llm(format!( + "{catalog} pagination exhausted after {MAX_CATALOG_PAGES} pages" + ))) +} - let (page_endpoints, next) = parse_v2_endpoints_page(&json)?; - all_endpoints.extend(page_endpoints); +struct ReadResponseBody { + bytes: Vec, + truncated: bool, +} + +enum CatalogRequestError { + Auth, + Status { + status: reqwest::StatusCode, + body: String, + }, + Transport(reqwest::Error), + Body(reqwest::Error), + InvalidJson(serde_json::Error), + BodyTooLarge, +} - match next { - Some(tok) if Some(&tok) != page_token.as_ref() => page_token = Some(tok), - _ => break, +async fn fetch_catalog_page( + http: &Client, + url: &str, + catalog: &str, + bearer: &str, + policy: CatalogRequestPolicy, +) -> Result { + let max_retries = policy.max_retries.max(1); + let error_body_limit = if bearer.len() > MAX_CATALOG_ERROR_BODY_BYTES { + 0 + } else { + MAX_CATALOG_ERROR_BODY_BYTES.saturating_add(bearer.len()) + }; + + for attempt in 0..max_retries { + let result = tokio::time::timeout(policy.timeout, async { + let response = http + .get(url) + .bearer_auth(bearer) + .send() + .await + .map_err(CatalogRequestError::Transport)?; + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + // Preserve the auth contract: do not consume an auth-failure + // body because gateways may echo credential material. The + // bounded attempt ends at headers for this intentionally + // redacted branch; all other status/body paths below consume + // their response body inside the same deadline. + return Err(CatalogRequestError::Auth); + } + if !status.is_success() { + let mut response = response; + let body = read_catalog_error_body(&mut response, error_body_limit) + .await + .map_err(CatalogRequestError::Body)?; + return Err(CatalogRequestError::Status { status, body }); + } + + let mut response = response; + if response + .content_length() + .is_some_and(|length| length > MAX_CATALOG_RESPONSE_BODY_BYTES as u64) + { + return Err(CatalogRequestError::BodyTooLarge); + } + let body = read_response_body(&mut response, MAX_CATALOG_RESPONSE_BODY_BYTES) + .await + .map_err(CatalogRequestError::Body)?; + if body.truncated { + return Err(CatalogRequestError::BodyTooLarge); + } + serde_json::from_slice(&body.bytes).map_err(CatalogRequestError::InvalidJson) + }) + .await; + + match result { + Ok(Ok(json)) => return Ok(json), + Ok(Err(CatalogRequestError::Auth)) => { + return Err(AgentError::LlmAuth(format!("{catalog} HTTP 401"))); + } + Ok(Err(CatalogRequestError::Status { status, body })) => { + if (status.as_u16() == 499 || status.is_server_error()) + && retry_catalog_attempt( + catalog, + attempt, + max_retries, + policy.retry_backoff, + Some(status.as_u16()), + "transient status", + ) + .await + { + continue; + } + return Err(catalog_http_error_body(catalog, status, &body, bearer)); + } + Ok(Err(CatalogRequestError::Transport(error))) => { + if (error.is_timeout() || error.is_connect() || error.is_request()) + && retry_catalog_attempt( + catalog, + attempt, + max_retries, + policy.retry_backoff, + None, + "transport error", + ) + .await + { + continue; + } + return Err(AgentError::Llm(format!( + "{catalog} request failed: {error}" + ))); + } + Ok(Err(CatalogRequestError::Body(error))) => { + if retry_catalog_attempt( + catalog, + attempt, + max_retries, + policy.retry_backoff, + None, + "response body error", + ) + .await + { + continue; + } + return Err(AgentError::Llm(format!( + "{catalog} response body read failed: {error}" + ))); + } + Ok(Err(CatalogRequestError::InvalidJson(error))) => { + if retry_catalog_attempt( + catalog, + attempt, + max_retries, + policy.retry_backoff, + None, + "invalid JSON response", + ) + .await + { + continue; + } + return Err(AgentError::Llm(format!( + "{catalog} response parse failed: {error}" + ))); + } + Ok(Err(CatalogRequestError::BodyTooLarge)) => { + return Err(AgentError::Llm(format!( + "{catalog} response exceeded {MAX_CATALOG_RESPONSE_BODY_BYTES} bytes" + ))); + } + Err(_) => { + if retry_catalog_attempt( + catalog, + attempt, + max_retries, + policy.retry_backoff, + None, + "attempt timeout", + ) + .await + { + continue; + } + return Err(AgentError::Llm(format!( + "{catalog} request timed out after {:?}", + policy.timeout + ))); + } } } - // Fall back to known-model list if the API returned nothing. - if all_endpoints.is_empty() { - return Ok(authenticated_empty_v2_catalog()); + Err(AgentError::Llm(format!( + "{catalog} request failed after {max_retries} attempts" + ))) +} + +async fn retry_catalog_attempt( + catalog: &str, + attempt: usize, + max_attempts: usize, + backoff: Duration, + status: Option, + reason: &'static str, +) -> bool { + if attempt + 1 >= max_attempts { + return false; } - sort_v2_endpoints_newest_first(&mut all_endpoints); + tracing::warn!( + catalog, + attempt = attempt + 1, + max_attempts, + status = ?status, + reason, + "Databricks model discovery catalog request retrying" + ); + tokio::time::sleep(backoff).await; + true +} + +fn catalog_http_error_body( + catalog: &str, + status: reqwest::StatusCode, + body: &str, + bearer: &str, +) -> AgentError { + if status == reqwest::StatusCode::UNAUTHORIZED { + return AgentError::LlmAuth(format!("{catalog} HTTP {status}")); + } - Ok(all_endpoints - .into_iter() - .map(|endpoint| endpoint.entry) - .collect()) + let body = if bearer.len() > MAX_CATALOG_ERROR_BODY_BYTES { + String::new() + } else if bearer.is_empty() { + body.to_string() + } else { + body.replace(bearer, "[redacted]") + }; + let body = truncate_utf8_bytes(&body, MAX_CATALOG_ERROR_BODY_BYTES); + let classification = if status.as_u16() == 499 || status.is_server_error() { + "transient" + } else { + "failed" + }; + AgentError::Llm(format!("{catalog} {classification} HTTP {status}: {body}")) +} + +async fn read_response_body( + response: &mut reqwest::Response, + limit: usize, +) -> Result { + let mut bytes = Vec::with_capacity(limit.min(16 * 1024)); + if limit == 0 { + return Ok(ReadResponseBody { + bytes, + truncated: true, + }); + } + + loop { + if bytes.len() == limit { + // Probe one frame past the bound. Without this read, a chunked body + // whose first chunk lands exactly on `limit` would be accepted + // without noticing the next frame. + let truncated = response.chunk().await?.is_some(); + return Ok(ReadResponseBody { bytes, truncated }); + } + + let Some(chunk) = response.chunk().await? else { + return Ok(ReadResponseBody { + bytes, + truncated: false, + }); + }; + let remaining = limit - bytes.len(); + if chunk.len() > remaining { + bytes.extend_from_slice(&chunk[..remaining]); + return Ok(ReadResponseBody { + bytes, + truncated: true, + }); + } + bytes.extend_from_slice(&chunk); + } } -/// A v2 gateway endpoint plus the key discovery orders the catalog by. +async fn read_catalog_error_body( + response: &mut reqwest::Response, + limit: usize, +) -> Result { + let body = read_response_body(response, limit).await?; + Ok(String::from_utf8_lossy(&body.bytes).into_owned()) +} + +fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_string(); + } + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +fn catalog_context_error(catalog: &str, error: AgentError, context: &str) -> AgentError { + match error { + AgentError::LlmAuth(message) => { + AgentError::LlmAuth(format!("{catalog} {context}: {message}")) + } + AgentError::Llm(message) => AgentError::Llm(format!("{catalog} {context}: {message}")), + other => AgentError::Llm(format!("{catalog} {context}: {other}")), + } +} + +/// A v2 gateway endpoint plus the key discovery order field. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct V2Endpoint { pub(crate) entry: ModelEntry, @@ -348,24 +796,15 @@ pub(crate) struct V2Endpoint { /// /// The gateway sends epoch milliseconds as a JSON *string* /// (`"created_timestamp": "1699610000000"`); accept a bare number too, so a -/// wire-shape change doesn't silently drop every endpoint to the bottom. -fn endpoint_created_ms(endpoint: &serde_json::Value) -> Option { +/// wire-shape change does not silently drop every endpoint to the bottom. +fn endpoint_created_ms(endpoint: &Value) -> Option { let value = endpoint.get("created_timestamp")?; value .as_i64() .or_else(|| value.as_str()?.trim().parse::().ok()) } -/// Order the catalog newest-first, breaking ties by name. -/// -/// The gateway returns endpoints in two phases — Databricks-managed first, then -/// workspace-created — each alphabetical by name, which buries a brand-new -/// frontier model deep in the list. Newest-first puts the models people are -/// reaching for at the top of the picker. -/// -/// Endpoints with no usable timestamp sort last, and the name tiebreak keeps the -/// result stable: several managed endpoints share one placeholder timestamp, so -/// without it their relative order would be arbitrary. +/// Order workspace endpoints newest-first, breaking ties by name. pub(crate) fn sort_v2_endpoints_newest_first(endpoints: &mut [V2Endpoint]) { endpoints.sort_by(|a, b| { // `None` < `Some(_)`, so reversing puts timestamped endpoints first. @@ -377,29 +816,20 @@ pub(crate) fn sort_v2_endpoints_newest_first(endpoints: &mut [V2Endpoint]) { /// Parse one page of a `GET api/ai-gateway/v2/endpoints` response. /// -/// Returns `(endpoints, next_page_token)`. An empty or absent `next_page_token` -/// signals the last page. Endpoints that cannot serve chat traffic are dropped -/// (see [`is_chat_capable_endpoint`]) so the model picker only offers models the -/// agent can actually run. Page order is preserved here; the caller sorts once -/// every page is in (see [`sort_v2_endpoints_newest_first`]). +/// Page order is preserved here; the caller sorts once every page is in. pub(crate) fn parse_v2_endpoints_page( - json: &serde_json::Value, + json: &Value, ) -> Result<(Vec, Option), AgentError> { let endpoints = json .get("endpoints") - .and_then(|v| v.as_array()) - .ok_or_else(|| { - AgentError::Llm( - "Databricks v2 model discovery: unexpected response (missing 'endpoints' array)" - .into(), - ) - })?; + .and_then(Value::as_array) + .ok_or_else(|| AgentError::Llm("unexpected response (missing 'endpoints' array)".into()))?; let models = endpoints .iter() .filter_map(|endpoint| { let name = endpoint.get("name")?.as_str()?.to_string(); - if !is_chat_capable_endpoint(&name) { + if name.is_empty() || !is_chat_capable_endpoint(&name) { return None; } Some(V2Endpoint { @@ -412,15 +842,66 @@ pub(crate) fn parse_v2_endpoints_page( }) .collect(); - let next_page_token = json - .get("next_page_token") - .and_then(|v| v.as_str()) - .filter(|token| !token.is_empty()) - .map(str::to_string); - + let next_page_token = next_page_token(json); Ok((models, next_page_token)) } +/// Parse one page of a `GET api/2.1/unity-catalog/model-services` response. +/// +/// Unity Catalog resource names are returned as `model-services/..`. +/// Only the exact resource prefix, a structurally valid three-component FQN, +/// and chat-capable service metadata are selectable. Missing or empty capability +/// metadata is retained for compatibility with older Databricks workspaces; a +/// non-empty capability list must advertise the MLflow chat API used for model- +/// service inference. The positive visibility filter is applied later. +pub(crate) fn parse_uc_model_services_page( + json: &Value, +) -> Result<(Vec, Option), AgentError> { + let services = json + .get("model_services") + .and_then(Value::as_array) + .ok_or_else(|| { + AgentError::Llm("unexpected response (missing 'model_services' array)".into()) + })?; + + let models = services + .iter() + .filter_map(|service| { + let resource_name = service.get("name")?.as_str()?; + let fqn = resource_name.strip_prefix("model-services/")?; + if !crate::model_capabilities::is_databricks_model_service_fqn(fqn) + || !uc_model_service_supports_chat(service) + { + return None; + } + Some(ModelEntry { + id: fqn.to_string(), + name: curated_model_name(fqn), + }) + }) + .collect(); + + Ok((models, next_page_token(json))) +} + +fn uc_model_service_supports_chat(service: &Value) -> bool { + let Some(api_types) = service.get("supported_api_types").and_then(Value::as_array) else { + return true; + }; + + api_types.is_empty() + || api_types + .iter() + .any(|api_type| api_type.as_str() == Some("mlflow/v1/chat/completions")) +} + +fn next_page_token(json: &Value) -> Option { + json.get("next_page_token") + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(str::to_string) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -429,8 +910,25 @@ pub(crate) fn parse_v2_endpoints_page( mod tests { use super::*; use async_trait::async_trait; + use axum::{extract::Query, http::StatusCode, routing::get, Json, Router}; + use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; + const TEST_CATALOG_DESCRIPTOR: CatalogDescriptor = CatalogDescriptor { + name: "test catalog", + path: "/catalog", + initial_query: "?page_size=100", + parse_page: parse_v2_endpoints_page, + }; + + fn test_policy(timeout: Duration, max_retries: usize) -> CatalogRequestPolicy { + CatalogRequestPolicy { + timeout, + max_retries, + retry_backoff: Duration::ZERO, + } + } + struct RefreshingTestTokenSource { refreshes: AtomicUsize, } @@ -490,7 +988,7 @@ mod tests { let source = Arc::new(RefreshingTestTokenSource { refreshes: AtomicUsize::new(0), }); - let cfg = Config::for_discovery(Provider::DatabricksV2, String::new(), host); + let cfg = Config::for_discovery(Provider::DatabricksV2, String::new(), host, None); let models = discover_databricks_models_with_token_source(&cfg, source.clone()) .await .unwrap(); @@ -500,6 +998,555 @@ mod tests { assert_eq!(requests.load(Ordering::SeqCst), 2); } + #[tokio::test] + async fn v2_discovery_merges_workspace_and_unity_catalog_after_filtering() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new() + .route( + "/api/ai-gateway/v2/endpoints", + get(|Query(query): Query>| async move { + assert_eq!(query.get("page_size").map(String::as_str), Some("100")); + Json(serde_json::json!({ + "endpoints": [ + {"name": "blocked-workspace", "created_timestamp": 3}, + {"name": "allowed-workspace", "created_timestamp": 2}, + ], + "next_page_token": null, + })) + }), + ) + .route( + "/api/2.1/unity-catalog/model-services", + get(|Query(query): Query>| async move { + assert_eq!(query.get("page_size").map(String::as_str), Some("100")); + assert_eq!(query.get("view").map(String::as_str), Some("FULL")); + Json(serde_json::json!({ + "model_services": [ + {"name": "model-services/catalog.schema.blocked-service"}, + {"name": "model-services/catalog.schema.allowed-service"}, + {"name": "model-services/catalog.schema.allowed-service"}, + ], + "next_page_token": null, + })) + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let filter = + DatabricksModelFilter::parse(Some("allowed-*,catalog.schema.allowed-*")).unwrap(); + let cfg = Config::for_discovery(Provider::DatabricksV2, "token".into(), host, filter); + let models = discover_databricks_models(&cfg).await.unwrap(); + assert_eq!( + models + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + vec!["allowed-workspace", "catalog.schema.allowed-service"] + ); + } + + #[tokio::test] + async fn v2_discovery_keeps_unity_catalog_when_workspace_catalog_fails() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new() + .route( + "/api/ai-gateway/v2/endpoints", + get(|| async { (StatusCode::SERVICE_UNAVAILABLE, "workspace unavailable") }), + ) + .route( + "/api/2.1/unity-catalog/model-services", + get(|| async { + Json(serde_json::json!({ + "model_services": [ + {"name": "model-services/catalog.schema.uc-service"} + ], + "next_page_token": null, + })) + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let cfg = Config::for_discovery(Provider::DatabricksV2, "token".into(), host, None); + let models = discover_databricks_models(&cfg).await.unwrap(); + assert_eq!( + models + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + vec!["catalog.schema.uc-service"] + ); + } + + #[tokio::test] + async fn v2_empty_catalog_fallback_is_disabled_by_filter() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new() + .route( + "/api/ai-gateway/v2/endpoints", + get(|| async { + Json(serde_json::json!({ + "endpoints": [], + "next_page_token": null, + })) + }), + ) + .route( + "/api/2.1/unity-catalog/model-services", + get(|| async { + Json(serde_json::json!({ + "model_services": [], + "next_page_token": null, + })) + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let unfiltered = + Config::for_discovery(Provider::DatabricksV2, "token".into(), host.clone(), None); + let fallback = discover_databricks_models(&unfiltered).await.unwrap(); + assert_eq!( + fallback + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + crate::model_capabilities::databricks_v2_known_models() + .iter() + .map(String::as_str) + .collect::>() + ); + + let filter = DatabricksModelFilter::parse(Some("no-match")).unwrap(); + let filtered = Config::for_discovery(Provider::DatabricksV2, "token".into(), host, filter); + assert!(discover_databricks_models(&filtered) + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn catalog_pagination_encodes_tokens_and_rejects_repeated_tokens() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/catalog", + get(|Query(query): Query>| async move { + match query.get("page_token").map(String::as_str) { + None => Json(serde_json::json!({ + "endpoints": [{"name": "first"}], + "next_page_token": "token with/slash", + })), + Some("token with/slash") => Json(serde_json::json!({ + "endpoints": [{"name": "second"}], + })), + Some(other) => panic!("unexpected decoded page token: {other}"), + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let entries = fetch_catalog_pages_with_policy( + &Client::new(), + &host, + "token", + TEST_CATALOG_DESCRIPTOR, + DEFAULT_CATALOG_REQUEST_POLICY, + ) + .await + .unwrap(); + assert_eq!(entries.len(), 2); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/catalog", + get(|| async { + Json(serde_json::json!({ + "endpoints": [{"name": "loop"}], + "next_page_token": "same-token", + })) + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let error = fetch_catalog_pages_with_policy( + &Client::new(), + &host, + "token", + TEST_CATALOG_DESCRIPTOR, + DEFAULT_CATALOG_REQUEST_POLICY, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("repeated page token")); + } + + #[tokio::test] + async fn catalog_pagination_errors_after_the_finite_page_cap() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(AtomicUsize::new(0)); + let requests_for_handler = requests.clone(); + let app = Router::new().route( + "/catalog", + get(move |Query(_query): Query>| { + let page = requests_for_handler.fetch_add(1, Ordering::SeqCst) + 1; + async move { + Json(serde_json::json!({ + "endpoints": [{"name": format!("model-{page}")}], + "next_page_token": format!("token-{page}"), + })) + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let error = fetch_catalog_pages_with_policy( + &Client::new(), + &host, + "token", + TEST_CATALOG_DESCRIPTOR, + DEFAULT_CATALOG_REQUEST_POLICY, + ) + .await + .unwrap_err(); + assert!(error + .to_string() + .contains("pagination exhausted after 20 pages")); + assert_eq!(requests.load(Ordering::SeqCst), 20); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn v2_discovery_degrades_a_stalled_secondary_catalog() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new() + .route( + "/api/ai-gateway/v2/endpoints", + get(|| async { + Json(serde_json::json!({ + "endpoints": [{"name": "workspace-only"}], + "next_page_token": null, + })) + }), + ) + .route( + "/api/2.1/unity-catalog/model-services", + get(|| async { + // The handler never sends headers. The catalog attempt + // deadline must still let the workspace result win. + tokio::time::sleep(Duration::from_secs(60)).await; + Json(serde_json::json!({ + "model_services": [], + "next_page_token": null, + })) + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let started = std::time::Instant::now(); + let models = fetch_v2_models_with_policy( + &Client::new(), + &host, + "token", + None, + false, + test_policy(Duration::from_millis(40), 1), + ) + .await + .unwrap(); + + assert!( + started.elapsed() < Duration::from_secs(1), + "stalled catalog exceeded its request deadline: {:?}", + started.elapsed() + ); + assert_eq!( + models + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + vec!["workspace-only"] + ); + } + + #[tokio::test] + async fn catalog_retries_499_and_5xx_then_recovers() { + for status in [ + StatusCode::from_u16(499).unwrap(), + StatusCode::SERVICE_UNAVAILABLE, + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(AtomicUsize::new(0)); + let requests_for_route = requests.clone(); + let app = Router::new().route( + "/catalog", + get(move || { + let attempt = requests_for_route.fetch_add(1, Ordering::SeqCst); + async move { + if attempt == 0 { + Err((status, "provider body secret-token")) + } else { + Ok(Json(serde_json::json!({ + "endpoints": [{"name": "recovered"}], + "next_page_token": null, + }))) + } + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let entries = fetch_catalog_pages_with_policy( + &Client::new(), + &host, + "secret-token", + TEST_CATALOG_DESCRIPTOR, + test_policy(Duration::from_secs(1), 3), + ) + .await + .unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].entry.id, "recovered"); + assert_eq!(requests.load(Ordering::SeqCst), 2); + } + } + + #[tokio::test] + async fn catalog_retries_malformed_json_then_recovers() { + use axum::response::IntoResponse; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(AtomicUsize::new(0)); + let requests_for_route = requests.clone(); + let app = Router::new().route( + "/catalog", + get(move || { + let attempt = requests_for_route.fetch_add(1, Ordering::SeqCst); + async move { + if attempt == 0 { + (StatusCode::OK, "not-json").into_response() + } else { + Json(serde_json::json!({ + "endpoints": [{"name": "json-recovered"}], + "next_page_token": null, + })) + .into_response() + } + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let entries = fetch_catalog_pages_with_policy( + &Client::new(), + &host, + "token", + TEST_CATALOG_DESCRIPTOR, + test_policy(Duration::from_secs(1), 3), + ) + .await + .unwrap(); + + assert_eq!(requests.load(Ordering::SeqCst), 2); + assert_eq!(entries[0].entry.id, "json-recovered"); + } + + #[tokio::test] + async fn catalog_transient_failure_exhausts_exactly_three_attempts_without_bearer_leak() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(AtomicUsize::new(0)); + let requests_for_route = requests.clone(); + let app = Router::new().route( + "/catalog", + get(move || { + requests_for_route.fetch_add(1, Ordering::SeqCst); + async { + ( + StatusCode::SERVICE_UNAVAILABLE, + "provider body secret-token", + ) + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let error = fetch_catalog_pages_with_policy( + &Client::new(), + &host, + "secret-token", + TEST_CATALOG_DESCRIPTOR, + test_policy(Duration::from_secs(1), 3), + ) + .await + .unwrap_err(); + + assert_eq!(requests.load(Ordering::SeqCst), 3); + let message = error.to_string(); + assert!( + message.contains("transient HTTP 503"), + "unexpected error: {message}" + ); + assert!( + message.contains("provider body"), + "body context was lost: {message}" + ); + assert!( + !message.contains("secret-token"), + "bearer leaked through catalog error: {message}" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn catalog_retries_when_headers_arrive_but_response_body_stalls() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(AtomicUsize::new(0)); + let headers_sent = Arc::new(AtomicUsize::new(0)); + let requests_for_server = requests.clone(); + let headers_for_server = headers_sent.clone(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let attempt = requests_for_server.fetch_add(1, Ordering::SeqCst); + let headers_sent = headers_for_server.clone(); + tokio::spawn(async move { + let mut request = Vec::new(); + let mut chunk = [0u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + match socket.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(read) => request.extend_from_slice(&chunk[..read]), + } + } + + if attempt == 0 { + socket + .write_all( + b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/json\r\n\ + Content-Length: 64\r\n\ + Connection: close\r\n\r\n\ + {\"endpoints\": [", + ) + .await + .ok(); + headers_sent.store(1, Ordering::SeqCst); + // Keep the declared body incomplete. The outer attempt + // timeout, not reqwest::send(), must terminate this read. + tokio::time::sleep(Duration::from_secs(60)).await; + } else { + let body = + r#"{"endpoints":[{"name":"body-recovered"}],"next_page_token":null}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.ok(); + } + }); + } + }); + + let entries = fetch_catalog_pages_with_policy( + &Client::new(), + &host, + "token", + TEST_CATALOG_DESCRIPTOR, + test_policy(Duration::from_millis(40), 2), + ) + .await + .unwrap(); + + assert_eq!(headers_sent.load(Ordering::SeqCst), 1); + assert_eq!(requests.load(Ordering::SeqCst), 2); + assert_eq!(entries[0].entry.id, "body-recovered"); + } + #[test] + fn v1_filter_applies_to_raw_ids_after_endpoint_filtering() { + let filter = DatabricksModelFilter::parse(Some("allowed-*")).unwrap(); + let models = apply_model_filter( + vec![ + ModelEntry { + id: "allowed-model".into(), + name: "Allowed".into(), + }, + ModelEntry { + id: "blocked-model".into(), + name: "Blocked".into(), + }, + ], + filter.as_ref(), + ); + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "allowed-model"); + } + + #[test] + fn catalog_error_body_is_bounded_and_redacts_bearer() { + let bearer = "secret-token"; + let provider_body = format!("prefix {bearer} {}", "x".repeat(8_192)); + let status = reqwest::StatusCode::SERVICE_UNAVAILABLE; + let error = catalog_http_error_body("test catalog", status, &provider_body, bearer); + let message = error.to_string(); + assert!( + message.contains("transient HTTP 503"), + "unexpected error: {message}" + ); + assert!( + message.contains("[redacted]"), + "bearer was not redacted: {message}" + ); + assert!(!message.contains(bearer), "bearer leaked: {message}"); + let prefix = format!("llm: test catalog transient HTTP {status}: "); + assert!( + message.starts_with(&prefix), + "unexpected catalog error prefix: message={message:?}, prefix={prefix:?}" + ); + let diagnostic = &message[prefix.len()..]; + assert!( + diagnostic.len() <= MAX_CATALOG_ERROR_BODY_BYTES, + "error body exceeded diagnostic bound: {}", + diagnostic.len() + ); + + // Keep the UTF-8 boundary behavior explicit as well. + let value = format!("{}é", "x".repeat(MAX_CATALOG_ERROR_BODY_BYTES)); + let truncated = truncate_utf8_bytes(&value, MAX_CATALOG_ERROR_BODY_BYTES); + assert_eq!(truncated.len(), MAX_CATALOG_ERROR_BODY_BYTES); + assert!(truncated.is_char_boundary(truncated.len())); + } + #[test] fn v1_parse_filters_ready_chat_endpoints() { let json = serde_json::json!({ @@ -599,9 +1646,6 @@ mod tests { #[test] fn v2_parse_drops_embedding_endpoints() { - // The v2 payload carries no `task`, so embedding endpoints are only - // recognisable by name. They reject chat requests, so offering them in - // the picker can only produce a 400 at send time. let json = serde_json::json!({ "endpoints": [ {"name": "databricks-bge-large-en"}, @@ -614,10 +1658,172 @@ mod tests { let (models, _) = parse_v2_endpoints_page(&json).unwrap(); let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect(); - // Image endpoints DO answer chat requests, so they are retained. assert_eq!( ids, - vec!["databricks-claude-opus-5", "databricks-gemini-3-pro-image"] + vec!["databricks-claude-opus-5", "databricks-gemini-3-pro-image",] + ); + } + + #[test] + fn uc_parse_requires_exact_prefix_and_structural_fqn() { + let json = serde_json::json!({ + "model_services": [ + {"name": "model-services/data_tools.goose.kimi-k3"}, + {"name": "model-services/catalog.schema.claude-gpt-5"}, + {"name": "model-services/two.parts"}, + {"name": "model-services/too.many.parts.here"}, + {"name": "Model-services/wrong.case.service"}, + {"name": "models/data_tools.goose.other"}, + {"name": "model-services/.schema.service"}, + {"name": "model-services/catalog..service"}, + {"name": "model-services/catalog.schema."}, + {"name": "model-services/catalog.schema/service"}, + ], + "next_page_token": "next token/1" + }); + + let (models, next) = parse_uc_model_services_page(&json).unwrap(); + let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect(); + assert_eq!( + ids, + vec!["data_tools.goose.kimi-k3", "catalog.schema.claude-gpt-5"] + ); + assert_eq!(next.as_deref(), Some("next token/1")); + } + + #[test] + fn uc_parse_filters_known_non_chat_services_and_preserves_unknown_capabilities() { + let json = serde_json::json!({ + "model_services": [ + { + "name": "model-services/system.ai.chat-model", + "supported_api_types": [ + "mlflow/v1/chat/completions", + "mlflow/v1/responses" + ] + }, + { + "name": "model-services/system.ai.embedding-model", + "supported_api_types": ["mlflow/v1/embeddings"] + }, + { + "name": "model-services/system.ai.responses-only-model", + "supported_api_types": ["mlflow/v1/responses"] + }, + { + "name": "model-services/catalog.schema.empty-capabilities", + "supported_api_types": [] + }, + {"name": "model-services/catalog.schema.absent-capabilities"}, + ] + }); + + let (models, _) = parse_uc_model_services_page(&json).unwrap(); + let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect(); + assert_eq!( + ids, + vec![ + "system.ai.chat-model", + "catalog.schema.empty-capabilities", + "catalog.schema.absent-capabilities", + ] + ); + } + + #[test] + fn uc_parse_requires_model_services_array() { + let err = parse_uc_model_services_page(&serde_json::json!({"data": []})).unwrap_err(); + assert!(err.to_string().contains("missing 'model_services' array")); + } + + #[test] + fn merge_deduplicates_raw_ids_and_preserves_workspace_then_lexical_uc_order() { + let workspace = vec![ + V2Endpoint { + entry: ModelEntry { + id: "workspace-new".into(), + name: "workspace-new".into(), + }, + created_ms: Some(2), + }, + V2Endpoint { + entry: ModelEntry { + id: "duplicate".into(), + name: "duplicate".into(), + }, + created_ms: Some(1), + }, + ]; + let uc = vec![ + ModelEntry { + id: "z.schema.service".into(), + name: "z.schema.service".into(), + }, + ModelEntry { + id: "a.schema.service".into(), + name: "a.schema.service".into(), + }, + ModelEntry { + id: "duplicate".into(), + name: "same leaf".into(), + }, + ModelEntry { + id: "a.other.service".into(), + name: "same leaf".into(), + }, + ]; + + let models = merge_v2_models(workspace, uc, None, false); + let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect(); + assert_eq!( + ids, + vec![ + "workspace-new", + "duplicate", + "a.other.service", + "a.schema.service", + "z.schema.service", + ] + ); + } + + #[test] + fn merge_applies_filter_after_union_and_does_not_restore_fallback() { + let filter = DatabricksModelFilter::parse(Some("allowed.*")).unwrap(); + let filter = filter.as_ref(); + let workspace = vec![V2Endpoint { + entry: ModelEntry { + id: "blocked-workspace".into(), + name: "blocked-workspace".into(), + }, + created_ms: Some(1), + }]; + let uc = vec![ModelEntry { + id: "allowed.schema.service".into(), + name: "allowed.schema.service".into(), + }]; + let models = merge_v2_models(workspace, uc, filter, false); + assert_eq!( + models.iter().map(|m| m.id.as_str()).collect::>(), + vec!["allowed.schema.service"] + ); + + let no_match = DatabricksModelFilter::parse(Some("no-match")).unwrap(); + assert!(merge_v2_models(Vec::new(), Vec::new(), no_match.as_ref(), true).is_empty()); + } + + #[test] + fn merge_uses_known_fallback_only_for_unfiltered_successful_empty_union() { + let models = merge_v2_models(Vec::new(), Vec::new(), None, true); + assert_eq!( + models + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + crate::model_capabilities::databricks_v2_known_models() + .iter() + .map(String::as_str) + .collect::>() ); } @@ -736,16 +1942,4 @@ mod tests { "custom-unlisted-endpoint" ); } - - #[test] - fn is_chat_capable_endpoint_keeps_unrecognised_names() { - // Prefer including over silently dropping — an unknown family is kept. - assert!(is_chat_capable_endpoint("databricks-glm-5-2")); - assert!(is_chat_capable_endpoint("some-teams-custom-endpoint")); - // `bge`/`gte` match as whole segments only, never as substrings. - assert!(is_chat_capable_endpoint("databricks-budget-gtex-model")); - assert!(!is_chat_capable_endpoint("databricks-bge-large-en")); - assert!(!is_chat_capable_endpoint("databricks-gte-large-en")); - assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b")); - } } diff --git a/desktop/scripts/demo-build-config.mjs b/desktop/scripts/demo-build-config.mjs index f3bc6f6c3f6..fd5c9ed2a1c 100644 --- a/desktop/scripts/demo-build-config.mjs +++ b/desktop/scripts/demo-build-config.mjs @@ -3,7 +3,11 @@ import { writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const PRODUCTION_IDENTIFIER = "xyz.block.buzz.app"; -const MAX_DEMO_NAME_LENGTH = 48; +// The build ID suffix is 17 characters including its separator, and the Rust +// build contract caps the complete demo slug at 48 ASCII bytes. +const MAX_DEMO_SLUG_LENGTH = 48; +const DEMO_BUILD_ID_SUFFIX_LENGTH = 17; +const MAX_DEMO_NAME_LENGTH = MAX_DEMO_SLUG_LENGTH - DEMO_BUILD_ID_SUFFIX_LENGTH; export const productionBuildIdentity = Object.freeze({ productName: "Buzz", diff --git a/desktop/scripts/demo-build-config.test.mjs b/desktop/scripts/demo-build-config.test.mjs index e451cff6f5e..db2ba568c7b 100644 --- a/desktop/scripts/demo-build-config.test.mjs +++ b/desktop/scripts/demo-build-config.test.mjs @@ -115,13 +115,19 @@ test("whitespace normalization preserves deterministic identity", () => { ); }); +test("maximum-length name produces a Rust-valid 48-byte slug", () => { + const config = demoBuildConfig("x".repeat(31), "1234567812345678"); + assert.equal(config.slug.length, 48); + assert.match(config.slug, /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/); +}); + for (const name of [ "", " ", "Workstream/Board", "Workstream_Board", "équipe", - "x".repeat(49), + "x".repeat(32), ]) { test(`rejects unusable name ${JSON.stringify(name)}`, () => assert.throws(() => demoBuildConfig(name, "1234567812345678"))); diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs index 5d319d0dca9..2668ee41e71 100644 --- a/desktop/src-tauri/src/build_identity.rs +++ b/desktop/src-tauri/src/build_identity.rs @@ -91,6 +91,14 @@ pub(crate) fn cli_name(is_dev: bool) -> String { mod tests { use super::*; + #[test] + #[ignore = "compiled with BUZZ_BUILD_DEMO_SLUG by the compiled-flags recipe"] + fn compiled_demo_slug_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_DEMO_SLUG") + .expect("BUZZ_TEST_EXPECTED_DEMO_SLUG must be set"); + assert_eq!(demo_slug(), Some(expected.as_str())); + } + #[test] fn ordinary_release_defaults_remain_production_identity() { if demo_slug().is_none() { From 54ad64f9ddeb177bf924c6ad13f3614420108101 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:15:28 -0400 Subject: [PATCH 15/17] fix(desktop): complete demo link and credential reset boundaries Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/deep_link.rs | 28 ++-- desktop/src-tauri/src/deep_link_tests.rs | 38 +++++- desktop/src-tauri/src/reset.rs | 103 ++++----------- desktop/src-tauri/src/reset_demo_tests.rs | 151 ++++++++++++++++++++++ desktop/src/shared/deep-link.test.mjs | 63 ++++++++- 5 files changed, 294 insertions(+), 89 deletions(-) create mode 100644 desktop/src-tauri/src/reset_demo_tests.rs diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 8446c59101a..614c62e1aaf 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -404,6 +404,18 @@ const ENTITY_LINK_TABS: [&str; 6] = [ "channels", ]; +/// Validate the build-specific transport URL, then hand the frontend its +/// canonical entity-link representation. Never broaden frontend scheme trust. +fn canonical_entity_deep_link(url: &Url, build_scheme: &str) -> Option { + if url.scheme() != build_scheme { + return None; + } + parse_entity_deep_link(url)?; + let mut canonical = url.clone(); + canonical.set_scheme("buzz").ok()?; + Some(canonical.into()) +} + /// The canonical-form rules match `parseEntityLink`: no path segments, no /// fragment, and no parameters beyond `owner`/`d` (plus `id` for event /// links and the optional `tab` for coordinate links), so a future @@ -678,17 +690,17 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let _ = app.emit("deep-link-message", payload); } Some("repo" | "project" | "pr" | "issue") => { - // `buzz://repo|project?owner=&d=` and - // `buzz://pr|issue?id=&owner=&d=` — the - // share links copied from the Projects UI. The frontend owns - // routing (`useEntityDeepLinks`), so the validated URL is - // forwarded unchanged. - if parse_entity_deep_link(&url).is_none() { + // OS routing uses this build's scheme; frontend navigation consumes + // canonical buzz:// entity links rather than transport identity. + let Some(href) = canonical_entity_deep_link( + &url, + crate::build_identity::deep_link_scheme().as_ref(), + ) else { eprintln!("buzz-desktop: malformed entity deep link: {url_str}"); return; - } + }; activate_main_window(app); - let pending = queue_entity_deep_link(app, url_str.to_owned()); + let pending = queue_entity_deep_link(app, href); let _ = app.emit("deep-link-entity", pending); } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs index 84a08c4c64e..da960f3a2d9 100644 --- a/desktop/src-tauri/src/deep_link_tests.rs +++ b/desktop/src-tauri/src/deep_link_tests.rs @@ -1,10 +1,11 @@ use url::Url; use super::{ - parse_add_community_deep_link, parse_channel_deep_link, parse_entity_deep_link, - parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, - PendingCommunityDeepLink, PendingCommunityDeepLinks, PendingEntityDeepLinks, - PendingNavigationDeepLink, PendingNavigationDeepLinks, ENTITY_LINK_TABS, + canonical_entity_deep_link, parse_add_community_deep_link, parse_channel_deep_link, + parse_entity_deep_link, parse_join_deep_link, parse_message_deep_link, + parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, + PendingEntityDeepLinks, PendingNavigationDeepLink, PendingNavigationDeepLinks, + ENTITY_LINK_TABS, }; fn entity_link_golden() -> serde_json::Value { @@ -12,6 +13,35 @@ fn entity_link_golden() -> serde_json::Value { .expect("valid entity-links golden fixture") } +#[test] +fn demo_entity_transport_produces_the_frontend_golden_contract() { + let golden = entity_link_golden(); + let scheme = "buzz-demo-board-1234567812345678"; + for canonical in golden["links"].as_object().unwrap().values() { + let canonical = canonical.as_str().unwrap(); + let transport = Url::parse(&canonical.replacen("buzz:", &format!("{scheme}:"), 1)).unwrap(); + let href = canonical_entity_deep_link(&transport, scheme).unwrap(); + // This same fixture is parsed and routed by the frontend entity tests. + assert_eq!(href, canonical); + let queue = PendingEntityDeepLinks::default(); + let pending = queue.enqueue(href); + assert_eq!(queue.first().unwrap().href, canonical); + assert!(queue.acknowledge(&pending.id)); + assert!(queue.first().is_none()); + assert!(canonical_entity_deep_link(&transport, "buzz").is_none()); + assert!( + canonical_entity_deep_link(&transport, "buzz-demo-other-8765432187654321").is_none() + ); + assert!(canonical_entity_deep_link(&Url::parse(canonical).unwrap(), scheme).is_none()); + assert_eq!( + canonical_entity_deep_link(&Url::parse(canonical).unwrap(), "buzz").as_deref(), + Some(canonical) + ); + } + let invalid = Url::parse(&format!("{scheme}://repo?owner=bad&d=repo")).unwrap(); + assert!(canonical_entity_deep_link(&invalid, scheme).is_none()); +} + #[test] fn parse_entity_deep_link_accepts_every_share_link_shape() { let golden = entity_link_golden(); diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index 09d95442585..e2b7144a8d6 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -225,9 +225,22 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom if let Some(ref nest) = ctx.nest_dir { let _ = std::fs::remove_dir_all(nest); } - if let Some(ref demo_config_dir) = ctx.demo_config_dir { - let _ = std::fs::remove_dir_all(demo_config_dir); - } + // A demo owns credentials here. Failure to remove them must keep the reset + // pending, even if the app data and keychain were successfully wiped. + let demo_config_removed = + ctx.demo_config_dir + .as_ref() + .is_none_or(|path| match std::fs::remove_dir_all(path) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => { + eprintln!( + "buzz-desktop reset: remove demo config {}: {error}", + path.display() + ); + false + } + }); if let Some(ref home) = ctx.home_dir { if !ctx.is_demo { let _ = std::fs::remove_dir_all(home.join(".sprout")); @@ -288,6 +301,11 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom .map(|p| !p.exists()) .unwrap_or(true); let nest_gone = ctx.nest_dir.as_ref().map(|n| !n.exists()).unwrap_or(true); + // `exists()` treats metadata errors as absence. Only NotFound establishes + // that credentials are gone; a dangling symlink is not an absent root. + let demo_config_gone = ctx.demo_config_dir.as_ref().is_none_or(|path| { + matches!(std::fs::symlink_metadata(path), Err(error) if error.kind() == std::io::ErrorKind::NotFound) + }); let trash_app_gone = !trash_app.exists(); let trash_legacy_gone = trash_legacy.as_ref().map(|p| !p.exists()).unwrap_or(true); let trash_webkit_gone = trash_webkit.as_ref().map(|p| !p.exists()).unwrap_or(true); @@ -296,6 +314,8 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom || !app_data_gone || !legacy_gone || !nest_gone + || !demo_config_removed + || !demo_config_gone || !trash_app_gone || !trash_legacy_gone || !trash_webkit_gone @@ -303,6 +323,7 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom eprintln!( "buzz-desktop reset: verification failed (keychain_wiped={keychain_ok}, \ app_data_gone={app_data_gone}, legacy_gone={legacy_gone}, nest_gone={nest_gone}, \ + demo_config_removed={demo_config_removed}, demo_config_gone={demo_config_gone}, \ trash_app_gone={trash_app_gone}, trash_legacy_gone={trash_legacy_gone}, \ trash_webkit_gone={trash_webkit_gone})" ); @@ -333,6 +354,10 @@ mod tests { use std::cell::Cell; use tempfile::TempDir; + mod demo { + include!("reset_demo_tests.rs"); + } + // ── Fake keychain ───────────────────────────────────────────────────────── struct FakeKeychain { @@ -834,78 +859,6 @@ mod tests { // ── Test 13: keychain-fail restores all dirs, retry cleans trash ────── - #[test] - fn test_demo_reset_preserves_shared_and_other_build_state() { - let tmp = TempDir::new().unwrap(); - let home = tmp.path().join("home"); - let app_data = tmp - .path() - .join("Application Support") - .join("xyz.block.buzz.app.demo.current-1234567812345678"); - let demo_nest = home.join(".buzz-demo-current-1234567812345678"); - let prod_nest = home.join(".buzz"); - let other_demo_nest = home.join(".buzz-demo-other-8765432187654321"); - let shared_sprout = home.join(".sprout"); - let shared_agent = home.join(".config").join("buzz-agent"); - let demo_config = home - .join("Library") - .join("Application Support") - .join("buzz-demo-current-1234567812345678"); - let demo_oauth = demo_config.join("buzz-agent").join("oauth"); - let other_demo_config = home - .join("Library") - .join("Application Support") - .join("buzz-demo-other-8765432187654321"); - let other_demo_oauth = other_demo_config.join("buzz-agent").join("oauth"); - - for path in [ - &app_data, - &demo_nest, - &prod_nest, - &other_demo_nest, - &shared_sprout, - &shared_agent, - &demo_oauth, - &other_demo_oauth, - ] { - std::fs::create_dir_all(path).unwrap(); - } - write_sentinel(&app_data).unwrap(); - - let kc = FakeKeychain::ok(); - let ctx = ResetContext { - app_data_dir: &app_data, - legacy_app_data_dir: None, - nest_dir: Some(demo_nest.clone()), - keychain: &kc, - home_dir: Some(home), - is_dev: false, - demo_config_dir: Some(demo_config.clone()), - is_demo: true, - }; - - let outcome = run_boot_reset_with_keychain(ctx); - - assert!(outcome.completed, "demo reset must complete"); - assert!(!app_data.exists(), "demo app data must be wiped"); - assert!(!demo_nest.exists(), "selected demo nest must be wiped"); - assert!( - !demo_config.exists(), - "selected demo auth root must be wiped" - ); - assert!( - other_demo_oauth.exists(), - "another demo's concrete auth root must survive" - ); - assert!(prod_nest.exists(), "production nest must survive"); - assert!(other_demo_nest.exists(), "another demo nest must survive"); - assert!(shared_sprout.exists(), "shared legacy state must survive"); - assert!( - shared_agent.exists(), - "shared agent auth state must survive" - ); - } - #[test] fn test_keychain_fail_restores_all_then_retry_cleans() { let tmp = TempDir::new().unwrap(); diff --git a/desktop/src-tauri/src/reset_demo_tests.rs b/desktop/src-tauri/src/reset_demo_tests.rs new file mode 100644 index 00000000000..4e28b7ba255 --- /dev/null +++ b/desktop/src-tauri/src/reset_demo_tests.rs @@ -0,0 +1,151 @@ +use super::*; + +#[test] +fn test_demo_reset_preserves_shared_and_other_build_state() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let app_data = tmp + .path() + .join("Application Support") + .join("xyz.block.buzz.app.demo.current-1234567812345678"); + let demo_nest = home.join(".buzz-demo-current-1234567812345678"); + let prod_nest = home.join(".buzz"); + let other_demo_nest = home.join(".buzz-demo-other-8765432187654321"); + let shared_sprout = home.join(".sprout"); + let shared_agent = home.join(".config").join("buzz-agent"); + let demo_config = home + .join("Library") + .join("Application Support") + .join("buzz-demo-current-1234567812345678"); + let demo_oauth = demo_config.join("buzz-agent").join("oauth"); + let other_demo_config = home + .join("Library") + .join("Application Support") + .join("buzz-demo-other-8765432187654321"); + let other_demo_oauth = other_demo_config.join("buzz-agent").join("oauth"); + + for path in [ + &app_data, + &demo_nest, + &prod_nest, + &other_demo_nest, + &shared_sprout, + &shared_agent, + &demo_oauth, + &other_demo_oauth, + ] { + std::fs::create_dir_all(path).unwrap(); + } + write_sentinel(&app_data).unwrap(); + + let kc = FakeKeychain::ok(); + let ctx = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: None, + nest_dir: Some(demo_nest.clone()), + keychain: &kc, + home_dir: Some(home), + is_dev: false, + demo_config_dir: Some(demo_config.clone()), + is_demo: true, + }; + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.completed, "demo reset must complete"); + assert!(!app_data.exists(), "demo app data must be wiped"); + assert!(!demo_nest.exists(), "selected demo nest must be wiped"); + assert!( + !demo_config.exists(), + "selected demo auth root must be wiped" + ); + assert!( + other_demo_oauth.exists(), + "another demo's concrete auth root must survive" + ); + assert!(prod_nest.exists(), "production nest must survive"); + assert!(other_demo_nest.exists(), "another demo nest must survive"); + assert!(shared_sprout.exists(), "shared legacy state must survive"); + assert!( + shared_agent.exists(), + "shared agent auth state must survive" + ); +} + +#[test] +fn demo_config_delete_failure_keeps_sentinel_until_retry() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let config = tmp.path().join("demo-config"); + let production = tmp.path().join("production/oauth/token.json"); + let sibling = tmp.path().join("sibling/oauth/token.json"); + for path in [&production, &sibling] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "preserve").unwrap(); + } + // A file at the directory path makes remove_dir_all fail on every platform, + // independent of the test user's privileges. + std::fs::write(&config, "obstruction").unwrap(); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let run = || { + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + ctx.demo_config_dir = Some(config.clone()); + run_boot_reset_with_keychain(ctx) + }; + let first = run(); + assert!(first.failed && !first.completed); + assert!(check_sentinel(&app_data)); + assert!(config.exists()); + + std::fs::remove_file(&config).unwrap(); + let token = config.join("buzz-agent/oauth/databricks/token.json"); + std::fs::create_dir_all(token.parent().unwrap()).unwrap(); + std::fs::write(&token, "demo credential").unwrap(); + let second = run(); + assert!(second.completed && !second.failed); + assert!(!check_sentinel(&app_data)); + assert!(!config.exists()); + for path in [&production, &sibling] { + assert_eq!(std::fs::read_to_string(path).unwrap(), "preserve"); + } + // A retry after a crash that already removed the root must also succeed. + write_sentinel(&app_data).unwrap(); + assert!(run().completed); + assert!(!check_sentinel(&app_data)); +} + +#[cfg(unix)] +#[test] +fn demo_oauth_permission_failure_preserves_retry_intent() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let config = tmp.path().join("demo-config"); + let oauth = config.join("buzz-agent/oauth/databricks"); + let token = oauth.join("token.json"); + std::fs::create_dir_all(&oauth).unwrap(); + std::fs::write(&token, "demo credential").unwrap(); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let run = || { + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + ctx.demo_config_dir = Some(config.clone()); + run_boot_reset_with_keychain(ctx) + }; + std::fs::set_permissions(&oauth, std::fs::Permissions::from_mode(0o500)).unwrap(); + let first = run(); + // Restore permissions before assertions so a failure never leaves test debris. + if oauth.exists() { + std::fs::set_permissions(&oauth, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + assert!(first.failed && !first.completed); + assert!(check_sentinel(&app_data)); + assert_eq!(std::fs::read_to_string(&token).unwrap(), "demo credential"); + assert!(run().completed); + assert!(!config.exists()); + assert!(!check_sentinel(&app_data)); +} diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index b6cad59567a..59087861be1 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { afterEach, test } from "node:test"; const ipcHandlers = new Map(); @@ -23,8 +24,11 @@ globalThis.window = { }; globalThis.__TAURI_INTERNALS__ = tauriInternals; -const { listenForNavigationDeepLinks, resetNavigationDeepLinkDrain } = - await import("@/shared/deep-link.ts"); +const { + listenForEntityDeepLinks, + listenForNavigationDeepLinks, + resetNavigationDeepLinkDrain, +} = await import("@/shared/deep-link.ts"); function deferred() { let resolve; @@ -449,3 +453,58 @@ test("rejected navigation remains queued and is not acknowledged", async () => { console.warn = originalWarn; } }); + +for (const delivery of ["cold-start", "running-instance"]) { + test(`canonical native demo entity payloads navigate before acknowledgement: ${delivery}`, async () => { + // Rust's canonical_entity_deep_link test proves all demo transport URLs + // produce these exact shared values, rather than passing demo schemes on. + const golden = JSON.parse( + readFileSync( + new URL("../../../test-fixtures/entity-links.json", import.meta.url), + "utf8", + ), + ); + const { parseEntityLink } = await import("@/shared/lib/entityLink.ts"); + const pending = Object.entries(golden.links).map(([id, href]) => ({ + id, + href, + })); + const queue = delivery === "cold-start" ? [...pending] : []; + const opened = []; + const acknowledged = []; + let notify; + ipcHandlers.set("plugin:event|listen", ({ handler }) => { + notify = callbacks.get(handler); + return handler; + }); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("take_pending_entity_deep_link", () => queue[0] ?? null); + ipcHandlers.set("acknowledge_pending_entity_deep_link", ({ id }) => { + assert.equal(queue[0]?.id, id); + assert.equal(opened.length, acknowledged.length + 1); + acknowledged.push(id); + queue.shift(); + return true; + }); + const unlisten = await listenForEntityDeepLinks((href) => { + const parsed = parseEntityLink(href); + assert.equal(parsed.ok, true, href); + opened.push(parsed.value); + return true; + }); + await settle(); + if (delivery === "running-instance") { + queue.push(...pending); + notify({ payload: pending[0] }); + } + await settle(); + await settle(); + assert.deepEqual( + acknowledged, + pending.map(({ id }) => id), + ); + assert.equal(opened.length, pending.length); + assert.equal(queue.length, 0); + unlisten(); + }); +} From 7c02b813cf077438d67dc630077c5c3a5b046332 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:23:24 -0400 Subject: [PATCH 16/17] fix(desktop): refuse unresolved demo credential roots Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/build_identity.rs | 46 +++++++++++++------ .../src/commands/agent_model_process.rs | 2 +- .../src/commands/agent_models_databricks.rs | 2 +- .../src-tauri/src/managed_agents/runtime.rs | 2 +- desktop/src-tauri/src/reset.rs | 21 ++++++++- desktop/src-tauri/src/reset_demo_tests.rs | 18 ++++++++ 6 files changed, 74 insertions(+), 17 deletions(-) diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs index 2668ee41e71..ee84696c7f0 100644 --- a/desktop/src-tauri/src/build_identity.rs +++ b/desktop/src-tauri/src/build_identity.rs @@ -16,29 +16,34 @@ pub(crate) fn is_demo_build() -> bool { pub(crate) const DEMO_AGENT_CONFIG_ENV: &str = "BUZZ_AGENT_CONFIG_DIR"; -pub(crate) fn demo_config_home() -> Option { +pub(crate) fn demo_config_home() -> Result, String> { demo_config_home_for(demo_slug(), dirs::config_dir()) } -pub(crate) fn demo_agent_oauth_cache_dir() -> Option { - demo_config_home().map(|dir| dir.join("buzz-agent").join("oauth")) +pub(crate) fn demo_agent_oauth_cache_dir() -> Result, String> { + Ok(demo_config_home()?.map(|dir| dir.join("buzz-agent").join("oauth"))) } /// Keep child config caches inside this demo build's identity. In particular, /// bundled buzz-agent OAuth tokens must not read or write production's root. -pub(crate) fn apply_demo_config_home(command: &mut std::process::Command) { - if let Some(config_home) = demo_config_home() { +/// Refuse launch if a demo cannot resolve its root; None means production only. +pub(crate) fn apply_demo_config_home(command: &mut std::process::Command) -> Result<(), String> { + if let Some(config_home) = demo_config_home()? { command.env(DEMO_AGENT_CONFIG_ENV, config_home); } + Ok(()) } fn demo_config_home_for( demo_slug: Option<&str>, config_dir: Option, -) -> Option { - demo_slug - .zip(config_dir) - .map(|(slug, dir)| dir.join(format!("buzz-demo-{slug}"))) +) -> Result, String> { + match demo_slug { + None => Ok(None), + Some(slug) => config_dir + .map(|dir| Some(dir.join(format!("buzz-demo-{slug}")))) + .ok_or_else(|| "cannot resolve demo credential directory".to_string()), + } } pub(crate) fn deep_link_scheme() -> Cow<'static, str> { @@ -112,10 +117,16 @@ mod tests { #[test] fn demo_agent_config_and_oauth_roots_are_build_scoped() { let base = std::path::PathBuf::from("/Users/demo/Library/Application Support"); - assert_eq!(demo_config_home_for(None, Some(base.clone())), None); - let first = - demo_config_home_for(Some("board-1234567812345678"), Some(base.clone())).unwrap(); - let second = demo_config_home_for(Some("board-8765432187654321"), Some(base)).unwrap(); + assert_eq!( + demo_config_home_for(None, Some(base.clone())).unwrap(), + None + ); + let first = demo_config_home_for(Some("board-1234567812345678"), Some(base.clone())) + .unwrap() + .unwrap(); + let second = demo_config_home_for(Some("board-8765432187654321"), Some(base)) + .unwrap() + .unwrap(); assert_eq!( first, std::path::PathBuf::from( @@ -131,6 +142,15 @@ mod tests { assert_ne!(first, second); } + #[test] + fn unresolved_demo_credentials_never_select_production_defaults() { + assert_eq!(demo_config_home_for(None, None).unwrap(), None); + assert_eq!( + demo_config_home_for(Some("board-1234567812345678"), None), + Err("cannot resolve demo credential directory".to_string()) + ); + } + #[test] fn duplicate_instance_links_follow_the_build_scheme() { assert!(is_deep_link_for_scheme("buzz://message?id=1", "buzz")); diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 40bdacaa0e9..f671983bbc6 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -55,7 +55,7 @@ pub(super) async fn run_agent_models_command( cmd.env(k, v); } // Demo identity is authoritative and must win over ambient/user env. - crate::build_identity::apply_demo_config_home(&mut cmd); + crate::build_identity::apply_demo_config_home(&mut cmd)?; crate::managed_agents::configure_runtime_cli(&mut cmd, known_acp_runtime(&agent_command)); crate::util::configure_no_window(&mut cmd); cmd.stdout(std::process::Stdio::piped()) diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs index 8284c98ef14..07f19f9a204 100644 --- a/desktop/src-tauri/src/commands/agent_models_databricks.rs +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -178,7 +178,7 @@ pub(super) async fn discover_databricks_models( parsed_filter.clone(), ); let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &api_key); - let oauth_cache_dir = crate::build_identity::demo_agent_oauth_cache_dir(); + let oauth_cache_dir = crate::build_identity::demo_agent_oauth_cache_dir()?; let entries = match buzz_agent_pkg::discover_databricks_models_with_cache_dir( &config, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 91d8649ebb5..f6bb758fbe5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -808,7 +808,7 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } - crate::build_identity::apply_demo_config_home(&mut command); + crate::build_identity::apply_demo_config_home(&mut command)?; // B5: carry persisted effort; harness resolves thought_level configId at first session. // Written AFTER descriptor.env so the canonical persisted value wins over any diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index e2b7144a8d6..401d63c9c49 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -131,6 +131,16 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { let legacy_dir = crate::migration::legacy_app_data_dir(app_data_dir); let nest_dir = crate::managed_agents::nest_dir(); + let demo_config_dir = match crate::build_identity::demo_config_home() { + Ok(dir) => dir, + Err(error) => { + eprintln!("buzz-desktop reset: {error}"); + return ResetOutcome { + completed: false, + failed: true, + }; + } + }; let ctx = ResetContext { app_data_dir, legacy_app_data_dir: legacy_dir, @@ -138,7 +148,7 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { keychain: &store, home_dir, is_dev, - demo_config_dir: crate::build_identity::demo_config_home(), + demo_config_dir, is_demo: crate::build_identity::is_demo_build(), }; @@ -173,6 +183,15 @@ fn rename_to_trash(src: &Path) -> Result { /// Core wipe logic — separated for testing. pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcome { + // An unknown demo credential root is not evidence of an absent root. Refuse + // before any destructive work and retain reset intent for the next boot. + if ctx.is_demo && ctx.demo_config_dir.is_none() { + eprintln!("buzz-desktop reset: cannot resolve demo credential directory"); + return ResetOutcome { + completed: false, + failed: true, + }; + } let app_data_dir = ctx.app_data_dir; // ── Step 1: rename app-data dir (atomic — sentinel survives the parent) ── diff --git a/desktop/src-tauri/src/reset_demo_tests.rs b/desktop/src-tauri/src/reset_demo_tests.rs index 4e28b7ba255..9db2ab3dc74 100644 --- a/desktop/src-tauri/src/reset_demo_tests.rs +++ b/desktop/src-tauri/src/reset_demo_tests.rs @@ -149,3 +149,21 @@ fn demo_oauth_permission_failure_preserves_retry_intent() { assert!(!config.exists()); assert!(!check_sentinel(&app_data)); } + +#[test] +fn unresolved_demo_config_keeps_reset_pending_without_deleting_state() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + assert!(ctx.demo_config_dir.is_none()); + let outcome = run_boot_reset_with_keychain(ctx); + assert!(outcome.failed && !outcome.completed); + assert!(check_sentinel(&app_data)); + assert!( + app_data.exists(), + "unresolved root must refuse before wiping" + ); +} From 11ce21ff97cb387ad676e7caa65b00964097d0bb Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:36:00 -0400 Subject: [PATCH 17/17] test(desktop): run the full suite with demo identity enabled Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- Justfile | 1 + .../src-tauri/src/managed_agents/nest/tests.rs | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Justfile b/Justfile index a5a46b3240a..4a7a3d570de 100644 --- a/Justfile +++ b/Justfile @@ -261,6 +261,7 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" \ BUZZ_TEST_EXPECTED_DEMO_SLUG="$DEMO_SLUG" \ cargo test compiled_demo_slug_matches_expected -- --ignored --nocapture + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" cargo test --workspace if node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..32})" /dev/null 1234567812345678; then echo "A 32-character demo name unexpectedly passed JavaScript validation" >&2 exit 1 diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index fd0f0ce7685..7d54c5a7b07 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -357,13 +357,19 @@ fn ensure_skill_symlinks_skip_dangling_symlink() { } #[test] -fn cli_link_name_prod_is_buzz() { - assert_eq!(cli_link_name(false), "buzz"); +fn cli_link_name_prod_follows_build_identity() { + let expected = crate::build_identity::demo_slug() + .map(|slug| format!("buzz-demo-{slug}")) + .unwrap_or_else(|| "buzz".to_string()); + assert_eq!(cli_link_name(false), expected); } #[test] -fn cli_link_name_dev_is_buzz_dev() { - assert_eq!(cli_link_name(true), "buzz-dev"); +fn cli_link_name_dev_follows_build_identity() { + let expected = crate::build_identity::demo_slug() + .map(|slug| format!("buzz-demo-{slug}")) + .unwrap_or_else(|| "buzz-dev".to_string()); + assert_eq!(cli_link_name(true), expected); } #[cfg(unix)] @@ -395,8 +401,8 @@ fn ensure_cli_symlink_creates_symlink_dev() { let local_bin = tmp.path().join("local_bin"); fs::create_dir_all(&local_bin).unwrap(); - // Dev link must be "buzz-dev", never "buzz". - assert_eq!(cli_link_name(true), "buzz-dev"); + // Dev and demo links must never overwrite production's "buzz". + assert_ne!(cli_link_name(true), "buzz"); let link = local_bin.join(cli_link_name(true)); std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap();