mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
When a user's agent runtime is `buzz-agent` with no cached Databricks OAuth token, the desktop app's passive model-discovery surfaces were forbidden from launching interactive auth. Discovery failed silently, so the model dropdown showed only built-in fallback models behind a vague "Could not load live models for `databricks_v2`" note (reported internally by Nick and Jose). ## What changed Both discovery surfaces — the passive draft-form discovery and the explicit saved-model picker — now launch the browser OAuth flow, matching goose's behavior. The only behavioral difference between them is cooldown handling: - **Passive draft discovery** fires on every form-state change, so a failed, cancelled, or timed-out sign-in records a per-host cooldown (5 min) that suppresses re-popping the browser on the next keystroke. While the cooldown is active it returns the "sign-in required" guidance instead of relaunching. - **The explicit model picker** is a deliberate user action, so it always launches and clears any stale cooldown first. Safety rails: - A 150s hard timeout (`AUTH_FLOW_TIMEOUT`) bounds the whole interactive flow so an abandoned SSO tab fails discovery cleanly rather than wedging the dropdown. Success clears the cooldown; failure and timeout both record it. - `AuthCooldown` recovers from a poisoned lock rather than wedging every future sign-in on one panic. The frontend maps the terminal Databricks sign-in states to typed, actionable copy in `formatModelDiscoveryErrorStatus`: "sign-in required" is a muted note pointing at the picker and `buzz-agent auth databricks`; a failed or timed-out sign-in is a warning pointing at the explicit retry. Other Databricks failures fall through to the existing generic notice. ## Scope Changes are confined to Databricks discovery and its frontend status formatter — no `agent_models.rs` call sites are touched. The interactive-auth helper takes an injected timeout so the timeout/cooldown policy is unit-testable without a live browser. ## Deferred Cooldown keys use the raw trimmed `DATABRICKS_HOST`, while the catalog and OAuth cache normalize trailing slashes (`crates/buzz-agent/src/catalog.rs:96`, `crates/buzz-agent/src/llm.rs:2046`). So `https://workspace/` and `https://workspace` share credentials but get separate cooldown entries — an equivalent-spelling change to the host field mid-cooldown can re-pop passive OAuth once within the 5-minute window. Self-limiting (one extra browser launch, never auth corruption). Follow-up: a `trim_end_matches('/')` on the cooldown key plus an equivalent-host test, picked up with the coordinator migration if [#5545](https://github.com/block/buzz/pull/5545) ever merges. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
302 lines
11 KiB
Rust
302 lines
11 KiB
Rust
//! Databricks v1/v2 model discovery and interactive reauthentication.
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::sync::{LazyLock, Mutex, MutexGuard};
|
|
use std::time::{Duration, Instant};
|
|
|
|
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<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(()));
|
|
|
|
// Hard cap on the interactive browser flow launched from a discovery surface.
|
|
// An abandoned SSO tab must fail discovery cleanly rather than wedge the
|
|
// dropdown forever. (`authenticate_databricks` has its own 60s callback wait;
|
|
// this outer bound also covers endpoint discovery and token exchange.)
|
|
const AUTH_FLOW_TIMEOUT: Duration = Duration::from_secs(150);
|
|
|
|
// How long a failed/cancelled interactive sign-in suppresses re-launching the
|
|
// browser from passive surfaces.
|
|
pub(super) const AUTH_COOLDOWN: Duration = Duration::from_secs(5 * 60);
|
|
|
|
/// Per-host record of a recently failed, cancelled, or timed-out interactive
|
|
/// sign-in.
|
|
///
|
|
/// Passive discovery surfaces fire on every form-state change, so without this
|
|
/// a cancelled SSO page would re-pop the browser on the very next keystroke.
|
|
/// Entries expire so a genuine later retry still launches; the saved-model
|
|
/// picker bypasses the cooldown and a success clears it.
|
|
#[derive(Default)]
|
|
pub(super) struct AuthCooldown {
|
|
until: Mutex<HashMap<String, Instant>>,
|
|
}
|
|
|
|
impl AuthCooldown {
|
|
fn map(&self) -> MutexGuard<'_, HashMap<String, Instant>> {
|
|
// The critical sections below are panic-free map ops, so recover from a
|
|
// poisoned lock rather than wedge every future sign-in on one panic.
|
|
self.until
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
}
|
|
|
|
pub(super) fn is_active(&self, host: &str, now: Instant) -> bool {
|
|
let mut map = self.map();
|
|
match map.get(host) {
|
|
Some(&expiry) if now < expiry => true,
|
|
Some(_) => {
|
|
map.remove(host);
|
|
false
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
pub(super) fn record(&self, host: &str, now: Instant) {
|
|
self.map().insert(host.to_string(), now + AUTH_COOLDOWN);
|
|
}
|
|
|
|
pub(super) fn clear(&self, host: &str) {
|
|
self.map().remove(host);
|
|
}
|
|
|
|
/// Whether the interactive browser flow may launch now under `auth_intent`.
|
|
/// Passive surfaces are suppressed while a per-host cooldown is active; the
|
|
/// explicit picker path always launches and clears any stale suppression.
|
|
pub(super) fn permits_launch(
|
|
&self,
|
|
auth_intent: DatabricksAuthIntent,
|
|
host: &str,
|
|
now: Instant,
|
|
) -> bool {
|
|
if auth_intent.respects_cooldown() {
|
|
!self.is_active(host, now)
|
|
} else {
|
|
self.clear(host);
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
static AUTH_COOLDOWNS: LazyLock<AuthCooldown> = LazyLock::new(AuthCooldown::default);
|
|
|
|
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, String>,
|
|
) -> 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 {
|
|
/// Passive draft discovery honors (and, on failure, writes) the per-host
|
|
/// cooldown so a cancelled SSO page does not re-pop on the next form
|
|
/// keystroke. The saved-model picker is an explicit user action, so it
|
|
/// bypasses the cooldown and clears it before launching. Both surfaces
|
|
/// launch the browser flow (Phase 2 goose-parity); this predicate is the
|
|
/// only behavioral difference between them.
|
|
fn respects_cooldown(self) -> bool {
|
|
matches!(self, Self::PassiveDraftDiscovery)
|
|
}
|
|
}
|
|
|
|
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 databricks_sign_in_timed_out_error() -> String {
|
|
"Databricks sign-in timed out; open the model picker to retry, or run `buzz-agent auth databricks`"
|
|
.to_string()
|
|
}
|
|
|
|
pub(super) fn should_start_interactive_auth(api_key: &str) -> bool {
|
|
// Phase 2: both discovery surfaces launch the browser flow when no static
|
|
// token is configured. Which surface is allowed to actually pop the browser
|
|
// (vs. respect a cooldown) is decided via `AuthCooldown::permits_launch`.
|
|
api_key.is_empty()
|
|
}
|
|
|
|
pub(super) async fn discover_databricks_models(
|
|
_client: &reqwest::Client,
|
|
provider: &DiscoveryProvider,
|
|
env: &BTreeMap<String, String>,
|
|
selected_model: Option<String>,
|
|
auth_intent: DatabricksAuthIntent,
|
|
) -> Result<Option<AgentModelsResponse>, 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) => {
|
|
let _auth = AUTH_GATE.lock().await;
|
|
match buzz_agent_pkg::discover_databricks_models(&config).await {
|
|
// A peer sign-in under the gate already succeeded.
|
|
Ok(entries) => entries,
|
|
Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => {
|
|
// Passive surfaces suppress the browser while a recent
|
|
// failure/cancel is cooling down; the explicit picker path
|
|
// always launches (and clears any stale cooldown).
|
|
if !AUTH_COOLDOWNS.permits_launch(auth_intent, &host, Instant::now()) {
|
|
return Err(databricks_sign_in_required_error());
|
|
}
|
|
run_interactive_databricks_auth(
|
|
buzz_agent_pkg::authenticate_databricks(&host),
|
|
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,
|
|
)
|
|
})?
|
|
}
|
|
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, String>,
|
|
) -> String {
|
|
let message = crate::managed_agents::redact_env_values_in(&error.to_string(), redaction_env);
|
|
format!("{context}: {message}")
|
|
}
|
|
|
|
/// Run the interactive browser OAuth flow under a hard timeout and maintain the
|
|
/// per-host cooldown. Success clears the cooldown; a failure, cancel, or
|
|
/// timeout records it so passive surfaces stop re-launching the browser on the
|
|
/// next form keystroke. `timeout` is injected (production passes
|
|
/// [`AUTH_FLOW_TIMEOUT`]) so the timeout/cooldown policy is unit-testable
|
|
/// without a live browser.
|
|
pub(super) async fn run_interactive_databricks_auth<Fut>(
|
|
auth: Fut,
|
|
timeout: Duration,
|
|
cooldowns: &AuthCooldown,
|
|
host: &str,
|
|
redaction_env: &BTreeMap<String, String>,
|
|
) -> Result<(), String>
|
|
where
|
|
Fut: std::future::Future<Output = Result<(), buzz_agent_pkg::AgentError>>,
|
|
{
|
|
match tokio::time::timeout(timeout, auth).await {
|
|
Ok(Ok(())) => {
|
|
cooldowns.clear(host);
|
|
Ok(())
|
|
}
|
|
Ok(Err(error)) => {
|
|
cooldowns.record(host, Instant::now());
|
|
Err(format_redacted_error(
|
|
"Databricks sign-in failed",
|
|
&error,
|
|
redaction_env,
|
|
))
|
|
}
|
|
Err(_elapsed) => {
|
|
cooldowns.record(host, Instant::now());
|
|
Err(databricks_sign_in_timed_out_error())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "agent_models_databricks_tests.rs"]
|
|
mod tests;
|