diff --git a/Cargo.lock b/Cargo.lock index e2bd238662..1c96f6911d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -580,6 +580,9 @@ dependencies = [ "gtk", "hex", "hmac", + "http-body-util", + "hyper", + "hyper-util", "image", "libc", "nostr 0.44.8", @@ -2359,6 +2362,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.11.1" @@ -2372,6 +2381,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index bdda1be786..b5df23dfc9 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -41,6 +41,10 @@ pub struct HostGrants { /// a process takes stdin and the caller appends its own arguments. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub processes: Vec, + /// The plugin may serve MCP servers on loopback HTTP to the agents it + /// runs, answering their messages itself. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub mcp: bool, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -168,6 +172,10 @@ pub fn bundled_manifests() -> Vec { }); vec![ builderlab, + serde_json::from_str(include_str!( + "../../../src/bundled/databricks/manifest.json" + )) + .expect("databricks manifest"), serde_json::from_str(include_str!("../../../src/bundled/pairing/manifest.json")) .expect("valid pairing manifest"), serde_json::from_str(include_str!("../../../src/bundled/todos/manifest.json")) @@ -222,6 +230,10 @@ pub fn bundled_manifests() -> Vec { .expect("claude manifest"), serde_json::from_str(include_str!("../../../src/bundled/codex/manifest.json")) .expect("codex manifest"), + serde_json::from_str(include_str!("../../../src/bundled/goose/manifest.json")) + .expect("goose manifest"), + serde_json::from_str(include_str!("../../../src/bundled/pi/manifest.json")) + .expect("pi manifest"), serde_json::from_str(include_str!("../../../src/bundled/workflows/manifest.json")) .expect("workflows manifest"), serde_json::from_str(include_str!("../../../src/bundled/feedback/manifest.json")) @@ -263,6 +275,8 @@ fn enabled_by_default(id: &str) -> bool { | "buzz.agents" | "buzz.claude-code" | "buzz.codex" + | "buzz.goose" + | "buzz.databricks" | "buzz.workflows" | "buzz.sessions" | "block.hosted-communities" diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 41c5adef90..7238d43fac 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -720,7 +720,7 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() { .any(|plugin| plugin.manifest.id == "buzz.me")); for plugin in &catalog.plugins { let id = plugin.manifest.id.as_str(); - let default = !matches!(id, "buzz.bestie" | "buzz.todos"); + let default = !matches!(id, "buzz.bestie" | "buzz.pi" | "buzz.todos"); assert_eq!(plugin.enabled, default, "{id}"); if id == "buzz.channels" { assert!(manager.change("disable", id).is_err()); diff --git a/docs/provider-plugins-exploration.md b/docs/provider-plugins-exploration.md new file mode 100644 index 0000000000..09660533fb --- /dev/null +++ b/docs/provider-plugins-exploration.md @@ -0,0 +1,53 @@ +# Shared provider plugins for Goose and Pi + +Goose and Pi can use several inference providers. Their credentials belong to the selected provider, rather than a single harness account such as the one used by Codex or Claude. A shared provider plugin would let a user configure Databricks or OpenRouter once and use that connection from either agent type. + +## Motivation + +In the Buzz discussions, Mic asked how general-purpose harnesses could share providers, and baxen proposed configuring credentials once for Goose and Pi. The proposed scope became credentials plus model discovery. This would avoid separate logins and duplicate configuration while giving Buzz one model picker across configured providers. See the [provider registry discussion](buzz://message?channel=099e2481-9499-48d6-adad-fa89a15163a0&id=385f6dae4cdf5e455a3ca506e8c96ee270b86198b053547d47a87587534f2514) and [shared provider proposal](buzz://message?channel=099e2481-9499-48d6-adad-fa89a15163a0&id=0cba713c360bf4b5310cd717df4868e8e6e0b644dfcf69f424645b64da8960ae). + +## Scope + +Provider integration involves three kinds of work: + +1. **Authentication and configuration:** credentials, endpoints, login, and token refresh. + +2. **Model discovery:** model IDs, aliases, available API routes, and capabilities. + +3. **Inference:** request formatting, streaming, thinking controls, and conversation replay. + +Share the first two. Leave inference to each agent plugin’s harness integration. Goose and Pi have different request and history representations; their existing adapters already translate those into provider APIs. The [Pi Databricks extension](https://github.com/squareup/pi-extensions/tree/main/packages/pi-databricks) also contains Pi-specific compatibility fixes that a shared catalog cannot replace. + +## Databricks example and boundaries + +A user configures **Work Databricks**, signs in once, and chooses a model. The shared plugin supplies the connection and catalog. The Pi integration registers a provider with the routes and compatibility settings of Pi’s Databricks extension; the Goose integration configures a supported Goose provider adapter. Each uses a supported API route and obtains current access tokens from the shared authentication owner. + +| Shared Databricks plugin owns | Each agent plugin integration owns | +| --- | --- | +| Workspace configuration and connection identity | Binding an agent to a connection and served model ID | +| Login, credential storage, and token refresh | Obtaining fresh access tokens while sessions remain running | +| Endpoint and alias discovery | Registering the catalog with its harness | +| Available API routes and provider capability facts | Filtering unsupported routes and mapping model and effort choices | +| Shared authentication and discovery status | Request formatting, streaming, history replay, and harness-specific compatibility fixes | + +The picker can lead with the model and show “via Work Databricks” underneath. Save the exact connection and served model ID. A model appearing in the shared catalog does not imply that both harnesses support it; each integration must check compatibility. + +## First slice: Databricks for Goose + +Add one shared connection to Block’s Databricks workspace, with browser login, private local credential storage, token refresh, and discovery. Goose selects that connection and an exact served model ID. Pi follows in the next slice; OpenRouter can follow after that. + +Goose’s native Databricks adapter does not accept an external credential callback. For this slice, the Goose integration generates a custom OpenAI adapter configuration with command auth. A small Buzz native helper supplies current access tokens; refresh tokens stay in the provider’s private local storage. File locking serializes refresh across Goose processes. Request formatting and streaming remain in Goose. + +Goose uses the gateway’s Responses route. Its Chat route rejects reasoning effort alongside tools for GPT-6 models, and GPT-6.1 Sol always requires reasoning effort. Goose offers a short list of ready, tool-capable endpoints that were verified with its tools: GPT-6 and 6.1, Claude Opus, Sonnet, and Haiku 5.5, GLM 5.3, and Grok 4.7. Gemini rejects Goose’s streaming options, and Gemma rejects a tool-schema keyword. Other models, aliases, workspace configuration, and per-agent thinking controls are deferred; Goose’s own thinking-effort setting applies. + +The generated Goose configuration contains no tokens and uses its usual history store. Cancellation applies while waiting for the browser. Once the callback has been accepted, the UI shows “Completing sign-in…” while native code saves the connection. + +Try it by enabling Databricks and Goose, opening **Settings → Providers → Databricks**, signing in, then choosing **Databricks in Buzz** in a Goose agent’s provider dialog. Verify a tool-bearing conversation, two agents using the same connection, history after restart, and continued work after token refresh. + +## Second slice: Databricks for Pi + +The catalog lists every gateway route a ready, tool-capable endpoint serves, and the shared plugin gives agent plugins the helper command that prints a current access token. Pi’s provider list leads with **Databricks in Buzz**; its sign-in is the shared connection, not a key in Pi’s `auth.json`. + +Buzz does not load Pi packages, so Pi’s host registers the provider itself instead of loading the Databricks extension. The Pi plugin chooses each model’s route the way the extension does: Anthropic Messages for Claude, OpenAI Responses for other families, and neither for Gemini or models with only a Chat route. The host asks the plugin for that binding when it first needs the provider and again when listing its models, then registers it with pi-ai. Context windows, output limits, and thinking levels come from Pi’s built-in model with the same name, and the extension’s gateway compatibility settings apply. Each request runs the token helper, reusing its answer for 30 seconds. Request formatting, streaming, and history stay in Pi. + +Try it by enabling Databricks and Pi, signing in under **Settings → Providers → Databricks** or from the Pi provider dialog, then choosing **Databricks in Buzz** and a Claude and a GPT model in two Pi agents. Verify tool-bearing turns on both routes, a goose and a Pi agent on the same connection, and continued work after token refresh. diff --git a/scripts/design-system/check-icons.mjs b/scripts/design-system/check-icons.mjs index 8cd476663c..0c6ef25feb 100644 --- a/scripts/design-system/check-icons.mjs +++ b/scripts/design-system/check-icons.mjs @@ -14,6 +14,9 @@ const nonIconDependencies = new Set([ "@buzz/author", "@deepseek-ai/cordis", "@dnd-kit/core", // Pointer drag and drop for sidebar channel moves. + "@earendil-works/chord", // The Pi host's packages, which pi.sh installs. + "@earendil-works/pi-ai", + "@earendil-works/pi-durable", "@emoji-mart/data", "@fontsource-variable/inter", "@fontsource/jetbrains-mono", @@ -53,6 +56,7 @@ const nonIconDependencies = new Set([ "motion", "nostr-tools", "postcss", + "proper-lockfile", // The Pi host's lock on each agent's storage. "prosemirror-commands", "prosemirror-history", "prosemirror-model", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fb21b2aa1a..60c35b4659 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,9 @@ sha2 = "0.10" hmac = "0.12" base64 = "0.22" reqwest = { version = "0.13", default-features = false, features = ["rustls", "stream"] } +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +http-body-util = "0.1" bytes = "1" url = "2" percent-encoding = "2" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 3672fd1c24..f7df1ef432 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -96,9 +96,13 @@ fn main() { "plugin_host_process_spawn", "plugin_host_process_write", "plugin_host_process_kill", + "plugin_host_mcp_serve", + "plugin_host_mcp_reply", + "plugin_host_mcp_close", "oauth_callback_begin", "oauth_callback_wait", "oauth_callback_cancel", + "databricks_provider", "app_agent_list", "app_agent_create", "app_agent_portable", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 2c302b5311..81b446ced5 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -66,9 +66,13 @@ "allow-plugin-host-process-spawn", "allow-plugin-host-process-write", "allow-plugin-host-process-kill", + "allow-plugin-host-mcp-serve", + "allow-plugin-host-mcp-reply", + "allow-plugin-host-mcp-close", "allow-oauth-callback-begin", "allow-oauth-callback-wait", "allow-oauth-callback-cancel", + "allow-databricks-provider", "allow-app-agent-list", "allow-app-agent-create", "allow-app-agent-portable", diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index 84ca3dcef8..e16ff38b24 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -113,6 +113,9 @@ fn native_command_permissions_allow_only_main_webview() { "plugin_host_process_spawn", "plugin_host_process_write", "plugin_host_process_kill", + "plugin_host_mcp_serve", + "plugin_host_mcp_reply", + "plugin_host_mcp_close", "oauth_callback_begin", "oauth_callback_wait", "oauth_callback_cancel", diff --git a/src-tauri/src/databricks_provider.rs b/src-tauri/src/databricks_provider.rs new file mode 100644 index 0000000000..297030a55d --- /dev/null +++ b/src-tauri/src/databricks_provider.rs @@ -0,0 +1,556 @@ +//! One shared Databricks connection. Credentials never enter the renderer or +//! Goose's config. Goose's command-auth adapter reads fresh access tokens here. +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use zeroize::Zeroize; + +const HOST: &str = "https://block-lakehouse-production.cloud.databricks.com"; +const CLIENT_ID: &str = "databricks-cli"; +type Result = std::result::Result; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Credential { + access: String, + refresh: String, + expires: u64, +} +impl Drop for Credential { + fn drop(&mut self) { + self.access.zeroize(); + self.refresh.zeroize(); + } +} +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} +fn directory() -> Result { + let dir = dirs::data_local_dir() + .ok_or("Provider storage is unavailable")? + .join("buzz") + .join("providers") + .join("databricks"); + private_directory(&dir)?; + Ok(dir) +} +fn private_directory(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir).map_err(|_| "Could not create provider storage")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .map_err(|_| "Could not protect provider storage")?; + } + Ok(()) +} +async fn lock(dir: &Path) -> Result { + let mut options = OpenOptions::new(); + options.create(true).read(true).write(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options + .open(dir.join("credential.lock")) + .map_err(|_| "Could not lock provider storage")?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + match FileExt::try_lock_exclusive(&file) { + Ok(()) => return Ok(file), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if tokio::time::Instant::now() >= deadline { + return Err("Databricks is busy. Try again.".into()); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + Err(_) => return Err("Could not lock provider storage".into()), + } + } +} +fn read(dir: &Path) -> Result> { + use std::io::Read; + match File::open(dir.join("credential.json")) { + Ok(file) => { + let mut bytes = Vec::new(); + file.take(64 * 1024) + .read_to_end(&mut bytes) + .map_err(|_| "Could not read Databricks credentials")?; + if bytes.len() == 64 * 1024 { + bytes.zeroize(); + return Err("Saved Databricks credentials are too large".into()); + } + let result = serde_json::from_slice(&bytes) + .map_err(|_| "Saved Databricks credentials are invalid. Sign in again.".into()); + bytes.zeroize(); + result.map(Some) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err("Could not read Databricks credentials".into()), + } +} +fn write(dir: &Path, value: &Credential) -> Result<()> { + let mut bytes = + serde_json::to_vec(value).map_err(|_| "Could not encode provider credentials")?; + let result = write_file(dir.join("credential.json"), &bytes); + bytes.zeroize(); + result +} +pub(super) fn write_file(path: PathBuf, bytes: &[u8]) -> Result<()> { + use std::io::Write; + let parent = path.parent().ok_or("Invalid provider storage")?; + private_directory(parent)?; + let mut file = tempfile::NamedTempFile::new_in(parent) + .map_err(|_| "Could not save provider configuration")?; + file.write_all(bytes) + .and_then(|_| file.as_file().sync_all()) + .map_err(|_| "Could not save provider configuration")?; + file.persist(path) + .map_err(|_| "Could not save provider configuration")?; + Ok(()) +} +fn client() -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "Could not create the Databricks client".into()) +} +async fn body(response: reqwest::Response) -> Result { + let status = response.status(); + if !status.is_success() { + return Err(format!( + "Databricks returned HTTP {}. Check your connection or sign in again.", + status.as_u16() + )); + } + use futures_util::StreamExt; + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "Could not read Databricks response")?; + if bytes.len() + chunk.len() > 8 * 1024 * 1024 { + return Err("Databricks response is too large".into()); + } + bytes.extend_from_slice(&chunk); + } + let result = + serde_json::from_slice(&bytes).map_err(|_| "Databricks returned invalid JSON".into()); + bytes.zeroize(); + result +} +async fn oidc(client: &reqwest::Client) -> Result { + body( + client + .get(format!( + "{HOST}/oidc/.well-known/oauth-authorization-server" + )) + .send() + .await + .map_err(|_| "Could not reach Databricks")?, + ) + .await +} +fn endpoint(value: &Value, key: &str) -> Result { + let text = value + .get(key) + .and_then(Value::as_str) + .ok_or("Invalid Databricks OAuth configuration")?; + let url = url::Url::parse(text).map_err(|_| "Invalid Databricks OAuth configuration")?; + if url.origin().ascii_serialization() != HOST + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err("Invalid Databricks OAuth origin".into()); + } + Ok(text.into()) +} +async fn exchange( + client: &reqwest::Client, + fields: &[(&str, &str)], + old_refresh: &str, +) -> Result { + let url = endpoint(&oidc(client).await?, "token_endpoint")?; + grant(client, &url, fields, old_refresh).await +} +async fn grant( + client: &reqwest::Client, + url: &str, + fields: &[(&str, &str)], + old_refresh: &str, +) -> Result { + let encoded = url::form_urlencoded::Serializer::new(String::new()) + .extend_pairs(fields.iter().copied()) + .finish(); + let response = client + .post(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(encoded) + .send() + .await + .map_err(|_| "Databricks sign-in could not complete")?; + let mut data = body(response).await?; + let access = data["access_token"] + .take() + .as_str() + .filter(|s| !s.is_empty()) + .ok_or("Databricks did not return an access token")? + .to_owned(); + let refresh = data["refresh_token"] + .take() + .as_str() + .unwrap_or(old_refresh) + .to_owned(); + let expires = data["expires_in"] + .as_u64() + .filter(|n| *n > 0) + .ok_or("Databricks did not return token expiry")?; + if refresh.is_empty() || access.len() > 16 * 1024 || refresh.len() > 16 * 1024 { + return Err("Invalid Databricks credentials".into()); + } + Ok(Credential { + access, + refresh, + expires: now().saturating_add(expires), + }) +} +/// Serializes refresh across Buzz and every Goose credential helper. +async fn token(client: &reqwest::Client, dir: &Path) -> Result { + let _lock = lock(dir).await?; + let saved = read(dir)?.ok_or("Sign in to Databricks in Buzz first")?; + if saved.expires > now().saturating_add(60) { + return Ok(saved); + } + let next = exchange( + client, + &[ + ("grant_type", "refresh_token"), + ("client_id", CLIENT_ID), + ("refresh_token", &saved.refresh), + ], + &saved.refresh, + ) + .await?; + write(dir, &next)?; + Ok(next) +} +/// Ready, tool-capable gateway endpoints, with the API routes each serves. +/// Each harness picks the routes it can use. +fn chat_models(data: &Value) -> Vec { + data["endpoints"].as_array().into_iter().flatten().filter_map(|ep| { + let id = ep["name"].as_str()?; + if ep["state"]["ready"] != "READY" || ep["task"] != "llm/v1/chat" || ep["capabilities"]["function_calling"] != true { return None; } + let foundation = ep["config"]["served_entities"].as_array()?.iter().find_map(|e| e.get("foundation_model"))?; + if foundation["ai_gateway_v2_supported"] != true { return None; } + let apis: Vec<&str> = foundation["api_types"].as_array()?.iter().filter_map(Value::as_str).collect(); + if apis.is_empty() { return None; } + Some(json!({"id":id, "label":foundation["display_name"].as_str().unwrap_or(id), "family":foundation["model_class"].as_str().unwrap_or(""), "apis":apis})) + }).collect() +} +async fn models(client: &reqwest::Client, dir: &Path) -> Result> { + let credential = token(client, dir).await?; + let response = client + .get(format!("{HOST}/api/2.0/serving-endpoints")) + .bearer_auth(&credential.access) + .send() + .await + .map_err(|_| "Could not list Databricks models")?; + let data = body(response).await?; + if !data["endpoints"].is_array() { + return Err("Databricks returned an invalid model catalog".into()); + } + Ok(chat_models(&data)) +} +fn argument<'a>(input: &'a Value, key: &str) -> Result<&'a str> { + input[key] + .as_str() + .filter(|v| !v.is_empty() && v.len() <= 4096) + .ok_or_else(|| format!("Invalid {key}")) +} +async fn execute(operation: &str, input: &Value) -> Result { + let dir = directory()?; + let client = client()?; + match operation { + "status" => Ok(json!({"connected":read(&dir)?.is_some(), "host":HOST})), + "authorize" => Ok( + json!({"url":endpoint(&oidc(&client).await?, "authorization_endpoint")?, "host":HOST}), + ), + "connect" => { + let redirect = argument(input, "redirect")?; + let url = url::Url::parse(redirect).map_err(|_| "Invalid OAuth redirect")?; + if url.scheme() != "http" + || url.host_str() != Some("localhost") + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + return Err("Invalid OAuth redirect".into()); + } + let credential = exchange( + &client, + &[ + ("grant_type", "authorization_code"), + ("client_id", CLIENT_ID), + ("code", argument(input, "code")?), + ("code_verifier", argument(input, "verifier")?), + ("redirect_uri", redirect), + ], + "", + ) + .await?; + let _lock = lock(&dir).await?; + write(&dir, &credential)?; + Ok(json!({"connected":true, "host":HOST})) + } + "disconnect" => { + let _lock = lock(&dir).await?; + match std::fs::remove_file(dir.join("credential.json")) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("Could not remove Databricks credentials".into()), + } + Ok(json!({"connected":false, "host":HOST})) + } + "models" => Ok(json!(models(&client, &dir).await?)), + // A harness process runs this for fresh access tokens; it prints one. + "helper" => Ok(json!({"host":HOST, "command":helper()?, "args":[HELPER_ARG]})), + "goose" => { + let model = argument(input, "model")?; + if read(&dir)?.is_none() { + return Err("Sign in to Databricks in Buzz first".into()); + } + // Launches reuse the written catalog; only a new model or binary refetches. + if let Some(env) = crate::goose_databricks::reuse(model) { + return Ok(env); + } + let catalog = models(&client, &dir).await?; + crate::goose_databricks::prepare(HOST, &catalog, model) + } + _ => Err("Unknown Databricks operation".into()), + } +} + +pub(crate) const HELPER_ARG: &str = "--databricks-provider-token"; +pub(crate) fn helper() -> Result { + std::env::current_exe().map_err(|_| "Could not find the Buzz credential helper".into()) +} + +#[tauri::command] +pub(crate) async fn databricks_provider(operation: String, input: Value) -> Result { + execute(&operation, &input).await +} + +/// Credential-command mode prints only the access token to Goose's pipe. +pub fn run_token_helper() -> bool { + if std::env::args().nth(1).as_deref() != Some(HELPER_ARG) { + return false; + } + let result = (|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| "Could not start provider helper")?; + runtime.block_on(async { + let dir = directory()?; + let credential = token(&client()?, &dir).await?; + println!("{}", credential.access); + Ok::<_, String>(()) + }) + })(); + if result.is_err() { + eprintln!("Databricks credentials are unavailable. Sign in again in Buzz."); + std::process::exit(1); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn server(status: &str, data: Value) -> (String, tokio::task::JoinHandle) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let payload = data.to_string(); + let response = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}", payload.len()); + let task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut chunk = [0; 1024]; + let n = socket.read(&mut chunk).await.unwrap(); + assert!(n > 0); + request.extend_from_slice(&chunk[..n]); + assert!(request.len() < 64 * 1024); + let text = String::from_utf8_lossy(&request); + if let Some((headers, body)) = text.split_once("\r\n\r\n") { + let length = headers + .lines() + .find_map(|line| { + line.to_lowercase() + .strip_prefix("content-length: ") + .and_then(|v| v.parse::().ok()) + }) + .unwrap_or(0); + if body.len() >= length { + break; + } + } + } + socket.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() + }); + (format!("http://{address}/token"), task) + } + + #[tokio::test] + async fn rotates_refresh_tokens_and_encodes_the_oauth_form() { + let (url, request) = server("200 OK", json!({"access_token":"next-access", "refresh_token":"next-refresh", "expires_in":3600})).await; + let before = now(); + let credential = grant( + &client().unwrap(), + &url, + &[ + ("grant_type", "refresh_token"), + ("refresh_token", "old+refresh&token"), + ], + "old+refresh&token", + ) + .await + .unwrap(); + assert_eq!(credential.access, "next-access"); + assert_eq!(credential.refresh, "next-refresh"); + assert!(credential.expires >= before + 3600); + assert!(request + .await + .unwrap() + .contains("refresh_token=old%2Brefresh%26token")); + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), &credential).unwrap(); + assert_eq!(read(dir.path()).unwrap().unwrap().refresh, "next-refresh"); + } + + #[tokio::test] + async fn retains_refresh_token_when_the_server_does_not_rotate_it() { + let (url, request) = server( + "200 OK", + json!({"access_token":"next-access", "expires_in":3600}), + ) + .await; + let credential = grant(&client().unwrap(), &url, &[], "saved-refresh") + .await + .unwrap(); + assert_eq!(credential.refresh, "saved-refresh"); + request.await.unwrap(); + } + + #[tokio::test] + async fn does_not_expose_http_error_bodies_or_overwrite_saved_credentials() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + &Credential { + access: "old-access".into(), + refresh: "old-refresh".into(), + expires: 1, + }, + ) + .unwrap(); + let (url, request) = + server("401 Unauthorized", json!({"error":"secret-from-server"})).await; + let error = grant(&client().unwrap(), &url, &[], "old-refresh") + .await + .err() + .unwrap(); + assert!(error.contains("HTTP 401")); + assert!(!error.contains("secret-from-server")); + assert_eq!(read(dir.path()).unwrap().unwrap().refresh, "old-refresh"); + request.await.unwrap(); + } + + #[tokio::test] + async fn refresh_storage_is_exclusive_across_independent_handles() { + let dir = tempfile::tempdir().unwrap(); + let held = lock(dir.path()).await.unwrap(); + let competing = OpenOptions::new() + .read(true) + .write(true) + .open(dir.path().join("credential.lock")) + .unwrap(); + assert_eq!( + FileExt::try_lock_exclusive(&competing).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + drop(held); + FileExt::try_lock_exclusive(&competing).unwrap(); + } + + #[test] + fn credentials_are_private_and_corrupt_storage_is_actionable() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + &Credential { + access: "access".into(), + refresh: "refresh".into(), + expires: 2, + }, + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(dir.path().join("credential.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + std::fs::write(dir.path().join("credential.json"), "corrupt-token-data").unwrap(); + let error = read(dir.path()).err().unwrap(); + assert!(error.contains("Sign in again")); + assert!(!error.contains("corrupt-token-data")); + } + + #[test] + fn discovery_keeps_exact_ids_and_requires_ready_tool_support_and_route_facts() { + let ready = json!({"name":"served-model", "task":"llm/v1/chat", "state":{"ready":"READY"}, "capabilities":{"function_calling":true}, "config":{"served_entities":[{"foundation_model":{"display_name":"GPT", "model_class":"gpt", "ai_gateway_v2_supported":true, "api_types":["mlflow/v1/responses"]}}]}}); + let mut no_tools = ready.clone(); + no_tools["capabilities"]["function_calling"] = json!(false); + let mut not_ready = ready.clone(); + not_ready["state"]["ready"] = json!("NOT_READY"); + let mut no_gateway = ready.clone(); + no_gateway["config"]["served_entities"][0]["foundation_model"]["ai_gateway_v2_supported"] = + json!(false); + let mut no_routes = ready.clone(); + no_routes["config"]["served_entities"][0]["foundation_model"]["api_types"] = json!([]); + assert_eq!( + chat_models(&json!({"endpoints":[ready,no_tools,not_ready,no_gateway,no_routes]})), + vec![ + json!({"id":"served-model", "label":"GPT", "family":"gpt", "apis":["mlflow/v1/responses"]}) + ] + ); + assert!(endpoint( + &json!({"token_endpoint":"https://attacker.invalid/token"}), + "token_endpoint" + ) + .is_err()); + } +} diff --git a/src-tauri/src/goose_databricks.rs b/src-tauri/src/goose_databricks.rs new file mode 100644 index 0000000000..b01383c399 --- /dev/null +++ b/src-tauri/src/goose_databricks.rs @@ -0,0 +1,110 @@ +//! Goose-specific binding of the shared Databricks connection. Goose owns all +//! model requests; this writes its supported command-auth configuration. +use crate::databricks_provider::{helper, HELPER_ARG}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +type Result = std::result::Result; +const ROUTE: &str = "ai-gateway/mlflow/v1/responses"; +/// The catalog's name for that route. +const API: &str = "mlflow/v1/responses"; + +fn env(model: &str) -> Value { + json!({"GOOSE_PROVIDER":"buzz_databricks", "GOOSE_MODEL":model}) +} +fn definition() -> Result { + Ok(goose_config_directory()?.join("custom_providers/buzz_databricks.json")) +} + +/// The launch environment when the written configuration already offers this +/// model through this Buzz binary, so starting an agent needs no catalog fetch. +pub(crate) fn reuse(model: &str) -> Option { + let saved: Value = serde_json::from_slice(&std::fs::read(definition().ok()?).ok()?).ok()?; + let listed = saved["models"] + .as_array()? + .iter() + .any(|m| m["name"] == model); + let current = saved["auth"]["command"].as_str()? == helper().ok()?.to_str()? + && saved["base_path"] == ROUTE; + (listed && current).then(|| env(model)) +} + +pub(crate) fn prepare(host: &str, catalog: &[Value], model: &str) -> Result { + let models: Vec<&Value> = catalog + .iter() + .filter(|m| { + m["apis"] + .as_array() + .is_some_and(|apis| apis.iter().any(|a| a == API)) + }) + .collect(); + if !models.iter().any(|m| m["id"] == model) { + return Err("This Databricks model is unavailable or has no Responses route".into()); + } + // Goose sends a Responses request when base_path names that route. The + // gateway's Chat route rejects reasoning effort alongside tools (GPT-6). + // Allow the 15s storage-lock wait plus two 10s OAuth requests. + let config = json!({ + "name":"buzz_databricks", "engine":"openai", "display_name":"Databricks in Buzz", + "base_url":host, "base_path":ROUTE, + "models":models.iter().map(|m| json!({"name":m["id"]})).collect::>(), + "supports_streaming":true, "dynamic_models":false, + "auth":{"command":helper()?,"args":[HELPER_ARG],"refresh_interval":30,"timeout_seconds":45} + }); + crate::databricks_provider::write_file( + definition()?, + &serde_json::to_vec(&config).map_err(|_| "Could not encode Goose provider")?, + )?; + Ok(env(model)) +} + +// Match Goose's Paths::config_dir (XDG on macOS/Linux). Keeping its ordinary +// root also keeps history readable after sign-out or changing providers. +fn goose_config_directory() -> Result { + if let Some(root) = std::env::var_os("GOOSE_PATH_ROOT") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + { + return Ok(root.join("config")); + } + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| dirs::home_dir().map(|p| p.join(".config"))) + .ok_or("Goose configuration storage is unavailable")?; + Ok(base.join("goose")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launches_reuse_a_written_catalog_only_for_listed_models_and_this_binary() { + let root = tempfile::tempdir().unwrap(); + std::env::set_var("GOOSE_PATH_ROOT", root.path()); + assert_eq!(reuse("served-gpt"), None); + let catalog = [ + json!({"id":"served-gpt", "family":"gpt", "apis":[API]}), + json!({"id":"chat-only", "family":"gpt", "apis":["mlflow/v1/chat/completions"]}), + ]; + assert!(prepare("https://workspace.example", &catalog, "other-model").is_err()); + assert!(prepare("https://workspace.example", &catalog, "chat-only").is_err()); + prepare("https://workspace.example", &catalog, "served-gpt").unwrap(); + assert_eq!(reuse("served-gpt"), Some(env("served-gpt"))); + assert_eq!(reuse("other-model"), None); + let path = definition().unwrap(); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved["base_url"], "https://workspace.example"); + assert_eq!(saved["base_path"], ROUTE); + // A file from an earlier route is rewritten rather than reused. + saved["base_path"] = json!("ai-gateway/mlflow/v1/chat/completions"); + std::fs::write(&path, saved.to_string()).unwrap(); + assert_eq!(reuse("served-gpt"), None); + saved["base_path"] = json!(ROUTE); + saved["auth"]["command"] = json!("/deleted/worktree/buzz"); + std::fs::write(&path, saved.to_string()).unwrap(); + assert_eq!(reuse("served-gpt"), None); + std::env::remove_var("GOOSE_PATH_ROOT"); + } +} diff --git a/src-tauri/src/host_mcp.rs b/src-tauri/src/host_mcp.rs new file mode 100644 index 0000000000..097d6100be --- /dev/null +++ b/src-tauri/src/host_mcp.rs @@ -0,0 +1,398 @@ +//! Loopback MCP servers a plugin serves to the agents it runs, over MCP's +//! Streamable HTTP transport. The listener and its token live here; each +//! JSON-RPC message is handed to the page that started the server, which +//! answers it. So a tool that signs with an agent's key runs in the app, and +//! the agent is given only the server's address and token. +//! +//! - It binds only `127.0.0.1`, on a port the OS picks. A random 256-bit token +//! per server authorizes every request as `Authorization: Bearer `. +//! `Host` must name the bound address, and a request that sends `Origin` +//! (a web page) is refused. Nothing is logged. +//! - A refused request gets 404, never 401 or 403: MCP clients answer those +//! by starting an OAuth sign-in. +//! - It is stateless and answers in JSON, never SSE. `POST /mcp` carries one +//! message: a request is answered with the page's reply, and a notification +//! or response with 202. `GET` and `DELETE` get 405, since there is no +//! server stream or session to end. +//! - Each connection answers one request and closes. A client that closes +//! before its answer drops the request; the page's late reply is refused. +//! - A server belongs to the plugin and the page that started it. It stops +//! when the plugin closes it, when the page reloads, or when the app exits. +use crate::{with_manager, PluginManager}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use http_body_util::{BodyExt, Full, Limited}; +use hyper::body::{Bytes, Incoming}; +use hyper::header::{self, HeaderName}; +use hyper::{server::conn::http1, service::service_fn, Method, Request, Response, StatusCode}; +use hyper_util::rt::{TokioIo, TokioTimer}; +use serde::Serialize; +use std::collections::HashMap; +use std::convert::Infallible; +use std::net::Ipv4Addr; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::Duration; +use tauri::ipc::Channel; +use tokio::net::TcpListener; +use tokio::sync::{oneshot, Semaphore}; +use tokio_util::sync::CancellationToken; + +/// Servers running at once across all plugins. +const MAX_SERVERS: usize = 64; +/// Open connections per server: an agent may call several tools at once. +const CONNECTIONS: usize = 32; +/// hyper's smallest read buffer, which bounds a request head. +const HEAD_LIMIT: usize = 8192; +const HEAD_TIMEOUT: Duration = Duration::from_secs(5); +const BODY_LIMIT: usize = 4 * 1024 * 1024; +const BODY_TIMEOUT: Duration = Duration::from_secs(30); +const REPLY_LIMIT: usize = 16 * 1024 * 1024; +const ENDPOINT: &str = "/mcp"; + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub(crate) enum McpEvent { + /// One JSON-RPC message, answered with `plugin_host_mcp_reply` naming + /// `request`. + Message { request: u64, body: String }, + /// The last event: the server stopped. + Closed, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct McpServerInfo { + handle: u64, + /// `http://127.0.0.1:/mcp` + url: String, + /// Sent by the agent as `Authorization: Bearer `. + token: String, +} + +struct Shared { + authority: String, + token: String, + events: Channel, + stop: CancellationToken, + pending: Mutex, +} +#[derive(Default)] +struct Pending { + next: u64, + replies: HashMap>>, +} +impl Shared { + fn pending(&self) -> MutexGuard<'_, Pending> { + self.pending.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +struct Entry { + plugin: String, + shared: Arc, +} + +#[derive(Clone, Default)] +pub(crate) struct HostMcpServers { + inner: Arc>, +} +#[derive(Default)] +struct Registry { + next: u64, + /// Counts page loads, so a server the page asked for before it reloaded + /// is refused rather than left running with nobody answering. + page: u64, + entries: HashMap, +} +impl HostMcpServers { + fn registry(&self) -> MutexGuard<'_, Registry> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + fn owned(&self, plugin: &str, handle: u64) -> Result, String> { + match self.registry().entries.get(&handle) { + Some(entry) if entry.plugin == plugin => Ok(entry.shared.clone()), + _ => Err("No such MCP server".into()), + } + } + /// The current page load, for `serve`. + pub(crate) fn page(&self) -> u64 { + self.registry().page + } + /// Starts a server owned by `plugin` whose messages go to `events`, unless + /// the page that asked during `page` has since gone. + fn serve( + &self, + page: u64, + plugin: String, + events: Channel, + ) -> Result { + let mut registry = self.registry(); + if registry.page != page { + return Err("The page reloaded".into()); + } + if registry.entries.len() >= MAX_SERVERS { + return Err("Too many MCP servers are running".into()); + } + let failed = |_| "Could not start the MCP server".to_owned(); + // Bound synchronously, so the address is known before any agent connects. + let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(failed)?; + listener.set_nonblocking(true).map_err(failed)?; + let authority = listener.local_addr().map_err(failed)?.to_string(); + let mut secret = [0; 32]; + getrandom::fill(&mut secret).map_err(|_| "Could not start the MCP server")?; + let token = URL_SAFE_NO_PAD.encode(secret); + let shared = Arc::new(Shared { + authority: authority.clone(), + token: token.clone(), + events, + stop: CancellationToken::new(), + pending: Mutex::default(), + }); + let handle = registry.next; + registry.next += 1; + registry.entries.insert( + handle, + Entry { + plugin, + shared: shared.clone(), + }, + ); + drop(registry); + let state = self.clone(); + tauri::async_runtime::spawn(async move { + accept(listener, shared.clone()).await; + state.registry().entries.remove(&handle); + // Requests still waiting are answered 503 as their senders drop. + shared.pending().replies.clear(); + let _ = shared.events.send(McpEvent::Closed); + }); + Ok(McpServerInfo { + handle, + url: format!("http://{authority}{ENDPOINT}"), + token, + }) + } + /// Answers `request` with `body`, or with 202 when there is none. + fn reply( + &self, + plugin: &str, + handle: u64, + request: u64, + body: Option, + ) -> Result<(), String> { + if body.as_ref().is_some_and(|body| body.len() > REPLY_LIMIT) { + return Err("Reply is too large".into()); + } + let shared = self.owned(plugin, handle)?; + let sender = shared.pending().replies.remove(&request); + sender + .ok_or("The request is no longer waiting")? + .send(body) + .map_err(|_| "The request is no longer waiting".into()) + } + fn close(&self, plugin: &str, handle: u64) -> Result<(), String> { + self.owned(plugin, handle)?.stop.cancel(); + Ok(()) + } + /// Stops every server: the page that answered them is gone. + pub(crate) fn stop_all(&self) { + let mut registry = self.registry(); + registry.page += 1; + for entry in registry.entries.values() { + entry.shared.stop.cancel(); + } + } +} + +async fn accept(listener: std::net::TcpListener, shared: Arc) { + let Ok(listener) = TcpListener::from_std(listener) else { + return; + }; + let connections = Arc::new(Semaphore::new(CONNECTIONS)); + loop { + let accepted = tokio::select! { + accepted = async { + let permit = connections.clone().acquire_owned().await.ok()?; + Some((permit, listener.accept().await)) + } => accepted, + () = shared.stop.cancelled() => return, + }; + let Some((permit, accepted)) = accepted else { + return; + }; + let Ok((socket, _)) = accepted else { + drop(permit); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + }; + let shared = shared.clone(); + tauri::async_runtime::spawn(async move { + let shared = &*shared; + // One request per connection. hyper drops a request whose client + // closes before its answer, so the page's late reply is refused. + let connection = http1::Builder::new() + .timer(TokioTimer::new()) + .header_read_timeout(HEAD_TIMEOUT) + .max_buf_size(HEAD_LIMIT) + .keep_alive(false) + .serve_connection( + TokioIo::new(socket), + service_fn(move |request| answer(shared, request)), + ); + tokio::select! { + _ = connection => {} + () = shared.stop.cancelled() => {} + } + drop(permit); + }); + } +} + +async fn answer( + shared: &Shared, + request: Request, +) -> Result>, Infallible> { + let (status, body) = match refuse(shared, &request) { + Some(status) => (status, None), + None => { + let body = Limited::new(request.into_body(), BODY_LIMIT).collect(); + match tokio::time::timeout(BODY_TIMEOUT, body).await { + Ok(Ok(body)) => match String::from_utf8(body.to_bytes().into()) { + Ok(body) => forward(shared, body).await, + Err(_) => (StatusCode::BAD_REQUEST, None), + }, + _ => (StatusCode::BAD_REQUEST, None), + } + } + }; + let mut response = Response::builder() + .status(status) + .header(header::CACHE_CONTROL, "no-store"); + if status == StatusCode::OK { + response = response.header(header::CONTENT_TYPE, "application/json"); + } + if status == StatusCode::METHOD_NOT_ALLOWED { + response = response.header(header::ALLOW, "POST"); + } + Ok(response + .body(Full::new(body.unwrap_or_default().into())) + .unwrap_or_default()) +} + +/// Hands `body` to the page and waits for its answer. +async fn forward(shared: &Shared, body: String) -> (StatusCode, Option) { + struct Waiting<'a>(&'a Shared, u64); + impl Drop for Waiting<'_> { + fn drop(&mut self) { + self.0.pending().replies.remove(&self.1); + } + } + let (sender, receiver) = oneshot::channel(); + let request = { + let mut pending = shared.pending(); + let request = pending.next; + pending.next += 1; + pending.replies.insert(request, sender); + request + }; + let _waiting = Waiting(shared, request); + if shared + .events + .send(McpEvent::Message { request, body }) + .is_err() + { + // Nobody is listening: the page that started it is gone. + shared.stop.cancel(); + return (StatusCode::SERVICE_UNAVAILABLE, None); + } + match receiver.await { + Ok(Some(reply)) => (StatusCode::OK, Some(reply)), + Ok(None) => (StatusCode::ACCEPTED, None), + Err(_) => (StatusCode::SERVICE_UNAVAILABLE, None), + } +} + +/// The status that refuses `request`, unless it is an authorized `POST /mcp`. +fn refuse(shared: &Shared, request: &Request) -> Option { + let token = header(request, header::AUTHORIZATION) + .and_then(|value| value.strip_prefix("Bearer ")) + .unwrap_or_default(); + if header(request, header::HOST) != Some(shared.authority.as_str()) + // An agent never sends one; a web page always does. + || request.headers().contains_key(header::ORIGIN) + || request.uri().path() != ENDPOINT + || !same(token.as_bytes(), shared.token.as_bytes()) + { + return Some(StatusCode::NOT_FOUND); + } + // No server stream for `GET` and no session for `DELETE` to end. + if request.method() != Method::POST { + return Some(StatusCode::METHOD_NOT_ALLOWED); + } + let json = header(request, header::CONTENT_TYPE) + .and_then(|value| value.split(';').next()) + .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("application/json")); + if !json { + return Some(StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + let length = header(request, header::CONTENT_LENGTH).and_then(|value| value.parse().ok()); + if length.is_some_and(|length: usize| length > BODY_LIMIT) { + return Some(StatusCode::PAYLOAD_TOO_LARGE); + } + None +} + +fn header(request: &Request, name: HeaderName) -> Option<&str> { + request.headers().get(name)?.to_str().ok() +} + +/// Compares the token without exposing how much of it matched. +fn same(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0, |diff, (x, y)| diff | (x ^ y)) == 0 +} + +/// Starts a loopback MCP server for a plugin that declares `host.mcp`. Each +/// message arrives on `on_event` and is answered with `plugin_host_mcp_reply`. +#[tauri::command] +pub(crate) async fn plugin_host_mcp_serve( + manager: tauri::State<'_, PluginManager>, + servers: tauri::State<'_, HostMcpServers>, + id: String, + revision: String, + on_event: Channel, +) -> Result { + let page = servers.page(); + let plugin = id.clone(); + let granted = with_manager(manager, move |manager| { + Ok(manager.host_grants(&id, &revision)?.mcp) + }) + .await?; + if !granted { + return Err("MCP servers are not declared".into()); + } + servers.serve(page, plugin, on_event) +} + +/// Answers a message with the JSON-RPC `body`; without one, with 202. +#[tauri::command] +pub(crate) fn plugin_host_mcp_reply( + servers: tauri::State<'_, HostMcpServers>, + id: String, + handle: u64, + request: u64, + body: Option, +) -> Result<(), String> { + servers.reply(&id, handle, request, body) +} + +/// Stops the server. Its `closed` event follows. +#[tauri::command] +pub(crate) fn plugin_host_mcp_close( + servers: tauri::State<'_, HostMcpServers>, + id: String, + handle: u64, +) -> Result<(), String> { + servers.close(&id, handle) +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/host_mcp/tests.rs b/src-tauri/src/host_mcp/tests.rs new file mode 100644 index 0000000000..bc9866f9bb --- /dev/null +++ b/src-tauri/src/host_mcp/tests.rs @@ -0,0 +1,186 @@ +use super::*; +use tauri::ipc::InvokeResponseBody; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::mpsc; + +fn channel() -> ( + Channel, + mpsc::UnboundedReceiver, +) { + let (sender, receiver) = mpsc::unbounded_channel(); + let channel = Channel::new(move |body| { + if let InvokeResponseBody::Json(json) = body { + let _ = sender.send(serde_json::from_str(&json).unwrap()); + } + Ok(()) + }); + (channel, receiver) +} + +async fn next(events: &mut mpsc::UnboundedReceiver) -> serde_json::Value { + tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .expect("MCP event") + .expect("open channel") +} + +/// The `host:port` of `info`'s server. +fn authority(info: &McpServerInfo) -> &str { + info.url + .trim_start_matches("http://") + .trim_end_matches(ENDPOINT) +} + +/// Sends a raw request to `info`'s server and returns the whole response. +async fn send(info: &McpServerInfo, request: String) -> String { + let authority = authority(info); + let mut socket = TcpStream::connect(authority).await.unwrap(); + socket.write_all(request.as_bytes()).await.unwrap(); + let mut response = String::new(); + socket.read_to_string(&mut response).await.unwrap(); + response +} + +fn post(info: &McpServerInfo, token: &str, extra: &str, body: &str) -> String { + let authority = authority(info); + format!( + "POST /mcp HTTP/1.1\r\nHost: {authority}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\n{extra}Content-Length: {}\r\n\r\n{body}", + body.len() + ) +} + +fn status(response: &str) -> &str { + response.split(' ').nth(1).unwrap_or_default() +} + +#[tokio::test] +async fn answers_a_request_with_the_page_reply() { + let servers = HostMcpServers::default(); + let (channel, mut events) = channel(); + let info = servers + .serve(servers.page(), "plugin".into(), channel) + .unwrap(); + assert!(info.url.starts_with("http://127.0.0.1:")); + let request = post( + &info, + &info.token, + "", + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#, + ); + let client = tokio::spawn({ + let info = info.clone(); + async move { send(&info, request).await } + }); + let event = next(&mut events).await; + assert_eq!(event["type"], "message"); + assert_eq!( + event["body"], + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"# + ); + let reply = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#; + // Another plugin cannot answer it. + assert!(servers + .reply( + "other", + info.handle, + event["request"].as_u64().unwrap(), + Some(reply.into()) + ) + .is_err()); + servers + .reply( + "plugin", + info.handle, + event["request"].as_u64().unwrap(), + Some(reply.into()), + ) + .unwrap(); + let response = client.await.unwrap(); + assert_eq!(status(&response), "200"); + assert!(response + .to_ascii_lowercase() + .contains("content-type: application/json\r\n")); + assert!(response.ends_with(reply)); +} + +#[tokio::test] +async fn accepts_a_notification_without_a_body() { + let servers = HostMcpServers::default(); + let (channel, mut events) = channel(); + let info = servers + .serve(servers.page(), "plugin".into(), channel) + .unwrap(); + let request = post( + &info, + &info.token, + "", + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + ); + let client = tokio::spawn({ + let info = info.clone(); + async move { send(&info, request).await } + }); + let event = next(&mut events).await; + servers + .reply( + "plugin", + info.handle, + event["request"].as_u64().unwrap(), + None, + ) + .unwrap(); + assert_eq!(status(&client.await.unwrap()), "202"); +} + +#[tokio::test] +async fn refuses_without_starting_oauth() { + let servers = HostMcpServers::default(); + let (channel, mut events) = channel(); + let info = servers + .serve(servers.page(), "plugin".into(), channel) + .unwrap(); + let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#; + // A wrong token and a web page are both told there is nothing here. + for request in [ + post(&info, "wrong", "", body), + post(&info, &info.token, "Origin: https://example.test\r\n", body), + ] { + assert_eq!(status(&send(&info, request).await), "404"); + } + let authority = authority(&info); + let get = format!( + "GET /mcp HTTP/1.1\r\nHost: {authority}\r\nAuthorization: Bearer {}\r\nAccept: text/event-stream\r\n\r\n", + info.token + ); + assert_eq!(status(&send(&info, get).await), "405"); + assert!(events.try_recv().is_err()); +} + +#[tokio::test] +async fn stops_with_the_page() { + let servers = HostMcpServers::default(); + let (events_channel, mut events) = channel(); + let page = servers.page(); + let info = servers + .serve(page, "plugin".into(), events_channel) + .unwrap(); + let request = post( + &info, + &info.token, + "", + r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#, + ); + let client = tokio::spawn({ + let info = info.clone(); + async move { send(&info, request).await } + }); + assert_eq!(next(&mut events).await["type"], "message"); + servers.stop_all(); + assert_eq!(next(&mut events).await["type"], "closed"); + // The request waiting on the page is dropped, not left hanging. + assert!(!client.await.unwrap().contains("200 OK")); + assert!(servers.reply("plugin", info.handle, 0, None).is_err()); + let (late, _events) = channel(); + assert!(servers.serve(page, "plugin".into(), late).is_err()); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1e0325baff..76d747baba 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,7 +15,10 @@ use browser::{ browser_status, }; mod app_agents; +mod databricks_provider; mod deep_links; +mod goose_databricks; +pub use databricks_provider::run_token_helper; mod dock; #[cfg(test)] #[path = "enterprise_adapter_url.rs"] @@ -26,6 +29,7 @@ mod enterprise_auth_build; mod enterprise_login_gate; mod enterprise_relay_url; mod host_command; +mod host_mcp; mod host_process; mod host_request; mod identity; @@ -63,6 +67,9 @@ use deep_links::{deep_link_take, deep_link_watch, DeepLinks}; use dock::{dock_permission, unread_indicator_set}; use enterprise_login_gate::enterprise_login_gate; use host_command::plugin_host_run_command; +use host_mcp::{ + plugin_host_mcp_close, plugin_host_mcp_reply, plugin_host_mcp_serve, HostMcpServers, +}; use host_process::{ plugin_host_process_kill, plugin_host_process_spawn, plugin_host_process_write, HostProcesses, }; @@ -464,9 +471,13 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen plugin_host_process_spawn, plugin_host_process_write, plugin_host_process_kill, + plugin_host_mcp_serve, + plugin_host_mcp_reply, + plugin_host_mcp_close, oauth_callback_begin, oauth_callback_wait, oauth_callback_cancel, + databricks_provider::databricks_provider, app_agents::app_agent_list, app_agents::app_agent_create, app_agents::app_agent_portable, @@ -659,6 +670,7 @@ pub fn run() { .manage(Imports::default()) .manage(Terminals::default()) .manage(HostProcesses::default()) + .manage(HostMcpServers::default()) .manage(OAuthCallbackHost::default()) .manage(Notifications::default()) .manage(DeepLinks::default()) @@ -694,6 +706,7 @@ pub fn run() { webview.state::().cancel_all(&webview.state::()); // Processes belong to the page that started them. webview.state::().stop_all(); + webview.state::().stop_all(); } browser::page_load(webview, payload); }) @@ -732,6 +745,7 @@ pub(crate) fn shut_down(app: &tauri::AppHandle) { .cancel_all(&app.state::()); app.state::().release(); app.state::().shutdown(); + app.state::().stop_all(); browser::shutdown(); if let Err(error) = app.state::().shutdown() { eprintln!("Terminal shutdown failed: {error}"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index caba166905..db85f49bba 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,5 +1,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + if buzz_foundation_lib::run_token_helper() { + return; + } buzz_foundation_lib::run(); } diff --git a/src-tauri/src/oauth_callback/README.md b/src-tauri/src/oauth_callback/README.md index b1818f4b22..0c679671ab 100644 --- a/src-tauri/src/oauth_callback/README.md +++ b/src-tauri/src/oauth_callback/README.md @@ -15,17 +15,21 @@ const { id, callbackUrl } = await invoke("oauth_callback_begin", { authorizationUrl: "https://provider.example/authorize?client_id=buzz&response_type=code", callbackPath: "/oauth2redirect/provider", useState: true, // optional; this is the default + callbackHost: "localhost", // optional; defaults to "127.0.0.1" }); ``` | Command | Contract | | --- | --- | -| `oauth_callback_begin({ authorizationUrl, callbackPath, useState? })` | Validates the request, generates an attempt ID, binds `127.0.0.1` on an ephemeral port, inserts the callback URL as `redirect_uri` and optional state, and launches the external browser through the existing opener. Returns `{ "id": "…", "callbackUrl": "http://127.0.0.1:{port}{path}" }`. Launch failure cleans up before rejecting. | +| `oauth_callback_begin({ authorizationUrl, callbackPath, useState?, callbackHost? })` | Validates the request, generates an attempt ID, binds `127.0.0.1` on an ephemeral port, inserts the callback URL as `redirect_uri` and optional state, and launches the external browser through the existing opener. Returns `{ "id": "…", "callbackUrl": "http://{host}:{port}{path}" }`. Launch failure cleans up before rejecting. | | `oauth_callback_wait(id)` | Consumes the matching attempt's result once. Resolves with `{ "parameters": [["name", "value"], …] }`, retaining decoded order and duplicates. Valid provider errors resolve with their parameters; transport failure, timeout, or cancellation rejects with a generic message. | | `oauth_callback_cancel(id)` | Closes and removes the matching attempt. Safe to repeat; a stale ID does not cancel another attempt. | The authorization URL must use HTTPS, with no embedded credentials or fragment. -The callback path must be a canonical absolute path without a query or fragment. +The callback path must be a canonical absolute path without a query or fragment, +or empty for a bare-origin redirect whose callback arrives at `/`. `callbackHost` +may be `"localhost"` for clients registered only for that loopback name; the +listener still binds `127.0.0.1`, and the callback's `Host` must match. Native code preserves other authorization parameters, including duplicates, while adding the generated callback URL under `redirect_uri`. Caller-supplied `state` or `redirect_uri` parameters are rejected. The attempt ID is a lifecycle diff --git a/src-tauri/src/oauth_callback/mod.rs b/src-tauri/src/oauth_callback/mod.rs index e41f7a660b..2dbf0184c9 100644 --- a/src-tauri/src/oauth_callback/mod.rs +++ b/src-tauri/src/oauth_callback/mod.rs @@ -27,6 +27,7 @@ struct OAuthRequest { authorization_url: String, callback_path: String, use_state: Option, + callback_host: Option, } type CallbackResult = Result; struct Attempt { @@ -75,7 +76,20 @@ impl OAuthCallbackHost { { return Err("Invalid authorization parameters".into()); } - let path = request.callback_path; + // Some registered clients accept only `localhost` with an ephemeral + // port. The listener still binds IPv4 loopback either way. + let host = match request.callback_host.as_deref() { + None | Some("127.0.0.1") => "127.0.0.1", + Some("localhost") => "localhost", + Some(_) => return Err("Invalid callback host".into()), + }; + // An empty path redirects to the bare origin, which the browser requests as `/`. + let suffix = request.callback_path; + let path = if suffix.is_empty() { + "/".to_owned() + } else { + suffix.clone() + }; let redirect = url::Url::parse(&format!("http://127.0.0.1{path}")) .map_err(|_| "Invalid callback path")?; if !path.starts_with('/') @@ -100,13 +114,14 @@ impl OAuthCallbackHost { listener .set_nonblocking(true) .map_err(|_| "Could not start the sign-in listener")?; - let authority = listener + let port = listener .local_addr() .map_err(|_| "Could not start the sign-in listener")? - .to_string(); + .port(); + let authority = format!("{host}:{port}"); let listener = TcpListener::from_std(listener).map_err(|_| "Could not start the sign-in listener")?; - let callback = format!("http://{authority}{path}"); + let callback = format!("http://{authority}{suffix}"); authorization .query_pairs_mut() .append_pair("redirect_uri", &callback); @@ -180,6 +195,7 @@ pub(crate) fn oauth_callback_begin( authorization_url: String, callback_path: String, use_state: Option, + callback_host: Option, ) -> Result { // Register and launch inline so reload cleanup cannot overtake a queued begin. // Enter the runtime for the Tokio listener; startup does not await anything. @@ -189,6 +205,7 @@ pub(crate) fn oauth_callback_begin( authorization_url, callback_path, use_state, + callback_host, }, |url| { app.opener() diff --git a/src-tauri/src/oauth_callback/tests.rs b/src-tauri/src/oauth_callback/tests.rs index 1744b24495..4f92c7c20b 100644 --- a/src-tauri/src/oauth_callback/tests.rs +++ b/src-tauri/src/oauth_callback/tests.rs @@ -6,6 +6,7 @@ fn options(path: &str) -> OAuthRequest { .into(), callback_path: path.into(), use_state: None, + callback_host: None, } } @@ -74,8 +75,8 @@ fn response(parameters: &[(&str, &str)]) -> OAuthCallback { #[test] fn selected_path_must_be_a_canonical_absolute_path_without_query_or_fragment() { let state = OAuthCallbackHost::default(); + // An empty path is the explicit bare-origin form. for path in [ - "", "callback", "//evil.example/callback", "/callback?x=y", @@ -269,7 +270,7 @@ async fn main_document_reload_cancels_wait_and_allows_new_sign_in() { use tauri::webview::PageLoadEvent::{Finished, Started}; // Async Tauri commands queue startup, allowing reload cleanup to overtake it. - let _: fn(_, _, _, _, _) -> Result = + let _: fn(_, _, _, _, _, _) -> Result = oauth_callback_begin::; let state = OAuthCallbackHost::default(); let first = start(&state, options("/callback/old")).unwrap().id; @@ -477,3 +478,53 @@ async fn launch_failure_cleans_up_but_cannot_cancel_a_replacement_after_reload() assert_eq!(host.0.lock().unwrap().as_ref().unwrap().id, next.id); host.cancel(&next.id).unwrap(); } + +#[tokio::test] +async fn localhost_bare_origin_redirect_receives_root_callbacks() { + let state = OAuthCallbackHost::default(); + let attempt = start( + &state, + OAuthRequest { + callback_host: Some("localhost".into()), + ..options("") + }, + ) + .unwrap(); + let port = url::Url::parse(&attempt.callback_url) + .unwrap() + .port() + .unwrap(); + assert_eq!(attempt.callback_url, format!("http://localhost:{port}")); + let expected = attempt + .authorization + .query_pairs() + .find(|(name, _)| name == "state") + .unwrap() + .1 + .into_owned(); + let wait = state.wait(&attempt.id); + let browser = async { + let mut stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + stream + .write_all(&request( + &format!("localhost:{port}"), + &format!("/?code=c&state={expected}"), + "", + )) + .await + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).await.unwrap(); + assert!(response.starts_with("HTTP/1.1 200")); + }; + let (result, ()) = tokio::join!(wait, browser); + assert_eq!(result, Ok(response(&[("code", "c"), ("state", &expected)]))); + assert!(start( + &state, + OAuthRequest { + callback_host: Some("example.com".into()), + ..options("/callback") + }, + ) + .is_err()); +} diff --git a/src/app/PluginImport.tsx b/src/app/PluginImport.tsx index 0a465269d4..ad394f9398 100644 --- a/src/app/PluginImport.tsx +++ b/src/app/PluginImport.tsx @@ -26,6 +26,9 @@ function hostGrants(manifest: PluginManifest): string[] { (process) => `Process ${process.id}: ${JSON.stringify([process.program, ...(process.args ?? [])])} and any further arguments · stdin, full user access`, ), + ...(manifest.host?.mcp + ? ["MCP servers: tools served to local agents over loopback HTTP"] + : []), ]; } diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index fc79d294d0..57dcab851f 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -58,7 +58,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", ); assert.equal( plugin.enabledByDefault, - !["buzz.bestie", "buzz.todos"].includes(plugin.manifest.id), + !["buzz.bestie", "buzz.pi", "buzz.todos"].includes(plugin.manifest.id), plugin.manifest.id, ); } diff --git a/src/bundled/claude-code/transcript.ts b/src/bundled/claude-code/transcript.ts index 11f3379efa..12efadea2b 100644 --- a/src/bundled/claude-code/transcript.ts +++ b/src/bundled/claude-code/transcript.ts @@ -64,7 +64,7 @@ const KINDS: Record = { }; /** A Buzz prompt, or a steer wrapping one, as the turn's opening item. */ -function prompt(id: string, at: number, raw: string): TranscriptItem { +export function prompt(id: string, at: number, raw: string): TranscriptItem { let sections = promptSections(raw); const steer = sections.find((section) => section.tag === STEER); if (steer) sections = promptSections(steer.body); diff --git a/src/bundled/databricks/index.tsx b/src/bundled/databricks/index.tsx new file mode 100644 index 0000000000..46fa1e6633 --- /dev/null +++ b/src/bundled/databricks/index.tsx @@ -0,0 +1,95 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { useEffect, useSyncExternalStore } from "react"; +import type { PluginModule } from "../../plugins/api"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Header } from "../../shared/design-system/ui/Header"; +import { DatabricksProvider, DatabricksService } from "./provider"; + +export const inject = ["settingsCards"]; +export const apply: PluginModule["apply"] = (ctx) => { + if (!isTauri() || !/Mac|Linux/i.test(navigator.platform)) return; + const provider = new DatabricksProvider(); + function Connection({ + onChange, + disabled = false, + }: { + onChange(): void; + disabled?: boolean; + }) { + const state = useSyncExternalStore( + provider.subscribe, + provider.snapshot, + provider.snapshot, + ); + useEffect(() => { + void provider.connected().catch(() => {}); + }, []); + const action = async (run: () => Promise) => { + await run(); + onChange(); + }; + return ( +
+

+ Connect to Block’s Databricks workspace once. This connection can be + used by every goose and Pi agent. +

+

+ {state.committing + ? "Completing sign-in…" + : state.pending + ? "Finish sign-in in your browser, then return to Buzz." + : state.connected + ? "Connected to Databricks" + : "Sign in to list and use Databricks models."} +

+
+ {state.committing ? null : state.pending ? ( + + ) : state.connected ? ( + + ) : ( + + )} +
+ {state.error && ( +

+ {state.error} +

+ )} +
+ ); + } + new DatabricksService(ctx, provider, { + id: provider.id, + name: provider.name, + connected: () => provider.connected(), + models: provider.models, + connection: provider.connection, + Connection, + }); + ctx.settingsCards.register({ + id: "databricks", + title: "Databricks", + group: "Providers", + component: () => ( +
+
+ {}} /> +
+ ), + }); +}; diff --git a/src/bundled/databricks/manifest.json b/src/bundled/databricks/manifest.json new file mode 100644 index 0000000000..1e8f5619ef --- /dev/null +++ b/src/bundled/databricks/manifest.json @@ -0,0 +1,5 @@ +{ + "id": "buzz.databricks", + "name": "Databricks", + "apiVersion": 1 +} diff --git a/src/bundled/databricks/provider.test.ts b/src/bundled/databricks/provider.test.ts new file mode 100644 index 0000000000..484f409461 --- /dev/null +++ b/src/bundled/databricks/provider.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import { DatabricksProvider, type BrowserCallback } from "./provider"; + +function gate() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function fixture({ + begin, + connect, +}: { + begin?: ReturnType>; + connect?: ReturnType>; +} = {}) { + let connected = false; + const connectStarted = gate(); + const beginStarted = gate(); + const waitStarted = gate(); + const callbackResult = gate<{ parameters: [string, string][] }>(); + const calls: { operation: string; input?: object }[] = []; + const native: ConstructorParameters[0] = async ( + operation: string, + input?: object, + ) => { + calls.push({ operation, ...(input ? { input } : {}) }); + if (operation === "authorize") + return { + url: "https://workspace.cloud.databricks.com/oidc/authorize", + } as T; + if (operation === "connect") { + connectStarted.resolve(); + if (connect) await connect.promise; + connected = true; + } + if (operation === "disconnect") connected = false; + return { connected } as T; + }; + const callback: BrowserCallback = { + begin: vi.fn(async () => { + beginStarted.resolve(); + return begin + ? begin.promise + : { + id: "attempt", + callbackUrl: "http://localhost:4567", + }; + }), + wait: vi.fn(async () => { + waitStarted.resolve(); + return callbackResult.promise; + }), + cancel: vi.fn(async () => {}), + }; + return { + provider: new DatabricksProvider(native, callback), + callback, + calls, + callbackResult, + connectStarted, + beginStarted, + waitStarted, + connected: () => connected, + }; +} + +describe("shared Databricks login", () => { + it("uses PKCE and one native-held connection for concurrent sign-in requests", async () => { + const f = fixture(); + const signingIn = f.provider.signIn(); + try { + await f.waitStarted.promise; + expect(f.provider.snapshot()).toMatchObject({ + connected: false, + pending: true, + }); + await f.provider.signIn(); + expect(f.callback.begin).toHaveBeenCalledTimes(1); + const startedUrl = vi.mocked(f.callback.begin).mock.calls[0]?.[0]; + if (!startedUrl) throw new Error("Browser sign-in did not start"); + const url = new URL(startedUrl); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.has("state")).toBe(false); // Native owns OAuth state. + f.callbackResult.resolve({ parameters: [["code", "one-time-code"]] }); + await signingIn; + const saved = f.calls.find((c) => c.operation === "connect")?.input as { + verifier: string; + redirect: string; + }; + const hash = new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(saved.verifier), + ), + ); + expect(Buffer.from(hash).toString("base64url")).toBe( + url.searchParams.get("code_challenge"), + ); + expect(saved.redirect).toBe("http://localhost:4567"); + expect(f.provider.snapshot()).toEqual({ + connected: true, + pending: false, + }); + expect(f.callback.cancel).toHaveBeenCalledWith("attempt"); + await f.provider.signOut(); + expect(f.connected()).toBe(false); + } finally { + f.callbackResult.resolve({ parameters: [["code", "unused"]] }); + await signingIn; + f.provider.dispose(); + } + }); + + it("cleans up cancellation while native browser startup is still pending", async () => { + const begin = gate<{ id: string; callbackUrl: string }>(); + const f = fixture({ begin }); + const signingIn = f.provider.signIn(); + try { + await f.beginStarted.promise; + f.provider.cancel(); + } finally { + begin.resolve({ + id: "late-attempt", + callbackUrl: "http://localhost:4567", + }); + await signingIn; + } + expect(f.callback.cancel).toHaveBeenCalledWith("late-attempt"); + expect(f.callback.wait).not.toHaveBeenCalled(); + expect(f.calls.some((c) => c.operation === "connect")).toBe(false); + expect(f.provider.snapshot()).toEqual({ connected: false, pending: false }); + }); + + it("makes the native commit explicit and finishes it even when the view closes", async () => { + const connect = gate(); + const f = fixture({ connect }); + const signingIn = f.provider.signIn(); + try { + await f.waitStarted.promise; + f.callbackResult.resolve({ parameters: [["code", "one-time-code"]] }); + await f.connectStarted.promise; + expect(f.provider.snapshot()).toMatchObject({ + pending: true, + committing: true, + }); + f.provider.cancel(); + f.provider.dispose(); + } finally { + connect.resolve(); + await signingIn; + } + expect(f.connected()).toBe(true); + expect(f.calls.some((c) => c.operation === "disconnect")).toBe(false); + }); +}); diff --git a/src/bundled/databricks/provider.ts b/src/bundled/databricks/provider.ts new file mode 100644 index 0000000000..b8907d1bda --- /dev/null +++ b/src/bundled/databricks/provider.ts @@ -0,0 +1,186 @@ +import { invoke } from "@tauri-apps/api/core"; +import { Service, type Context } from "@deepseek-ai/cordis"; +import { + DATABRICKS_ID, + type ProviderConnection, + type ProviderModel, + type SharedProvider, +} from "../../features/providers/shared"; + +type State = Readonly<{ + connected: boolean; + pending: boolean; + committing?: true; + error?: string; +}>; +type Native = (operation: string, input?: object) => Promise; +const native: Native = (operation, input = {}) => + invoke("databricks_provider", { operation, input }); +const encode = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + +export type BrowserCallback = { + begin(url: string): Promise<{ id: string; callbackUrl: string }>; + wait(id: string): Promise<{ parameters: [string, string][] }>; + cancel(id: string): Promise; +}; +const browser: BrowserCallback = { + begin: (authorizationUrl) => + invoke("oauth_callback_begin", { + authorizationUrl, + // databricks-cli is registered for http://localhost with any port. + callbackPath: "", + callbackHost: "localhost", + useState: true, + }), + wait: (id) => invoke("oauth_callback_wait", { id }), + cancel: (id) => invoke("oauth_callback_cancel", { id }), +}; + +/** One provider owns login and catalog; its snapshots never contain tokens. */ +export class DatabricksProvider { + readonly id = DATABRICKS_ID; + readonly name = "Databricks in Buzz"; + private state: State = { connected: false, pending: false }; + private listeners = new Set<() => void>(); + private attempt: AbortController | undefined; + private disposed = false; + + constructor( + private readonly call: Native = native, + private readonly callback = browser, + ) {} + snapshot = () => this.state; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + private publish(next: State) { + if (this.disposed) return; + this.state = Object.freeze(next); + for (const listener of this.listeners) listener(); + } + async connected() { + try { + const result = await this.call<{ connected: boolean }>("status"); + if (!this.attempt) + this.publish({ connected: result.connected, pending: false }); + return result.connected; + } catch (error) { + this.publish({ ...this.state, error: message(error) }); + throw error; + } + } + models = () => this.call("models"); + connection = () => this.call("helper"); + + async signIn() { + if (this.attempt || this.disposed) return; + const attempt = new AbortController(); + this.attempt = attempt; + this.publish({ connected: this.state.connected, pending: true }); + let id: string | undefined; + const cancel = () => { + if (id) void this.callback.cancel(id).catch(() => {}); + }; + try { + const { url } = await this.call<{ url: string }>("authorize"); + attempt.signal.throwIfAborted(); + const verifier = encode(crypto.getRandomValues(new Uint8Array(32))); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(verifier), + ); + attempt.signal.throwIfAborted(); + const authorization = new URL(url); + authorization.search = new URLSearchParams({ + client_id: "databricks-cli", + response_type: "code", + scope: "all-apis offline_access", + code_challenge: encode(new Uint8Array(digest)), + code_challenge_method: "S256", + }).toString(); + const started = await this.callback.begin(authorization.href); + id = started.id; + attempt.signal.addEventListener("abort", cancel, { once: true }); + attempt.signal.throwIfAborted(); + const result = await this.callback.wait(id); + attempt.signal.throwIfAborted(); + const params = new URLSearchParams(result.parameters); + const codes = params.getAll("code"); + if (params.has("error") || codes.length !== 1 || !codes[0]) + throw new Error("Databricks sign-in failed. Try again."); + // The validated callback starts a commit. Cancellation applies only to + // browser waiting; native saving may finish even if this view closes. + this.publish({ + connected: this.state.connected, + pending: true, + committing: true, + }); + await this.call("connect", { + code: codes[0], + verifier, + redirect: started.callbackUrl, + }); + this.publish({ connected: true, pending: false }); + } catch (error) { + if (this.attempt === attempt) + this.publish({ + connected: this.state.connected, + pending: false, + ...(!attempt.signal.aborted ? { error: message(error) } : {}), + }); + } finally { + attempt.signal.removeEventListener("abort", cancel); + try { + if (id) await this.callback.cancel(id); + } finally { + if (this.attempt === attempt) this.attempt = undefined; + } + } + } + cancel = () => { + if (!this.state.committing) this.attempt?.abort(); + }; + async signOut() { + this.cancel(); + try { + await this.call("disconnect"); + this.publish({ connected: false, pending: false }); + } catch (error) { + this.publish({ ...this.state, error: message(error) }); + } + } + dispose() { + this.cancel(); + this.disposed = true; + this.listeners.clear(); + } +} +const message = (error: unknown) => + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Databricks connection failed. Try again."; + +export class DatabricksService extends Service { + constructor( + ctx: Context, + readonly provider: DatabricksProvider, + readonly shared: SharedProvider, + ) { + super(ctx, "databricks"); + ctx.effect(() => () => provider.dispose()); + } +} +declare module "@deepseek-ai/cordis" { + interface Context { + databricks: DatabricksService; + } +} diff --git a/src/bundled/goose/goose-testing.ts b/src/bundled/goose/goose-testing.ts new file mode 100644 index 0000000000..d0a0c1da3b --- /dev/null +++ b/src/bundled/goose/goose-testing.ts @@ -0,0 +1,406 @@ +// A stand-in for `goose-acp`'s ACP over stdio, for tests. +import { + answerMcp, + type HostMcpServer, + type HostProcess, + type HostProcessOptions, + type McpHandler, +} from "../../features/host/service"; + +type Message = { + jsonrpc: "2.0"; + id?: number | string; + method?: string; + params?: Record; + result?: unknown; + error?: { code: number; message: string }; +}; + +export type FakeTurn = { + sessionId: string; + runId: string; + text: string; + /** Texts steered into it. */ + steered: string[]; + finish(stopReason?: string): void; +}; + +export type FakeGoose = { + id: string; + options: HostProcessOptions; + /** Every message written to its stdin. */ + received: Message[]; + /** Each `session/new`'s params. */ + opened: Record[]; + /** Each system prompt set, by session. */ + prompts: Map; + /** Turns, in the order they began. */ + turns: FakeTurn[]; + closed: string[]; + cancelled: string[]; + deleted: string[]; + /** The environment it started with. */ + env: Readonly>; + /** When set, a turn stays open until it is finished. */ + hold: boolean; + killed: boolean; + /** Asks the client a request and resolves with its response. */ + ask(method: string, params: Record): Promise; + /** An MCP message for the Buzz tools from `sessionId`, sent to the server + * its `session/new` declared. */ + mcp( + sessionId: string, + method: string, + params?: Record, + ): Promise; + update(sessionId: string, update: Record): void; + exit(code: number | null, stderr?: string): void; +}; + +/** A session goose saved, with its messages as goose exports them. */ +export type FakeSession = Readonly<{ + sessionId: string; + title: string; + cwd: string; + updatedAt: string; + conversation: readonly Record[]; +}>; + +/** A loopback MCP server `serve` started, as the app's host would. */ +export type FakeMcpServer = { + url: string; + token: string; + handler: McpHandler; + closed: boolean; +}; + +/** A spawn whose `goose` processes answer like `goose-acp`, and whose + * `workspace` process prints `home` for a `~/…` directory; and a `serve` that + * starts in-memory MCP servers those processes reach. */ +export function fakeSpawn( + options: Readonly<{ + /** Whether goose says it can reach HTTP MCP servers. */ + http?: boolean; + home?: string; + hold?: boolean; + /** What a turn writes before it ends. */ + reply?: string; + /** `providers/list` entries, and goose's default provider and model. */ + providers?: readonly Record[]; + defaults?: Readonly<{ providerId?: string; modelId?: string }>; + /** Provider fields saved, by provider; `config/save` adds to it. */ + saved?: Map>; + /** What `supported-models/list` returns. */ + supported?: readonly string[]; + /** Saved sessions `session/list` and `session/export` read. */ + history?: readonly FakeSession[]; + }> = {}, +) { + const processes: FakeGoose[] = []; + const servers: FakeMcpServer[] = []; + const serve = async (handler: McpHandler): Promise => { + const index = servers.length + 1; + let stopped!: () => void; + const closed = new Promise((resolve) => { + stopped = resolve; + }); + const server: FakeMcpServer = { + url: `http://127.0.0.1:${40_000 + index}/mcp`, + token: `token-${index}`, + handler, + closed: false, + }; + servers.push(server); + return { + url: server.url, + headers: [{ name: "Authorization", value: `Bearer ${server.token}` }], + close: async () => { + server.closed = true; + stopped(); + }, + closed, + }; + }; + const spawn = async ( + id: string, + process: HostProcessOptions = {}, + ): Promise => { + let resolveExit!: (code: number | null) => void; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + if (id === "workspace") { + const cwd = process.cwd ?? ""; + queueMicrotask(() => { + process.onStdout?.( + `${cwd.replace(/^~/, options.home ?? "/home/test")}\n`, + ); + resolveExit(0); + }); + return { + write: async () => undefined, + end: async () => undefined, + kill: async () => undefined, + exited, + }; + } + let alive = true; + let sessions = 0; + let requests = 0; + let mcpRequests = 0; + /** The Buzz MCP server each session was given. */ + const declared = new Map(); + const answers = new Map void>(); + const send = (message: Message) => + queueMicrotask(() => { + if (alive) process.onStdout?.(`${JSON.stringify(message)}\n`); + }); + const reply = (id: number | string | undefined, result: unknown) => + send({ jsonrpc: "2.0", ...(id !== undefined && { id }), result }); + const running = new Map(); + const fake: FakeGoose = { + id, + options: process, + received: [], + opened: [], + prompts: new Map(), + turns: [], + closed: [], + cancelled: [], + deleted: [], + env: process.env ?? {}, + hold: options.hold ?? false, + killed: false, + ask: (method, params) => { + const requestId = `goose-${++requests}`; + const answer = new Promise((resolve) => + answers.set(requestId, resolve), + ); + send({ jsonrpc: "2.0", id: requestId, method, params }); + return answer; + }, + mcp: async (sessionId, method, params = {}) => { + const server = servers.find( + (item) => item.url === declared.get(sessionId), + ); + if (!server || server.closed) + throw new Error(`No Buzz MCP server for ${sessionId}`); + const body = { jsonrpc: "2.0", id: ++mcpRequests, method, params }; + return (await answerMcp( + server.handler, + JSON.stringify(body), + )) as Message; + }, + update: (sessionId, update) => + send({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId, update }, + }), + exit: (code, stderr = "") => { + if (!alive) return; + if (stderr) process.onStderr?.(stderr); + alive = false; + resolveExit(code); + }, + }; + processes.push(fake); + const handle = (message: Message) => { + fake.received.push(message); + const { id, method, params = {} } = message; + if (method === undefined) { + if (id !== undefined) answers.get(id)?.(message); + answers.delete(id ?? ""); + return; + } + const sessionId = String(params.sessionId ?? ""); + switch (method) { + case "initialize": + return reply(id, { + protocolVersion: 1, + agentCapabilities: { + mcpCapabilities: { http: options.http ?? true, sse: false }, + }, + }); + case "session/new": { + fake.opened.push(params); + const sessionId = `session-${++sessions}`; + const extensions = ((params._meta as Record) + ?.enabledExtensions ?? []) as { server?: { url?: string } }[]; + const url = extensions.find((item) => item.server?.url)?.server?.url; + if (url) declared.set(sessionId, url); + return reply(id, { sessionId }); + } + case "_goose/unstable/session/system-prompt/set": + fake.prompts.set(sessionId, String(params.text)); + return reply(id, {}); + case "session/prompt": { + const runId = `run-${fake.turns.length + 1}`; + const text = (params.prompt as { text: string }[]) + .map((block) => block.text) + .join(""); + const turn: FakeTurn = { + sessionId, + runId, + text, + steered: [], + finish: (stopReason = "end_turn") => { + if (running.get(sessionId) !== turn) return; + running.delete(sessionId); + fake.update(sessionId, { + sessionUpdate: "session_info_update", + _meta: { goose: { activeRunId: null } }, + }); + reply(id, { stopReason }); + }, + }; + fake.turns.push(turn); + running.set(sessionId, turn); + fake.update(sessionId, { + sessionUpdate: "session_info_update", + _meta: { goose: { activeRunId: runId } }, + }); + if (options.reply) + fake.update(sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: options.reply }, + }); + if (!fake.hold) queueMicrotask(() => turn.finish()); + return; + } + case "_goose/unstable/session/steer": { + const turn = running.get(sessionId); + if (!turn || turn.runId !== params.expectedRunId) + return send({ + jsonrpc: "2.0", + ...(id !== undefined && { id }), + error: { code: -32602, message: "No active run" }, + }); + turn.steered.push( + (params.prompt as { text: string }[]) + .map((block) => block.text) + .join(""), + ); + return reply(id, { runId: turn.runId, messageId: "m" }); + } + case "session/cancel": + fake.cancelled.push(sessionId); + running.get(sessionId)?.finish("cancelled"); + return; + case "_goose/unstable/providers/list": + // Saving a provider's fields sets it up, as goose lists it after. + return reply(id, { + entries: (options.providers ?? []).map((entry) => ({ + ...entry, + configured: + Boolean(entry.configured) || + Boolean(options.saved?.get(String(entry.providerId))?.size), + })), + }); + case "_goose/unstable/defaults/read": + return reply(id, options.defaults ?? {}); + case "_goose/unstable/providers/config/read": { + const set = options.saved?.get(String(params.providerId)); + return reply(id, { + fields: [...(set ?? [])].map((key) => ({ key, isSet: true })), + }); + } + case "_goose/unstable/providers/config/save": { + const providerId = String(params.providerId); + const set = options.saved?.get(providerId) ?? new Set(); + for (const field of params.fields as { key: string }[]) + set.add(field.key); + options.saved?.set(providerId, set); + return reply(id, { status: { providerId, isConfigured: true } }); + } + case "_goose/unstable/providers/supported-models/list": + return reply(id, { + providerId: params.providerId, + models: options.supported ?? [], + }); + case "session/list": { + const query = String( + (params._meta as { query?: string } | undefined)?.query ?? "", + ); + const mentions = (session: FakeSession) => + JSON.stringify(session.conversation).includes(query); + return reply(id, { + sessions: (options.history ?? []) + .filter( + (session) => + session.cwd === params.cwd && (!query || mentions(session)), + ) + .map(({ sessionId, title, cwd, updatedAt }) => ({ + sessionId, + title, + cwd, + updatedAt, + })), + }); + } + case "_goose/unstable/session/export": { + const session = options.history?.find( + (item) => item.sessionId === params.sessionId, + ); + if (!session) + return send({ + jsonrpc: "2.0", + ...(id !== undefined && { id }), + error: { code: -32603, message: "Session not found" }, + }); + return reply(id, { + data: JSON.stringify({ id: session.sessionId, ...session }), + }); + } + case "session/delete": + fake.deleted.push(sessionId); + return reply(id, {}); + case "session/close": + fake.closed.push(sessionId); + return reply(id, {}); + default: + return send({ + jsonrpc: "2.0", + ...(id !== undefined && { id }), + error: { code: -32601, message: `Unknown ${method}` }, + }); + } + }; + let buffer = ""; + return { + write: async (data) => { + if (!alive) throw new Error("goose is not running"); + buffer += data; + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + handle(JSON.parse(line)); + } + }, + end: async () => undefined, + kill: async () => { + fake.killed = true; + fake.exit(null); + }, + exited, + }; + }; + return { + spawn, + serve, + processes, + /** The MCP servers `serve` started, in order. */ + servers, + /** The `goose` processes started, without `workspace` lookups. */ + get geese() { + return processes.filter((process) => process.id === "goose"); + }, + }; +} + +/** Lets queued microtasks and the timers they start run. */ +export const flush = async (rounds = 10) => { + for (let index = 0; index < rounds; index++) await Promise.resolve(); +}; diff --git a/src/bundled/goose/goose.live.test.ts b/src/bundled/goose/goose.live.test.ts new file mode 100644 index 0000000000..0960076a41 --- /dev/null +++ b/src/bundled/goose/goose.live.test.ts @@ -0,0 +1,281 @@ +// @vitest-environment node +// Drives a real `goose-acp` with the owner's goose config: run with +// GOOSE_ACP_BIN=/path/to/goose-acp. The Buzz tools are listed as the app lists +// them, from a loopback HTTP MCP server that follows the app's native one; +// their calls are recorded, not sent to a relay. +import { spawn as spawnChild } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { mkdtempSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { respond } from "../../buzz-mcp/rpc"; +import { + answerMcp, + type HostProcessOptions, +} from "../../features/host/service"; +import { systemPrompt } from "../claude-code/prompt"; +import { + GooseProcess, + type ServeMcp, + type Spawn, + type ToolServer, +} from "./goose"; +import { GooseSetup } from "./setup"; + +const BIN = process.env.GOOSE_ACP_BIN; + +const spawn: Spawn = async (_id, options: HostProcessOptions = {}) => { + const env: Record = { ...process.env }; + for (const [key, value] of Object.entries(options.env ?? {})) + if (value === null) delete env[key]; + else env[key] = value; + const child = spawnChild(BIN ?? "goose-acp", [], { + cwd: options.cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdout.setEncoding("utf8").on("data", (data) => { + if (process.env.GOOSE_ACP_DEBUG) process.stderr.write(`<< ${data}`); + options.onStdout?.(data); + }); + child.stderr.setEncoding("utf8").on("data", (data) => { + if (process.env.GOOSE_ACP_DEBUG) process.stderr.write(data); + options.onStderr?.(data); + }); + const exited = new Promise((resolve) => + child.on("exit", (code) => resolve(code)), + ); + return { + write: (data) => + new Promise((resolve, reject) => + child.stdin.write(data, (error) => (error ? reject(error) : resolve())), + ), + end: async (data) => { + child.stdin.end(data); + }, + kill: async () => { + child.kill(); + }, + exited, + }; +}; + +/** The HTTP requests the MCP servers received, by method and status. */ +const hits: { method: string; status: number }[] = []; + +/** What the app's native listener does: a token per server, 404 for a wrong + * one, 405 for `GET`, and a JSON reply or 202 for each `POST /mcp`. */ +const serve: ServeMcp = async (handler) => { + const token = randomBytes(32).toString("base64url"); + const server = createServer((request, response) => { + const end = (status: number, body?: string) => { + hits.push({ method: request.method ?? "", status }); + response.writeHead( + status, + body ? { "Content-Type": "application/json" } : {}, + ); + response.end(body); + }; + if ( + request.url?.split("?")[0] !== "/mcp" || + request.headers.authorization !== `Bearer ${token}` + ) + return end(404); + if (request.method !== "POST") return end(405); + let body = ""; + request.setEncoding("utf8").on("data", (data) => (body += data)); + request.on("end", async () => { + const reply = await answerMcp(handler, body); + end(reply === undefined ? 202 : 200, JSON.stringify(reply)); + }); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()), + ); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + const closed = new Promise((resolve) => + server.on("close", () => resolve()), + ); + return { + url: `http://127.0.0.1:${port}/mcp`, + headers: [{ name: "Authorization", value: `Bearer ${token}` }], + close: async () => { + server.closeAllConnections(); + server.close(); + }, + closed, + }; +}; + +type Call = { + conversation: string | undefined; + method: string; + params: unknown; +}; +type Send = { + name?: string; + arguments?: { text?: string; final?: boolean }; +}; + +/** A goose whose Buzz tool calls and own requests are recorded; sends + * succeed without reaching a relay. */ +async function live() { + const calls: Call[] = []; + const tools: ToolServer = async (conversation, message) => { + calls.push({ + conversation, + method: message.method, + params: message.params, + }); + if (message.method === "tools/call") + return { + jsonrpc: "2.0", + id: message.id, + result: { content: [{ type: "text", text: "Sent." }] }, + }; + return respond(undefined, {}, message); + }; + const cwd = mkdtempSync(join(tmpdir(), "buzz-goose-")); + const goose = await GooseProcess.start(spawn, { cwd, tools, serve }); + const requests: { method: string; ok?: boolean }[] = []; + const request = goose.request.bind(goose); + goose.request = (method, params) => { + const record: { method: string; ok?: boolean } = { method }; + requests.push(record); + const answer = request(method, params); + answer.then( + () => (record.ok = true), + () => (record.ok = false), + ); + return answer; + }; + const sends = () => + calls + .filter((call) => call.method === "tools/call") + .map((call) => call.params as Send) + .filter((call) => call.name === "send"); + return { goose, cwd, calls, requests, sends }; +} + +describe.skipIf(!BIN)("goose-acp", () => { + it("answers with the Buzz tools, the Buzz prompt and a title", { + timeout: 180_000, + }, async () => { + const { goose, cwd, calls, sends } = await live(); + const code = `zebra-${Math.random().toString(36).slice(2, 8)}`; + try { + const title = "Ada · #general · 01234567"; + const session = await goose.open({ + cwd, + title, + systemPrompt: systemPrompt({ + scope: "thread", + cwd, + agent: { name: "Ada", pubkey: "a".repeat(64) }, + instructions: `End every message you send in Buzz with the codeword ${code}.`, + }), + }); + session.conversation = "channel/root"; + const info = (await goose.request("_goose/unstable/session/info", { + sessionId: session.id, + })) as { session: { title?: string } }; + expect(info.session.title).toBe(title); + const settled = await session.send("Say hello to me in Buzz."); + expect(settled).toEqual({ ok: true }); + expect(calls.some((call) => call.method === "tools/list")).toBe(true); + // goose reached the tools over HTTP with the token, and no request was + // refused. + expect(hits.some((hit) => hit.status === 200)).toBe(true); + expect(hits.filter((hit) => hit.status === 404)).toEqual([]); + expect( + calls.find((call) => call.method === "tools/call")?.conversation, + ).toBe("channel/root"); + const last = sends().at(-1); + expect(last?.arguments?.final).toBe(true); + expect(last?.arguments?.text).toContain(code); + } finally { + await goose.kill(); + } + }); + + it("steers a message into the running turn", { + timeout: 180_000, + }, async () => { + const { goose, cwd, requests, sends } = await live(); + try { + const session = await goose.open({ + cwd, + systemPrompt: + "You are a Buzz agent. You reply in Buzz only by calling the send tool, once, with final true.", + }); + const steps: string[] = []; + const first = session.send( + "Run the shell command `sleep 10`, then reply in Buzz with the word done.", + { step: (tool) => steps.push(tool) }, + ); + await expect + .poll( + () => + steps.some( + (step) => step !== "Thinking" && step !== "Writing reply", + ), + { + timeout: 60_000, + interval: 200, + }, + ) + .toBe(true); + const second = session.send("Also put the word banana in that reply."); + await expect(first).resolves.toEqual({ ok: true }); + await expect(second).resolves.toEqual({ ok: true }); + expect( + requests.filter( + (item) => item.method === "_goose/unstable/session/steer", + ), + ).toEqual([{ method: "_goose/unstable/session/steer", ok: true }]); + expect( + requests.filter((item) => item.method === "session/prompt"), + ).toHaveLength(1); + expect(sends().at(-1)?.arguments?.text?.toLowerCase()).toContain( + "banana", + ); + } finally { + await goose.kill(); + } + }); + + it("lists goose's providers and tests its default one", { + timeout: 120_000, + }, async () => { + // goose resolves `~` with the workspace process, which prints the folder. + const setup = new GooseSetup(async (id, options = {}) => { + if (id !== "workspace") return spawn(id, options); + queueMicrotask(() => + options.onStdout?.(`${String(options.cwd).replace(/^~/, homedir())}\n`), + ); + return { + write: async () => undefined, + end: async () => undefined, + kill: async () => undefined, + exited: Promise.resolve(0), + }; + }); + const { providers, defaults } = await setup.providers(); + expect(providers.length).toBeGreaterThan(0); + expect(providers.some((item) => item.keys.length > 0)).toBe(true); + expect(await setup.test({ provider: "", model: "" })).toEqual({ + ok: true, + message: "Connected", + }); + if (defaults.provider) + expect( + await setup.test({ + provider: defaults.provider, + model: "no-such-model-xyz", + }), + ).toMatchObject({ ok: false }); + }); +}); diff --git a/src/bundled/goose/goose.test.ts b/src/bundled/goose/goose.test.ts new file mode 100644 index 0000000000..b6d1d8d7cc --- /dev/null +++ b/src/bundled/goose/goose.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it, vi } from "vitest"; +import { GooseProcess, stepLabel, type ToolServer } from "./goose"; +import { fakeSpawn, flush } from "./goose-testing"; + +const ok: ToolServer = async (_conversation, message) => ({ + jsonrpc: "2.0", + id: message.id, + result: { content: [{ type: "text", text: "done" }] }, +}); + +const start = async (tools: ToolServer = ok) => { + const fake = fakeSpawn(); + const goose = await GooseProcess.start(fake.spawn, { + cwd: "/work", + env: { GOOSE_MODEL: "m" }, + tools, + serve: fake.serve, + }); + const [process] = fake.geese; + if (!process) throw new Error("goose did not start"); + return { goose, process, fake }; +}; + +describe("GooseProcess", () => { + it("starts goose-acp without the app's Buzz identity", async () => { + const { process } = await start(); + expect(process.options.cwd).toBe("/work"); + expect(process.options.env).toMatchObject({ + GOOSE_MODE: "auto", + GOOSE_DISABLE_SESSION_NAMING: "true", + GOOSE_MODEL: "m", + BUZZ_PRIVATE_KEY: null, + BUZZ_AUTH_TAG: null, + BUZZ_RELAY_URL: null, + }); + }); + + it("refuses a goose that cannot serve the Buzz tools", async () => { + const fake = fakeSpawn({ http: false }); + await expect( + GooseProcess.start(fake.spawn, { cwd: "/work", tools: ok }), + ).rejects.toThrow(/HTTP MCP/); + expect(fake.geese[0]?.killed).toBe(true); + }); + + it("opens a session with its own Buzz MCP server and its system prompt", async () => { + const { goose, process, fake } = await start(); + const session = await goose.open({ cwd: "/work", systemPrompt: "Be Buzz" }); + const buzz = { + type: "http", + name: "buzz", + url: "http://127.0.0.1:40001/mcp", + headers: [{ name: "Authorization", value: "Bearer token-1" }], + }; + expect(fake.servers).toHaveLength(1); + expect(process.opened).toEqual([ + { + cwd: "/work", + mcpServers: [buzz], + _meta: { + enabledExtensions: [ + { type: "platform", name: "developer" }, + { type: "mcp", server: buzz }, + ], + }, + }, + ]); + expect(process.prompts.get(session.id)).toBe("Be Buzz"); + }); + + it("gives each session its own server, closed with it", async () => { + const { goose, fake } = await start(); + const first = await goose.open({ cwd: "/work", systemPrompt: "" }); + await goose.open({ cwd: "/work", systemPrompt: "" }); + await goose.open({ cwd: "/work", systemPrompt: "", bare: true }); + expect(fake.servers.map((server) => server.url)).toEqual([ + "http://127.0.0.1:40001/mcp", + "http://127.0.0.1:40002/mcp", + ]); + await goose.close(first); + expect(fake.servers.map((server) => server.closed)).toEqual([true, false]); + }); + + it("closes the session servers when goose exits", async () => { + const { goose, process, fake } = await start(); + await goose.open({ cwd: "/work", systemPrompt: "" }); + process.exit(1, "boom"); + await goose.exited; + expect(fake.servers[0]?.closed).toBe(true); + }); + + it("settles a message when its turn ends", async () => { + const { goose, process } = await start(); + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + await expect(session.send("hello")).resolves.toEqual({ ok: true }); + expect(process.turns.map((turn) => turn.text)).toEqual(["hello"]); + expect(session.busy).toBe(false); + }); + + it("answers the Buzz tools' MCP messages for the session's conversation", async () => { + const tools = vi.fn(ok); + const { goose, process } = await start(tools); + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + session.conversation = "channel/root"; + const response = await process.mcp(session.id, "tools/list"); + expect(tools).toHaveBeenCalledWith("channel/root", { + id: 1, + method: "tools/list", + params: {}, + }); + // goose matches the reply to its request by the id it sent. + expect(response).toEqual({ + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: "done" }] }, + }); + }); + + it("answers no MCP notification", async () => { + const tools = vi.fn(ok); + const { goose, fake } = await start(tools); + await goose.open({ cwd: "/work", systemPrompt: "" }); + await expect( + fake.servers[0]?.handler({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + ).resolves.toBeUndefined(); + expect(tools).not.toHaveBeenCalled(); + }); + + it("returns a tool server's failure as the MCP error", async () => { + const { goose, process } = await start(async () => { + throw new Error("relay down"); + }); + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + const response = await process.mcp(session.id, "tools/call", { + name: "send", + arguments: {}, + }); + expect(response).toEqual({ + jsonrpc: "2.0", + id: 1, + error: { code: -32603, message: "relay down" }, + }); + }); + + it("counts the work done when a final send succeeds", async () => { + const { goose, process } = await start(); + process.hold = true; + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + const settled = session.send("hello"); + await flush(); + await process.mcp(session.id, "tools/call", { + name: "send", + arguments: { content: "hi", final: true }, + }); + await expect(settled).resolves.toEqual({ ok: true }); + expect(session.busy).toBe(false); + process.turns[0]?.finish(); + }); + + it("steers a message that arrives mid-turn into it", async () => { + const { goose, process } = await start(); + process.hold = true; + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + const acknowledged = vi.fn(); + const first = session.send("one"); + await flush(); + const second = session.send("two", { step: () => {}, acknowledged }); + await flush(); + expect(process.turns[0]?.steered).toEqual(["two"]); + expect(acknowledged).toHaveBeenCalled(); + process.turns[0]?.finish(); + await expect(first).resolves.toEqual({ ok: true }); + await expect(second).resolves.toEqual({ ok: true }); + expect(process.turns).toHaveLength(1); + }); + + it("steers a message sent before goose names the turn", async () => { + const { goose, process } = await start(); + process.hold = true; + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + const first = session.send("one"); + const second = session.send("two"); + await flush(); + expect(process.turns.map((turn) => [turn.text, turn.steered])).toEqual([ + ["one", ["two"]], + ]); + process.turns[0]?.finish(); + await expect(first).resolves.toEqual({ ok: true }); + await expect(second).resolves.toEqual({ ok: true }); + }); + + it("names a session with its title", async () => { + const { goose, process } = await start(); + await goose.open({ + cwd: "/work", + systemPrompt: "", + title: "Ada · #general", + }); + expect(process.opened[0]?._meta).toMatchObject({ + sessionTitle: "Ada · #general", + }); + }); + + it("runs a message the turn could not take as the next turn", async () => { + const { goose, process } = await start(); + process.hold = true; + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + const first = session.send("one"); + await flush(); + // The run ended on goose's side, but its answer has not arrived yet. + process.update(session.id, { + sessionUpdate: "session_info_update", + _meta: { goose: { activeRunId: "run-gone" } }, + }); + await flush(); + const second = session.send("two"); + await flush(); + process.turns[0]?.finish(); + await flush(); + expect(process.turns.map((turn) => turn.text)).toEqual(["one", "two"]); + process.turns[1]?.finish(); + await expect(first).resolves.toEqual({ ok: true }); + await expect(second).resolves.toEqual({ ok: true }); + }); + + it("shows the latest message's steps", async () => { + const { goose, process } = await start(); + process.hold = true; + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + const step = vi.fn(); + void session.send("one", { step }); + await flush(); + process.update(session.id, { + sessionUpdate: "tool_call", + toolCallId: "t", + title: "shell · ls", + rawInput: { command: "ls" }, + }); + process.update(session.id, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hi" }, + }); + await flush(); + expect(step.mock.calls).toEqual([ + ["shell", "ls"], + ["Writing reply", undefined], + ]); + process.turns[0]?.finish(); + }); + + it("ends work that only wrote text with what goose wrote", async () => { + const { goose, process } = await start(); + process.hold = true; + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + const settled = session.send("hello"); + await flush(); + process.update(session.id, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Network error: no provider" }, + }); + await flush(); + process.turns[0]?.finish(); + await expect(settled).resolves.toEqual({ + ok: true, + text: "Network error: no provider", + }); + }); + + it("ends work that acted in Buzz without its closing text", async () => { + const { goose, process } = await start(); + process.hold = true; + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + const settled = session.send("hello"); + await flush(); + await process.mcp(session.id, "tools/call", { + name: "send", + arguments: { content: "hi" }, + }); + process.update(session.id, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Sent it." }, + }); + await flush(); + process.turns[0]?.finish(); + await expect(settled).resolves.toEqual({ ok: true }); + }); + + it("allows what goose asks permission for", async () => { + const { process } = await start(); + const response = await process.ask("session/request_permission", { + sessionId: "s", + options: [ + { optionId: "no", kind: "reject_once" }, + { optionId: "once", kind: "allow_once" }, + { optionId: "always", kind: "allow_always" }, + ], + }); + expect(response.result).toEqual({ + outcome: { outcome: "selected", optionId: "always" }, + }); + }); + + it("fails a working session with goose's last words when it exits", async () => { + const { goose, process } = await start(); + process.hold = true; + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + const settled = session.send("hello"); + await flush(); + process.exit(1, "starting\nNo provider configured\n"); + await expect(settled).resolves.toEqual({ + ok: false, + error: "No provider configured", + }); + expect(goose.running).toBe(false); + }); + + it("cancels a session's turn when it closes", async () => { + const { goose, process } = await start(); + process.hold = true; + const session = await goose.open({ cwd: "/work", systemPrompt: "" }); + const settled = session.send("hello"); + await flush(); + await goose.close(session); + expect(process.cancelled).toEqual([session.id]); + expect(process.closed).toEqual([session.id]); + await expect(settled).resolves.toMatchObject({ ok: false }); + }); +}); + +describe("stepLabel", () => { + it("names a tool call by its tool and target, as goose titles it", () => { + expect( + stepLabel({ + sessionUpdate: "tool_call", + title: "shell · sleep 10", + rawInput: { command: "sleep 10" }, + }), + ).toEqual({ tool: "shell", target: "sleep 10" }); + expect( + stepLabel({ + sessionUpdate: "tool_call", + title: "buzz: send", + rawInput: { text: "hi", path: "/a/b/notes.md" }, + }), + ).toEqual({ tool: "send", target: "notes.md" }); + }); +}); diff --git a/src/bundled/goose/goose.ts b/src/bundled/goose/goose.ts new file mode 100644 index 0000000000..a435fe7654 --- /dev/null +++ b/src/bundled/goose/goose.ts @@ -0,0 +1,602 @@ +// One `goose-acp` process for one agent, driven over ACP's line-delimited +// JSON-RPC. Each conversation the agent is in is one ACP session in it. Each +// session gets its own loopback HTTP MCP server for the Buzz tools, which the +// app serves and answers: goose is given its address and token in +// `session/new`, never a Buzz key. +import type { + HostMcpServer, + HostProcess, + HostProcessOptions, + McpHandler, +} from "../../features/host/service"; + +export type Spawn = ( + id: string, + options?: HostProcessOptions, +) => Promise; +export type ServeMcp = (handler: McpHandler) => Promise; + +/** How a burst of work ended: every message sent so far has been answered, + * or the session or its process is gone. */ +/** The most of a turn's text kept, to post when it calls no Buzz tool. */ +const WRITTEN_LIMIT = 20_000; + +export type Settled = Readonly<{ + ok: boolean; + error?: string; + /** What goose wrote, when it called no Buzz tool. Nobody in Buzz saw it. */ + text?: string; +}>; + +export type ActivityReceipt = Readonly<{ + /** Steps show on the latest acknowledged message of each scope. */ + activityScope?: string; + step?: (tool: string, target?: string) => void; + acknowledged?: () => void; + superseded?: () => void; +}>; + +/** Answers one MCP message from a session serving `conversation` with its + * JSON-RPC response. */ +export type ToolServer = ( + conversation: string | undefined, + message: Readonly<{ id: string | number; method: string; params?: unknown }>, +) => Promise; + +/** The HTTP MCP server every session declares; goose names its tools + * `buzz__`. */ +export const TOOL_SERVER = "buzz"; +const DEVELOPER = { type: "platform", name: "developer" }; + +/** What every `goose-acp` runs with: tools run without asking, sessions are + * not named (one model call per turn, not two), and no Buzz identity reaches + * it from the app's environment. */ +export const ENV: Readonly> = { + GOOSE_MODE: "auto", + GOOSE_DISABLE_SESSION_NAMING: "true", + BUZZ_PRIVATE_KEY: null, + BUZZ_AUTH_TAG: null, + BUZZ_RELAY_URL: null, +}; + +const STDERR_LIMIT = 4_000; +const STEP_KEYS = [ + "description", + "path", + "file_path", + "pattern", + "url", + "query", + "command", +]; + +type Wire = { + jsonrpc?: string; + id?: number | string | null; + method?: string; + params?: Record; + result?: unknown; + error?: { code?: number; message: string; data?: unknown }; +}; +type Pending = { + resolve: (result: unknown) => void; + reject: (error: Error) => void; +}; + +export class GooseProcess { + readonly exited: Promise; + private readonly sessions = new Map(); + private readonly calls = new Map(); + private requests = 0; + private buffer = ""; + private stderr = ""; + private alive = true; + + private constructor( + private readonly process: HostProcess, + private readonly tools: ToolServer, + private readonly serve: ServeMcp | undefined, + ) { + this.exited = process.exited.then((code) => { + this.alive = false; + const error = new Error( + this.stderr.trim().split("\n").at(-1) || + `goose exited (${code ?? "signal"})`, + ); + for (const call of this.calls.values()) call.reject(error); + this.calls.clear(); + for (const session of this.sessions.values()) { + session.fail(error.message); + void session.server?.close(); + } + this.sessions.clear(); + return code; + }); + } + + /** Starts `goose-acp` and checks it can reach the Buzz tools. Sessions + * with tools need `serve`, which starts their MCP server. */ + static async start( + spawn: Spawn, + launch: Readonly<{ + cwd: string; + env?: Readonly>; + tools: ToolServer; + serve?: ServeMcp; + }>, + ) { + let goose: GooseProcess | undefined; + const early: string[] = []; + const process = await spawn("goose", { + cwd: launch.cwd, + env: { ...ENV, ...launch.env }, + onStdout: (data) => (goose ? goose.read(data) : early.push(data)), + onStderr: (data) => goose?.readError(data), + }).catch((error: unknown) => { + throw /os error 2|no such file|not found/i.test(errorMessage(error)) + ? new Error( + "goose-acp is not installed. Buzz looks for it on your PATH, including ~/.local/bin.", + ) + : error; + }); + goose = new GooseProcess(process, launch.tools, launch.serve); + for (const data of early) goose.read(data); + try { + const init = (await goose.request("initialize", { + protocolVersion: 1, + clientCapabilities: {}, + clientInfo: { name: "buzz", version: "1" }, + })) as { + agentCapabilities?: { mcpCapabilities?: { http?: boolean } }; + }; + if (!init.agentCapabilities?.mcpCapabilities?.http) + throw new Error( + "This goose cannot reach Buzz tools (no HTTP MCP support)", + ); + } catch (error) { + void process.kill(); + throw error; + } + return goose; + } + + get running() { + return this.alive; + } + get busy() { + return [...this.sessions.values()].some((session) => session.busy); + } + + /** Opens a new session in `cwd`, named `title`, whose system prompt has + * `systemPrompt` appended. A `bare` one has no tools at all. */ + async open( + launch: Readonly<{ + cwd: string; + systemPrompt: string; + title?: string; + bare?: boolean; + }>, + ): Promise { + let server: HostMcpServer | undefined; + // goose connects while it opens the session, before it is known here. + let session: GooseSession | undefined; + if (!launch.bare) { + if (!this.serve) throw new Error("This goose serves no Buzz tools"); + server = await this.serve((message) => this.mcp(session, message)); + } + const buzz = server && { + type: "http", + name: TOOL_SERVER, + url: server.url, + headers: server.headers, + }; + let sessionId: string; + try { + ({ sessionId } = (await this.request("session/new", { + cwd: launch.cwd, + mcpServers: buzz ? [buzz] : [], + _meta: { + // The session's whole tool set: goose's developer tools and the + // Buzz tools, so extensions in the owner's goose config and plugins + // never load into a Buzz agent. goose then ignores `mcpServers`. + enabledExtensions: buzz + ? [DEVELOPER, { type: "mcp", server: buzz }] + : [], + ...(launch.title ? { sessionTitle: launch.title } : {}), + }, + })) as { sessionId: string }); + } catch (error) { + void server?.close(); + throw error; + } + session = new GooseSession(this, sessionId, server); + this.sessions.set(sessionId, session); + try { + // Appended under goose's own prompt, which carries each extension's + // instructions; `set` would replace it, and render ours as a template. + if (launch.systemPrompt) + await this.request("_goose/unstable/session/system-prompt/set", { + sessionId, + mode: "append", + key: "buzz", + text: launch.systemPrompt, + }); + } catch (error) { + void this.close(session); + throw error; + } + return session; + } + + /** Ends a session. Its turn, if any, is cancelled first. */ + async close(session: GooseSession) { + this.sessions.delete(session.id); + session.fail("The session was closed"); + void session.server?.close(); + if (!this.alive) return; + this.notify("session/cancel", { sessionId: session.id }); + await this.request("session/close", { sessionId: session.id }).catch( + (error) => console.warn("goose did not close a session", error), + ); + } + + kill() { + return this.process.kill(); + } + + /** Sends a request and resolves with its result. */ + request(method: string, params: Record) { + if (!this.alive) return Promise.reject(new Error("goose is not running")); + const id = ++this.requests; + const answer = new Promise((resolve, reject) => + this.calls.set(id, { resolve, reject }), + ); + this.write({ jsonrpc: "2.0", id, method, params }).catch((error) => { + this.calls.get(id)?.reject(new Error(String(error))); + this.calls.delete(id); + }); + return answer; + } + notify(method: string, params: Record) { + void this.write({ jsonrpc: "2.0", method, params }).catch(() => undefined); + } + + private write(message: Wire) { + return this.process.write(`${JSON.stringify(message)}\n`); + } + private readError(data: string) { + this.stderr = (this.stderr + data).slice(-STDERR_LIMIT); + } + private read(data: string) { + this.buffer += data; + let newline = this.buffer.indexOf("\n"); + while (newline >= 0) { + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + newline = this.buffer.indexOf("\n"); + if (!line) continue; + let message: Wire; + try { + message = JSON.parse(line); + } catch { + continue; + } + this.handle(message); + } + } + private handle(message: Wire) { + const { id, method } = message; + if (method === undefined) { + if (typeof id !== "number") return; + const call = this.calls.get(id); + this.calls.delete(id); + if (message.error) call?.reject(new Error(errorMessage(message.error))); + else call?.resolve(message.result); + return; + } + if (id === undefined || id === null) { + if (method === "session/update") this.update(message.params ?? {}); + return; + } + void this.answer(id, method, message.params ?? {}); + } + private update(params: Record) { + const session = this.sessions.get(String(params.sessionId)); + const update = params.update as Record | undefined; + if (session && update) session.update(update); + } + /** Answers what goose asks its client: since tools run without asking, + * any permission prompt with allow. */ + private async answer( + id: number | string, + method: string, + params: Record, + ) { + let reply: Wire; + if (method === "session/request_permission") + reply = { jsonrpc: "2.0", id, result: { outcome: allow(params) } }; + else + reply = { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Buzz does not handle ${method}` }, + }; + return this.write(reply).catch(() => undefined); + } + /** One MCP message for a session's Buzz tools, answered with its JSON-RPC + * response; a notification gets none. A final `send` that succeeded ends + * its turn. The host answers a throw with an internal error. */ + private async mcp( + session: GooseSession | undefined, + message: unknown, + ): Promise { + const { id, method = "", params } = (message ?? {}) as Wire; + if (id === undefined || id === null) return undefined; + const response = (await this.tools(session?.conversation, { + id, + method, + params, + })) as Wire; + if (method === "tools/call") session?.used(); + const call = (params ?? {}) as { + name?: unknown; + arguments?: { final?: unknown }; + }; + if ( + method === "tools/call" && + !response.error && + call.name === "send" && + call.arguments?.final === true && + !(response.result as { isError?: unknown } | undefined)?.isError + ) + session?.finished(); + return response; + } +} + +/** One conversation's ACP session. Messages sent while a turn runs are + * steered into it; one that arrives as the turn ends runs as the next turn. */ +export class GooseSession { + /** The conversation it serves. */ + conversation: string | undefined; + /** goose's id for the running turn, which a steer must name. */ + private runId: string | undefined; + /** Steers waiting for the running turn's id, which goose sends just after + * the turn starts. */ + private runWaiters: ((runId: string | undefined) => void)[] = []; + /** A `session/prompt` is in flight. */ + private prompting = false; + private steering = 0; + private readonly queue: string[] = []; + /** Messages sent and not yet answered. */ + private pending = 0; + private waiters: ((settled: Settled) => void)[] = []; + private readonly active = new Map(); + private lastStep = ""; + private lastError: string | undefined; + /** Whether this work called a Buzz tool, and what goose wrote meanwhile: a + * provider failure comes back as text, and text is never posted. */ + private acted = false; + private written = ""; + private closed = false; + + constructor( + private readonly goose: GooseProcess, + readonly id: string, + /** Serves this session's Buzz tools; closed with it. */ + readonly server?: HostMcpServer, + ) {} + + get busy() { + return this.pending > 0; + } + get open() { + return !this.closed; + } + + /** Sends one user message. Resolves when it, and anything sent after it, + * has been answered, or a final `send` ended the work. */ + send(text: string, activity: ActivityReceipt = {}): Promise { + if (this.closed) + return Promise.resolve({ ok: false, error: "The session was closed" }); + this.pending++; + const settled = new Promise((resolve) => + this.waiters.push(resolve), + ); + if (this.prompting) void this.steer(text, activity); + else { + this.queue.push(text); + this.acknowledge(activity); + void this.pump(); + } + return settled; + } + + /** A final `send` succeeded: the work counts as done at once, unless + * another message is still waiting for its answer. goose still ends its turn + * with one more model call, whose text nobody posts. */ + finished() { + if (this.pending === 1 && !this.queue.length && !this.steering) + this.settle({ ok: true }); + } + + /** goose called a Buzz tool. */ + used() { + this.acted = true; + } + + /** Called by the process with each `session/update` for this session. */ + update(update: Record) { + const kind = update.sessionUpdate; + if (kind === "agent_message_chunk" && !this.acted) { + const content = update.content as { text?: unknown } | undefined; + if (typeof content?.text === "string") + this.written = (this.written + content.text).slice(0, WRITTEN_LIMIT); + } + if (kind === "session_info_update") { + const goose = (update._meta as { goose?: { activeRunId?: unknown } }) + ?.goose; + if (goose && "activeRunId" in goose) { + this.runId = + typeof goose.activeRunId === "string" ? goose.activeRunId : undefined; + if (this.runId) this.ranAs(this.runId); + } + return; + } + const step = stepLabel(update); + if (!step) return; + const label = `${step.tool}\u0000${step.target ?? ""}`; + if (label === this.lastStep) return; + this.lastStep = label; + for (const receipt of this.active.values()) + receipt.step?.(step.tool, step.target); + } + + /** Ends the session's work with `error`; nothing more is sent to it. */ + fail(error: string) { + this.closed = true; + this.queue.length = 0; + this.settle({ ok: false, error }); + } + + private async steer(text: string, activity: ActivityReceipt) { + this.steering++; + try { + const runId = + this.runId ?? + (this.prompting + ? await new Promise((resolve) => + this.runWaiters.push(resolve), + ) + : undefined); + if (runId) { + try { + await this.goose.request("_goose/unstable/session/steer", { + sessionId: this.id, + prompt: [{ type: "text", text }], + expectedRunId: runId, + }); + this.acknowledge(activity); + return; + } catch { + // The turn ended, or another took its place: run it as the next. + } + } + this.queue.push(text); + this.acknowledge(activity); + } finally { + this.steering--; + } + void this.pump(); + } + + private async pump() { + if (this.prompting) return; + this.prompting = true; + try { + while (this.queue.length && !this.closed) { + const text = this.queue.shift() ?? ""; + try { + const { stopReason } = (await this.goose.request("session/prompt", { + sessionId: this.id, + prompt: [{ type: "text", text }], + })) as { stopReason?: string }; + if (stopReason === "refusal") + this.lastError = "goose refused the request"; + } catch (error) { + this.lastError = errorMessage(error); + } + } + } finally { + this.prompting = false; + this.runId = undefined; + this.ranAs(undefined); + } + if (!this.steering && !this.closed) this.done(); + } + + private ranAs(runId: string | undefined) { + for (const resolve of this.runWaiters.splice(0)) resolve(runId); + } + private acknowledge(activity: ActivityReceipt) { + // Watches and timers have no message activity: they must not hide a mention. + if (!activity.step) return; + const scope = activity.activityScope ?? "conversation"; + this.active.get(scope)?.superseded?.(); + this.active.set(scope, activity); + this.lastStep = ""; + activity.acknowledged?.(); + } + private done() { + // Work that wrote text and never acted in Buzz answered only goose: a + // model that forgot `send`, or goose saying it cannot reach its provider. + const text = this.acted ? "" : this.written.trim(); + const error = this.lastError; + this.settle({ + ...(error ? { ok: false, error } : { ok: true }), + ...(text ? { text } : {}), + }); + } + private settle(settled: Settled) { + this.pending = 0; + this.active.clear(); + this.lastError = undefined; + this.acted = false; + this.written = ""; + for (const resolve of this.waiters.splice(0)) resolve(settled); + } +} + +/** A `session/update` as the step it shows: a tool call by its title and + * target, thinking, or writing. */ +export function stepLabel( + update: Record, +): { tool: string; target?: string } | undefined { + switch (update.sessionUpdate) { + case "tool_call": { + const title = typeof update.title === "string" ? update.title : ""; + // goose titles a call `shell · sleep 10`, or `buzz: send`. + const tool = + title.split(" · ")[0]?.split(/__|: /).at(-1)?.trim() || "Tool"; + const input = (update.rawInput ?? {}) as Record; + const target = STEP_KEYS.map((key) => input[key]).find( + (value): value is string => typeof value === "string" && !!value, + ); + return target ? { tool, target: shortTarget(target) } : { tool }; + } + case "agent_thought_chunk": + return { tool: "Thinking" }; + case "agent_message_chunk": + return { tool: "Writing reply" }; + default: + return undefined; + } +} + +/** The option that allows the call, preferring one that keeps allowing. */ +function allow(params: Record) { + const options = Array.isArray(params.options) + ? (params.options as { optionId?: unknown; kind?: unknown }[]) + : []; + const option = + options.find((item) => item.kind === "allow_always") ?? + options.find((item) => item.kind === "allow_once"); + return option && typeof option.optionId === "string" + ? { outcome: "selected", optionId: option.optionId } + : { outcome: "cancelled" }; +} + +function errorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (error && typeof error === "object" && "message" in error) { + const { message, data } = error as { message: unknown; data?: unknown }; + return typeof data === "string" && data + ? `${message}: ${data}` + : String(message); + } + return String(error); +} + +function shortTarget(value: string) { + return value.startsWith("/") ? (value.split("/").at(-1) ?? value) : value; +} diff --git a/src/bundled/goose/index.ts b/src/bundled/goose/index.ts new file mode 100644 index 0000000000..c7dfd3c4c6 --- /dev/null +++ b/src/bundled/goose/index.ts @@ -0,0 +1,95 @@ +// goose agents: an agent type whose agents are goose on this computer, one +// `goose-acp` process per agent. Each conversation the agent is in is one ACP +// session in it; goose answers with the in-process Buzz tools, served to it +// over the same ACP connection. +import { isTauri } from "@tauri-apps/api/core"; +import type { PluginModule } from "../../plugins/api"; +import { GooseLogoIcon } from "../../shared/design-system/icons"; +import { type Config, config, DEFAULT_CONFIG, GooseRuntime } from "./runtime"; +import { GooseSetup } from "./setup"; +import { createTabs } from "./tabs"; +import { GooseHistory } from "./transcript"; +import { sharedProviderEnv } from "./shared-provider"; + +export const TYPE_ID = "goose"; +/** A turn's run returns once goose has it, so this bounds only the hand-over. */ +const HANDOVER_MS = 2 * 60_000; + +export const inject = { + agents: { required: true }, + host: { required: true }, + databricks: { required: false }, +}; +export const apply: PluginModule["apply"] = (ctx) => { + // Only the desktop app can start `goose-acp`, and file uploads run `base64`, + // which stock Windows lacks; no unusable type elsewhere. + if (!isTauri() || !/Mac|Linux/i.test(navigator.platform)) return; + const { agents, host } = ctx; + const typeKey = `${ctx.pluginOwner?.id ?? "buzz.goose"}/${TYPE_ID}`; + const shared = ctx.databricks?.shared; + const runtime = new GooseRuntime(host, agents.relay, (config) => + sharedProviderEnv(config.provider, config.model, Boolean(shared)), + ); + const spawn = host.spawn?.bind(host); + const { Create, CreateModel, SettingsTab, ActivityTab } = createTabs({ + setup: new GooseSetup(spawn, shared), + runtime, + history: new GooseHistory(spawn), + }); + const sync = () => { + const snapshot = agents.snapshot(); + if (snapshot.status === "loading") return; + runtime.sync(snapshot.running.filter((agent) => agent.type === typeKey)); + }; + if (host.spawn) { + const unsubscribe = agents.subscribe(sync); + sync(); + ctx.effect(() => () => { + unsubscribe(); + runtime.dispose(); + }); + } + + agents.register({ + id: TYPE_ID, + title: "Goose", + icon: GooseLogoIcon, + description: + "goose on this computer, with any provider goose supports. Each thread it is mentioned in is one continuing session.", + defaults: () => ({ config: DEFAULT_CONFIG }), + Create, + CreateModel, + adopt: (carry, saved) => ({ + ...saved, + instructions: carry.instructions ?? saved.instructions, + workspace: carry.workspace ?? saved.workspace, + }), + carry: (saved) => { + const { instructions, workspace } = config(saved); + return { + ...(instructions.trim() && { instructions }), + ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), + }; + }, + summary: (agent) => { + const { model, provider } = config(agent.config); + return [provider, model].filter(Boolean).join(" · ") || "Goose"; + }, + tabs: [ + { + id: "settings", + title: "Settings", + role: "settings", + component: SettingsTab, + }, + { + id: "activity", + title: "Activity", + role: "activity", + component: ActivityTab, + }, + ], + run: (delivery) => runtime.run(delivery), + timeoutMs: HANDOVER_MS, + }); +}; diff --git a/src/bundled/goose/manifest.json b/src/bundled/goose/manifest.json new file mode 100644 index 0000000000..836765c1e4 --- /dev/null +++ b/src/bundled/goose/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "buzz.goose", + "name": "Goose", + "apiVersion": 1, + "host": { + "processes": [ + { "id": "goose", "program": "goose-acp" }, + { "id": "read", "program": "base64", "args": ["-i"] }, + { "id": "workspace", "program": "pwd" } + ], + "mcp": true + } +} diff --git a/src/bundled/goose/runtime.test.ts b/src/bundled/goose/runtime.test.ts new file mode 100644 index 0000000000..8e86916fb7 --- /dev/null +++ b/src/bundled/goose/runtime.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { sessionTitle } from "./runtime"; + +describe("sessionTitle", () => { + it("names the agent, the channel and a thread's root", () => { + expect(sessionTitle("Ada", "#general", "0123456789abcdef")).toBe( + "Ada · #general · 01234567", + ); + expect(sessionTitle(undefined, "DM")).toBe("Buzz agent · DM"); + }); + + it("keeps the root when a long name is cut", () => { + const title = sessionTitle("A".repeat(100), "#general", "0123456789"); + expect(title).toHaveLength(80); + expect(title.endsWith(" · 01234567")).toBe(true); + }); +}); diff --git a/src/bundled/goose/runtime.ts b/src/bundled/goose/runtime.ts new file mode 100644 index 0000000000..40d893afb7 --- /dev/null +++ b/src/bundled/goose/runtime.ts @@ -0,0 +1,643 @@ +// Turns Agents deliveries into goose turns. Each agent of this type has its +// own `goose-acp` process; a delivery becomes a prompt in the harness format, +// sent to the ACP session for its conversation; a watch or timer wake runs as +// a one-off turn in a new session, in its Interest's lane. The run returns once +// the turn is handed over, so one agent works in several conversations at once. +// goose acts in Buzz with the in-process Buzz tools over loopback HTTP MCP, +// signed by the agent's native-held key; the runtime only reports a turn that +// fails. +import type { Context, Memory } from "../../buzz-mcp/client"; +import { respond } from "../../buzz-mcp/rpc"; +import { appClient } from "../../buzz-mcp/app-client"; +import { + fromOwner, + type Agent, + type AgentHandle, + type Delivery, +} from "../../features/agents/service"; +import type { Host } from "../../features/host/service"; +import type { EventData } from "../../features/relay/events"; +import type { RelayData } from "../../features/relay/service"; +import { threadReference } from "../../features/relay/thread-reference"; +import { Lanes } from "../claude-code/lanes"; +import { + ATTENTION_REVIEW, + CONTEXT_LIMIT, + type Scope, + steerPrompt, + systemPrompt, + timerPrompt, + turnPrompt, + watchPrompt, +} from "../claude-code/prompt"; +import type { ServeMcp, Settled, Spawn, ToolServer } from "./goose"; +import { AgentSessions } from "./sessions"; + +export type Config = Readonly<{ + /** A model the provider offers; empty uses the goose config's. */ + model: string; + /** A goose provider; empty uses the goose config's. */ + provider: string; + instructions: string; + /** The agent's working directory, absolute or `~/…`. */ + workspace: string; + /** Whether each thread is its own session, or the whole channel is one. */ + scope: Scope; +}>; +export const DEFAULT_CONFIG: Config = Object.freeze({ + model: "", + provider: "", + instructions: "", + workspace: "~/.buzz", + scope: "thread", +}); +export const config = (value: unknown): Config => ({ + ...DEFAULT_CONFIG, + ...(value && typeof value === "object" ? (value as Partial) : {}), +}); + +/** How long a read of the agent's core memory is reused. */ +const MEMORY_TTL_MS = 5 * 60_000; +const MEMORY_TIMEOUT_MS = 5_000; +const NAMES_TIMEOUT_MS = 2_000; +const CHAT = [9, 40002]; +const TITLE_LIMIT = 80; + +/** A session's title in goose, as harness agents name theirs: + * `Agent · #channel`, and the thread's root when each thread is a session. */ +export function sessionTitle( + agent: string | undefined, + place: string, + root?: string, +) { + const suffix = root ? ` · ${root.slice(0, 8)}` : ""; + const title = `${agent?.trim() || "Buzz agent"} · ${place}` + .replace(/\s+/g, " ") + .slice(0, TITLE_LIMIT - suffix.length) + .trim(); + return `${title}${suffix}`; +} + +type Entry = { + sessions: AgentSessions; + config: Config; + /** The team sections of the latest run; a change reopens idle sessions. */ + team: string; + /** The agent's name, for its prompt; a rename reopens idle sessions. */ + name?: string; + /** Whether the owner has attention on: new sessions get its tools, its + * guidance and the end-of-turn review. */ + attention: boolean; + /** Watch and timer turns, one lane per Interest. */ + lanes: Lanes; + memory?: { value: string | null | undefined; at: number }; + /** The agent's handle from its latest delivery, for the tools to act with. */ + handle?: AgentHandle; + /** Where each conversation's tools default to: its latest turn's thread. */ + contexts: Map; +}; + +/** The environment that picks the agent's model and provider over the goose + * config's. */ +const env = ({ model, provider }: Config) => ({ + ...(model.trim() ? { GOOSE_MODEL: model.trim() } : {}), + ...(provider.trim() ? { GOOSE_PROVIDER: provider.trim() } : {}), +}); + +export class GooseRuntime { + private readonly agents = new Map(); + private readonly reading = new Map>(); + private readonly listeners = new Set<() => void>(); + private readonly views = new Map< + string, + ReturnType + >(); + + constructor( + private readonly host: Host, + /** The owner's connection to each agent's own community. */ + private readonly relay: (pubkey: string) => RelayData | undefined, + private readonly providerEnv?: ( + config: Config, + ) => Promise>, + ) {} + + private get spawn(): Spawn { + const spawn = this.host.spawn?.bind(this.host); + if (!spawn) throw new Error("goose agents run only in the desktop app"); + return spawn; + } + private get serve(): ServeMcp { + const serve = this.host.serveMcp?.bind(this.host); + if (!serve) throw new Error("goose agents run only in the desktop app"); + return serve; + } + + /** Brings the runtime in line with this type's agents: changed settings + * reopen idle sessions, and removed agents stop. */ + sync(agents: readonly Agent[]) { + const current = new Set(agents.map((agent) => agent.pubkey)); + for (const [pubkey, entry] of this.agents) + if (!current.has(pubkey)) { + entry.lanes.stop(); + entry.sessions.dispose(); + this.agents.delete(pubkey); + } + for (const agent of agents) { + const next = config(agent.config); + const entry = this.agents.get(agent.pubkey); + if (!entry) + this.entry(agent.pubkey, next, agent.attentionEnabled).name = + agent.name; + else if ( + JSON.stringify(entry.config) !== JSON.stringify(next) || + entry.name !== agent.name || + entry.attention !== agent.attentionEnabled + ) { + if (!agent.attentionEnabled) entry.lanes.clear(); + entry.config = next; + entry.name = agent.name; + entry.attention = agent.attentionEnabled; + entry.sessions.reconfigure(); + } + } + this.notify(); + } + + /** Conversations each agent has a session for, for its status view. */ + sessions(pubkey: string) { + let view = this.views.get(pubkey); + if (!view) { + view = this.agents.get(pubkey)?.sessions.snapshot() ?? []; + this.views.set(pubkey, view); + } + return view; + } + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + private notify() { + this.views.clear(); + for (const listener of this.listeners) listener(); + } + + dispose() { + for (const entry of this.agents.values()) { + entry.lanes.stop(); + entry.sessions.dispose(); + } + this.agents.clear(); + } + + async run({ + trigger, + agent, + config: raw, + channelId, + team, + workingUntil, + ignored, + signal, + step, + acknowledged, + superseded, + current, + }: Delivery) { + const settings = config(raw); + // goose runs commands as you; mentions need owner or delegated authority. + // A watch is the agent's own attention: its event is observed data, + // framed as such, from anyone. + if (trigger.type === "mention") { + const snapshot = this.relay(agent.pubkey)?.snapshot(); + if ( + !(await fromOwner(trigger.event, agent.owner, { + agent: agent.pubkey, + authority: + snapshot?.status === "ready" + ? snapshot.session.workflowAuthority + : undefined, + profiles: + snapshot?.status === "ready" + ? snapshot.session.profiles + : undefined, + })) + ) { + ignored?.(); + return; + } + signal.throwIfAborted(); + } + const entry = this.entry(agent.pubkey, settings, agent.attention.enabled()); + entry.handle = agent; + entry.team = team ?? ""; + void this.memory(agent.pubkey, entry); + if (trigger.type !== "mention") { + workingUntil(this.wake(entry, agent, trigger, current)); + return; + } + const { event } = trigger; + const channel = + channelId ?? event.tags.find((tag) => tag[0] === "h")?.[1] ?? undefined; + if (!channel) return; + // `fresh` is for a session that has been shown nothing yet. + const prompt = async (fresh: boolean) => { + const turn = await this.turn(entry, agent.pubkey, event, channel, fresh); + const { channel: where } = turn; + return { + key: turn.key, + place: where.name + ? `#${where.name}` + : where.dm + ? "DM" + : where.id.slice(0, 8), + text: this.review( + entry, + turnPrompt({ ...turn, agent: agent.pubkey, label: "@mention" }), + ), + }; + }; + const { key, text, place } = await prompt(false); + entry.contexts.set( + key, + key === channel + ? { channel } + : { channel, root: threadReference(event)?.rootId ?? event.id }, + ); + const done = entry.sessions.deliver(key, text, event.created_at, { + steer: steerPrompt(text), + fresh: async () => (await prompt(true)).text, + title: sessionTitle(entry.name, place, key.split("/")[1]), + activityScope: threadReference(event)?.rootId ?? event.id, + ...(step ? { step } : {}), + ...(acknowledged ? { acknowledged } : {}), + ...(superseded ? { superseded } : {}), + }); + workingUntil(done); + this.notify(); + void done + .then((result) => { + if (!result.ok) + return this.report(agent, channel, event, result.error ?? ""); + // An answer goose wrote without `send` is posted for it, so the + // thread is not left waiting. + if (result.text) return this.post(agent, channel, event, result.text); + }) + .catch((error) => console.warn("goose turn failed", error)) + .finally(() => this.notify()); + } + + /** A watch or timer wake: a one-off turn in its Interest's lane. It joins + * no conversation. The host says whether it is still `current`, asked when + * its lane starts it and again once its session is open. */ + private wake( + entry: Entry, + agent: AgentHandle, + trigger: Exclude, + current: () => boolean, + ): Promise { + const { slug } = trigger; + const id = slug.replace(/^watch\//, ""); + const lane = (trigger.type === "watch" ? trigger.watch : trigger.timer) + .interest_id; + let settle!: (result: Settled) => void; + const settled = new Promise((resolve) => { + settle = resolve; + }); + const dropped = (why: string) => { + console.warn(`goose dropped a ${trigger.type} turn: ${why}`); + settle({ ok: true }); + }; + const queued = entry.lanes.add( + lane, + async () => { + try { + if (!current()) return settle({ ok: true }); + const label = `${trigger.type}/${id}`; + const done = entry.sessions.runOnce( + label, + () => this.wakePrompt(agent, trigger, id, current), + { title: sessionTitle(entry.name, label) }, + ); + this.notify(); + const result = await done; + this.notify(); + if (!result.ok) + console.warn( + `goose ${trigger.type} turn failed: ${id}`, + result.error, + ); + settle(result); + } finally { + settle({ ok: false, error: "The turn failed" }); + } + }, + () => dropped("attention was turned off or the agent stopped"), + ); + if (queued !== "queued") + dropped( + queued === "full" + ? `too many are waiting (Interest ${lane})` + : "the agent stopped", + ); + return settled; + } + + /** The prompt for a wake as its watch now is, or undefined when it should + * no longer run. */ + private async wakePrompt( + agent: AgentHandle, + trigger: Exclude, + id: string, + stillCurrent: () => boolean, + ) { + const { attention } = agent; + if (!stillCurrent()) return undefined; + const current = (await attention.show(trigger.slug)).object; + const type = trigger.type === "watch" ? "event" : "timer"; + if (!current || current.type !== type) return undefined; + const interest = (await attention.show(`interest/${current.interest_id}`)) + .object; + const instructions = + interest?.type === "interest" + ? { instructions: interest.instructions } + : {}; + const text = + trigger.type === "watch" + ? watchPrompt({ + id, + interest: current.interest_id, + ...instructions, + event: trigger.event, + ...(trigger.classifier ? { classifier: trigger.classifier } : {}), + }) + : timerPrompt({ + id, + interest: current.interest_id, + prompt: current.type === "timer" ? current.prompt : "", + ...instructions, + spent: trigger.spent, + }); + return stillCurrent() ? `${text}\n\n${ATTENTION_REVIEW}` : undefined; + } + + /** `text` with the end-of-turn attention review, when attention is on. */ + private review(entry: Entry, text: string) { + return entry.attention ? `${text}\n\n${ATTENTION_REVIEW}` : text; + } + + /** Says in the thread that a turn failed, so nobody waits on it. */ + private report( + agent: AgentHandle, + channel: string, + event: EventData, + error: string, + ) { + return this.post( + agent, + channel, + event, + `⚠️ I couldn't finish that: ${error.slice(0, 500)}`, + [["p", agent.owner]], + ); + } + + /** Replies in the event's thread as the agent. */ + private post( + agent: AgentHandle, + channel: string, + event: EventData, + content: string, + tags: string[][] = [], + ) { + return agent + .publish({ + kind: 9, + content, + tags: [ + ["h", channel], + ["e", threadReference(event)?.rootId ?? event.id, "", "reply"], + ...tags, + ], + }) + .catch((failure) => console.warn("goose could not post", failure)); + } + + /** Where the event's turn goes, and what its prompt needs. */ + private async turn( + entry: Entry, + self: string, + event: EventData, + channelId: string, + fresh = false, + ) { + const snapshot = this.relay(self)?.snapshot(); + const session = snapshot?.status === "ready" ? snapshot.session : undefined; + const summary = + session?.channels.list().channels.find((item) => item.id === channelId) ?? + session?.channels.get?.(channelId); + const dm = summary?.channelType === "dm"; + const thread = threadReference(event); + const rootId = thread?.rootId ?? event.id; + const scope = entry.config.scope; + const key = + dm || scope === "channel" ? channelId : `${channelId}/${rootId}`; + const seen = fresh ? 0 : entry.sessions.seen(key); + let earlier: readonly EventData[] = []; + if (session && (thread || dm)) + try { + const events = await session.read( + thread + ? [ + { ids: [rootId], limit: 1 }, + { kinds: CHAT, "#h": [channelId], "#e": [rootId], limit: 200 }, + ] + : [{ kinds: CHAT, "#h": [channelId], limit: CONTEXT_LIMIT + 1 }], + { signal: AbortSignal.timeout(NAMES_TIMEOUT_MS * 2) }, + ); + earlier = events + .filter( + (item) => + CHAT.includes(item.kind) && + item.id !== event.id && + item.created_at <= event.created_at, + ) + .sort((a, b) => a.created_at - b.created_at); + } catch (error) { + console.warn("goose could not read the conversation", error); + } + // A session already has its own replies; a new one is shown them too. + const unseen = earlier.filter( + (item) => item.created_at > seen && (!seen || item.pubkey !== self), + ); + const pubkeys = [ + ...new Set([ + event.pubkey, + ...unseen.map((item) => item.pubkey), + ...event.tags + .filter((tag) => tag[0] === "p") + .map((tag) => tag[1] ?? ""), + ]), + ].filter(Boolean); + if (session) + await Promise.race([ + session.profiles.ensure(pubkeys, "foreground").catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, NAMES_TIMEOUT_MS)), + ]); + const members = summary?.members ?? []; + return { + key, + event, + scope, + channel: { + id: channelId, + ...(summary?.name ? { name: summary.name } : {}), + dm, + }, + ...(thread ? { thread } : {}), + context: unseen, + total: earlier.length + 1, + name: (pubkey: string) => + session?.names.resolve(pubkey, undefined, members) ?? + session?.profiles.snapshot().get(pubkey)?.name ?? + undefined, + }; + } + + private entry(pubkey: string, initial: Config, attention: boolean): Entry { + const existing = this.agents.get(pubkey); + if (existing) return existing; + const entry: Entry = { + config: initial, + team: "", + attention, + lanes: new Lanes(), + sessions: undefined as unknown as AgentSessions, + contexts: new Map(), + }; + // One process serves every session, so the tools read the settings, the + // handle and the conversation's place as they are when called. + const tools: ToolServer = async (conversation, message) => { + const handle = entry.handle; + const client = handle && { + ...appClient(handle, { + spawn: this.spawn, + cwd: entry.config.workspace, + memories: () => this.memories(pubkey), + }), + remember: async (slug: string, body: string, after: number) => { + await handle.remember(slug, body, after); + if (slug === "core") delete entry.memory; + }, + }; + const context = (conversation && entry.contexts.get(conversation)) || {}; + return respond(client, context, message, { + offered: entry.attention, + ...(handle ? { api: handle.attention } : {}), + }); + }; + entry.sessions = new AgentSessions({ + spawn: (id, options) => this.spawn(id, options), + tools, + serve: (handler) => this.serve(handler), + onChange: () => this.notify(), + processFingerprint: () => + JSON.stringify([ + entry.config.workspace, + entry.config.model, + entry.config.provider, + ]), + fingerprint: () => + JSON.stringify([ + entry.config.workspace, + entry.config.instructions, + entry.config.scope, + entry.team, + entry.name, + entry.attention, + ]), + launch: async () => { + const { workspace, instructions, scope } = entry.config; + const memory = await this.memory(pubkey, entry); + return { + workspace, + env: { + ...env(entry.config), + ...(await this.providerEnv?.(entry.config)), + }, + systemPrompt: systemPrompt({ + scope, + cwd: workspace, + ...(entry.name ? { agent: { name: entry.name, pubkey } } : {}), + instructions, + team: entry.team, + attention: entry.attention, + ...(memory === undefined ? {} : { memory }), + }), + }; + }, + }); + this.agents.set(pubkey, entry); + return entry; + } + + /** The agent's `core` memory, as a new session's prompt carries it: its + * text, `null` when it has none, or undefined when it could not be read. */ + private async memory(pubkey: string, entry: Entry) { + if (entry.memory && Date.now() - entry.memory.at < MEMORY_TTL_MS) + return entry.memory.value; + let value: string | null | undefined; + try { + const memories = await this.memories(pubkey); + value = memories.find((memory) => memory.slug === "core")?.body ?? null; + } catch (error) { + console.warn("goose could not read the agent's memory", error); + } + entry.memory = { value, at: Date.now() }; + return value; + } + + /** The agent's memory, read back through the owner's session: it is + * encrypted to the owner, so no agent key is needed to read it. */ + private memories(pubkey: string): Promise { + let reading = this.reading.get(pubkey); + if (!reading) { + reading = this.readMemories(pubkey).finally(() => + this.reading.delete(pubkey), + ); + this.reading.set(pubkey, reading); + } + return reading; + } + private async readMemories(pubkey: string): Promise { + const snapshot = this.relay(pubkey)?.snapshot(); + if (snapshot?.status !== "ready") throw new Error("Buzz is not connected"); + const view = snapshot.session.agentMemories.open(pubkey); + try { + const done = new Promise((resolve) => { + const settled = () => + !["idle", "loading"].includes(view.snapshot().status) && resolve(); + view.subscribe(settled); + settled(); + }); + void view.refresh(); + await Promise.race([ + done, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Memory read timed out")), + MEMORY_TIMEOUT_MS, + ), + ), + ]); + const { status, listing } = view.snapshot(); + if (status !== "ready" || !listing) + throw new Error(`Memory unavailable (${status})`); + return listing.entries; + } finally { + view.dispose(); + } + } +} diff --git a/src/bundled/goose/sessions.test.ts b/src/bundled/goose/sessions.test.ts new file mode 100644 index 0000000000..e60765bd9e --- /dev/null +++ b/src/bundled/goose/sessions.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ToolServer } from "./goose"; +import { fakeSpawn, flush } from "./goose-testing"; +import { AgentSessions, IDLE_MS, type Launch } from "./sessions"; + +const tools: ToolServer = async (_conversation, message) => ({ + jsonrpc: "2.0", + id: message.id, + result: {}, +}); + +const setup = (initial: Partial = {}, hold = false) => { + const fake = fakeSpawn({ home: "/home/test", hold }); + let settings: Launch = { + workspace: "~/.buzz", + env: {}, + systemPrompt: "prompt", + ...initial, + }; + const sessions = new AgentSessions({ + spawn: fake.spawn, + tools, + serve: fake.serve, + launch: async () => settings, + processFingerprint: () => + JSON.stringify([settings.workspace, settings.env]), + fingerprint: () => + JSON.stringify([settings.workspace, settings.systemPrompt]), + }); + return { + fake, + sessions, + change: (next: Partial, reconfigure = true) => { + settings = { ...settings, ...next }; + if (reconfigure) sessions.reconfigure(); + }, + }; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("AgentSessions", () => { + it("serves each conversation from its own session in one process", async () => { + const { fake, sessions } = setup(); + await sessions.deliver("a", "one", 1); + await sessions.deliver("b", "two", 2); + await sessions.deliver("a", "three", 3); + expect(fake.geese).toHaveLength(1); + const [goose] = fake.geese; + expect(goose?.opened).toHaveLength(2); + expect(goose?.turns.map((turn) => [turn.sessionId, turn.text])).toEqual([ + ["session-1", "one"], + ["session-2", "two"], + ["session-1", "three"], + ]); + expect(sessions.seen("a")).toBe(3); + }); + + it("opens sessions in the workspace as an absolute path", async () => { + const { fake, sessions } = setup(); + await sessions.deliver("a", "one", 1); + expect(fake.geese[0]?.options.cwd).toBe("/home/test/.buzz"); + expect(fake.geese[0]?.opened[0]?.cwd).toBe("/home/test/.buzz"); + }); + + it("opens one session for messages that arrive together", async () => { + const { fake, sessions } = setup(); + await Promise.all([ + sessions.deliver("a", "one", 1), + sessions.deliver("a", "two", 2), + ]); + expect(fake.geese[0]?.opened).toHaveLength(1); + }); + + it("closes idle sessions when their settings change", async () => { + const { fake, sessions, change } = setup(); + await sessions.deliver("a", "one", 1); + change({ systemPrompt: "newer" }); + await flush(30); + expect(fake.geese[0]?.closed).toEqual(["session-1"]); + expect(sessions.seen("a")).toBe(0); + }); + + it("shows a reopened conversation everything again", async () => { + const { fake, sessions, change } = setup(); + const fresh = vi.fn(async () => "everything so far"); + await sessions.deliver("a", "one", 1, { fresh }); + expect(fresh).not.toHaveBeenCalled(); + change({ systemPrompt: "newer" }, false); + await sessions.deliver("a", "two", 2, { fresh }); + const [goose] = fake.geese; + expect(fake.geese).toHaveLength(1); + expect(goose?.closed).toEqual(["session-1"]); + expect(goose?.prompts.get("session-2")).toBe("newer"); + expect(goose?.turns.at(-1)?.text).toBe("everything so far"); + }); + + it("restarts the process for a new model once nothing runs in it", async () => { + const { fake, sessions, change } = setup(); + await sessions.deliver("a", "one", 1); + const [first] = fake.geese; + if (first) first.hold = true; + const working = sessions.deliver("b", "two", 2); + await flush(30); + change({ env: { GOOSE_MODEL: "other" } }); + await flush(30); + // `a` was idle, so it closed; `b` keeps the old process while it works. + expect(first?.closed).toEqual(["session-1"]); + expect(first?.killed).toBe(false); + await sessions.deliver("c", "three", 3); + expect(fake.geese).toHaveLength(2); + expect(fake.geese[1]?.options.env).toMatchObject({ GOOSE_MODEL: "other" }); + first?.turns.at(-1)?.finish(); + await working; + await flush(30); + expect(first?.killed).toBe(true); + }); + + it("closes a quiet conversation's session", async () => { + vi.useFakeTimers(); + const { fake, sessions } = setup(); + await sessions.deliver("a", "one", 1); + await vi.advanceTimersByTimeAsync(IDLE_MS); + expect(fake.geese[0]?.closed).toEqual(["session-1"]); + expect(sessions.snapshot()).toEqual([]); + expect(sessions.seen("a")).toBe(0); + }); + + it("starts goose again after it exits", async () => { + const { fake, sessions } = setup(); + await sessions.deliver("a", "one", 1); + fake.geese[0]?.exit(1, "crashed"); + await flush(30); + await expect(sessions.deliver("a", "two", 2)).resolves.toEqual({ + ok: true, + }); + expect(fake.geese).toHaveLength(2); + }); + + it("reports why goose could not start", async () => { + const fake = fakeSpawn({ http: false }); + const sessions = new AgentSessions({ + spawn: fake.spawn, + tools, + serve: fake.serve, + launch: async () => ({ workspace: "/w", env: {}, systemPrompt: "" }), + processFingerprint: () => "", + fingerprint: () => "", + }); + await expect(sessions.deliver("a", "one", 1)).resolves.toMatchObject({ + ok: false, + error: expect.stringMatching(/HTTP MCP/), + }); + }); + + it("runs a one-off turn in a session it then closes", async () => { + const { fake, sessions } = setup(); + await expect(sessions.runOnce("watch/x", () => "look")).resolves.toEqual({ + ok: true, + }); + const [goose] = fake.geese; + expect(goose?.turns.map((turn) => turn.text)).toEqual(["look"]); + expect(goose?.closed).toEqual(["session-1"]); + expect(goose?.killed).toBe(false); + expect(sessions.snapshot()).toEqual([]); + }); + + it("stops a one-off turn at its deadline", async () => { + vi.useFakeTimers(); + const { fake, sessions } = setup({}, true); + const running = sessions.runOnce("timer/x", () => "wait", { + deadline: 1_000, + }); + await vi.advanceTimersByTimeAsync(1_000); + const [goose] = fake.geese; + await expect(running).resolves.toMatchObject({ ok: false }); + expect(goose?.cancelled).toEqual(["session-1"]); + }); + + it("stops its processes when disposed", async () => { + const { fake, sessions } = setup(); + await sessions.deliver("a", "one", 1); + sessions.dispose(); + await flush(30); + expect(fake.geese[0]?.killed).toBe(true); + await expect(sessions.deliver("a", "two", 2)).resolves.toMatchObject({ + ok: false, + }); + }); +}); diff --git a/src/bundled/goose/sessions.ts b/src/bundled/goose/sessions.ts new file mode 100644 index 0000000000..27c4dce453 --- /dev/null +++ b/src/bundled/goose/sessions.ts @@ -0,0 +1,360 @@ +// One agent's goose sessions: one `goose-acp` process for the agent, and in +// it one ACP session per conversation (a thread, a whole DM, or a channel). +// A quiet conversation's session is closed; its next message opens a new one, +// shown the conversation again. A one-off turn (a watch or timer wake) gets a +// session of its own, closed when it ends. Nothing is saved: sessions live as +// long as the app does. +import { + GooseProcess, + type ActivityReceipt, + type GooseSession, + type ServeMcp, + type Settled, + type Spawn, + type ToolServer, +} from "./goose"; + +/** A conversation's session closes after this long without a message. */ +export const IDLE_MS = 15 * 60_000; +/** How long a one-off turn can run before it is given up; Janet's turn + * deadline. */ +export const ONCE_DEADLINE_MS = 5 * 60_000; + +/** What a session is opened with. */ +export type Launch = Readonly<{ + /** The agent's working directory, absolute or `~/…`. */ + workspace: string; + /** Added to the process's environment: its model and provider. */ + env: Readonly>; + systemPrompt: string; +}>; + +export type SessionsOptions = Readonly<{ + spawn: Spawn; + tools: ToolServer; + /** Starts a session's MCP server for `tools`. */ + serve: ServeMcp; + /** The agent's current settings, for a new session. */ + launch(): Promise; + /** Changes when a setting the process was started with has changed. */ + processFingerprint(): string; + /** Changes when a setting a session was opened with has changed. */ + fingerprint(): string; + /** Called when a conversation's session opens or closes. */ + onChange?(): void; +}>; + +type Process = { goose: GooseProcess; cwd: string; fingerprint: string }; +type Live = { + session: GooseSession; + process: Process; + fingerprint: string; + timer?: ReturnType; +}; + +export class AgentSessions { + /** The process new sessions open in. One started with older settings keeps + * running while a session in it works. */ + private current: { fingerprint: string; ready: Promise } | undefined; + private readonly live = new Map(); + /** One-off turns running or starting, by label. */ + private readonly once = new Map(); + private onceCount = 0; + private readonly opening = new Map>(); + /** created_at of the newest event each conversation's session was shown. */ + private readonly shown = new Map(); + /** Conversations that have had a session, whose next new one may have been + * prompted for the old one. */ + private readonly had = new Set(); + private disposed = false; + + constructor(private readonly options: SessionsOptions) {} + + /** What this conversation's session has been shown. */ + seen(key: string) { + return this.shown.get(key) ?? 0; + } + /** Conversations with a session, and whether each is working. */ + snapshot(): { key: string; busy: boolean; sessionId?: string }[] { + return [ + ...[...this.live].map(([key, live]) => ({ + key, + busy: live.session.busy, + sessionId: live.session.id, + })), + ...[...this.once.keys()].map((key) => ({ key, busy: true })), + ]; + } + + /** Sends `text` into the conversation `key`, and resolves when the session + * has answered it. `steer` replaces `text` when the session is mid-turn; + * `fresh` builds it again when the session it was built for was closed; + * `title` names a session opened for it. */ + async deliver( + key: string, + text: string, + seen: number, + { + steer = text, + fresh, + title, + ...activity + }: Readonly<{ + steer?: string; + fresh?: () => Promise; + title?: string; + }> & + ActivityReceipt = {}, + ): Promise { + if (this.disposed) return { ok: false, error: "goose was stopped" }; + // A message that arrives while its conversation's session opens uses it. + const opening = this.opening.get(key); + if (opening) await opening.catch(() => undefined); + let live = this.live.get(key); + if ( + live && + (!live.session.open || (!live.session.busy && this.stale(live))) + ) { + this.drop(key, live); + live = undefined; + } + if (live) { + this.touch(key, live, seen); + return this.answer(key, live, live.session.busy ? steer : text, activity); + } + // Perhaps built for a session closed since, which had been shown the rest. + if (this.had.has(key) && fresh) text = await fresh(); + const starting = this.open(key, title); + this.opening.set(key, starting); + try { + live = await starting; + } catch (error) { + console.warn("goose could not start", error); + return { ok: false, error: `goose could not start: ${message(error)}` }; + } finally { + if (this.opening.get(key) === starting) this.opening.delete(key); + } + if (this.disposed) { + void live.process.goose.close(live.session); + return { ok: false, error: "goose was stopped" }; + } + this.adopt(key, live, seen); + return this.answer(key, live, text, activity); + } + + /** Runs one turn in a new session, then closes it. `label` names it in the + * snapshot. It gives up, and stops the turn, after `deadline` ms. `prompt` + * is asked for the turn's text once the session is open, so a turn that + * should no longer run by then returns undefined and sends nothing. */ + async runOnce( + label: string, + prompt: () => string | undefined | Promise, + { + deadline = ONCE_DEADLINE_MS, + title, + }: Readonly<{ + deadline?: number; + title?: string; + }> = {}, + ): Promise { + if (this.disposed) return { ok: false, error: "goose was stopped" }; + const key = `${label} #${++this.onceCount}`; + this.once.set(key, undefined); + this.options.onChange?.(); + let live: Live | undefined; + let timer: ReturnType | undefined; + try { + live = await this.open(key, title); + if (this.disposed) return { ok: false, error: "goose was stopped" }; + this.once.set(key, live); + const text = await prompt(); + if (text === undefined || this.disposed) return { ok: true }; + return await Promise.race([ + live.session.send(text), + new Promise((resolve) => { + timer = setTimeout( + () => + resolve({ + ok: false, + error: `The turn ran past its ${Math.round(deadline / 1000)}s deadline and was stopped`, + }), + deadline, + ); + }), + ]); + } catch (error) { + console.warn("goose could not start a one-off session", error); + return { ok: false, error: `goose could not start: ${message(error)}` }; + } finally { + clearTimeout(timer); + this.once.delete(key); + if (live) { + await live.process.goose.close(live.session); + this.retire(live.process); + } + this.options.onChange?.(); + } + } + + /** Closes idle sessions opened with old settings; busy ones close when they + * next fall idle. A process started with old settings stops once nothing + * runs in it. */ + reconfigure() { + for (const [key, live] of this.live) + if (!live.session.busy && this.stale(live)) this.drop(key, live); + void this.current?.ready + .then((process) => this.retire(process)) + .catch(() => undefined); + } + + dispose() { + this.disposed = true; + const processes = new Set(); + for (const [key, live] of this.live) { + processes.add(live.process); + this.drop(key, live); + } + for (const live of this.once.values()) + if (live) processes.add(live.process); + for (const process of processes) void process.goose.kill(); + void this.current?.ready + .then((process) => process.goose.kill()) + .catch(() => undefined); + this.current = undefined; + } + + private stale(live: Live) { + return ( + live.fingerprint !== this.options.fingerprint() || + live.process.fingerprint !== this.options.processFingerprint() + ); + } + + private async open(key: string, title?: string): Promise { + // Read before `launch` reads the settings, so it never describes newer ones. + const fingerprint = this.options.fingerprint(); + const launch = await this.options.launch(); + const process = await this.process(launch); + const session = await process.goose.open({ + cwd: process.cwd, + systemPrompt: launch.systemPrompt, + ...(title ? { title } : {}), + }); + session.conversation = key; + if (fingerprint === this.options.fingerprint() || this.disposed) + return { session, process, fingerprint }; + // Settings saved while it opened: it would work with the old ones. + void process.goose.close(session); + return this.open(key, title); + } + + /** The process for the current settings, started when there is none. */ + private process(launch: Launch): Promise { + const fingerprint = this.options.processFingerprint(); + if (this.current?.fingerprint === fingerprint) return this.current.ready; + const old = this.current; + const current = { + fingerprint, + ready: (async (): Promise => { + const cwd = await absolute(this.options.spawn, launch.workspace); + const goose = await GooseProcess.start(this.options.spawn, { + cwd, + env: launch.env, + tools: this.options.tools, + serve: this.options.serve, + }); + const process = { goose, cwd, fingerprint }; + void goose.exited.then(() => { + if (this.current === current) this.current = undefined; + for (const [key, live] of this.live) + if (live.process === process) this.drop(key, live); + }); + return process; + })(), + }; + // A process that failed to start is started again on the next message. + current.ready.catch(() => { + if (this.current === current) this.current = undefined; + }); + this.current = current; + void old?.ready + .then((process) => this.retire(process)) + .catch(() => undefined); + return current.ready; + } + + /** Stops `process` when it has old settings and nothing runs in it. */ + private retire(process: Process) { + if ( + !this.disposed && + process.fingerprint === this.options.processFingerprint() + ) + return; + const users = [...this.live.values(), ...this.once.values()]; + if (users.some((live) => live?.process === process)) return; + if (this.current?.fingerprint === process.fingerprint) + this.current = undefined; + void process.goose.kill(); + } + + private adopt(key: string, live: Live, seen: number) { + this.had.add(key); + this.live.set(key, live); + this.touch(key, live, seen); + this.options.onChange?.(); + } + private async answer( + key: string, + live: Live, + text: string, + activity: ActivityReceipt, + ) { + const settled = await live.session.send(text, activity); + if (this.live.get(key) === live) { + this.touch(key, live); + if (!live.session.busy && this.stale(live)) this.drop(key, live); + } + return settled; + } + /** Notes what the session has been shown and restarts its idle countdown. */ + private touch(key: string, live: Live, seen?: number) { + clearTimeout(live.timer); + live.timer = setTimeout(() => { + if (live.session.busy) this.touch(key, live); + else if (this.live.get(key) === live) this.drop(key, live); + }, IDLE_MS); + if (seen !== undefined) + this.shown.set(key, Math.max(seen, this.shown.get(key) ?? 0)); + } + private drop(key: string, live: Live) { + clearTimeout(live.timer); + if (this.live.get(key) === live) { + this.live.delete(key); + this.shown.delete(key); + this.options.onChange?.(); + } + void live.process.goose.close(live.session).then(() => { + this.retire(live.process); + }); + } +} + +/** `workspace` as an absolute path, which ACP sessions need: the host + * resolves `~/…` for the process it starts there. */ +export async function absolute(spawn: Spawn, workspace: string) { + if (workspace.startsWith("/")) return workspace; + let directory = ""; + const process = await spawn("workspace", { + cwd: workspace, + onStdout: (data) => { + directory += data; + }, + }); + if ((await process.exited) !== 0 || !directory.trim()) + throw new Error(`Could not find the workspace ${workspace}`); + return directory.trim(); +} + +function message(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/bundled/goose/setup.test.ts b/src/bundled/goose/setup.test.ts new file mode 100644 index 0000000000..f88c992b85 --- /dev/null +++ b/src/bundled/goose/setup.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import type { Spawn } from "./goose"; +import { fakeSpawn } from "./goose-testing"; +import { GooseSetup, keyLabel } from "./setup"; + +const ANTHROPIC = { + providerId: "anthropic", + providerName: "Anthropic", + description: "Claude models", + defaultModel: "claude-sonnet-5-5", + configured: false, + visibleInSetup: true, + configKeys: [ + { name: "ANTHROPIC_API_KEY", required: true, secret: true }, + { + name: "ANTHROPIC_HOST", + required: false, + secret: false, + default: "https://api.anthropic.com", + }, + ], + setupSteps: ["Create a key at console.anthropic.com"], + models: [ + { id: "claude-haiku-5-5", name: "Claude Haiku 5.5" }, + { id: "claude-opus-5-5", name: "Claude Opus 5.5", recommended: true }, + ], +}; + +const PROVIDERS = [ + ANTHROPIC, + { + providerId: "ollama", + providerName: "Ollama", + configured: true, + visibleInSetup: true, + configKeys: [{ name: "OLLAMA_HOST", required: true, secret: false }], + }, + { + providerId: "databricks", + providerName: "Databricks", + configured: false, + visibleInSetup: true, + configKeys: [ + { name: "DATABRICKS_HOST", required: true, secret: false }, + { + name: "DATABRICKS_TOKEN", + required: false, + secret: true, + oauthFlow: true, + }, + ], + }, + { providerId: "hidden", providerName: "Hidden", visibleInSetup: false }, + { + providerId: "old", + providerName: "Old", + visibleInSetup: true, + deprecated: true, + }, + { + providerId: "claude-acp", + providerName: "Claude Code", + visibleInSetup: true, + acp: true, + }, +]; + +const setup = (options: Parameters[0] = {}) => { + const fake = fakeSpawn({ + providers: PROVIDERS, + defaults: { providerId: "ollama", modelId: "qwen3" }, + ...options, + }); + return { fake, setup: new GooseSetup(fake.spawn) }; +}; + +describe("GooseSetup", () => { + it("lists the providers set up here, by name, with goose's defaults", async () => { + const { fake, setup: goose } = setup(); + const listed = await goose.providers(); + expect(listed.providers.map((item) => item.id)).toEqual([ + "anthropic", + "databricks", + "ollama", + ]); + expect(listed.defaults).toEqual({ provider: "ollama", model: "qwen3" }); + expect(listed.providers[0]).toMatchObject({ + name: "Anthropic", + defaultModel: "claude-sonnet-5-5", + signsIn: false, + steps: ["Create a key at console.anthropic.com"], + models: [ + { + id: "claude-opus-5-5", + label: "Claude Opus 5.5", + description: "claude-opus-5-5", + }, + { id: "claude-haiku-5-5", label: "Claude Haiku 5.5" }, + ], + }); + expect(fake.geese.every((goose) => goose.killed)).toBe(true); + await goose.providers(); + expect(fake.geese).toHaveLength(1); + }); + + it("leaves a provider that signs in to goose", async () => { + const { setup: goose } = setup(); + const databricks = (await goose.providers()).providers.find( + (item) => item.id === "databricks", + ); + expect(databricks?.signsIn).toBe(true); + expect(databricks?.keys.map((key) => key.name)).toEqual([ + "DATABRICKS_HOST", + ]); + }); + + it("lists a set-up provider's models from its API when goose has none", async () => { + const { setup: goose } = setup({ supported: ["qwen3", "llama4"] }); + expect(await goose.models("ollama")).toEqual([ + { id: "qwen3", label: "qwen3" }, + { id: "llama4", label: "llama4" }, + ]); + expect((await goose.models("anthropic")).map((item) => item.id)).toEqual([ + "claude-opus-5-5", + "claude-haiku-5-5", + ]); + expect(await goose.models("databricks")).toEqual([]); + }); + + it("saves a provider's fields into goose and reads the list again", async () => { + const saved = new Map>(); + const { fake, setup: goose } = setup({ saved }); + await goose.providers(); + expect(await goose.saved("anthropic")).toEqual(new Set()); + await goose.save("anthropic", { ANTHROPIC_API_KEY: "sk-test" }); + const request = fake.geese + .flatMap((goose) => goose.received) + .find((message) => message.method?.endsWith("config/save")); + expect(request?.params).toEqual({ + providerId: "anthropic", + fields: [{ key: "ANTHROPIC_API_KEY", value: "sk-test" }], + }); + expect(await goose.saved("anthropic")).toEqual( + new Set(["ANTHROPIC_API_KEY"]), + ); + const before = fake.geese.length; + await goose.providers(); + expect(fake.geese).toHaveLength(before + 1); + }); + + it("passes a test the provider answers OK, in chat mode with no tools", async () => { + const { fake, setup: goose } = setup({ reply: "OK." }); + expect( + await goose.test({ provider: "anthropic", model: "claude-opus-5-5" }), + ).toEqual({ ok: true, message: "Connected" }); + const [tested] = fake.geese; + expect(tested?.env).toMatchObject({ + GOOSE_MODE: "chat", + GOOSE_PROVIDER: "anthropic", + GOOSE_MODEL: "claude-opus-5-5", + }); + expect(tested?.opened[0]).toMatchObject({ + mcpServers: [], + _meta: { enabledExtensions: [] }, + }); + expect(tested?.turns.map((turn) => turn.text)).toEqual([ + "Reply with exactly: OK", + ]); + expect(tested?.closed).toHaveLength(1); + // goose would otherwise keep the test as a "New Chat" session. + expect(tested?.deleted).toEqual(tested?.closed); + expect(tested?.killed).toBe(true); + }); + + it("fails a test with what goose replied instead", async () => { + const { setup: goose } = setup({ + reply: "Authentication failed: invalid x-api-key", + }); + expect(await goose.test({ provider: "anthropic", model: "" })).toEqual({ + ok: false, + message: "Authentication failed: invalid x-api-key", + }); + }); + + it("says when goose-acp is not installed", async () => { + const spawn: Spawn = async (id, options) => { + if (id === "goose") + throw new Error( + "Could not start goose-acp: No such file or directory (os error 2)", + ); + return fakeSpawn().spawn(id, options); + }; + const goose = new GooseSetup(spawn); + await expect(goose.providers()).rejects.toThrow( + "goose-acp is not installed", + ); + expect(await goose.test({ provider: "", model: "" })).toMatchObject({ + ok: false, + message: expect.stringContaining("goose-acp is not installed"), + }); + }); +}); + +describe("keyLabel", () => { + it("names a key by what follows the provider", () => { + expect(keyLabel("ANTHROPIC_API_KEY")).toBe("API key"); + expect(keyLabel("OLLAMA_HOST")).toBe("Host"); + expect(keyLabel("AZURE_OPENAI_ENDPOINT_URL")).toBe("Openai endpoint URL"); + expect(keyLabel("TOKEN")).toBe("Token"); + }); +}); diff --git a/src/bundled/goose/setup.ts b/src/bundled/goose/setup.ts new file mode 100644 index 0000000000..c13cc35b16 --- /dev/null +++ b/src/bundled/goose/setup.ts @@ -0,0 +1,308 @@ +// goose's providers, as its own config holds them: the ones it offers, their +// models, saving a provider's key, and testing that a provider answers. Each +// call runs a short-lived `goose-acp`; keys go to goose's secret store, never +// into Buzz. +import type { ModelChoice } from "../../features/agents/ModelPicker"; +import { GooseProcess, type Spawn } from "./goose"; +import { absolute } from "./sessions"; +import type { SharedProvider } from "../../features/providers/shared"; +import { DATABRICKS_ID } from "../../features/providers/shared"; +import { gooseModels, sharedProviderEnv } from "./shared-provider"; + +export type ConfigKey = Readonly<{ + name: string; + required: boolean; + secret: boolean; + default?: string | null; +}>; + +export type Provider = Readonly<{ + id: string; + name: string; + description: string; + defaultModel: string; + configured: boolean; + /** The fields a key or host is entered in. */ + keys: readonly ConfigKey[]; + /** Signs in through goose rather than with a key entered here. */ + signsIn: boolean; + steps: readonly string[]; + models: readonly ModelChoice[]; + shared?: true; +}>; + +export type Providers = Readonly<{ + providers: readonly Provider[]; + /** What goose's config picks when an agent names no provider or model. */ + defaults: Readonly<{ provider?: string; model?: string }>; +}>; + +export type Tested = Readonly<{ ok: boolean; message: string }>; + +type Entry = { + providerId: string; + providerName: string; + description?: string; + defaultModel?: string; + configured?: boolean; + visibleInSetup?: boolean; + deprecated?: boolean; + acp?: boolean; + setupSteps?: string[]; + configKeys?: (ConfigKey & { + oauthFlow?: boolean; + deviceCodeFlow?: boolean; + })[]; + models?: { id: string; name?: string; recommended?: boolean }[]; +}; + +const TEST_PROMPT = "Reply with exactly: OK"; +const TEST_TIMEOUT_MS = 60_000; + +export class GooseSetup { + private listing: Promise | undefined; + private readonly live = new Map>(); + + constructor( + private readonly spawn: Spawn | undefined, + readonly shared?: SharedProvider, + ) {} + refresh() { + this.listing = undefined; + this.live.clear(); + } + + /** goose's providers, read once until something is saved. */ + async providers(): Promise { + this.listing ??= this.with(async (goose) => { + const [{ entries }, defaults] = (await Promise.all([ + goose.request("_goose/unstable/providers/list", { providerIds: [] }), + goose.request("_goose/unstable/defaults/read", {}), + ])) as [ + { entries: Entry[] }, + { providerId?: string | null; modelId?: string | null }, + ]; + return { + // Providers that are other agents (`claude-acp`, …) are not offered. + providers: entries + .filter( + (entry) => + entry.visibleInSetup && + !entry.deprecated && + !entry.acp && + entry.providerId !== "buzz_databricks", + ) + .map(provider) + .sort((a, b) => a.name.localeCompare(b.name)), + defaults: { + ...(defaults.providerId ? { provider: defaults.providerId } : {}), + ...(defaults.modelId ? { model: defaults.modelId } : {}), + }, + }; + }); + this.listing.catch(() => { + this.listing = undefined; + }); + const listed = await this.listing; + if (!this.shared) return listed; + const configured = await this.shared.connected().catch(() => false); + return { + ...listed, + providers: [ + { + id: this.shared.id, + name: this.shared.name, + description: "Shared connection managed by Buzz", + defaultModel: "", + configured, + keys: [], + signsIn: false, + steps: [], + models: [], + shared: true, + }, + ...listed.providers, + ], + }; + } + + /** A provider's models: goose's catalog, or else what its API lists. */ + async models(id: string): Promise { + if (id === this.shared?.id) + return (await this.shared.connected()) + ? id === DATABRICKS_ID + ? gooseModels(await this.shared.models()) + : await this.shared.models() + : []; + const listed = (await this.providers()).providers.find( + (item) => item.id === id, + ); + if (listed?.models.length || !listed?.configured) + return listed?.models ?? []; + let models = this.live.get(id); + if (!models) { + models = this.with(async (goose) => { + const { models } = (await goose.request( + "_goose/unstable/providers/supported-models/list", + { providerId: id }, + )) as { models: string[] }; + return models.map((model) => ({ id: model, label: model })); + }); + models.catch(() => this.live.delete(id)); + this.live.set(id, models); + } + return models; + } + + /** Which of a provider's fields goose already holds. */ + saved(id: string): Promise> { + return this.with(async (goose) => { + const { fields } = (await goose.request( + "_goose/unstable/providers/config/read", + { providerId: id }, + )) as { fields: { key: string; isSet: boolean }[] }; + return new Set( + fields.filter((field) => field.isSet).map((field) => field.key), + ); + }); + } + + /** Saves a provider's fields in goose's config and secret store. */ + async save(id: string, fields: Readonly>) { + await this.with((goose) => + goose.request("_goose/unstable/providers/config/save", { + providerId: id, + fields: Object.entries(fields).map(([key, value]) => ({ key, value })), + }), + ); + this.listing = undefined; + this.live.delete(id); + } + + /** Asks the provider for one short reply, without tools. goose reports a + * failed call as its reply, so only the expected answer passes. */ + test(choice: Readonly<{ provider: string; model: string }>): Promise { + const env = { + GOOSE_MODE: "chat", + ...(choice.provider ? { GOOSE_PROVIDER: choice.provider } : {}), + ...(choice.model ? { GOOSE_MODEL: choice.model } : {}), + }; + const run = (launch: Readonly>) => + briefly( + this.spawn, + async (goose, cwd) => { + const session = await goose.open({ + cwd, + systemPrompt: "", + bare: true, + }); + try { + const settled = await Promise.race([ + session.send(TEST_PROMPT), + new Promise((_, reject) => + setTimeout( + () => + reject(new Error("The provider did not answer in time")), + TEST_TIMEOUT_MS, + ), + ), + ]); + const reply = settled.text?.trim() ?? ""; + if (/^\W*ok\W*$/i.test(reply)) + return { ok: true, message: "Connected" }; + return { + ok: false, + message: reply || settled.error || "The provider sent no reply", + }; + } finally { + await goose.close(session); + // goose keeps every session; this one is not worth keeping. + await goose + .request("session/delete", { sessionId: session.id }) + .catch(() => undefined); + } + }, + launch, + ); + return sharedProviderEnv( + choice.provider, + choice.model, + Boolean(this.shared), + ) + .then((shared) => run({ ...env, ...shared })) + .catch((error: unknown) => ({ + ok: false, + message: error instanceof Error ? error.message : String(error), + })); + } + + private with( + run: (goose: GooseProcess, cwd: string) => Promise, + env: Readonly> = {}, + ): Promise { + return briefly(this.spawn, run, env); + } +} + +/** Runs `run` against a `goose-acp` started for it in the home folder, and + * stops it after. It serves no Buzz tools. */ +export async function briefly( + spawn: Spawn | undefined, + run: (goose: GooseProcess, cwd: string) => Promise, + env: Readonly> = {}, +): Promise { + if (!spawn) throw new Error("goose runs only in the desktop app"); + const cwd = await absolute(spawn, "~"); + const goose = await GooseProcess.start(spawn, { + cwd, + env, + tools: async () => { + throw new Error("No Buzz tools here"); + }, + }); + try { + return await run(goose, cwd); + } finally { + await goose.kill(); + } +} + +function provider(entry: Entry): Provider { + const keys = entry.configKeys ?? []; + const models = [...(entry.models ?? [])].sort( + (a, b) => Number(Boolean(b.recommended)) - Number(Boolean(a.recommended)), + ); + return { + id: entry.providerId, + name: entry.providerName, + description: entry.description ?? "", + defaultModel: entry.defaultModel ?? "", + configured: Boolean(entry.configured), + keys: keys + .filter((key) => !key.oauthFlow && !key.deviceCodeFlow) + .map(({ name, required, secret, default: fallback }) => ({ + name, + required, + secret, + default: fallback ?? null, + })), + signsIn: keys.some((key) => key.oauthFlow || key.deviceCodeFlow), + steps: entry.setupSteps ?? [], + models: models.map((model) => ({ + id: model.id, + label: model.name ?? model.id, + ...(model.name && model.name !== model.id + ? { description: model.id } + : {}), + })), + }; +} + +/** `ANTHROPIC_API_KEY` reads as `API key`, `OPENAI_HOST` as `Host`. */ +export function keyLabel(name: string) { + const words = name.split("_").slice(1); + const text = (words.length ? words : [name]).join(" ").toLowerCase(); + return (text.charAt(0).toUpperCase() + text.slice(1)) + .replace(/\bapi\b/i, "API") + .replace(/\burl\b/i, "URL"); +} diff --git a/src/bundled/goose/shared-provider.live.test.ts b/src/bundled/goose/shared-provider.live.test.ts new file mode 100644 index 0000000000..54dc4529a0 --- /dev/null +++ b/src/bundled/goose/shared-provider.live.test.ts @@ -0,0 +1,211 @@ +// @vitest-environment node +// Optional interoperability check against the installed Goose binary. A local +// gateway records its real tool payload, auth retry, replay, and history. It +// speaks Chat Completions; the Responses route Buzz writes is checked live. +import { spawn as spawnChild } from "node:child_process"; +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { respond } from "../../buzz-mcp/rpc"; +import { GooseProcess, type Spawn } from "./goose"; + +const BIN = process.env.GOOSE_ACP_BIN; + +describe.skipIf(!BIN)("shared-provider command auth with real Goose", () => { + it("calls Buzz tools, refreshes rejected auth in a warm session, and exports history", { + timeout: 60_000, + }, async () => { + const root = await mkdtemp(join(tmpdir(), "buzz-goose-provider-")); + const auth: string[] = []; + const payloads: Record[] = []; + const sends: unknown[] = []; + let expected = "Bearer first-access"; + let calls = 0; + const server = createServer(async (req, res) => { + if (req.method === "GET") { + res + .writeHead(200, { "Content-Type": "application/json" }) + .end( + JSON.stringify({ data: [{ id: "served-gpt", object: "model" }] }), + ); + return; + } + let raw = ""; + for await (const chunk of req) raw += chunk; + auth.push(req.headers.authorization ?? ""); + if (req.headers.authorization !== expected) { + res.writeHead(401).end('{"error":{"message":"expired"}}'); + return; + } + const data = JSON.parse(raw); + payloads.push(data); + const last = data.messages.at(-1); + const tool = last.role !== "tool"; + const delta = tool + ? { + role: "assistant", + tool_calls: [ + { + index: 0, + id: `call-${++calls}`, + type: "function", + function: { + name: "buzz__send", + arguments: JSON.stringify({ + text: "Provider fixture reply", + final: true, + }), + }, + }, + ], + } + : { role: "assistant", content: "Done." }; + const chunk = (delta: object, finish_reason: string | null) => + JSON.stringify({ + id: "completion", + object: "chat.completion.chunk", + created: 1, + model: "served-gpt", + choices: [{ index: 0, delta, finish_reason }], + }); + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.end( + `data: ${chunk(delta, null)}\n\ndata: ${chunk({}, tool ? "tool_calls" : "stop")}\n\ndata: [DONE]\n\n`, + ); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("No fixture address"); + const token = join(root, "access.txt"); + const helper = join(root, "token.mjs"); + await writeFile(token, "first-access"); + await writeFile( + helper, + 'import { readFileSync } from "node:fs"; process.stdout.write(readFileSync(process.argv[2], "utf8"));', + ); + await mkdir(join(root, "config/custom_providers"), { recursive: true }); + await writeFile( + join(root, "config/custom_providers/buzz_databricks.json"), + JSON.stringify({ + name: "buzz_databricks", + engine: "openai", + display_name: "Databricks in Buzz", + base_url: `http://127.0.0.1:${address.port}/ai-gateway/mlflow/v1/chat/completions`, + models: [{ name: "served-gpt" }], + supports_streaming: true, + dynamic_models: false, + auth: { + command: process.execPath, + args: [helper, token], + refresh_interval: 30, + timeout_seconds: 45, + }, + }), + ); + const spawn: Spawn = async (_id, options = {}) => { + const env: Record = { ...process.env }; + for (const [key, value] of Object.entries(options.env ?? {})) + if (value === null) delete env[key]; + else env[key] = value; + const child = spawnChild(BIN ?? "goose-acp", [], { + cwd: options.cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdout + .setEncoding("utf8") + .on("data", (data) => options.onStdout?.(data)); + child.stderr + .setEncoding("utf8") + .on("data", (data) => options.onStderr?.(data)); + const exited = new Promise((resolve) => + child.on("exit", resolve), + ); + return { + write: (data) => + new Promise((resolve, reject) => + child.stdin.write(data, (error) => + error ? reject(error) : resolve(), + ), + ), + end: async (data) => { + child.stdin.end(data); + }, + kill: async () => { + child.kill(); + await exited; + }, + exited, + }; + }; + let goose: GooseProcess | undefined; + try { + goose = await GooseProcess.start(spawn, { + cwd: root, + env: { + GOOSE_PATH_ROOT: root, + GOOSE_PROVIDER: "buzz_databricks", + GOOSE_MODEL: "served-gpt", + OPENAI_API_KEY: null, + DATABRICKS_TOKEN: null, + }, + tools: async (_conversation, message) => { + if (message.method !== "tools/call") + return respond(undefined, {}, message); + sends.push(message.params); + return { + jsonrpc: "2.0", + id: message.id, + result: { content: [{ type: "text", text: "Sent." }] }, + }; + }, + }); + const session = await goose.open({ + cwd: root, + systemPrompt: "Use the Buzz send tool to reply.", + }); + expect(await session.send("First turn")).toEqual({ ok: true }); + expect(sends).toHaveLength(1); + expected = "Bearer second-access"; + await writeFile(token, "second-access"); + expect(await session.send("Second turn")).toEqual({ ok: true }); + expect(sends).toHaveLength(2); + expect(auth).toContain("Bearer first-access"); + expect(auth).toContain("Bearer second-access"); + expect(payloads[0]?.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + function: expect.objectContaining({ name: "buzz__send" }), + }), + ]), + ); + // History reads must still work after stopping the agent and signing out. + const sessionId = session.id; + await goose.kill(); + await rm(token); + goose = await GooseProcess.start(spawn, { + cwd: root, + env: { GOOSE_PATH_ROOT: root, GOOSE_PROVIDER: null, GOOSE_MODEL: null }, + tools: async () => { + throw new Error("History needs no tools"); + }, + }); + const exported = (await goose.request("_goose/unstable/session/export", { + sessionId, + format: "json", + })) as { data: string }; + expect(exported.data).toContain("First turn"); + expect(exported.data).toContain("Second turn"); + } finally { + await goose?.kill(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/bundled/goose/shared-provider.test.ts b/src/bundled/goose/shared-provider.test.ts new file mode 100644 index 0000000000..4c9aa2ca21 --- /dev/null +++ b/src/bundled/goose/shared-provider.test.ts @@ -0,0 +1,125 @@ +import { expect, it, vi } from "vitest"; +import { + DATABRICKS_ID, + type SharedProvider, +} from "../../features/providers/shared"; +import { GooseSetup } from "./setup"; +import { fakeSpawn } from "./goose-testing"; +import { sharedProviderEnv } from "./shared-provider"; + +const invoke = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); + +it("uses the shared catalog and launch credentials without saving them in goose", async () => { + let connected = false; + const shared: SharedProvider = { + id: DATABRICKS_ID, + name: "Databricks in Buzz", + connected: async () => connected, + models: async () => [ + { + id: "databricks-gpt-6-sol", + label: "Team model", + apis: ["mlflow/v1/responses"], + family: "gpt", + }, + { + id: "databricks-gemini-3-5-flash", + label: "Gemini", + apis: ["mlflow/v1/responses"], + family: "gemini", + }, + ], + connection: async () => ({ + host: "https://workspace.example", + command: "buzz", + args: [], + }), + Connection: () => null, + }; + const fake = fakeSpawn({ + reply: "OK", + providers: [ + { + providerId: "openrouter", + providerName: "OpenRouter", + visibleInSetup: true, + configured: true, + }, + ], + }); + const setup = new GooseSetup(fake.spawn, shared); + expect( + (await setup.providers()).providers.find((p) => p.id === DATABRICKS_ID) + ?.configured, + ).toBe(false); + expect(await setup.models(DATABRICKS_ID)).toEqual([]); + connected = true; + setup.refresh(); + expect((await setup.providers()).providers.map((p) => p.id)).toEqual([ + DATABRICKS_ID, + "openrouter", + ]); + expect(await setup.models(DATABRICKS_ID)).toMatchObject([ + { id: "databricks-gpt-6-sol", label: "Team model" }, + ]); + invoke.mockResolvedValue({ + GOOSE_PROVIDER: "buzz_databricks", + GOOSE_MODEL: "databricks-gpt-6-sol", + }); + expect( + await setup.test({ + provider: DATABRICKS_ID, + model: "databricks-gpt-6-sol", + }), + ).toMatchObject({ ok: true }); + expect(invoke).toHaveBeenCalledWith("databricks_provider", { + operation: "goose", + input: { model: "databricks-gpt-6-sol" }, + }); + expect(fake.geese.at(-1)?.env).toMatchObject({ + GOOSE_PROVIDER: "buzz_databricks", + GOOSE_MODEL: "databricks-gpt-6-sol", + DATABRICKS_TOKEN: null, + }); + expect( + fake.geese + .flatMap((g) => g.received) + .some((r) => r.method === "_goose/unstable/providers/config/save"), + ).toBe(false); + await expect( + sharedProviderEnv(DATABRICKS_ID, "databricks-gpt-6-sol", false), + ).rejects.toThrow("Enable the Databricks provider plugin"); +}); + +it("keeps other Goose providers usable when the shared connection cannot be read", async () => { + const shared: SharedProvider = { + id: DATABRICKS_ID, + name: "Databricks in Buzz", + connected: async () => { + throw new Error("Sign in again"); + }, + models: async () => [], + connection: async () => ({ + host: "https://workspace.example", + command: "buzz", + args: [], + }), + Connection: () => null, + }; + const fake = fakeSpawn({ + providers: [ + { + providerId: "openrouter", + providerName: "OpenRouter", + visibleInSetup: true, + configured: true, + }, + ], + }); + const result = await new GooseSetup(fake.spawn, shared).providers(); + expect(result.providers).toMatchObject([ + { id: DATABRICKS_ID, configured: false }, + { id: "openrouter", configured: true }, + ]); +}); diff --git a/src/bundled/goose/shared-provider.ts b/src/bundled/goose/shared-provider.ts new file mode 100644 index 0000000000..293a402224 --- /dev/null +++ b/src/bundled/goose/shared-provider.ts @@ -0,0 +1,49 @@ +// Goose maps a shared connection to its own command-auth provider. Request +// formatting and streaming remain in Goose, and no refresh token leaves Buzz. +import { invoke } from "@tauri-apps/api/core"; +import { + DATABRICKS_ID, + type ProviderModel, +} from "../../features/providers/shared"; + +/** Served models verified with Goose's tools over the Responses route, in + * picker order. Others in the catalog are not yet known to work. */ +const GOOSE_MODELS = [ + "databricks-gpt-6-1-sol", + "databricks-gpt-6-sol", + "databricks-gpt-6-luna", + "databricks-gpt-6-astra", + "databricks-claude-opus-5-5", + "databricks-claude-sonnet-5-5", + "databricks-claude-haiku-5-5", + "databricks-glm-5-3", + "databricks-grok-4-7", +]; +export const gooseModels = (models: readonly ProviderModel[]) => + GOOSE_MODELS.flatMap((id) => + models.filter( + (model) => model.id === id && model.apis.includes("mlflow/v1/responses"), + ), + ); + +export async function sharedProviderEnv( + provider: string, + model: string, + available = true, +) { + if (provider !== DATABRICKS_ID) return {}; + if (!available) + throw new Error("Enable the Databricks provider plugin first"); + if (!model.trim()) throw new Error("Choose a Databricks model first"); + const prepared = await invoke>("databricks_provider", { + operation: "goose", + input: { model }, + }); + return { + DATABRICKS_TOKEN: null, + DATABRICKS_HOST: null, + OPENAI_API_KEY: null, + ANTHROPIC_API_KEY: null, + ...prepared, + }; +} diff --git a/src/bundled/goose/tabs.test.tsx b/src/bundled/goose/tabs.test.tsx new file mode 100644 index 0000000000..cc51fec4d8 --- /dev/null +++ b/src/bundled/goose/tabs.test.tsx @@ -0,0 +1,447 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + DATABRICKS_ID, + type SharedProvider, +} from "../../features/providers/shared"; +import type { Agent } from "../../features/agents/service"; +import { Button } from "../../shared/design-system/ui/Button"; +import { fakeSpawn } from "./goose-testing"; +import { type Config, DEFAULT_CONFIG, type GooseRuntime } from "./runtime"; +import { GooseSetup } from "./setup"; +import { createTabs } from "./tabs"; +import { GooseHistory } from "./transcript"; + +/** New agent's fields read no sessions. */ +const unused = { + runtime: {} as GooseRuntime, + history: new GooseHistory(undefined), +}; + +vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => true })); + +beforeEach(() => { + // jsdom has no layout observers; the model picker sizes itself with one. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const PROVIDERS = [ + { + providerId: "anthropic", + providerName: "Anthropic", + description: "Claude models", + defaultModel: "claude-sonnet-5-5", + visibleInSetup: true, + configKeys: [{ name: "ANTHROPIC_API_KEY", required: true, secret: true }], + models: [{ id: "claude-opus-5-5", name: "Claude Opus 5.5" }], + }, + { + providerId: "databricks", + providerName: "Databricks", + configured: true, + visibleInSetup: true, + // As goose lists it: both fields entered, the token optional. + configKeys: [ + { name: "DATABRICKS_HOST", required: true, secret: false }, + { name: "DATABRICKS_TOKEN", required: false, secret: true }, + ], + }, +]; + +function created( + options: Readonly<{ + reply?: string; + spawn?: (fake: ReturnType) => GooseSetup; + }> = {}, +) { + const saved = new Map>([ + ["databricks", new Set(["DATABRICKS_HOST"])], + ]); + const fake = fakeSpawn({ + providers: PROVIDERS, + defaults: { providerId: "databricks", modelId: "goose-claude" }, + saved, + reply: options.reply ?? "OK", + }); + const { Create, CreateModel } = createTabs({ + setup: options.spawn?.(fake) ?? new GooseSetup(fake.spawn), + ...unused, + }); + const changes: Config[] = []; + function Harness() { + const [config, setConfig] = useState(DEFAULT_CONFIG); + const onChange = (next: Config) => { + changes.push(next); + setConfig(next); + }; + return ( + <> + + + + ); + } + render(); + return { fake, saved, changes }; +} + +async function choose(label: string, option: string) { + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox", { name: label })); + await user.click(await screen.findByRole("option", { name: option })); +} + +/** Opens the provider dialog from the Model field. */ +async function openChoices() { + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Provider…" })); + return screen.findByRole("dialog", { name: "Provider and model" }); +} + +it("starts New agent's Model field on goose's default, with the provider kept to its dialog", async () => { + created(); + expect(screen.queryByRole("combobox", { name: "Provider" })).toBeNull(); + await waitFor(() => + expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent( + "goose default (Databricks · goose-claude)", + ), + ); + expect(screen.getByLabelText("Describe what I should do")).toBeVisible(); +}); + +it("chooses a provider in its dialog, saves its key into goose, and tests it", async () => { + const { fake, saved, changes } = created(); + const user = userEvent.setup(); + const dialog = await openChoices(); + await waitFor(() => + expect( + within(dialog).getByRole("combobox", { name: "Provider" }), + ).toHaveTextContent("From goose config (Databricks)"), + ); + // A provider goose has set up offers to change its fields, not empty ones. + expect( + within(dialog).getByText("Databricks is set up in goose."), + ).toBeVisible(); + + await choose("Provider", "Anthropic"); + const key = await within(dialog).findByLabelText("API key"); + expect(key).toHaveAttribute("type", "password"); + await user.type(key, "sk-test"); + await user.click( + within(dialog).getByRole("button", { name: "Save to goose" }), + ); + await waitFor(() => + expect( + within(dialog).getByText("Anthropic is set up in goose."), + ).toBeVisible(), + ); + expect(saved.get("anthropic")).toEqual(new Set(["ANTHROPIC_API_KEY"])); + + await choose("Model", "Claude Opus 5.5"); + await user.click( + within(dialog).getByRole("button", { name: "Test connection" }), + ); + expect(await within(dialog).findByRole("status")).toHaveTextContent( + "Connected", + ); + expect(fake.geese.at(-1)?.env).toMatchObject({ + GOOSE_PROVIDER: "anthropic", + GOOSE_MODEL: "claude-opus-5-5", + }); + // Nothing reaches the agent until the choice is used. + expect(changes).toEqual([]); + await user.click(within(dialog).getByRole("button", { name: "Use" })); + expect(changes.at(-1)).toMatchObject({ + provider: "anthropic", + model: "claude-opus-5-5", + }); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + await waitFor(() => + expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent( + "Claude Opus 5.5", + ), + ); +}); + +it("shows what the provider said when the test fails", async () => { + created({ reply: "Authentication failed: invalid x-api-key" }); + const dialog = await openChoices(); + await waitFor(() => + expect( + within(dialog).getByRole("combobox", { name: "Provider" }), + ).toBeEnabled(), + ); + await userEvent + .setup() + .click(within(dialog).getByRole("button", { name: "Test connection" })); + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + "Authentication failed: invalid x-api-key", + ); +}); + +it("says when goose-acp is not installed, and tries again", async () => { + let installed = false; + created({ + spawn: (fake) => + new GooseSetup(async (id, options) => { + if (id === "goose" && !installed) + throw new Error("No such file or directory (os error 2)"); + return fake.spawn(id, options); + }), + }); + const dialog = await openChoices(); + expect( + await within(dialog).findByText(/goose-acp is not installed/), + ).toBeInTheDocument(); + installed = true; + await userEvent + .setup() + .click(within(dialog).getByRole("button", { name: "Try again" })); + await waitFor(() => + expect( + within(dialog).queryByText(/goose-acp is not installed/), + ).not.toBeInTheDocument(), + ); +}); + +it("lists the agent's goose sessions and shows one's transcript", async () => { + const agent = "c".repeat(64); + const context = [ + "", + "Scope: thread", + "Session scope: thread", + "Channel: general (#chan)", + `Thread root: ${"1".repeat(64)}`, + `Agent: ${agent}`, + "", + "", + '', + "Content: @Gander what's up?", + "", + ].join("\n"); + const fake = fakeSpawn({ + home: "/home/test", + history: [ + { + sessionId: "s1", + title: "Gander · #general · 11111111", + cwd: "/home/test/.buzz", + updatedAt: "2027-01-15T08:00:00Z", + conversation: [ + { + role: "user", + created: 1_800_000_000, + content: [{ type: "text", text: context }], + metadata: { userVisible: true }, + }, + { + role: "assistant", + created: 1_800_000_001, + content: [ + { + type: "toolRequest", + id: "t1", + toolCall: { + status: "success", + value: { + name: "buzz__send", + arguments: { text: "Hi from goose", final: true }, + }, + }, + }, + ], + }, + ], + }, + ], + }); + // The runtime keeps one snapshot until its sessions change. + const none: never[] = []; + const runtime = { + subscribe: () => () => {}, + sessions: () => none, + } as unknown as GooseRuntime; + const { ActivityTab } = createTabs({ + setup: new GooseSetup(fake.spawn), + runtime, + history: new GooseHistory(fake.spawn), + }); + render( + + } + />, + ); + const user = userEvent.setup(); + expect(await screen.findByText("#general · 11111111")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "View transcript" })); + expect(await screen.findByText("Hi from goose")).toBeInTheDocument(); + expect(screen.getAllByText(/@Gander what's up\?/).length).toBeGreaterThan(0); +}); + +it("loads a shared connection's catalog after login and saves its exact model route", async () => { + let connected = false; + const shared: SharedProvider = { + id: DATABRICKS_ID, + name: "Databricks in Buzz", + connected: async () => connected, + models: async () => [ + { + id: "databricks-gpt-6-sol", + label: "Team GPT", + apis: ["mlflow/v1/responses"], + family: "gpt", + }, + { + id: "databricks-gemini-3-5-flash", + label: "Gemini", + apis: ["mlflow/v1/responses"], + family: "gemini", + }, + ], + connection: async () => ({ + host: "https://workspace.example", + command: "buzz", + args: [], + }), + Connection: ({ onChange }) => ( + + ), + }; + const { fake, changes } = created({ + spawn: (fake) => new GooseSetup(fake.spawn, shared), + }); + const dialog = await openChoices(); + await waitFor(() => + expect( + within(dialog).getByRole("combobox", { name: "Provider" }), + ).toBeEnabled(), + ); + await choose("Provider", "Databricks in Buzz"); + const user = userEvent.setup(); + expect(within(dialog).getByRole("button", { name: "Use" })).toBeDisabled(); + await user.click( + within(dialog).getByRole("button", { name: "Connect fixture" }), + ); + await waitFor(() => + expect( + within(dialog).getByRole("combobox", { name: "Model" }), + ).toBeEnabled(), + ); + await user.click(within(dialog).getByRole("combobox", { name: "Model" })); + const model = await screen.findByRole("option", { name: "Team GPT" }); + expect(screen.queryByRole("option", { name: "Custom model" })).toBeNull(); + expect(screen.queryByRole("option", { name: "Gemini" })).toBeNull(); + await user.click(model); + await user.click(within(dialog).getByRole("button", { name: "Use" })); + expect(changes.at(-1)).toMatchObject({ + provider: DATABRICKS_ID, + model: "databricks-gpt-6-sol", + }); + expect( + fake.geese + .flatMap((g) => g.received) + .some((r) => r.method === "_goose/unstable/providers/config/save"), + ).toBe(false); +}); + +it("does not save a shared connection in settings until a model is chosen", async () => { + const shared: SharedProvider = { + id: DATABRICKS_ID, + name: "Databricks in Buzz", + connected: async () => true, + models: async () => [ + { + id: "databricks-gpt-6-sol", + label: "Team GPT", + apis: ["mlflow/v1/responses"], + family: "gpt", + }, + ], + connection: async () => ({ + host: "https://workspace.example", + command: "buzz", + args: [], + }), + Connection: () => null, + }; + const fake = fakeSpawn({}); + const none: never[] = []; + const { SettingsTab } = createTabs({ + setup: new GooseSetup(fake.spawn, shared), + runtime: { + subscribe: () => () => {}, + sessions: () => none, + } as unknown as GooseRuntime, + history: new GooseHistory(fake.spawn), + }); + const save = vi.fn(async () => {}); + render( + + } + save={save} + />, + ); + const user = userEvent.setup(); + const button = screen.getByRole("button", { name: "Save" }); + await user.type(screen.getByRole("textbox", { name: "Workspace" }), "x"); + expect(button).toHaveAttribute("aria-disabled", "true"); + await user.click(button); + expect(save).not.toHaveBeenCalled(); + await user.click(screen.getByRole("combobox", { name: "Model" })); + await user.click(await screen.findByRole("option", { name: "Team GPT" })); + expect(button).toHaveAttribute("aria-disabled", "false"); + await user.click(button); + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + provider: DATABRICKS_ID, + model: "databricks-gpt-6-sol", + }), + ); +}); diff --git a/src/bundled/goose/tabs.tsx b/src/bundled/goose/tabs.tsx new file mode 100644 index 0000000000..88f4c7a9a8 --- /dev/null +++ b/src/bundled/goose/tabs.tsx @@ -0,0 +1,815 @@ +// goose's New agent choices and agent settings. Providers, their keys and +// their models are goose's own: listed from it, and saved into its config. +import { useEffect, useState, useSyncExternalStore } from "react"; +import { ActivityTranscript } from "../../features/agents/ActivityTranscript"; +import { AgentInstructions } from "../../features/agents/AgentInstructions"; +import { + type ModelChoice, + ModelPicker, +} from "../../features/agents/ModelPicker"; +import { + OpenedConversation, + useOpenedSession, + useOpenTranscript, +} from "../../features/agents/OpenedConversation"; +import { CreateModelChoices } from "../../features/agents/CreateModelChoices"; +import type { + AgentCreateProps, + AgentViewProps, +} from "../../features/agents/service"; +import { PanelSubview } from "../../features/panels/PanelSubview"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Input } from "../../shared/design-system/ui/Input"; +import { Select } from "../../shared/design-system/ui/Select"; +import { + type Config, + DEFAULT_CONFIG, + type GooseRuntime, + config as readConfig, +} from "./runtime"; +import { + type GooseSetup, + keyLabel, + type Provider, + type Providers, + type Tested, +} from "./setup"; +import { + type GooseHistory, + type GooseTranscript, + type Place, + TURN_LIMIT, +} from "./transcript"; + +const message = (error: unknown) => + error instanceof Error ? error.message : String(error); + +const SCOPES = [ + { + label: "", + options: [ + { value: "thread", label: "One session per thread" }, + { value: "channel", label: "One session per channel" }, + ], + }, +]; + +type Choice = Readonly<{ provider: string; model: string }>; +type Read = + | { status: "loading"; value?: T } + | { status: "ready"; value: T } + | { status: "error"; error: string; value?: T }; + +/** `read()` as it settles, read again when `key` changes or on `retry`. A + * value already shown stays while it is read again. */ +function useRead(read: () => Promise, key: string) { + const [state, setState] = useState>({ status: "loading" }); + const [round, setRound] = useState(0); + // biome-ignore lint/correctness/useExhaustiveDependencies: `read` follows `key`. + useEffect(() => { + let current = true; + setState((prior) => + prior.value === undefined + ? { status: "loading" } + : { status: "loading", value: prior.value }, + ); + read().then( + (value) => current && setState({ status: "ready", value }), + (error: unknown) => + current && setState({ status: "error", error: message(error) }), + ); + return () => { + current = false; + }; + }, [key, round]); + return [state, () => setRound((value) => value + 1)] as const; +} + +/** A session the profile can show: one found for a place, or one goose + * listed by its title. */ +type Row = Readonly<{ + key: string; + sessionId: string; + title: string; + channelId: string; + root?: string; + at?: number; +}>; +const RECENT_LIMIT = 30; +const placeOf = (key: string): Place => { + const [channelId = key, root] = key.split("/"); + return { channelId, ...(root ? { root } : {}) }; +}; +/** Buzz titles a session `Agent · place · root`; the agent's name is the + * profile's own. */ +const titled = (name: string, title: string) => + title.startsWith(`${name} · `) ? title.slice(name.length + 3) : title; + +export function createTabs({ + setup, + runtime, + history, +}: Readonly<{ + setup: GooseSetup; + runtime: GooseRuntime; + history: GooseHistory; +}>) { + /** The provider, its key and its model, and a test that they answer. */ + function ProviderFields({ + value, + onChange, + disabled = false, + }: Readonly<{ + value: Choice; + onChange(value: Choice): void; + disabled?: boolean; + }>) { + const [listing, reload] = useRead(() => setup.providers(), "providers"); + const providers: Providers | undefined = listing.value; + const fallback = providers?.defaults.provider; + const id = value.provider || fallback || ""; + const provider = providers?.providers.find((item) => item.id === id); + const [models] = useRead( + () => (id ? setup.models(id) : Promise.resolve([] as ModelChoice[])), + `${id}:${provider?.configured ?? false}`, + ); + // A result belongs to the choice it tested. + const choice = `${value.provider}\u0000${value.model}`; + const [result, setResult] = useState<{ + choice: string; + tested: Tested | "testing"; + }>(); + const tested = result?.choice === choice ? result.tested : undefined; + const named = (key: string | undefined) => + providers?.providers.find((item) => item.id === key)?.name ?? key; + const modelDefault = value.provider + ? provider?.defaultModel + : (providers?.defaults.model ?? provider?.defaultModel); + return ( + <> + + setDraft({ ...draft, [key.name]: text }) + } + /> + + ); + })} +
+ + {error && ( +

+ {error} +

+ )} +
+ + ); + } + + /** One session's turns, or a place's across its sessions, read on open and + * on Refresh. A refresh keeps the last result until the new one arrives. */ + function TranscriptView({ + agent, + row, + working, + }: { + agent: AgentViewProps["agent"]; + row: Row; + working?: string | undefined; + }) { + const workspace = readConfig(agent.config).workspace; + const [read, setRead] = useState< + | { status: "ready"; transcript: GooseTranscript } + | { status: "missing" } + | { status: "error"; error: string } + >(); + const [loading, setLoading] = useState(true); + const [attempt, setAttempt] = useState(0); + // A turn ending rereads, so its last steps show without a Refresh. + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt is the explicit Refresh. + useEffect(() => { + const abort = new AbortController(); + setLoading(true); + history + .transcript( + { + agent: agent.pubkey, + workspace, + sessionId: row.sessionId, + ...(row.channelId ? { place: placeOf(row.key) } : {}), + ...(working ? { working } : {}), + }, + abort.signal, + ) + .then( + (transcript) => + transcript + ? ({ status: "ready", transcript } as const) + : ({ status: "missing" } as const), + (reason: unknown) => + ({ status: "error", error: message(reason) }) as const, + ) + .then((next) => { + if (abort.signal.aborted) return; + setRead(next); + setLoading(false); + }); + return () => abort.abort(); + }, [row, workspace, working, attempt]); + return ( +
+
+ +
+ {!read ? ( +

+ Reading the goose session… +

+ ) : read.status === "missing" ? ( +

+ goose has no session for this conversation on this computer. It may + have been deleted, or it ran in a different workspace. +

+ ) : read.status === "error" ? ( +

+ Could not read the goose session: {read.error} +

+ ) : ( + (working ? "working" : undefined)} + before={ + read.transcript.more && ( +

+ Showing the latest {TURN_LIMIT} turns. +

+ ) + } + /> + )} +
+ ); + } + + /** The agent's sessions: the one it was opened from, recent ones, and their + * transcripts. */ + function ActivityTab({ + agent, + conversation, + }: { + agent: AgentViewProps["agent"]; + conversation?: AgentViewProps["conversation"]; + }) { + const workspace = readConfig(agent.config).workspace; + const live = useSyncExternalStore( + (listener) => runtime.subscribe(listener), + () => runtime.sessions(agent.pubkey), + ); + const running = live.map( + (session): Row => ({ + key: session.key, + sessionId: session.sessionId ?? "", + title: "", + ...placeOf(session.key), + }), + ); + const { + session: here, + searching, + error: lookupError, + } = useOpenedSession( + running.filter((row) => row.sessionId), + conversation, + async (place, signal) => { + const found = await history.find( + agent.pubkey, + workspace, + place, + signal, + ); + return ( + found && { + ...found, + ...place, + key: place.root + ? `${place.channelId}/${place.root}` + : place.channelId, + } + ); + }, + ); + const [recent, reload] = useRead(() => { + const abort = new AbortController(); + return history.recent(agent.name, workspace, abort.signal); + }, `${agent.name}\u0000${workspace}`); + const busy = (sessionId: string) => + live.find((session) => session.sessionId === sessionId)?.busy; + const status = (sessionId: string) => { + const working = busy(sessionId); + return working === undefined ? undefined : working ? "Working" : "Idle"; + }; + const label = (row: Row) => + row.title + ? titled(agent.name, row.title) + : `${row.channelId.slice(0, 8)} · ${row.root ? `thread ${row.root.slice(0, 8)}` : "channel"}`; + const { open, show, back } = useOpenTranscript(conversation, label); + const rows = (recent.value ?? []) + .filter((row) => row.sessionId !== here?.sessionId) + .slice(0, RECENT_LIMIT) + .map((row): Row => ({ ...row, key: row.sessionId, channelId: "" })); + return ( +
+ {conversation && ( + here && show(here)} + /> + )} +
+
+

Recent sessions

+ +
+ {recent.status === "error" ? ( +

+ Could not list goose's sessions: {recent.error} +

+ ) : rows.length ? ( +
    + {rows.map((row) => ( +
  • + + {label(row)} + + {[ + status(row.sessionId), + row.at && new Date(row.at).toLocaleString(), + ] + .filter(Boolean) + .join(" · ")} + + + +
  • + ))} +
+ ) : recent.status === "ready" ? ( +

+ None. A conversation's session starts when the agent is mentioned. +

+ ) : null} +
+ {open && ( + + + + )} +
+ ); + } + + /** The instructions a new agent starts with. */ + function Create({ config, onChange, disabled }: AgentCreateProps) { + const current = readConfig(config); + return ( + onChange({ ...current, instructions })} + /> + ); + } + + /** A provider, its key and its model, chosen apart from the card. */ + function ChooseDialog({ + value, + onChange, + onClose, + }: Readonly<{ + value: Choice; + onChange(choice: Choice): void; + onClose(): void; + }>) { + const [draft, setDraft] = useState({ + provider: value.provider, + model: value.model, + }); + return ( + { + if (!open) onClose(); + }} + title="Provider and model" + description="Choose a shared Buzz connection or a provider configured in goose." + finalFocus={false} + actions={ + <> + + + + } + > +
+ +
+
+ ); + } + + /** The agent's model in New agent and on its library card: its provider's + * models, and the provider dialog to set up, test and use another. */ + function CreateModel({ + config, + onChange, + ...props + }: AgentCreateProps) { + const current = readConfig(config); + const [listing, reload] = useRead(() => setup.providers(), "providers"); + const [choosing, setChoosing] = useState(false); + const providers = listing.value; + const id = current.provider || providers?.defaults.provider || ""; + const provider = providers?.providers.find((item) => item.id === id); + const [models, retryModels] = useRead( + () => (id ? setup.models(id) : Promise.resolve([] as ModelChoice[])), + `${id}:${provider?.configured ?? false}`, + ); + const named = (key: string | undefined) => + providers?.providers.find((item) => item.id === key)?.name ?? key; + const fallback = current.provider + ? provider?.defaultModel + : [named(providers?.defaults.provider), providers?.defaults.model] + .filter(Boolean) + .join(" · "); + return ( + <> + onChange({ ...current, model })} + action={ + + } + /> + {choosing && ( + setChoosing(false)} + onChange={(choice) => { + onChange({ ...current, ...choice }); + setChoosing(false); + }} + /> + )} + + ); + } + + function SettingsTab({ agent, save, settingsHost }: AgentViewProps) { + const saved = readConfig(agent.config); + const [draft, setDraft] = useState(saved); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const configChanged = JSON.stringify(draft) !== JSON.stringify(saved); + const changed = configChanged || Boolean(settingsHost?.pendingSave); + // A shared connection has no provider default to fall back on. + const incomplete = + draft.provider === setup.shared?.id && !draft.model.trim(); + const submit = async () => { + if (incomplete) return; + setSaving(true); + setError(""); + try { + await save({ + ...draft, + model: draft.model.trim(), + workspace: draft.workspace.trim() || DEFAULT_CONFIG.workspace, + }); + } catch (reason) { + setError(message(reason)); + } finally { + setSaving(false); + } + }; + return ( +
{ + event.preventDefault(); + void submit(); + }} + > + {settingsHost?.typeControl?.(configChanged)} + setDraft({ ...draft, ...choice })} + /> + + setDraft({ ...draft, workspace })} + /> + + ({ + value: item.id, + label: item.name, + ...(item.configured ? { description: "Set up" } : {}), + })), + ], + }, + ]} + onValueChange={(provider) => onChange({ provider, model: "" })} + /> + {listing.status === "error" && ( +
+ +
+ )} + {provider?.shared && setup.shared ? ( + { + setup.refresh(); + reload(); + }} + /> + ) : ( + provider && ( + + ) + )} + onChange({ ...value, model })} + /> +
+ + {tested && tested !== "testing" && ( +

+ {tested.message} +

+ )} +
+ + ); + } + + /** A provider's API key, saved with Pi's login. One that signs in without + * a key is set up with the `pi` command. */ + function ProviderKey({ + provider, + onSaved, + }: Readonly<{ provider: Provider; onSaved(): void }>) { + const [key, setKey] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [changing, setChanging] = useState(false); + const saveKey = async () => { + setSaving(true); + setError(""); + try { + await setup.login(provider.id, key.trim()); + setKey(""); + setChanging(false); + onSaved(); + } catch (reason) { + setError(message(reason)); + } finally { + setSaving(false); + } + }; + if (provider.configured && (!provider.key || !changing)) + return ( +
+

+ {provider.name} is set up in Pi. +

+ {provider.key && ( + + )} +
+ ); + if (!provider.key) + return ( +

+ {provider.name} signs in through Pi. Run pi, then{" "} + /login, to set it up, then choose it here. +

+ ); + return ( + <> + + { + if (event.key !== "Enter" || event.nativeEvent.isComposing) + return; + // The key's own save, not the settings form's. + event.preventDefault(); + if (key.trim()) void saveKey(); + }} + /> + +
+ + {error && ( +

+ {error} +

+ )} +
+ + ); + } + + /** The instructions a new agent starts with. */ + function Create({ config, onChange, disabled }: AgentCreateProps) { + const current = readConfig(config); + return ( + onChange({ ...current, instructions })} + /> + ); + } + + /** A provider, its key and its model, chosen apart from the card. */ + function ChooseDialog({ + value, + onChange, + onClose, + }: Readonly<{ + value: Choice; + onChange(choice: Choice): void; + onClose(): void; + }>) { + const [draft, setDraft] = useState({ + provider: value.provider, + model: value.model, + }); + const [listing, reload] = useRead(() => setup.providers(), "providers"); + return ( + { + if (!open) onClose(); + }} + title="Provider and model" + description="Pi's own settings are the default. Keys are saved in Pi, for every Pi agent and the pi command." + actions={ + <> + + + + } + > +
+ +
+
+ ); + } + + /** The agent's model in New agent and on its library card: its provider's + * models, and the provider dialog to set up, test and use another. */ + function CreateModel({ + config, + onChange, + ...props + }: AgentCreateProps) { + const current = readConfig(config); + const [listing, reload] = useRead(() => setup.providers(), "providers"); + const [choosing, setChoosing] = useState(false); + const providers = listing.value; + const id = current.provider || providers?.defaults.provider || ""; + const [models, retryModels] = useRead( + () => (id ? setup.models(id) : Promise.resolve([] as PiModel[])), + id, + ); + const unknown = unknownProvider(current, providers); + const fallback = defaultModel(current, providers?.defaults); + const named = providers?.providers.find((item) => item.id === id)?.name; + return ( + <> + onChange({ ...current, model })} + action={ + + } + /> + {choosing && ( + setChoosing(false)} + onChange={(choice) => { + onChange({ ...current, ...choice }); + setChoosing(false); + }} + /> + )} + + ); + } + + /** One conversation's turns, read on open, on Refresh, and when a turn + * there ends. A refresh keeps the last result until the new one arrives. */ + function TranscriptView({ + agent, + place, + working, + }: { + agent: AgentViewProps["agent"]; + place: Place; + working: boolean; + }) { + const [read, refresh] = useRead( + (signal) => runtime.transcript(agent.pubkey, place, working, signal), + `${agent.pubkey}\u0000${place.key}\u0000${place.conversation}\u0000${working}`, + ); + return ( +
+
+ +
+ {read.status === "error" ? ( +

+ Could not read the Pi conversation: {read.error} +

+ ) : !read.value ? ( +

+ Reading the Pi conversation… +

+ ) : ( + (working ? "working" : undefined)} + before={ + read.value.more && ( +

+ Showing the latest {TURN_LIMIT} turns. +

+ ) + } + /> + )} +
+ ); + } + + /** The Pi conversation of the place the profile was opened from, and the + * agent's other recent ones, each with its transcript. Pi's storage is + * shared by every Buzz build here, so the list is the whole history. */ + function ActivityTab({ + agent, + conversation, + }: { + agent: AgentViewProps["agent"]; + conversation?: AgentViewProps["conversation"]; + }) { + const working = useSyncExternalStore( + (listener) => runtime.subscribe(listener), + () => runtime.working(agent.pubkey), + ); + // Read again as turns start and end, which can add a conversation. + const [listing, reload] = useRead( + () => runtime.places(agent.pubkey), + `${agent.pubkey}\u0000${working.join()}`, + ); + const places = listing.value; + if (!places) + return listing.status === "error" ? ( +
+

+ Could not list Pi's conversations: {listing.error} +

+
+ +
+
+ ) : ( +

+ Reading Pi's conversations… +

+ ); + return ( + + ); + } + + function PlaceList({ + agent, + conversation, + places, + working, + }: { + agent: AgentViewProps["agent"]; + conversation?: AgentViewProps["conversation"]; + places: readonly Place[]; + working: readonly number[]; + }) { + const { session: here } = useOpenedSession( + places, + conversation, + async () => undefined, + ); + const status = (place: Place) => + working.includes(place.conversation) ? "Working" : undefined; + const { open, show, back } = useOpenTranscript(conversation, label); + const recent = places + .filter((place) => place.key !== here?.key) + .slice(0, CONVERSATION_LIMIT); + return ( +
+ {conversation && ( + here && show(here)} + /> + )} +
+

Recent conversations

+ {recent.length ? ( +
    + {recent.map((place) => ( +
  • + + {label(place)} + + {[status(place), new Date(place.at).toLocaleString()] + .filter(Boolean) + .join(" · ")} + + + +
  • + ))} +
+ ) : ( +

+ None. A conversation starts when the agent is mentioned. +

+ )} +
+ {open && ( + + + + )} +
+ ); + } + + function SettingsTab({ agent, save, settingsHost }: AgentViewProps) { + const saved = readConfig(agent.config); + const [draft, setDraft] = useState(saved); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [listing, reload] = useRead(() => setup.providers(), "providers"); + const configChanged = JSON.stringify(draft) !== JSON.stringify(saved); + const changed = configChanged || Boolean(settingsHost?.pendingSave); + const submit = async () => { + setSaving(true); + setError(""); + try { + await save({ + ...draft, + workspace: draft.workspace.trim() || DEFAULT_CONFIG.workspace, + }); + } catch (reason) { + setError(message(reason)); + } finally { + setSaving(false); + } + }; + return ( + { + event.preventDefault(); + void submit(); + }} + > + {settingsHost?.typeControl?.(configChanged)} + setDraft({ ...draft, ...choice })} + listing={listing} + reload={reload} + /> + + setDraft({ ...draft, workspace })} + /> + +