From 4a2305170eef565bf1836e2859247e67c030f8af Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Tue, 4 Aug 2026 15:20:49 -0700 Subject: [PATCH 1/4] fix: reauthenticate databricks model discovery (#4008) ## Summary - preserve Databricks catalog 401 responses as authentication failures and retry discovery exactly once after silently refreshing the rejected bearer - preserve runtime OAuth recovery: when discovery has no usable OAuth credential, `session/new` succeeds with only the trimmed configured model so the first `session/prompt` can run the existing browser PKCE flow - reject a rejected configured `DATABRICKS_TOKEN` with actionable, non-interactive guidance; static credentials cannot recover through PKCE - use the configured-model fallback for non-auth discovery failures without caching failed or fallback catalogs, so later sessions retry discovery - keep known Databricks v2 models only for authenticated empty-catalog responses and mark their provenance - resolve discovery before MCP spawn or session registration, preventing failed discovery from leaking resources or consuming session capacity - permit serialized interactive PKCE only from the explicit saved-agent model picker; passive draft discovery never opens a browser ## Runtime flow 1. OAuth discovery attempts cached credentials and silent refresh without opening a browser. 2. If no usable OAuth bearer exists, `session/new` advertises only the configured model and succeeds. 3. The first `session/prompt` uses `TokenSource::bearer()`, which may launch browser PKCE. 4. A later session retries discovery and caches only the authenticated catalog. ## Regression coverage - rejected-but-locally-fresh OAuth bearer performs one refresh and one catalog retry - OAuth mode with no cached token allows `session/new` and returns exactly the trimmed configured model - the OAuth fallback is not cached; a later authenticated session retries discovery and caches the returned catalog - rejected static tokens still reject `session/new` - failed discovery does not consume the sole session slot or spawn the supplied MCP process - Desktop interactive/passive auth intent, static-token redaction, and authenticated empty-catalog provenance ## Verification - `cargo test -p buzz-agent` - `cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib commands::agent_models` - `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` - `cargo fmt --all -- --check` - `git diff --check` - full pre-push hooks ## Review Adversarial review found and drove fixes for session/MCP resource leakage, duplicate concurrent PKCE flows, sensitive error propagation, incorrect 403 reauthentication, missing discovery-level coverage, passive browser launch, and the Desktop file-size ratchet. The final follow-up preserves the existing prompt-time OAuth flow while retaining static-token rejection and pre-allocation discovery ordering. --------- Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- crates/buzz-agent/src/catalog.rs | 253 +++++++------ crates/buzz-agent/src/lib.rs | 253 ++++++------- crates/buzz-agent/src/llm.rs | 34 +- crates/buzz-agent/tests/databricks_oauth.rs | 342 +++++++++++++++++- .../src-tauri/src/commands/agent_models.rs | 111 +----- .../src/commands/agent_models_databricks.rs | 174 +++++++++ .../src/commands/agent_models_tests.rs | 41 +++ 7 files changed, 845 insertions(+), 363 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_models_databricks.rs diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index aa2a121c99..0aaa2da7ea 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -7,12 +7,17 @@ //! //! - Static bearer (`DATABRICKS_TOKEN`): returned immediately. //! - PKCE cache hit: returned from disk without a network round-trip. -//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)` — the -//! caller degrades gracefully; no browser, no hang. +//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)`. +//! +//! This helper never opens a browser. Callers choose whether to reject, degrade, +//! or start a separate interactive authentication flow. + +use std::sync::Arc; use reqwest::Client; use crate::{ + auth::TokenSource, config::{Config, Provider}, llm::build_token_source, types::AgentError, @@ -26,57 +31,22 @@ pub struct ModelEntry { pub name: String, } -/// Known Databricks AI Gateway v2 models — used as a fallback when the -/// `api/ai-gateway/v2/endpoints` call returns an empty list. +/// Known Databricks AI Gateway v2 models — used only when an authenticated +/// `api/ai-gateway/v2/endpoints` call succeeds with an empty list. /// Mirrors goose's `DATABRICKS_V2_KNOWN_MODELS`. pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = &["databricks-gpt-5-5", "databricks-claude-opus-4-7"]; -/// Returns the discovery-failure fallback catalog for a Databricks provider. -/// -/// This is the list of models advertised by `session/new` when -/// `discover_databricks_models` returns an error (e.g., no token available). -/// -/// - `DatabricksV2` falls back to the configured model plus -/// [`DATABRICKS_V2_KNOWN_MODELS`] so the model-picker is always populated for -/// AI Gateway v2 users. The configured model leads: without it a fallback -/// catalog can omit the very model the agent is running, leaving the picker -/// unable to represent the current selection. -/// - Legacy `Databricks` falls back to only the configured model — the -/// `DATABRICKS_V2_KNOWN_MODELS` IDs are AI Gateway v2 endpoints that the -/// `/serving-endpoints/{model}/invocations` API may not serve. -/// -/// Extracting this as a pure function makes the split testable without -/// spawning an async runtime or making network calls. -pub fn discovery_failure_fallback(provider: Provider, configured_model: &str) -> Vec { - // `resolve_model` does not trim, so a padded `DATABRICKS_MODEL` reaches here: - // normalize once, or the dedupe below misses and the picker lists the model - // twice (once padded, once from the known slate). - let configured_model = configured_model.trim(); - let configured = ModelEntry { - id: configured_model.to_string(), - name: configured_model.to_string(), - }; - match provider { - Provider::DatabricksV2 => { - let mut entries = Vec::with_capacity(DATABRICKS_V2_KNOWN_MODELS.len() + 1); - if !configured_model.is_empty() { - entries.push(configured); - } - entries.extend( - DATABRICKS_V2_KNOWN_MODELS - .iter() - .filter(|id| **id != configured_model) - .map(|id| ModelEntry { - id: id.to_string(), - name: id.to_string(), - }), - ); - entries - } - Provider::Databricks => vec![configured], - _ => vec![configured], - } +const AUTHENTICATED_EMPTY_CATALOG_SUFFIX: &str = " (default catalog)"; + +fn authenticated_empty_v2_catalog() -> Vec { + DATABRICKS_V2_KNOWN_MODELS + .iter() + .map(|id| ModelEntry { + id: id.to_string(), + name: format!("{id}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}"), + }) + .collect() } /// Heuristic: `true` when a v2 AI Gateway endpoint name looks like it serves @@ -109,23 +79,47 @@ pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { /// /// Returns a non-empty `Vec` on success. Returns /// `Err(AgentError::LlmAuth)` when no token is available (no static token, -/// no PKCE cache) — callers should degrade gracefully rather than hanging. +/// no PKCE cache). The helper itself never starts interactive authentication. /// /// # Panics /// Never panics. pub async fn discover_databricks_models(cfg: &Config) -> Result, AgentError> { - let token_source = build_token_source(cfg)?; - let bearer = token_source.bearer_no_browser().await?; + discover_databricks_models_with_token_source(cfg, build_token_source(cfg)?).await +} +async fn discover_databricks_models_with_token_source( + cfg: &Config, + token_source: Arc, +) -> Result, AgentError> { + let mut bearer = token_source.bearer_no_browser().await?; let http = Client::new(); let host = cfg.base_url.trim_end_matches('/'); + let mut refreshed = false; - match cfg.provider { - Provider::Databricks => fetch_v1_models(&http, host, &bearer).await, - Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await, - _ => Err(AgentError::InvalidParams( - "discover_databricks_models called for non-Databricks provider".into(), - )), + loop { + let result = match cfg.provider { + 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(), + )); + } + }; + + match result { + Err(AgentError::LlmAuth(_)) if !refreshed => { + refreshed = true; + let fresh = token_source.refresh_now(&bearer).await?; + if fresh == bearer { + return Err(AgentError::LlmAuth( + "Databricks rejected the configured credential".into(), + )); + } + bearer = fresh; + } + result => return result, + } } } @@ -149,6 +143,11 @@ async fn fetch_v1_models( 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}" ))); @@ -264,6 +263,11 @@ async fn fetch_v2_models( 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}" ))); @@ -286,13 +290,7 @@ async fn fetch_v2_models( // Fall back to known-model list if the API returned nothing. if all_endpoints.is_empty() { - return Ok(DATABRICKS_V2_KNOWN_MODELS - .iter() - .map(|id| ModelEntry { - id: id.to_string(), - name: id.to_string(), - }) - .collect()); + return Ok(authenticated_empty_v2_catalog()); } sort_v2_endpoints_newest_first(&mut all_endpoints); @@ -396,6 +394,77 @@ pub(crate) fn parse_v2_endpoints_page( #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct RefreshingTestTokenSource { + refreshes: AtomicUsize, + } + + #[async_trait] + impl TokenSource for RefreshingTestTokenSource { + async fn bearer(&self) -> Result { + Ok("rejected".into()) + } + + async fn refresh_now(&self, rejected: &str) -> Result { + assert_eq!(rejected, "rejected"); + self.refreshes.fetch_add(1, Ordering::SeqCst); + Ok("fresh".into()) + } + } + + #[tokio::test] + async fn discovery_refreshes_rejected_bearer_once_then_retries_successfully() { + use axum::{ + extract::Query, + http::{HeaderMap, StatusCode}, + routing::get, + Json, Router, + }; + use std::collections::HashMap; + + 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( + "/api/ai-gateway/v2/endpoints", + get( + move |headers: HeaderMap, Query(_query): Query>| { + let requests = requests_for_route.clone(); + async move { + requests.fetch_add(1, Ordering::SeqCst); + match headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + { + Some("Bearer fresh") => Ok(Json(serde_json::json!({ + "endpoints": [{"name": "discovered-model"}], + "next_page_token": null, + }))), + _ => Err((StatusCode::UNAUTHORIZED, "rejected")), + } + } + }, + ), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let source = Arc::new(RefreshingTestTokenSource { + refreshes: AtomicUsize::new(0), + }); + let cfg = Config::for_discovery(Provider::DatabricksV2, String::new(), host); + let models = discover_databricks_models_with_token_source(&cfg, source.clone()) + .await + .unwrap(); + + assert_eq!(models[0].id, "discovered-model"); + assert_eq!(source.refreshes.load(Ordering::SeqCst), 1); + assert_eq!(requests.load(Ordering::SeqCst), 2); + } #[test] fn v1_parse_filters_ready_chat_endpoints() { @@ -574,6 +643,17 @@ mod tests { ); } + #[test] + fn authenticated_empty_v2_catalog_marks_fallback_provenance() { + let models = authenticated_empty_v2_catalog(); + let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect(); + + assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS); + assert!(models.iter().all(|model| { + model.name == format!("{}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}", model.id) + })); + } + #[test] fn is_chat_capable_endpoint_keeps_unrecognised_names() { // Prefer including over silently dropping — an unknown family is kept. @@ -585,47 +665,4 @@ mod tests { assert!(!is_chat_capable_endpoint("databricks-gte-large-en")); assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b")); } - - #[test] - fn v2_discovery_failure_fallback_leads_with_configured_model() { - let result = discovery_failure_fallback(Provider::DatabricksV2, "databricks-claude-opus-5"); - let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - - // The running model must be representable in the picker even when - // discovery failed, so it leads the fallback catalog. - assert_eq!(ids.first(), Some(&"databricks-claude-opus-5")); - for known in DATABRICKS_V2_KNOWN_MODELS { - assert!(ids.contains(known), "fallback must retain '{known}'"); - } - } - - #[test] - fn v2_discovery_failure_fallback_does_not_duplicate_configured_model() { - let configured = DATABRICKS_V2_KNOWN_MODELS[0]; - let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - let occurrences = result.iter().filter(|m| m.id == configured).count(); - assert_eq!(occurrences, 1, "got: {result:?}"); - assert_eq!(result.len(), DATABRICKS_V2_KNOWN_MODELS.len()); - } - - #[test] - fn v2_discovery_failure_fallback_tolerates_blank_configured_model() { - for configured in ["", " "] { - let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec()); - } - } - - #[test] - fn v2_discovery_failure_fallback_dedupes_a_padded_configured_model() { - // `DATABRICKS_MODEL=" databricks-gpt-5-5 "` reaches here untrimmed, and an - // untrimmed comparison would list the model twice — once padded, once from - // the known slate. - let configured = DATABRICKS_V2_KNOWN_MODELS[0]; - let result = - discovery_failure_fallback(Provider::DatabricksV2, &format!(" {configured} ")); - let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec()); - } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 6cd7b6808f..940bd2a9c2 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -54,10 +54,10 @@ struct App { llm: Arc, sessions: Mutex>, /// Cached model catalog for Databricks providers. Populated lazily on the - /// first successful `session/new` discovery call. When discovery fails (e.g. - /// auth missing or a transient network error) the cell is intentionally left - /// empty so the next `session/new` call retries — a transient failure never - /// pins the degraded fallback catalog for the process lifetime. + /// first successful `session/new` discovery call. Failed discovery is never + /// cached: static-token authentication errors reject session creation, while + /// OAuth authentication and non-auth errors use the configured model for that + /// response and retry on the next session. models_cache: tokio::sync::OnceCell>, } @@ -135,6 +135,12 @@ pub fn run() -> Result<(), Box> { Ok(()) } +pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> { + auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config(host))? + .interactive_login() + .await +} + /// `buzz-agent auth ` — run the interactive auth flow for a /// provider and persist the result, then exit. Today this supports Databricks /// OAuth 2.0 PKCE. Reads `DATABRICKS_HOST` from env; needs a browser on the @@ -145,18 +151,7 @@ async fn auth_subcommand(args: &[String]) -> Result<(), Box { let host = std::env::var("DATABRICKS_HOST") .map_err(|_| "auth databricks: DATABRICKS_HOST required")?; - let pkce = auth::PkceOAuthConfig { - discovery_url: format!( - "{}/oidc/.well-known/oauth-authorization-server", - host.trim_end_matches('/') - ), - client_id: "databricks-cli".into(), - scopes: vec!["all-apis".into(), "offline_access".into()], - cache_namespace: "databricks".into(), - cache_dir_override: None, - }; - let src = auth::PkceOAuthTokenSource::new(pkce)?; - src.interactive_login().await?; + authenticate_databricks(&host).await?; eprintln!("Authenticated. Token cached under ~/.config/buzz-agent/oauth/databricks/."); Ok(()) } @@ -317,26 +312,27 @@ async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { /// /// Tries to use a previously-cached successful discovery result. If the cache is empty, /// runs `discover` and — on success — populates the cache for future calls. On failure -/// the cell is intentionally left empty so the next session retries; the provider-aware -/// fallback is returned for the immediate response only. +/// the error is returned and the cell is intentionally left empty so the next session retries. /// /// Extracted from `session_new` so that tests can drive this path with an injected /// discovery future without requiring a full `App` / transport stack. async fn resolve_models_catalog( cache: &tokio::sync::OnceCell>, - provider: crate::config::Provider, - model: &str, discover: impl std::future::Future, AgentError>>, -) -> Vec { - match cache.get_or_try_init(|| discover).await { - Ok(cached) => cached.clone(), - Err(e) => { - tracing::warn!( - "model catalog discovery failed: {e}; using fallback (will retry next session)" - ); - crate::catalog::discovery_failure_fallback(provider, model) - } - } +) -> Result, AgentError> { + cache.get_or_try_init(|| discover).await.cloned() +} + +/// Return the configured model as a one-entry catalog for this response. +/// +/// This value is never written to `models_cache`; failed discovery must be retried by +/// the next session rather than pinning degraded state for the process lifetime. +fn configured_model_fallback(model: &str) -> Vec { + let model = model.trim().to_string(); + vec![ModelEntry { + id: model.clone(), + name: model, + }] } async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { @@ -400,6 +396,50 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen } Arc::from(prompt) }; + // Resolve the model catalog before spawning MCP servers or registering a + // session. A configured static credential cannot recover interactively, so + // its authentication failure rejects before allocation. OAuth authentication + // failures and other catalog failures use only the configured model for this + // response, without caching, so session/prompt can run the existing PKCE flow. + let available_models: Vec = { + use crate::config::Provider; + match app.cfg.provider { + Provider::Databricks | Provider::DatabricksV2 => { + let models = match resolve_models_catalog( + &app.models_cache, + discover_databricks_models(&app.cfg), + ) + .await + { + Ok(models) => models, + Err(error @ AgentError::LlmAuth(_)) if !app.cfg.api_key.is_empty() => { + return reject(wire_tx, id, error.json_rpc_code(), &error.to_string()) + .await; + } + Err(error @ AgentError::LlmAuth(_)) => { + tracing::warn!( + error = %error, + "Databricks OAuth model catalog unavailable; using configured model" + ); + configured_model_fallback(&app.cfg.model) + } + Err(error) => { + tracing::warn!( + error = %error, + "Databricks model catalog unavailable; using configured model" + ); + configured_model_fallback(&app.cfg.model) + } + }; + models + .iter() + .map(|m| json!({ "modelId": m.id, "name": m.name })) + .collect() + } + _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })], + } + }; + let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await { Ok(m) => Arc::new(m), Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await, @@ -445,36 +485,6 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen ); drop(sessions); - // Build a models catalog for the `session/new` response. For Databricks - // providers this advertises available models so the desktop ModelPicker and - // pool can resolve `session/set_model` switches. For Anthropic/OpenAI we - // report only the configured model — live switching on those providers - // effectively requires respawn. - // - // `models_cache` caches only a successful discovery result (`get_or_try_init` - // leaves the cell empty on error so the next `session/new` call retries). On - // discovery failure the fallback is used for the immediate response without - // being written to the cell. - let available_models: Vec = { - use crate::config::Provider; - match app.cfg.provider { - Provider::Databricks | Provider::DatabricksV2 => { - let models = resolve_models_catalog( - &app.models_cache, - app.cfg.provider, - &app.cfg.model, - discover_databricks_models(&app.cfg), - ) - .await; - models - .iter() - .map(|m| json!({ "modelId": m.id, "name": m.name })) - .collect() - } - _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })], - } - }; - wire::send( wire_tx, wire::ok( @@ -870,8 +880,7 @@ fn session_token() -> Result { #[cfg(test)] mod tests { - use crate::catalog::{discovery_failure_fallback, ModelEntry, DATABRICKS_V2_KNOWN_MODELS}; - use crate::config::Provider; + use crate::catalog::ModelEntry; use crate::types::AgentError; /// Regression: a discovery error must not pin the models_cache for the process lifetime. @@ -884,23 +893,14 @@ mod tests { #[tokio::test] async fn models_cache_does_not_pin_on_discovery_error() { let cache: tokio::sync::OnceCell> = tokio::sync::OnceCell::new(); - let provider = Provider::DatabricksV2; - let model = "my-configured-model"; - // First call — discovery fails. Cell must remain empty; fallback returned. - let first = crate::resolve_models_catalog(&cache, provider, model, async { - Err::, AgentError>(AgentError::LlmAuth("transient failure".into())) + // First call — discovery failure is surfaced and leaves the cell empty. + let error = crate::resolve_models_catalog(&cache, async { + Err::, AgentError>(AgentError::Llm("transient failure".into())) }) - .await; - assert!( - cache.get().is_none(), - "cell must be empty after a discovery error — next session must retry" - ); - let expected_fallback = discovery_failure_fallback(provider, model); - assert_eq!( - first, expected_fallback, - "error path must return the provider-aware fallback" - ); + .await + .unwrap_err(); + assert!(matches!(error, AgentError::Llm(_))); // Second call — discovery succeeds. Cell is now populated and returned. let discovered = vec![ModelEntry { @@ -908,10 +908,11 @@ mod tests { name: "databricks-meta-llama-3-1-70b-instruct".into(), }]; let discovered_clone = discovered.clone(); - let second = crate::resolve_models_catalog(&cache, provider, model, async move { + let second = crate::resolve_models_catalog(&cache, async move { Ok::, AgentError>(discovered_clone) }) - .await; + .await + .unwrap(); assert_eq!( second, discovered, "second call must return the discovered catalog" @@ -927,78 +928,40 @@ mod tests { ); } - /// Regression: legacy `Provider::Databricks` must not advertise v2 AI Gateway model IDs - /// on discovery failure (Wes W1). This test calls `discovery_failure_fallback` directly — - /// the same helper used by `session_new` — and verifies the split behavior. It FAILS if - /// the arm is un-split (i.e., if both providers return the v2 catalog on failure). - #[test] - fn databricks_discovery_failure_fallback_legacy_returns_configured_model_only() { - let configured = "my-serving-endpoint"; - let result = discovery_failure_fallback(Provider::Databricks, configured); + #[tokio::test] + async fn models_catalog_does_not_cache_oauth_auth_fallback() { + let cache: tokio::sync::OnceCell> = tokio::sync::OnceCell::new(); + let error = crate::resolve_models_catalog(&cache, async { + Err::, AgentError>(AgentError::LlmAuth("sign in again".into())) + }) + .await + .unwrap_err(); - // Legacy Databricks must advertise exactly the configured model — nothing more. - assert_eq!( - result.len(), - 1, - "legacy Databricks fallback must contain exactly one entry, got: {result:?}" - ); - assert_eq!( - result[0].id, configured, - "legacy Databricks fallback must be the configured model" - ); + assert!(matches!(error, AgentError::LlmAuth(_))); + assert!(cache.get().is_none()); - // Crucially: must NOT contain any DATABRICKS_V2_KNOWN_MODELS entry. - let v2_ids: Vec<&str> = DATABRICKS_V2_KNOWN_MODELS.to_vec(); - for id in &result { - assert!( - !v2_ids.contains(&id.id.as_str()), - "legacy Databricks fallback must not include v2 ID '{}' — that endpoint \ - may not be served by /serving-endpoints/{{model}}/invocations", - id.id - ); - } + let discovered = vec![ModelEntry { + id: "authenticated-model".into(), + name: "authenticated-model".into(), + }]; + let result = crate::resolve_models_catalog(&cache, async { + Ok::, AgentError>(discovered.clone()) + }) + .await + .unwrap(); + + assert_eq!(result, discovered); + assert_eq!(cache.get(), Some(&discovered)); } #[test] - fn databricks_discovery_failure_fallback_v2_returns_known_models_catalog() { - let configured = "my-configured-model"; - let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - - // DatabricksV2 must return the full DATABRICKS_V2_KNOWN_MODELS list, - // plus the configured model so the picker can still represent the model - // the agent is actually running. + fn configured_model_fallback_is_trimmed_and_singular() { assert_eq!( - result.len(), - DATABRICKS_V2_KNOWN_MODELS.len() + 1, - "DatabricksV2 fallback must return all known models plus the configured model" - ); - let result_ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); - for known_id in DATABRICKS_V2_KNOWN_MODELS { - assert!( - result_ids.contains(known_id), - "DatabricksV2 fallback must include known model '{known_id}'" - ); - } - assert!( - result_ids.contains(&configured), - "DatabricksV2 fallback must include the configured model" - ); - } - - #[test] - fn databricks_discovery_failure_fallback_split_verified() { - // This test FAILS if the v1/v2 arms are merged back into one — it directly verifies - // that the two providers' error-path behavior diverges (Wes W1 protection). - let v1 = discovery_failure_fallback(Provider::Databricks, "my-endpoint"); - let v2 = discovery_failure_fallback(Provider::DatabricksV2, "my-endpoint"); - - let v1_ids: Vec<&str> = v1.iter().map(|m| m.id.as_str()).collect(); - let v2_ids: Vec<&str> = v2.iter().map(|m| m.id.as_str()).collect(); - - assert_ne!( - v1_ids, v2_ids, - "Provider::Databricks and Provider::DatabricksV2 must return different \ - fallback catalogs — if they are equal, the W1 arm split has been reverted" + crate::configured_model_fallback(" configured-model "), + vec![ModelEntry { + id: "configured-model".into(), + name: "configured-model".into(), + }] ); } } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 73c7e1faf2..220d99f9a4 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1910,6 +1910,22 @@ where unreachable!("loop always returns on its final iteration (attempt + 1 == MAX_RETRIES)"); } +pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { + PkceOAuthConfig { + discovery_url: format!( + "{}/oidc/.well-known/oauth-authorization-server", + host.trim_end_matches('/') + ), + client_id: DATABRICKS_CLIENT_ID.into(), + scopes: DATABRICKS_OAUTH_SCOPES + .iter() + .map(|scope| (*scope).into()) + .collect(), + cache_namespace: "databricks".into(), + cache_dir_override: None, + } +} + /// Build the `TokenSource` for the configured provider. /// /// - `Provider::Anthropic`: a static source seeded from `cfg.api_key`. It's @@ -1929,21 +1945,9 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A if !cfg.api_key.is_empty() { return Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))); } - let discovery_url = format!( - "{}/oidc/.well-known/oauth-authorization-server", - cfg.base_url.trim_end_matches('/') - ); - let pkce = PkceOAuthConfig { - discovery_url, - client_id: DATABRICKS_CLIENT_ID.into(), - scopes: DATABRICKS_OAUTH_SCOPES - .iter() - .map(|s| (*s).into()) - .collect(), - cache_namespace: "databricks".into(), - cache_dir_override: None, - }; - Ok(PkceOAuthTokenSource::new(pkce)?) + Ok(PkceOAuthTokenSource::new(databricks_pkce_config( + &cfg.base_url, + ))?) } } } diff --git a/crates/buzz-agent/tests/databricks_oauth.rs b/crates/buzz-agent/tests/databricks_oauth.rs index 52acee1076..fbe0dc1f86 100644 --- a/crates/buzz-agent/tests/databricks_oauth.rs +++ b/crates/buzz-agent/tests/databricks_oauth.rs @@ -20,6 +20,7 @@ use axum::{routing::get, routing::post, Json, Router}; use buzz_agent::auth::{PkceOAuthConfig, PkceOAuthTokenSource, TokenSource}; use serde::Deserialize; use serde_json::json; +use sha2::{Digest, Sha256}; use tempfile::TempDir; #[derive(Deserialize)] @@ -457,6 +458,7 @@ struct AgentHarness { stdin: tokio::process::ChildStdin, stdout: BufReader, next_id: i64, + _home: Option, } impl Drop for AgentHarness { @@ -467,20 +469,65 @@ impl Drop for AgentHarness { impl AgentHarness { async fn spawn_provider(provider: &str, base_url: &str, model: &str) -> Self { + Self::spawn_provider_with_options(provider, base_url, model, 1, Some("test-bearer")).await + } + + async fn spawn_oauth_provider( + provider: &str, + base_url: &str, + model: &str, + max_sessions: usize, + ) -> Self { + Self::spawn_provider_with_options(provider, base_url, model, max_sessions, None).await + } + + async fn spawn_provider_with_max_sessions( + provider: &str, + base_url: &str, + model: &str, + max_sessions: usize, + ) -> Self { + Self::spawn_provider_with_options( + provider, + base_url, + model, + max_sessions, + Some("test-bearer"), + ) + .await + } + + async fn spawn_provider_with_options( + provider: &str, + base_url: &str, + model: &str, + max_sessions: usize, + token: Option<&str>, + ) -> Self { let bin = env!("CARGO_BIN_EXE_buzz-agent"); + let home = token + .is_none() + .then(|| TempDir::new().expect("create isolated OAuth home")); let mut cmd = tokio::process::Command::new(bin); cmd.env("BUZZ_AGENT_PROVIDER", provider) .env("DATABRICKS_HOST", base_url) .env("DATABRICKS_MODEL", model) - .env("DATABRICKS_TOKEN", "test-bearer") + .env_remove("DATABRICKS_TOKEN") .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") .env("BUZZ_AGENT_MAX_ROUNDS", "2") + .env("BUZZ_AGENT_MAX_SESSIONS", max_sessions.to_string()) .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true); + if let Some(token) = token { + cmd.env("DATABRICKS_TOKEN", token); + } + if let Some(home) = &home { + cmd.env("HOME", home.path()); + } let mut child = cmd.spawn().expect("spawn buzz-agent"); let stdin = child.stdin.take().unwrap(); let stdout = BufReader::new(child.stdout.take().unwrap()); @@ -489,9 +536,17 @@ impl AgentHarness { stdin, stdout, next_id: 1, + _home: home, } } + fn oauth_home(&self) -> &std::path::Path { + self._home + .as_ref() + .expect("harness was not started in OAuth mode") + .path() + } + async fn send(&mut self, method: &str, params: serde_json::Value) -> i64 { let id = self.next_id; self.next_id += 1; @@ -938,3 +993,288 @@ async fn session_set_model_empty_model_id_returns_error() { "error message must mention modelId, got: {msg}" ); } + +#[tokio::test] +async fn model_discovery_surfaces_rejected_static_token_as_auth_failure() { + use axum::http::StatusCode; + use buzz_agent::config::{Config, Provider}; + use buzz_agent::discover_databricks_models; + + let requests = Arc::new(AtomicU64::new(0)); + let requests_for_route = requests.clone(); + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([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(move || { + let requests = requests_for_route.clone(); + async move { + requests.fetch_add(1, Ordering::SeqCst); + (StatusCode::UNAUTHORIZED, "rejected bearer rejected") + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let cfg = Config::for_discovery(Provider::DatabricksV2, "rejected".into(), host); + let error = discover_databricks_models(&cfg).await.unwrap_err(); + + assert!( + error.to_string().starts_with("llm auth:"), + "401 must retain auth semantics: {error}" + ); + assert!( + !error.to_string().contains("rejected bearer"), + "auth errors must not propagate provider bodies that may echo credentials: {error}" + ); + assert_eq!( + requests.load(Ordering::SeqCst), + 1, + "a static token cannot refresh, so discovery must not issue a duplicate request" + ); +} + +fn databricks_oauth_cache_path(home: &std::path::Path, host: &str) -> std::path::PathBuf { + let discovery_url = format!( + "{}/oidc/.well-known/oauth-authorization-server", + host.trim_end_matches('/') + ); + let mut hasher = Sha256::new(); + hasher.update(discovery_url.as_bytes()); + hasher.update(b"|"); + hasher.update(b"databricks-cli"); + hasher.update(b"|"); + hasher.update(b"all-apis,offline_access"); + let hash = hex::encode(hasher.finalize()); + home.join(".config") + .join("buzz-agent") + .join("oauth") + .join("databricks") + .join(format!("{hash}.json")) +} + +fn write_cached_oauth_token(home: &std::path::Path, host: &str, access_token: &str) { + let path = databricks_oauth_cache_path(home, host); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + serde_json::to_vec(&json!({ + "access_token": access_token, + "refresh_token": null, + "expires_at": SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600, + })) + .unwrap(), + ) + .unwrap(); +} + +#[tokio::test] +async fn oauth_missing_token_uses_configured_model_then_retries_discovery() { + let attempts = Arc::new(AtomicU64::new(0)); + let attempts_for_route = attempts.clone(); + let listener = 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(move || { + let attempts = attempts_for_route.clone(); + async move { + attempts.fetch_add(1, Ordering::SeqCst); + Json(json!({ + "endpoints": [{"name": "authenticated-model"}], + "next_page_token": null, + })) + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let configured_model = " configured-model "; + let mut h = + AgentHarness::spawn_oauth_provider("databricks_v2", &host, configured_model, 2).await; + let initialize = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + assert!(h.recv_for(initialize).await.get("result").is_some()); + + let first = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let first_response = h.recv_for(first).await; + assert!( + first_response["result"]["sessionId"].is_string(), + "missing OAuth token blocked session creation: {first_response}" + ); + assert_eq!( + first_response["result"]["models"]["availableModels"], + json!([{"modelId": "configured-model", "name": "configured-model"}]) + ); + assert_eq!(attempts.load(Ordering::SeqCst), 0); + + write_cached_oauth_token(h.oauth_home(), &host, "cached-bearer"); + + let second = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let second_response = h.recv_for(second).await; + assert!( + second_response["result"]["sessionId"].is_string(), + "later authenticated session failed: {second_response}" + ); + assert_eq!( + second_response["result"]["models"]["availableModels"], + json!([{"modelId": "authenticated-model", "name": "authenticated-model"}]) + ); + assert_eq!( + attempts.load(Ordering::SeqCst), + 1, + "OAuth fallback was cached instead of retrying discovery" + ); +} + +#[tokio::test] +async fn non_auth_discovery_failure_uses_configured_model_without_caching_fallback() { + use axum::http::StatusCode; + + let attempts = Arc::new(AtomicU64::new(0)); + let attempts_for_route = attempts.clone(); + let listener = 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(move || { + let attempts = attempts_for_route.clone(); + async move { + attempts.fetch_add(1, Ordering::SeqCst); + (StatusCode::SERVICE_UNAVAILABLE, "catalog unavailable") + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let configured_model = " configured-model "; + let normalized_configured_model = configured_model.trim(); + let mut h = + AgentHarness::spawn_provider_with_max_sessions("databricks_v2", &host, configured_model, 2) + .await; + let initialize = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + assert!(h.recv_for(initialize).await.get("result").is_some()); + + for expected_attempts in 1..=2 { + let request = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let response = h.recv_for(request).await; + assert!( + response["result"]["sessionId"].is_string(), + "non-auth catalog failure blocked session creation: {response}" + ); + assert_eq!( + response["result"]["models"]["availableModels"], + json!([{"modelId": normalized_configured_model, "name": normalized_configured_model}]) + ); + assert_eq!(attempts.load(Ordering::SeqCst), expected_attempts); + } +} + +#[tokio::test] +async fn rejected_static_token_does_not_consume_capacity_or_spawn_mcp() { + use axum::http::StatusCode; + + let attempts = Arc::new(AtomicU64::new(0)); + let attempts_for_route = attempts.clone(); + let listener = 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(move || { + let attempts = attempts_for_route.clone(); + async move { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + Err((StatusCode::UNAUTHORIZED, "rejected")) + } else { + Ok(Json(json!({ + "endpoints": [{"name": "discovered-model"}], + "next_page_token": null, + }))) + } + } + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let mut h = AgentHarness::spawn_provider("databricks_v2", &host, "discovered-model").await; + let initialize = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + assert!(h.recv_for(initialize).await.get("result").is_some()); + + let pid_dir = TempDir::new().unwrap(); + let pid_file = pid_dir.path().join("mcp.pid"); + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let mcp_servers = json!([{ + "name": "must-not-spawn", + "command": fake_mcp, + "args": [], + "env": [{ + "name": "FAKE_MCP_PID_FILE", + "value": pid_file.to_string_lossy(), + }], + }]); + + let failed = h + .send( + "session/new", + json!({ "cwd": "/tmp", "mcpServers": mcp_servers }), + ) + .await; + let failed_response = h.recv_for(failed).await; + assert!(failed_response.get("error").is_some(), "{failed_response}"); + assert!( + failed_response["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("llm auth"), + "rejected static token did not retain auth semantics: {failed_response}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !pid_file.exists(), + "MCP process spawned before failed discovery was resolved" + ); + + let retry = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let retry_response = h.recv_for(retry).await; + assert!( + retry_response["result"]["sessionId"].is_string(), + "failed discovery consumed the sole session slot: {retry_response}" + ); + assert_eq!(attempts.load(Ordering::SeqCst), 2); +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 7ce03b140b..4704582372 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -137,6 +137,7 @@ pub async fn get_agent_models( &effective_provider, &merged_env, persisted_model.clone(), + DatabricksAuthIntent::InteractiveModelPicker, ) .await? { @@ -307,9 +308,14 @@ pub async fn discover_agent_models( return Ok(models); } - if let Some(models) = - discover_databricks_models(&state.http_client, &effective_provider, &merged_env, None) - .await? + if let Some(models) = discover_databricks_models( + &state.http_client, + &effective_provider, + &merged_env, + None, + DatabricksAuthIntent::PassiveDraftDiscovery, + ) + .await? { return Ok(models); } @@ -681,97 +687,14 @@ async fn discover_anthropic_models( })) } -// --------------------------------------------------------------------------- -// Databricks model discovery (v1 + v2) -// --------------------------------------------------------------------------- -// -// Delegates to buzz_agent_pkg::catalog::discover_databricks_models, which -// acquires auth in-process via build_token_source: -// - Static bearer (DATABRICKS_TOKEN): returned immediately. -// - PKCE cache hit: returned from disk without a browser flow. -// - No token, no cache: returns Err(LlmAuth) → we return Ok(None) and fall -// through to run_agent_models_command. Never hangs, never opens a browser. - -fn is_databricks_provider(provider: Option<&str>) -> bool { - matches!( - provider - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("databricks" | "databricks_v2" | "databricks-v2") - ) -} - -fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider { - if provider.trim().eq_ignore_ascii_case("databricks_v2") - || provider.trim().eq_ignore_ascii_case("databricks-v2") - { - buzz_agent_pkg::config::Provider::DatabricksV2 - } else { - buzz_agent_pkg::config::Provider::Databricks - } -} - -async fn discover_databricks_models( - _client: &reqwest::Client, - provider: &DiscoveryProvider, - env: &BTreeMap, - selected_model: Option, -) -> Result, String> { - let provider_str = match provider.as_deref() { - Some(p) if is_databricks_provider(Some(p)) => p, - _ => return Ok(None), - }; - - let host = match env_or_process_value(env, "DATABRICKS_HOST") { - Some(h) => h, - None => return Ok(None), // no host → fall through to subprocess - }; - - // api_key = DATABRICKS_TOKEN (empty string = use PKCE cache). - let api_key = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default(); - - let agent_provider = databricks_agent_provider(provider_str); - let cfg = buzz_agent_pkg::config::Config::for_discovery(agent_provider, api_key, host); - - // Build a redaction env so the token never appears in surfaced errors. - let token_for_redact = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default(); - let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &token_for_redact); - - let entries = match buzz_agent_pkg::discover_databricks_models(&cfg).await { - Ok(e) => e, - Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { - // No token + no PKCE cache → fall through to subprocess. - return Ok(None); - } - Err(e) => { - let msg = crate::managed_agents::redact_env_values_in(&e.to_string(), &redaction_env); - return Err(format!("Databricks model discovery failed: {msg}")); - } - }; - - if entries.is_empty() { - return Err("Databricks model discovery returned no models".to_string()); - } - - let models = entries - .into_iter() - .map(|e| AgentModelInfo { - id: e.id, - name: Some(e.name), - description: None, - }) - .collect(); - - Ok(Some(AgentModelsResponse { - agent_name: provider_str.trim().to_string(), - agent_version: "models-api".to_string(), - models, - agent_default_model: None, - selected_model, - supports_switching: true, - })) -} +#[path = "agent_models_databricks.rs"] +mod databricks; +#[cfg(test)] +use databricks::{ + databricks_sign_in_required_error, databricks_static_token_error, is_databricks_provider, + should_start_interactive_auth, +}; +use databricks::{discover_databricks_models, DatabricksAuthIntent}; /// Apply an `UpdateManagedAgentRequest`'s model/provider/system_prompt patch /// to `record`, enforcing the linked-instance write guard: a definition-linked diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs new file mode 100644 index 0000000000..63b4564e61 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -0,0 +1,174 @@ +//! Databricks v1/v2 model discovery and interactive reauthentication. + +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use crate::commands::agent_models_env::{ + env_or_process_value, redaction_env_with_value, DiscoveryProvider, +}; +use crate::managed_agents::AgentModelInfo; +use crate::managed_agents::AgentModelsResponse; + +// Model discovery can be triggered by multiple dialogs at once. Permit only one +// callback listener/browser flow for the process-wide OAuth cache. +static AUTH_GATE: LazyLock> = LazyLock::new(|| tokio::sync::Mutex::new(())); + +pub(super) fn is_databricks_provider(provider: Option<&str>) -> bool { + matches!( + provider + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("databricks" | "databricks_v2" | "databricks-v2") + ) +} + +fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider { + if provider.trim().eq_ignore_ascii_case("databricks_v2") + || provider.trim().eq_ignore_ascii_case("databricks-v2") + { + buzz_agent_pkg::config::Provider::DatabricksV2 + } else { + buzz_agent_pkg::config::Provider::Databricks + } +} + +pub(super) fn databricks_static_token_error( + error: &str, + redaction_env: &BTreeMap, +) -> String { + let message = crate::managed_agents::redact_env_values_in(error, redaction_env); + format!("Databricks rejected DATABRICKS_TOKEN; update it in agent settings: {message}") +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum DatabricksAuthIntent { + /// A saved agent's model picker was opened by the user. + InteractiveModelPicker, + /// Discovery was triggered automatically from unsaved form state. + PassiveDraftDiscovery, +} + +impl DatabricksAuthIntent { + fn allows_interactive_auth(self) -> bool { + matches!(self, Self::InteractiveModelPicker) + } +} + +pub(super) fn databricks_sign_in_required_error() -> String { + "Databricks sign-in is required; save this agent, then open its model picker to sign in, or run `buzz-agent auth databricks`" + .to_string() +} + +pub(super) fn should_start_interactive_auth( + api_key: &str, + auth_intent: DatabricksAuthIntent, +) -> bool { + api_key.is_empty() && auth_intent.allows_interactive_auth() +} + +pub(super) async fn discover_databricks_models( + _client: &reqwest::Client, + provider: &DiscoveryProvider, + env: &BTreeMap, + selected_model: Option, + auth_intent: DatabricksAuthIntent, +) -> Result, String> { + let provider_name = match provider.as_deref() { + Some(provider_name) if is_databricks_provider(Some(provider_name)) => provider_name, + _ => return Ok(None), + }; + + let host = match env_or_process_value(env, "DATABRICKS_HOST") { + Some(host) => host, + None => return Ok(None), + }; + let api_key = env_or_process_value(env, "DATABRICKS_TOKEN").unwrap_or_default(); + let config = buzz_agent_pkg::config::Config::for_discovery( + databricks_agent_provider(provider_name), + api_key.clone(), + host.clone(), + ); + let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &api_key); + + let entries = match buzz_agent_pkg::discover_databricks_models(&config).await { + Ok(entries) => entries, + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) + if should_start_interactive_auth(&api_key, auth_intent) => + { + let _auth = AUTH_GATE.lock().await; + match buzz_agent_pkg::discover_databricks_models(&config).await { + Ok(entries) => entries, + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { + buzz_agent_pkg::authenticate_databricks(&host) + .await + .map_err(|error| { + format_redacted_error( + "Databricks sign-in failed", + &error, + &redaction_env, + ) + })?; + buzz_agent_pkg::discover_databricks_models(&config) + .await + .map_err(|error| { + format_redacted_error( + "Databricks model discovery failed after sign-in", + &error, + &redaction_env, + ) + })? + } + Err(error) => { + return Err(format_redacted_error( + "Databricks model discovery failed", + &error, + &redaction_env, + )); + } + } + } + Err(buzz_agent_pkg::AgentError::LlmAuth(error)) if !api_key.is_empty() => { + return Err(databricks_static_token_error(&error, &redaction_env)); + } + Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { + return Err(databricks_sign_in_required_error()); + } + Err(error) => { + return Err(format_redacted_error( + "Databricks model discovery failed", + &error, + &redaction_env, + )); + } + }; + + if entries.is_empty() { + return Err("Databricks model discovery returned no models".to_string()); + } + + Ok(Some(AgentModelsResponse { + agent_name: provider_name.trim().to_string(), + agent_version: "models-api".to_string(), + models: entries + .into_iter() + .map(|entry| AgentModelInfo { + id: entry.id, + name: Some(entry.name), + description: None, + }) + .collect(), + agent_default_model: None, + selected_model, + supports_switching: true, + })) +} + +fn format_redacted_error( + context: &str, + error: &impl std::fmt::Display, + redaction_env: &BTreeMap, +) -> String { + let message = crate::managed_agents::redact_env_values_in(&error.to_string(), redaction_env); + format!("{context}: {message}") +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 14c981d730..e7d0e70fd0 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -576,6 +576,29 @@ fn is_databricks_provider_matches_both_variants() { assert!(!is_databricks_provider(None)); } +#[test] +fn databricks_interactive_auth_requires_explicit_intent_and_no_static_token() { + assert!(should_start_interactive_auth( + "", + DatabricksAuthIntent::InteractiveModelPicker + )); + assert!(!should_start_interactive_auth( + "", + DatabricksAuthIntent::PassiveDraftDiscovery + )); + assert!(!should_start_interactive_auth( + "static-token", + DatabricksAuthIntent::InteractiveModelPicker + )); +} + +#[test] +fn databricks_passive_auth_error_has_reachable_create_flow_guidance() { + let error = databricks_sign_in_required_error(); + assert!(error.contains("save this agent, then open its model picker")); + assert!(error.contains("buzz-agent auth databricks")); +} + #[test] fn model_discovery_error_converts_dangling_sentinel_to_sentence() { // get_agent_models is a user-facing surface: a dangling harness must @@ -881,3 +904,21 @@ fn draft_agent_model_discovery_env_layers_all_three_tiers_in_order() { ); } } + +#[test] +fn databricks_static_token_error_redacts_echoed_token() { + let token = "secret-databricks-token"; + let redaction_env = BTreeMap::from([("DATABRICKS_TOKEN".to_string(), token.to_string())]); + + let error = databricks_static_token_error( + &format!("Databricks rejected bearer {token}"), + &redaction_env, + ); + + assert!(error.contains("[REDACTED]"), "got: {error}"); + assert!(!error.contains(token), "token leaked in error: {error}"); + assert!( + error.contains("update it in agent settings"), + "error lost its remediation: {error}" + ); +} From a0ed13de14ee64dd90c32335790f7d3b4e94330d Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 4 Aug 2026 17:00:29 -0600 Subject: [PATCH 2/4] chore(release): release Buzz Desktop version 0.5.5 (#4800) ## Buzz Desktop release v0.5.5 - **Frozen main:** `4a2305170eef565bf1836e2859247e67c030f8af` - **Reviewed candidate:** `2d03d37b05b68186b2caad9da79080032be3ac72` - **Previous desktop release:** `desktop-v0.5.4` - **Proposed immutable tag:** `desktop-v0.5.5` This PR must be **squash merged** only after the Desktop Release Candidate check passes. The branch must remain based directly on current `main`; stale base, payload drift, incomplete notes, or an unauthorized merge produce no tag. The checked-in changelog accounts for every non-merge commit in the release range. Publication remains bound to the immutable candidate tag. Signed-off-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 10 +++--- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 60 insertions(+), 9 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 1ba64765ff..e40fce5c3a 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,8 +1,8 @@ { "schema": 1, - "version": "0.5.4", - "base_sha": "6de85fe31d781122756aecf954bae7d357a56b9a", - "previous_tag": "desktop-v0.5.3", - "tag": "desktop-v0.5.4", - "commit_count": 40 + "version": "0.5.5", + "base_sha": "4a2305170eef565bf1836e2859247e67c030f8af", + "previous_tag": "desktop-v0.5.4", + "tag": "desktop-v0.5.5", + "commit_count": 41 } diff --git a/CHANGELOG.md b/CHANGELOG.md index e30941a355..962b2ce29c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## v0.5.5 + +### Desktop and shared changes + +- fix: reauthenticate databricks model discovery ([#4008](https://github.com/block/buzz/pull/4008)) ([`4a2305170eef565bf1836e2859247e67c030f8af`](https://github.com/block/buzz/commit/4a2305170eef565bf1836e2859247e67c030f8af)) +- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4797](https://github.com/block/buzz/pull/4797)) ([`8faf09f9aedb4989e57c7b6c5bd1052a444a3370`](https://github.com/block/buzz/commit/8faf09f9aedb4989e57c7b6c5bd1052a444a3370)) +- feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues ([#4695](https://github.com/block/buzz/pull/4695)) ([`a1d78f2959b41c63f063ff818076d38c31071a47`](https://github.com/block/buzz/commit/a1d78f2959b41c63f063ff818076d38c31071a47)) +- fix(desktop): serialize tray channel actions for frontend ([#4762](https://github.com/block/buzz/pull/4762)) ([`4c665aeac366fca5097eaa1088fb87f3d248eac7`](https://github.com/block/buzz/commit/4c665aeac366fca5097eaa1088fb87f3d248eac7)) +- chore(release): release Buzz Desktop version 0.5.5 ([#4788](https://github.com/block/buzz/pull/4788)) ([`b948c54792c4933b4e003d2b227dc6e1f7c05fb4`](https://github.com/block/buzz/commit/b948c54792c4933b4e003d2b227dc6e1f7c05fb4)) +- feat(projects): support multiple repositories ([#4671](https://github.com/block/buzz/pull/4671)) ([`e30db7028f9f1dc7646b5814ed03b4c54a4d2a48`](https://github.com/block/buzz/commit/e30db7028f9f1dc7646b5814ed03b4c54a4d2a48)) +- fix(desktop): widen post-Enter timeouts in empty-edit-delete spec ([#4792](https://github.com/block/buzz/pull/4792)) ([`7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453`](https://github.com/block/buzz/commit/7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453)) +- fix(desktop): wait for terminal frame before splash ([#4781](https://github.com/block/buzz/pull/4781)) ([`65f7a100353b9a5302da2614f2d85edee1c136a2`](https://github.com/block/buzz/commit/65f7a100353b9a5302da2614f2d85edee1c136a2)) +- fix(desktop): integer-align custom reaction emoji ([#4779](https://github.com/block/buzz/pull/4779)) ([`8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a`](https://github.com/block/buzz/commit/8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a)) +- Polish Huddle voice controls ([#4694](https://github.com/block/buzz/pull/4694)) ([`ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d`](https://github.com/block/buzz/commit/ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d)) +- fix(local-archive): default both archive settings to enabled ([#4750](https://github.com/block/buzz/pull/4750)) ([`5179726737108a4a91076d262c30a53d4a7237e9`](https://github.com/block/buzz/commit/5179726737108a4a91076d262c30a53d4a7237e9)) +- fix(desktop): close reconnect gaps that previously required CMD+R ([#4737](https://github.com/block/buzz/pull/4737)) ([`e5efd047050f5e2a64fe6cd9e3faed1685b03f5c`](https://github.com/block/buzz/commit/e5efd047050f5e2a64fe6cd9e3faed1685b03f5c)) +- Dock Buzz Term within channel workspace ([#4724](https://github.com/block/buzz/pull/4724)) ([`cb4a73e17d0760eba6c3c01811da07e1d3a6b85e`](https://github.com/block/buzz/commit/cb4a73e17d0760eba6c3c01811da07e1d3a6b85e)) +- fix(agents): canonicalize stale persona harness pins ([#4631](https://github.com/block/buzz/pull/4631)) ([`0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8`](https://github.com/block/buzz/commit/0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8)) +- Refine community invite links ([#4734](https://github.com/block/buzz/pull/4734)) ([`e1287c92cc7ea9b52f10b80515b98cdd1c7f9a31`](https://github.com/block/buzz/commit/e1287c92cc7ea9b52f10b80515b98cdd1c7f9a31)) +- feat(desktop): persist sidebar observed-unread across webview reload ([#3976](https://github.com/block/buzz/pull/3976)) ([`0afeac8a7c173fd3ede8a22e27919e63161bf07c`](https://github.com/block/buzz/commit/0afeac8a7c173fd3ede8a22e27919e63161bf07c)) +- feat(desktop): surface config diff in restart-required badge ([#3637](https://github.com/block/buzz/pull/3637)) ([`f86dfc58838a272a5d0504ebf216a79b7288f027`](https://github.com/block/buzz/commit/f86dfc58838a272a5d0504ebf216a79b7288f027)) +- Polish sidebar unread hierarchy ([#4573](https://github.com/block/buzz/pull/4573)) ([`540b58920cef205b838da8be8442aae62bceaaa5`](https://github.com/block/buzz/commit/540b58920cef205b838da8be8442aae62bceaaa5)) +- fix(desktop): show cached display names on startup ([#3317](https://github.com/block/buzz/pull/3317)) ([`d0d4acd4fa02893ad2460b447d7e13da00506be3`](https://github.com/block/buzz/commit/d0d4acd4fa02893ad2460b447d7e13da00506be3)) +- Remove blur from Welcome composer guidance ([#4691](https://github.com/block/buzz/pull/4691)) ([`d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1`](https://github.com/block/buzz/commit/d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1)) +- Refine desktop timeline activity presentation ([#4582](https://github.com/block/buzz/pull/4582)) ([`a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a`](https://github.com/block/buzz/commit/a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a)) +- Defer desktop media uploads until send ([#4522](https://github.com/block/buzz/pull/4522)) ([`f18a9cb10688deaa3f618869170bfe9303c4be62`](https://github.com/block/buzz/commit/f18a9cb10688deaa3f618869170bfe9303c4be62)) +- fix(desktop): stop clipping focus ring on channel intro action cards (#2392) ([#4374](https://github.com/block/buzz/pull/4374)) ([`ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996`](https://github.com/block/buzz/commit/ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996)) +- Polish mobile inbox and media flows ([#4512](https://github.com/block/buzz/pull/4512)) ([`feccf4eabc23fdba94ce3537a194357ed17b197c`](https://github.com/block/buzz/commit/feccf4eabc23fdba94ce3537a194357ed17b197c)) +- feat: ship Buzz Term ([#4347](https://github.com/block/buzz/pull/4347)) ([`631b05c883f58e9533e9038b4669ebdfb1d9cf27`](https://github.com/block/buzz/commit/631b05c883f58e9533e9038b4669ebdfb1d9cf27)) +- feat(mobile): sync per-group channel sorting ([#4231](https://github.com/block/buzz/pull/4231)) ([`b42b093613edfb7138acb0961a0ad9218b39691a`](https://github.com/block/buzz/commit/b42b093613edfb7138acb0961a0ad9218b39691a)) +- feat(desktop): redesign the Huddle experience ([#4281](https://github.com/block/buzz/pull/4281)) ([`b29c8cdaa456307ecdd63e565de4beb14402128e`](https://github.com/block/buzz/commit/b29c8cdaa456307ecdd63e565de4beb14402128e)) +- feat(agents): model-tuning parity in global Agent Defaults editor ([#4578](https://github.com/block/buzz/pull/4578)) ([`985cdcc6eac33ccd77bc50c26e22c701d07eda4e`](https://github.com/block/buzz/commit/985cdcc6eac33ccd77bc50c26e22c701d07eda4e)) +- Polish Share Compute settings ([#3735](https://github.com/block/buzz/pull/3735)) ([`027a74a61c8643a1d1086d3e8307fad89d7735f7`](https://github.com/block/buzz/commit/027a74a61c8643a1d1086d3e8307fad89d7735f7)) +- fix(reactions): wrap long popover names ([#3834](https://github.com/block/buzz/pull/3834)) ([`79815978483ef0ab78f7159c0add3492da6457a1`](https://github.com/block/buzz/commit/79815978483ef0ab78f7159c0add3492da6457a1)) +- fix(desktop): clarify inherited agent parallelism ([#4010](https://github.com/block/buzz/pull/4010)) ([`d4a4570b9769743899d97480b3bf482860b51d9c`](https://github.com/block/buzz/commit/d4a4570b9769743899d97480b3bf482860b51d9c)) +- feat(desktop): make onboarding model defaults skippable ([#3968](https://github.com/block/buzz/pull/3968)) ([`5c98932c59ee5344e9e8c14525c51f3de16ad2c2`](https://github.com/block/buzz/commit/5c98932c59ee5344e9e8c14525c51f3de16ad2c2)) + +### Other repository changes + +- fix(ci): make desktop cache test version agnostic ([#4791](https://github.com/block/buzz/pull/4791)) ([`383d9e1eafd569b44b9c835200dba69ef7cec9dc`](https://github.com/block/buzz/commit/383d9e1eafd569b44b9c835200dba69ef7cec9dc)) +- fix(mobile): stop oversized read-state retry loop ([#4595](https://github.com/block/buzz/pull/4595)) ([`7bee84da8267605ada939c4f911d90f1b0ff1a11`](https://github.com/block/buzz/commit/7bee84da8267605ada939c4f911d90f1b0ff1a11)) +- perf(relay): index channel-id lookups and skip trace-only reads ([#4647](https://github.com/block/buzz/pull/4647)) ([`bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c`](https://github.com/block/buzz/commit/bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c)) +- docs(acp): explain per-channel session model in base prompt ([#4729](https://github.com/block/buzz/pull/4729)) ([`56003ebf98c22367fb6357f295494e26efbd8ae6`](https://github.com/block/buzz/commit/56003ebf98c22367fb6357f295494e26efbd8ae6)) +- docs(nip-am): normative amendment — cache SHOULD/MUST + pricingIdentity + consumer cost guidance ([#4632](https://github.com/block/buzz/pull/4632)) ([`0542bc8b955756a62b4133aa70f84441d93616ee`](https://github.com/block/buzz/commit/0542bc8b955756a62b4133aa70f84441d93616ee)) +- feat(mobile): add channel scroll navigation ([#4239](https://github.com/block/buzz/pull/4239)) ([`d5da74e4e078a9551b9ce9e47e77cf9ed5840596`](https://github.com/block/buzz/commit/d5da74e4e078a9551b9ce9e47e77cf9ed5840596)) +- feat(mobile): bring channel menus to desktop parity ([#3940](https://github.com/block/buzz/pull/3940)) ([`ede8d22dd5b336f146e0a6d760fd9dff78a42613`](https://github.com/block/buzz/commit/ede8d22dd5b336f146e0a6d760fd9dff78a42613)) +- ci: add guarded desktop release cache prewarm ([#4575](https://github.com/block/buzz/pull/4575)) ([`e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0`](https://github.com/block/buzz/commit/e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0)) +- fix(mobile): recover stale relay sessions ([#4372](https://github.com/block/buzz/pull/4372)) ([`ce56e34411d2940e70a6c0de653ffae36d334701`](https://github.com/block/buzz/commit/ce56e34411d2940e70a6c0de653ffae36d334701)) + +[Compare desktop-v0.5.4...desktop-v0.5.5](https://github.com/block/buzz/compare/desktop-v0.5.4...desktop-v0.5.5) + ## v0.5.4 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index 036533308c..a1fd2e919d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.4", + "version": "0.5.5", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index da80c5b07a..afd119c84d 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1060,7 +1060,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.4" +version = "0.5.5" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3b97ff1fe9..1ba814da47 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.4" +version = "0.5.5" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 4bda55fd09..0d4417a754 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.4", + "version": "0.5.5", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 79c52166cfe6b6d36bdc7686f943595c74e2f578 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 4 Aug 2026 17:09:33 -0600 Subject: [PATCH 3/4] Revert "chore(release): release Buzz Desktop version 0.5.5" (#4808) Reverts block/buzz#4800 --- .release/desktop-candidate.json | 10 +++--- CHANGELOG.md | 51 ------------------------------- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 9 insertions(+), 60 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index e40fce5c3a..1ba64765ff 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,8 +1,8 @@ { "schema": 1, - "version": "0.5.5", - "base_sha": "4a2305170eef565bf1836e2859247e67c030f8af", - "previous_tag": "desktop-v0.5.4", - "tag": "desktop-v0.5.5", - "commit_count": 41 + "version": "0.5.4", + "base_sha": "6de85fe31d781122756aecf954bae7d357a56b9a", + "previous_tag": "desktop-v0.5.3", + "tag": "desktop-v0.5.4", + "commit_count": 40 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 962b2ce29c..e30941a355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,56 +1,5 @@ # Changelog -## v0.5.5 - -### Desktop and shared changes - -- fix: reauthenticate databricks model discovery ([#4008](https://github.com/block/buzz/pull/4008)) ([`4a2305170eef565bf1836e2859247e67c030f8af`](https://github.com/block/buzz/commit/4a2305170eef565bf1836e2859247e67c030f8af)) -- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4797](https://github.com/block/buzz/pull/4797)) ([`8faf09f9aedb4989e57c7b6c5bd1052a444a3370`](https://github.com/block/buzz/commit/8faf09f9aedb4989e57c7b6c5bd1052a444a3370)) -- feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues ([#4695](https://github.com/block/buzz/pull/4695)) ([`a1d78f2959b41c63f063ff818076d38c31071a47`](https://github.com/block/buzz/commit/a1d78f2959b41c63f063ff818076d38c31071a47)) -- fix(desktop): serialize tray channel actions for frontend ([#4762](https://github.com/block/buzz/pull/4762)) ([`4c665aeac366fca5097eaa1088fb87f3d248eac7`](https://github.com/block/buzz/commit/4c665aeac366fca5097eaa1088fb87f3d248eac7)) -- chore(release): release Buzz Desktop version 0.5.5 ([#4788](https://github.com/block/buzz/pull/4788)) ([`b948c54792c4933b4e003d2b227dc6e1f7c05fb4`](https://github.com/block/buzz/commit/b948c54792c4933b4e003d2b227dc6e1f7c05fb4)) -- feat(projects): support multiple repositories ([#4671](https://github.com/block/buzz/pull/4671)) ([`e30db7028f9f1dc7646b5814ed03b4c54a4d2a48`](https://github.com/block/buzz/commit/e30db7028f9f1dc7646b5814ed03b4c54a4d2a48)) -- fix(desktop): widen post-Enter timeouts in empty-edit-delete spec ([#4792](https://github.com/block/buzz/pull/4792)) ([`7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453`](https://github.com/block/buzz/commit/7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453)) -- fix(desktop): wait for terminal frame before splash ([#4781](https://github.com/block/buzz/pull/4781)) ([`65f7a100353b9a5302da2614f2d85edee1c136a2`](https://github.com/block/buzz/commit/65f7a100353b9a5302da2614f2d85edee1c136a2)) -- fix(desktop): integer-align custom reaction emoji ([#4779](https://github.com/block/buzz/pull/4779)) ([`8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a`](https://github.com/block/buzz/commit/8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a)) -- Polish Huddle voice controls ([#4694](https://github.com/block/buzz/pull/4694)) ([`ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d`](https://github.com/block/buzz/commit/ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d)) -- fix(local-archive): default both archive settings to enabled ([#4750](https://github.com/block/buzz/pull/4750)) ([`5179726737108a4a91076d262c30a53d4a7237e9`](https://github.com/block/buzz/commit/5179726737108a4a91076d262c30a53d4a7237e9)) -- fix(desktop): close reconnect gaps that previously required CMD+R ([#4737](https://github.com/block/buzz/pull/4737)) ([`e5efd047050f5e2a64fe6cd9e3faed1685b03f5c`](https://github.com/block/buzz/commit/e5efd047050f5e2a64fe6cd9e3faed1685b03f5c)) -- Dock Buzz Term within channel workspace ([#4724](https://github.com/block/buzz/pull/4724)) ([`cb4a73e17d0760eba6c3c01811da07e1d3a6b85e`](https://github.com/block/buzz/commit/cb4a73e17d0760eba6c3c01811da07e1d3a6b85e)) -- fix(agents): canonicalize stale persona harness pins ([#4631](https://github.com/block/buzz/pull/4631)) ([`0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8`](https://github.com/block/buzz/commit/0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8)) -- Refine community invite links ([#4734](https://github.com/block/buzz/pull/4734)) ([`e1287c92cc7ea9b52f10b80515b98cdd1c7f9a31`](https://github.com/block/buzz/commit/e1287c92cc7ea9b52f10b80515b98cdd1c7f9a31)) -- feat(desktop): persist sidebar observed-unread across webview reload ([#3976](https://github.com/block/buzz/pull/3976)) ([`0afeac8a7c173fd3ede8a22e27919e63161bf07c`](https://github.com/block/buzz/commit/0afeac8a7c173fd3ede8a22e27919e63161bf07c)) -- feat(desktop): surface config diff in restart-required badge ([#3637](https://github.com/block/buzz/pull/3637)) ([`f86dfc58838a272a5d0504ebf216a79b7288f027`](https://github.com/block/buzz/commit/f86dfc58838a272a5d0504ebf216a79b7288f027)) -- Polish sidebar unread hierarchy ([#4573](https://github.com/block/buzz/pull/4573)) ([`540b58920cef205b838da8be8442aae62bceaaa5`](https://github.com/block/buzz/commit/540b58920cef205b838da8be8442aae62bceaaa5)) -- fix(desktop): show cached display names on startup ([#3317](https://github.com/block/buzz/pull/3317)) ([`d0d4acd4fa02893ad2460b447d7e13da00506be3`](https://github.com/block/buzz/commit/d0d4acd4fa02893ad2460b447d7e13da00506be3)) -- Remove blur from Welcome composer guidance ([#4691](https://github.com/block/buzz/pull/4691)) ([`d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1`](https://github.com/block/buzz/commit/d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1)) -- Refine desktop timeline activity presentation ([#4582](https://github.com/block/buzz/pull/4582)) ([`a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a`](https://github.com/block/buzz/commit/a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a)) -- Defer desktop media uploads until send ([#4522](https://github.com/block/buzz/pull/4522)) ([`f18a9cb10688deaa3f618869170bfe9303c4be62`](https://github.com/block/buzz/commit/f18a9cb10688deaa3f618869170bfe9303c4be62)) -- fix(desktop): stop clipping focus ring on channel intro action cards (#2392) ([#4374](https://github.com/block/buzz/pull/4374)) ([`ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996`](https://github.com/block/buzz/commit/ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996)) -- Polish mobile inbox and media flows ([#4512](https://github.com/block/buzz/pull/4512)) ([`feccf4eabc23fdba94ce3537a194357ed17b197c`](https://github.com/block/buzz/commit/feccf4eabc23fdba94ce3537a194357ed17b197c)) -- feat: ship Buzz Term ([#4347](https://github.com/block/buzz/pull/4347)) ([`631b05c883f58e9533e9038b4669ebdfb1d9cf27`](https://github.com/block/buzz/commit/631b05c883f58e9533e9038b4669ebdfb1d9cf27)) -- feat(mobile): sync per-group channel sorting ([#4231](https://github.com/block/buzz/pull/4231)) ([`b42b093613edfb7138acb0961a0ad9218b39691a`](https://github.com/block/buzz/commit/b42b093613edfb7138acb0961a0ad9218b39691a)) -- feat(desktop): redesign the Huddle experience ([#4281](https://github.com/block/buzz/pull/4281)) ([`b29c8cdaa456307ecdd63e565de4beb14402128e`](https://github.com/block/buzz/commit/b29c8cdaa456307ecdd63e565de4beb14402128e)) -- feat(agents): model-tuning parity in global Agent Defaults editor ([#4578](https://github.com/block/buzz/pull/4578)) ([`985cdcc6eac33ccd77bc50c26e22c701d07eda4e`](https://github.com/block/buzz/commit/985cdcc6eac33ccd77bc50c26e22c701d07eda4e)) -- Polish Share Compute settings ([#3735](https://github.com/block/buzz/pull/3735)) ([`027a74a61c8643a1d1086d3e8307fad89d7735f7`](https://github.com/block/buzz/commit/027a74a61c8643a1d1086d3e8307fad89d7735f7)) -- fix(reactions): wrap long popover names ([#3834](https://github.com/block/buzz/pull/3834)) ([`79815978483ef0ab78f7159c0add3492da6457a1`](https://github.com/block/buzz/commit/79815978483ef0ab78f7159c0add3492da6457a1)) -- fix(desktop): clarify inherited agent parallelism ([#4010](https://github.com/block/buzz/pull/4010)) ([`d4a4570b9769743899d97480b3bf482860b51d9c`](https://github.com/block/buzz/commit/d4a4570b9769743899d97480b3bf482860b51d9c)) -- feat(desktop): make onboarding model defaults skippable ([#3968](https://github.com/block/buzz/pull/3968)) ([`5c98932c59ee5344e9e8c14525c51f3de16ad2c2`](https://github.com/block/buzz/commit/5c98932c59ee5344e9e8c14525c51f3de16ad2c2)) - -### Other repository changes - -- fix(ci): make desktop cache test version agnostic ([#4791](https://github.com/block/buzz/pull/4791)) ([`383d9e1eafd569b44b9c835200dba69ef7cec9dc`](https://github.com/block/buzz/commit/383d9e1eafd569b44b9c835200dba69ef7cec9dc)) -- fix(mobile): stop oversized read-state retry loop ([#4595](https://github.com/block/buzz/pull/4595)) ([`7bee84da8267605ada939c4f911d90f1b0ff1a11`](https://github.com/block/buzz/commit/7bee84da8267605ada939c4f911d90f1b0ff1a11)) -- perf(relay): index channel-id lookups and skip trace-only reads ([#4647](https://github.com/block/buzz/pull/4647)) ([`bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c`](https://github.com/block/buzz/commit/bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c)) -- docs(acp): explain per-channel session model in base prompt ([#4729](https://github.com/block/buzz/pull/4729)) ([`56003ebf98c22367fb6357f295494e26efbd8ae6`](https://github.com/block/buzz/commit/56003ebf98c22367fb6357f295494e26efbd8ae6)) -- docs(nip-am): normative amendment — cache SHOULD/MUST + pricingIdentity + consumer cost guidance ([#4632](https://github.com/block/buzz/pull/4632)) ([`0542bc8b955756a62b4133aa70f84441d93616ee`](https://github.com/block/buzz/commit/0542bc8b955756a62b4133aa70f84441d93616ee)) -- feat(mobile): add channel scroll navigation ([#4239](https://github.com/block/buzz/pull/4239)) ([`d5da74e4e078a9551b9ce9e47e77cf9ed5840596`](https://github.com/block/buzz/commit/d5da74e4e078a9551b9ce9e47e77cf9ed5840596)) -- feat(mobile): bring channel menus to desktop parity ([#3940](https://github.com/block/buzz/pull/3940)) ([`ede8d22dd5b336f146e0a6d760fd9dff78a42613`](https://github.com/block/buzz/commit/ede8d22dd5b336f146e0a6d760fd9dff78a42613)) -- ci: add guarded desktop release cache prewarm ([#4575](https://github.com/block/buzz/pull/4575)) ([`e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0`](https://github.com/block/buzz/commit/e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0)) -- fix(mobile): recover stale relay sessions ([#4372](https://github.com/block/buzz/pull/4372)) ([`ce56e34411d2940e70a6c0de653ffae36d334701`](https://github.com/block/buzz/commit/ce56e34411d2940e70a6c0de653ffae36d334701)) - -[Compare desktop-v0.5.4...desktop-v0.5.5](https://github.com/block/buzz/compare/desktop-v0.5.4...desktop-v0.5.5) - ## v0.5.4 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index a1fd2e919d..036533308c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.5", + "version": "0.5.4", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index afd119c84d..da80c5b07a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1060,7 +1060,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.5" +version = "0.5.4" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1ba814da47..3b97ff1fe9 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.5" +version = "0.5.4" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 0d4417a754..4bda55fd09 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.5", + "version": "0.5.4", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 25a9cf1be6d245fbd7373cb1160dbc790baf5bd5 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Tue, 4 Aug 2026 16:16:14 -0700 Subject: [PATCH 4/4] feat: paste composer text without formatting (#4801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - handle Cmd+Shift+V on macOS and Ctrl+Shift+V on Windows/Linux in the message composer - read plain text through the native Tauri/arboard clipboard path in packaged builds, with a browser-only Clipboard API fallback - re-enter ProseMirror's paste pipeline with populated `text/plain` clipboard data so selection, undo, multiline behavior, and paste observers remain intact - cover both platform mappings with rendered composer E2E tests that assert the native command path ## Testing - `pnpm test` — 4,286 passed - `pnpm check` - `pnpm typecheck` - `pnpm exec playwright test composer-selection-formatting.spec.ts --project=smoke` — 26 passed - `cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target aarch64-apple-darwin` - `just desktop-tauri-test` — 2,206 core tests plus integration and doc-test groups passed - full pre-push hooks passed ## Manual verification Physical packaged-app clipboard verification remains recommended on macOS, Windows, and Linux. The automated E2E uses mocked Tauri IPC but asserts the native `read_clipboard_text` command is invoked. Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/clipboard.rs | 19 ++++++ desktop/src-tauri/src/lib.rs | 1 + .../messages/lib/useRichTextEditor.ts | 34 ++++++++++ desktop/src/shared/api/tauriMedia.ts | 19 +++++- desktop/src/testing/e2eBridge.ts | 2 + .../e2e/composer-selection-formatting.spec.ts | 62 +++++++++++++++++++ 6 files changed, 136 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/clipboard.rs b/desktop/src-tauri/src/commands/clipboard.rs index b4fe072ef8..c904e4a2d9 100644 --- a/desktop/src-tauri/src/commands/clipboard.rs +++ b/desktop/src-tauri/src/commands/clipboard.rs @@ -34,3 +34,22 @@ pub fn with_clipboard( operation(stored.as_mut().expect("clipboard initialized")) .map_err(|e| format!("clipboard error: {e}")) } + +/// Read plain text from the system clipboard through the native shell. +/// +/// Browser clipboard reads are permission-gated or unavailable in embedded +/// webviews. Arboard provides one consistent path across WKWebView, WebView2, +/// and WebKitGTK. The operation runs on the main thread for macOS/AppKit safety. +#[tauri::command] +pub async fn read_clipboard_text(app: tauri::AppHandle) -> Result { + let (tx, rx) = std::sync::mpsc::sync_channel::>(1); + let clipboard_app = app.clone(); + app.run_on_main_thread(move || { + let result = with_clipboard(&clipboard_app, arboard::Clipboard::get_text); + let _ = tx.send(result); + }) + .map_err(|e| format!("main thread dispatch failed: {e}"))?; + + rx.recv() + .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1e73b15232..a7c191c43b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -732,6 +732,7 @@ pub fn run() { fetch_media_bytes, copy_image_to_clipboard, copy_text_to_clipboard, + read_clipboard_text, fetch_snapshot_bytes, relay_requires_membership, list_relay_members, diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 4fee2db46f..c761081cc3 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -9,6 +9,7 @@ import { Extension, type KeyboardShortcutCommand } from "@tiptap/core"; import { Plugin, Selection, TextSelection } from "@tiptap/pm/state"; import type { ResolvedPos } from "@tiptap/pm/model"; +import { readTextFromSystemClipboard } from "@/shared/api/tauriMedia"; import { hasPrimaryShortcutModifier, isMacPlatform, @@ -532,6 +533,39 @@ export function useRichTextEditor({ return true; } + // Cmd+Shift+V / Ctrl+Shift+V → paste the clipboard's plain-text + // representation. Embedded webviews permission-gate the browser + // clipboard API differently across operating systems, so packaged + // builds read through the native arboard command. Browser builds use + // navigator.clipboard as a fallback. Feed the result through + // ProseMirror's paste pipeline with clipboardData populated so its + // plain-text observers keep normal paste behavior. + if ( + event.key.toLowerCase() === "v" && + hasPrimaryShortcutModifier(event) && + event.shiftKey && + !event.altKey && + !event.repeat && + !event.isComposing + ) { + event.preventDefault(); + void readTextFromSystemClipboard() + .then((text) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + view.pasteText( + text, + new ClipboardEvent("paste", { clipboardData }), + ); + }) + .catch(() => { + // The key is already consumed. Letting a delayed native paste + // race the asynchronous read could duplicate or unexpectedly + // format content. + }); + return true; + } + // ⌘K / Ctrl+K → link editor. The formatting toolbar has always // advertised this shortcut on its link button; bind it here so it // actually works. Kept alongside the ArrowUp handling below rather diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 60205a45fe..daedebde5c 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -1,4 +1,4 @@ -import { invoke as invokeTauriRaw } from "@tauri-apps/api/core"; +import { invoke as invokeTauriRaw, isTauri } from "@tauri-apps/api/core"; import { type BlobDescriptor, invokeTauri } from "./tauri"; function encodeRawIpcHeader(value: string): string { @@ -66,6 +66,23 @@ export async function fetchMediaBytes( return new Uint8Array(bytes); } +/** Read plain text without depending on embedded-webview clipboard grants. */ +export async function readTextFromSystemClipboard(): Promise { + // E2E installs Tauri's mocked IPC surface in a browser page, where the SDK's + // `isTauri()` marker remains false. Exercise the packaged-app command path in + // that build so tests detect accidental regressions to permission-gated DOM + // clipboard reads. + if (isTauri() || import.meta.env.MODE === "e2e") { + return invokeTauri("read_clipboard_text"); + } + + const clipboard = navigator.clipboard; + if (!clipboard?.readText) { + throw new Error("Clipboard text reading is unavailable"); + } + return clipboard.readText(); +} + /** Write text through the native clipboard after an asynchronous workflow. */ export async function copyTextToSystemClipboard( text: string, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index cf05ce6f19..204f67f51c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12516,6 +12516,8 @@ export function maybeInstallE2eTauriMocks() { case "copy_text_to_clipboard": await navigator.clipboard.writeText((payload as { text: string }).text); return; + case "read_clipboard_text": + return navigator.clipboard.readText(); case "get_event": return handleGetEvent( payload as Parameters[0], diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index b8ab46503d..202ec96869 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -177,6 +177,68 @@ async function applyCaretFormat( await page.getByRole("button", { name: label, exact: true }).click(); } +for (const platform of [ + { name: "macOS", navigatorPlatform: "MacIntel", shortcut: "Meta+Shift+V" }, + { + name: "Windows/Linux", + navigatorPlatform: "Win32", + shortcut: "Control+Shift+V", + }, +]) { + test(`pastes rich clipboard content without formatting on ${platform.name}`, async ({ + page, + }) => { + await page.addInitScript((navigatorPlatform) => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: navigatorPlatform, + }); + }, platform.navigatorPlatform); + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); + await openGeneral(page); + + await page.evaluate(async () => { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob( + [ + '

Bold and linked

  • list item
', + ], + { type: "text/html" }, + ), + "text/plain": new Blob(["Bold and linked\nlist item"], { + type: "text/plain", + }), + }), + ]); + }); + + const input = page.getByTestId("message-input"); + await input.click(); + await page.keyboard.press(platform.shortcut); + + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [], + ), + ) + .toContain("read_clipboard_text"); + await expect(input).toHaveText("Bold and linkedlist item"); + await expect(input.locator("strong, a, ul, li")).toHaveCount(0); + await expect(input.locator(":scope > p")).toHaveText([ + "Bold and linked", + "list item", + ]); + }); +} + for (const format of [ { label: "Code block", selector: "pre" }, { label: "Bullet list", selector: "ul" },