From 4f77fd2dcd2087bb4a92b303788eb748e2a8c652 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Wed, 23 Sep 2026 20:21:25 -0400 Subject: [PATCH 1/2] feat(desktop): add opt-in bundled Goose runtime Signed-off-by: Salman Mohammed --- Justfile | 4 + crates/buzz-acp/TESTING.md | 5 + crates/buzz-acp/src/config.rs | 21 +++- crates/buzz-acp/src/git_runtime_tests.rs | 78 ++++++++---- desktop/src-tauri/Cargo.toml | 2 + desktop/src-tauri/build.rs | 17 +++ .../src-tauri/src/commands/agent_config.rs | 4 +- .../src/commands/agent_config_tests.rs | 26 ++++ .../src-tauri/src/commands/agents_deploy.rs | 5 + .../managed_agents/config_bridge/reader.rs | 17 ++- .../config_bridge/reader_tests.rs | 4 +- .../src/managed_agents/custom_harnesses.rs | 4 +- .../src-tauri/src/managed_agents/discovery.rs | 36 +++++- .../discovery/bundled_goose_tests.rs | 113 ++++++++++++++++++ .../src/managed_agents/discovery/catalog.rs | 97 +++++++++------ .../discovery/runtime_metadata.rs | 13 ++ .../src/managed_agents/discovery/tests.rs | 2 +- .../src-tauri/src/managed_agents/readiness.rs | 12 +- desktop/src/features/agents/AGENTS.md | 11 ++ .../features/agents/ui/AgentConfigFields.tsx | 27 ++++- .../agents/ui/agentConfigOptions.test.mjs | 9 ++ .../features/agents/ui/agentConfigOptions.tsx | 9 +- .../features/onboarding/ui/agentReadiness.ts | 6 +- .../onboarding/ui/harnessConnectionOptions.ts | 3 +- .../ui/onboardingRuntimeSelection.ts | 1 + .../e2e/onboarding-agent-defaults.spec.ts | 73 ++++++++++- docs/bundled-goose.md | 66 ++++++++++ scripts/build-bundled-goose.sh | 59 +++++++++ scripts/goose-build.json | 6 + scripts/verify-bundled-goose.sh | 15 +++ 30 files changed, 661 insertions(+), 84 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/discovery/bundled_goose_tests.rs create mode 100644 docs/bundled-goose.md create mode 100755 scripts/build-bundled-goose.sh create mode 100644 scripts/goose-build.json create mode 100755 scripts/verify-bundled-goose.sh diff --git a/Justfile b/Justfile index ecaf6e15f56..4174b166091 100644 --- a/Justfile +++ b/Justfile @@ -1201,3 +1201,7 @@ benchmark-check: # Stop the benchmark Docker stack (state and channels are kept) benchmark-down: docker compose --project-name buzz-benchmark down + +# Opt-in internal macOS runtime; OSS sidecar builds remain unchanged. +bundled-goose: + ./scripts/build-bundled-goose.sh diff --git a/crates/buzz-acp/TESTING.md b/crates/buzz-acp/TESTING.md index 528bac21384..251194dbf4e 100644 --- a/crates/buzz-acp/TESTING.md +++ b/crates/buzz-acp/TESTING.md @@ -59,3 +59,8 @@ Goose and uses its provider to invoke the native developer shell. Both operate only on temporary local repositories, verify commit/tag signatures and identity, check unrelated-remote credential scoping, and assert keyfile removal. They do not replace authenticated relay clone/push/readback testing. + +For the bundled Goose pilot, set `BUZZ_TEST_GOOSE_ACP` to the absolute staged +`goose-acp` executable when running `git_runtime_tests`. The test then starts +ACP directly instead of invoking the installed `goose acp`. See +[`docs/bundled-goose.md`](../../docs/bundled-goose.md) for packaged-app checks. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index b4d27903c62..d3e0edc6407 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -791,7 +791,7 @@ fn default_agent_args(command: &str) -> Option> { match normalize_agent_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" - | "claudecode" | "buzz-agent" => Some(Vec::new()), + | "claudecode" | "buzz-agent" | "goose-acp" => Some(Vec::new()), _ => None, } } @@ -1642,6 +1642,25 @@ mod tests { assert!(!f.require_mention); } + #[test] + fn bundled_goose_starts_acp_directly() { + assert_eq!( + normalize_agent_args("goose-acp", vec![]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("/app/goose-acp", vec!["acp".into()]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args( + "goose-acp", + vec!["--with-builtin".into(), "developer".into()] + ), + vec!["--with-builtin", "developer"] + ); + } + #[test] fn normalizes_goose_args_to_acp() { assert_eq!(normalize_agent_args("goose", Vec::new()), vec!["acp"]); diff --git a/crates/buzz-acp/src/git_runtime_tests.rs b/crates/buzz-acp/src/git_runtime_tests.rs index 4d5a89be361..9d3e7d649ec 100644 --- a/crates/buzz-acp/src/git_runtime_tests.rs +++ b/crates/buzz-acp/src/git_runtime_tests.rs @@ -1,5 +1,5 @@ //! Opt-in real runtime checks. Uses real ACP, MCP and Git processes, with a -//! deterministic local model for buzz-agent and the installed Goose provider. +//! deterministic local model for buzz-agent / pinned Goose, or an installed Goose provider. use super::*; use std::path::PathBuf; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -8,7 +8,8 @@ async fn scripted_model(command: String) -> (String, tokio::task::JoinHandle<()> let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let task = tokio::spawn(async move { - for round in 0..4 { + let mut round = 0; + for _ in 0..8 { let (mut socket, _) = listener.accept().await.unwrap(); let mut request = Vec::new(); let mut buf = [0; 8192]; @@ -22,6 +23,12 @@ async fn scripted_model(command: String) -> (String, tokio::task::JoinHandle<()> } }; let headers = String::from_utf8_lossy(&request[..headers_end]); + if headers.starts_with("GET ") { + let body = r#"{"object":"list","data":[{"id":"probe","object":"model","owned_by":"test"}]}"#; + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + socket.write_all(response.as_bytes()).await.unwrap(); + continue; + } let length: usize = headers .lines() .find_map(|line| { @@ -38,17 +45,14 @@ async fn scripted_model(command: String) -> (String, tokio::task::JoinHandle<()> } let request: serde_json::Value = serde_json::from_slice(&request[headers_end..]).unwrap(); - let (message, reason) = if round == 0 { - let name = request["tools"] - .as_array() - .unwrap() - .iter() - .find_map(|tool| { - tool["function"]["name"] - .as_str() - .filter(|name| name.ends_with("__shell")) - }) - .unwrap(); + let shell = request["tools"].as_array().and_then(|tools| { + tools.iter().find_map(|tool| { + tool["function"]["name"] + .as_str() + .filter(|name| *name == "shell" || name.ends_with("__shell")) + }) + }); + let (message, reason) = if let Some(name) = shell.filter(|_| round == 0) { ( serde_json::json!({"role":"assistant", "content":null,"tool_calls":[{"id":"git-probe","type":"function","function":{"name":name,"arguments":serde_json::json!({"command":command}).to_string()}}]}), "tool_calls", @@ -59,9 +63,26 @@ async fn scripted_model(command: String) -> (String, tokio::task::JoinHandle<()> "stop", ) }; - let body = serde_json::json!({"id":"probe","object":"chat.completion","model":"probe","choices":[{"index":0,"message":message,"finish_reason":reason}]}).to_string(); - let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()); + let (content_type, body) = if request["stream"].as_bool() == Some(true) { + let mut delta = message; + if let Some(calls) = delta.get_mut("tool_calls").and_then(|v| v.as_array_mut()) { + for (index, call) in calls.iter_mut().enumerate() { + call["index"] = index.into(); + } + } + let chunk = serde_json::json!({"id":"probe","object":"chat.completion.chunk","model":"probe","choices":[{"index":0,"delta":delta,"finish_reason":reason}]}); + ( + "text/event-stream", + format!("data: {chunk}\n\ndata: [DONE]\n\n"), + ) + } else { + ("application/json", serde_json::json!({"id":"probe","object":"chat.completion","model":"probe","choices":[{"index":0,"message":message,"finish_reason":reason}]}).to_string()) + }; + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()); socket.write_all(response.as_bytes()).await.unwrap(); + if shell.is_some() { + round += 1; + } } }); (url, task) @@ -117,10 +138,27 @@ printf 'passed' > ../result config .persona_env_vars .push(("GOOSE_MODE".into(), "auto".into())); - ( - "goose".into(), - vec!["acp".into(), "--with-builtin".into(), "developer".into()], - ) + match std::env::var("BUZZ_TEST_GOOSE_ACP") { + Ok(binary) => { + // The pinned sidecar uses a local scripted provider, never operator credentials. + config.persona_env_vars.extend([ + ("GOOSE_PROVIDER".into(), "openai".into()), + ("GOOSE_MODEL".into(), "probe".into()), + ("OPENAI_HOST".into(), url), + ("OPENAI_API_KEY".into(), "test".into()), + ( + "GOOSE_PATH_ROOT".into(), + temp.path().join("goose").to_string_lossy().into_owned(), + ), + ("GOOSE_DISABLE_KEYRING".into(), "true".into()), + ]); + (binary, vec!["--with-builtin".into(), "developer".into()]) + } + Err(_) => ( + "goose".into(), + vec!["acp".into(), "--with-builtin".into(), "developer".into()], + ), + } } else { config.persona_env_vars.extend([ ("BUZZ_AGENT_PROVIDER".into(), "openai".into()), @@ -176,7 +214,7 @@ async fn real_buzz_agent_git_shell() { } #[tokio::test] -#[ignore = "requires installed/configured Goose and built buzz-acp; BUZZ_TEST_BIN_DIR"] +#[ignore = "requires built buzz-acp and installed Goose or BUZZ_TEST_GOOSE_ACP; BUZZ_TEST_BIN_DIR"] async fn real_goose_native_git_shell() { check_runtime(true).await; } diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 91f8cecaf50..97cab2907d5 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -23,6 +23,8 @@ crate-type = ["staticlib", "cdylib", "rlib"] [features] default = ["system-keyring"] +# Internal macOS pilot; packaging must also include the pinned goose-acp sidecar. +bundled-goose = [] mesh-llm = ["dep:iroh", "dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime", "dep:mesh-llm-client", "dep:mesh-llm-node", "dep:mesh-llm-system", "dep:mesh-llm-events"] # OS keyring backing for desktop secret storage (nsec private keys). When # disabled, secrets fall back to 0o600 files. On by default for real builds. diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 8b0e63f12bc..b1b141b6492 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -63,6 +63,23 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUZZ_AGENT_MODEL={model}"); } + for key in ["PROVIDER", "MODEL"] { + let input = format!("BUZZ_BUILD_BUNDLED_GOOSE_{key}"); + println!("cargo:rerun-if-env-changed={input}"); + if let Ok(value) = std::env::var(&input) { + assert!( + !value.trim().is_empty() && !value.contains(['\n', '\r']), + "invalid {input}" + ); + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUNDLED_GOOSE_{key}={value}"); + } + } + assert_eq!( + std::env::var_os("BUZZ_BUILD_BUNDLED_GOOSE_PROVIDER").is_some(), + std::env::var_os("BUZZ_BUILD_BUNDLED_GOOSE_MODEL").is_some(), + "bundled Goose provider and model defaults must be supplied together" + ); + // Generic KEY=VALUE pairs to inject into every spawned agent process. // Newline-delimited; each line must be non-empty and contain exactly one // `=` separator with a non-empty key. OSS builds leave this unset. diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 16b4c93b3e6..09aaf68d848 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -154,13 +154,13 @@ fn resolve_config_surface( /// "Set in goose config" instead of surfacing a false required-field marker. /// /// Returns `null` when the runtime has no config file or it cannot be parsed. -/// Currently only "goose" is supported; other runtimes return `null`. +/// Both Goose runtimes share file configuration; other runtimes return `null`. #[tauri::command] pub async fn get_runtime_file_config( runtime_id: String, ) -> Result, String> { tokio::task::spawn_blocking(move || match runtime_id.as_str() { - "goose" => { + "goose" | "goose-bundled" => { let cfg = read_goose_file_config()?; let satisfied_env_keys = cfg .extra diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 7d41f005214..93e02bd94b3 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -771,3 +771,29 @@ fn live_switch_null_models_parses_to_no_current_model() { ); assert!(available.is_empty()); } + +#[cfg(all(feature = "bundled-goose", target_os = "macos"))] +#[test] +fn bundled_goose_command_surface_keeps_explicit_model() { + let mut record = agent_record(); + record.runtime = Some("goose-bundled".into()); + record.persona_id = None; + record.model = Some("chosen-model".into()); + record.provider = Some("anthropic".into()); + let surface = resolve_config_surface( + record, + &[], + crate::managed_agents::known_acp_runtime("goose-bundled"), + None, + &GlobalAgentConfig::default(), + None, + ); + assert_eq!( + surface.normalized.model.unwrap().value.as_deref(), + Some("chosen-model") + ); + assert_eq!( + surface.normalized.provider.unwrap().value.as_deref(), + Some("anthropic") + ); +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index cccc23c7085..23a8ba0b33d 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -215,6 +215,11 @@ pub(crate) fn build_deploy_payload( let descriptor = crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) .map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?; + if crate::managed_agents::known_acp_runtime(&descriptor.command) + .is_some_and(|rt| rt.id == "goose-bundled") + { + return Err("Goose (bundled) is available only on this Mac. Select Goose for a separately configured remote runtime.".into()); + } let owner_pubkey = super::workspace_owner_hex(state)?; let launch = build_launch_block_for_policy( record, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 84eec8db33a..e8791570f6b 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -24,12 +24,15 @@ pub(crate) fn read_config_surface( claude_config_dir: Option<&std::path::Path>, ) -> RuntimeConfigSurface { let is_pre_spawn = session_cache.is_none(); + let defaults = runtime_meta + .map(|m| m.configuration_defaults()) + .unwrap_or_default(); // Tier 2b: config file values. let (file_config, file_was_read) = runtime_meta .map(|m| m.id) .and_then(|id| match id { - "goose" => super::goose::read_config_file().map(|c| (c, true)), + "goose" | "goose-bundled" => super::goose::read_config_file().map(|c| (c, true)), "claude" => super::claude::read_config_file(claude_config_dir).map(|c| (c, true)), "codex" => super::codex::read_config_file().map(|c| (c, true)), "buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)), @@ -82,6 +85,9 @@ pub(crate) fn read_config_surface( required_fields.contains(&"model"), model_overridden, tiers, + model_env_var + .and_then(|key| defaults.get(key)) + .map(String::as_str), )), provider: build_provider_field( record, @@ -90,6 +96,9 @@ pub(crate) fn read_config_surface( provider_locked, required_fields.contains(&"provider"), tiers, + provider_env_var + .and_then(|key| defaults.get(key)) + .map(String::as_str), ), mode: build_mode_field(&file_config.mode, &acp_mode, is_pre_spawn, session_cache), thinking_effort: build_thinking_field( @@ -272,7 +281,7 @@ fn mcp_config_file_path_for_runtime( claude_config_dir: Option<&std::path::Path>, ) -> Option { match runtime.id { - "goose" => { + "goose" | "goose-bundled" => { super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) } // #3493: the claude 2.1.x binary resolves .claude.json as @@ -326,6 +335,7 @@ fn build_model_field( is_required: bool, model_overridden: bool, tiers: &InheritedConfigTiers, + runtime_default: Option<&str>, ) -> NormalizedField { let [rec_env, pers_env, glob_env, def_env] = model_env_var .map(|k| { @@ -356,6 +366,7 @@ fn build_model_field( (struct_record, ConfigOrigin::BuzzExplicit), (struct_persona, ConfigOrigin::PersonaDefault), (struct_global, ConfigOrigin::GlobalDefault), + (runtime_default, ConfigOrigin::HarnessDefault), (file_model.as_deref(), ConfigOrigin::ConfigFile), ]; // "Configured" = any non-file candidate. The file entry is always last, so @@ -474,6 +485,7 @@ fn build_provider_field( provider_locked: bool, is_required: bool, tiers: &InheritedConfigTiers, + runtime_default: Option<&str>, ) -> Option { if provider_locked { return Some(NormalizedField { @@ -514,6 +526,7 @@ fn build_provider_field( tiers.global_provider.as_deref(), ConfigOrigin::GlobalDefault, ), + (runtime_default, ConfigOrigin::HarnessDefault), (file_provider.as_deref(), ConfigOrigin::ConfigFile), ]; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index b5ef9a0045e..425dfb49e8f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -798,6 +798,7 @@ fn missing_required_provider_still_returns_dropdown_field() { false, true, &no_tiers(), + None, ) .expect("required provider field should be surfaced even when empty"); @@ -815,7 +816,8 @@ fn missing_optional_provider_stays_hidden() { Some("GOOSE_PROVIDER"), false, false, - &no_tiers() + &no_tiers(), + None, ) .is_none()); } diff --git a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs index ba0448beaff..f79e961865a 100644 --- a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs +++ b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs @@ -220,7 +220,7 @@ pub(crate) fn validate_harness_definition_pub(def: &HarnessDefinition) -> Result /// tier-1 runtimes — no hand-maintained copy. Adding a preset to /// `PRESET_HARNESSES` automatically reserves its ID without a separate edit. fn builtin_ids() -> impl Iterator { - const TIER1: &[&str] = &["goose", "claude", "codex", "buzz-agent"]; + const TIER1: &[&str] = &["goose", "goose-bundled", "claude", "codex", "buzz-agent"]; let tier2 = crate::managed_agents::discovery::preset_harness_ids(); TIER1.iter().copied().chain(tier2.iter().copied()) } @@ -537,7 +537,7 @@ mod tests { #[test] fn builtin_ids_are_rejected() { // Tier-1 hard-coded IDs must always be reserved. - for id in &["goose", "claude", "codex", "buzz-agent"] { + for id in &["goose", "goose-bundled", "claude", "codex", "buzz-agent"] { assert!(check_id_collision(id).is_err(), "{id} should be rejected"); } // Tier-2 preset IDs must also be reserved (derived from PRESET_HARNESSES). diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index e4b87e7557a..e195945b3a8 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -323,7 +323,7 @@ fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" - | "claudecode" | "buzz-agent" => Some(Vec::new()), + | "claudecode" | "buzz-agent" | "goose-acp" => Some(Vec::new()), _ => None, } } @@ -412,6 +412,27 @@ fn resolve_workspace_command(command: &str) -> Option { .find(|candidate| is_executable_file(candidate)) } +// A bundled selection must never silently resolve an unrelated PATH installation. +fn resolve_bundled_goose() -> Option { + let executable = std::env::current_exe().ok()?; + let bundled = executable.parent()?.join("goose-acp"); + if is_executable_file(&bundled) { + return Some(bundled); + } + // Source-tree runs use the same target-suffixed artifact staged for Tauri. + if cfg!(debug_assertions) { + let target = if cfg!(target_arch = "aarch64") { + "aarch64-apple-darwin" + } else { + "x86_64-apple-darwin" + }; + let staged = + workspace_root_dir().join(format!("desktop/src-tauri/binaries/goose-acp-{target}")); + return is_executable_file(&staged).then_some(staged); + } + None +} + fn resolve_cache() -> &'static std::sync::Mutex>> { use std::collections::HashMap; @@ -424,6 +445,10 @@ fn resolve_cache() -> &'static std::sync::Mutex Option { + if cfg!(all(feature = "bundled-goose", target_os = "macos")) && command == "goose-acp" { + return resolve_bundled_goose(); + } + if let Some(managed) = resolve_buzz_managed_command(command) { return Some(managed); } @@ -461,6 +486,10 @@ pub fn resolve_command(command: &str) -> Option { /// freeze the cheap path exists to avoid. `resolve_command` (the forced path) /// is the sole prober and cache populator. pub fn resolve_command_cached(command: &str) -> Option { + if cfg!(all(feature = "bundled-goose", target_os = "macos")) && command == "goose-acp" { + return resolve_bundled_goose(); + } + if let Some(managed) = resolve_buzz_managed_command(command) { return Some(managed); } @@ -1057,7 +1086,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime, force: bool) - auth_status: AuthStatus::Unknown, login_hint: None, source: HarnessSource::Builtin, - definition_env: Default::default(), + definition_env: runtime.configuration_defaults(), max_parallelism: super::parallelism::harness_max_parallelism(runtime.id), }, } @@ -1256,3 +1285,6 @@ pub fn managed_agent_avatar_url(command: &str) -> Option { #[cfg(test)] mod tests; + +#[cfg(all(test, feature = "bundled-goose", target_os = "macos"))] +mod bundled_goose_tests; diff --git a/desktop/src-tauri/src/managed_agents/discovery/bundled_goose_tests.rs b/desktop/src-tauri/src/managed_agents/discovery/bundled_goose_tests.rs new file mode 100644 index 00000000000..c6bcdc1e4af --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/bundled_goose_tests.rs @@ -0,0 +1,113 @@ +use super::tests::record_with; +use super::*; + +#[test] +fn bundled_goose_preserves_external_goose_and_default() { + let bundled = known_acp_runtime("goose-acp").unwrap(); + assert_eq!(bundled.id, "goose-bundled"); + assert_eq!(bundled.label, "Goose (bundled)"); + assert_eq!(bundled.underlying_cli, None); + assert_eq!(bundled.mcp_command, None); + assert_eq!(bundled.model_env_var, Some("GOOSE_MODEL")); + assert_eq!(bundled.provider_env_var, Some("GOOSE_PROVIDER")); + assert!(bundled.cli_install_commands.is_empty()); + assert_eq!( + normalize_agent_args("goose-acp", vec!["acp".into()]), + Vec::::new() + ); + assert_eq!(normalize_agent_args("goose", vec![]), vec!["acp"]); + assert_eq!(known_acp_runtime("goose").unwrap().commands, &["goose"]); + assert_eq!(default_agent_command(), "buzz-agent"); + + let mut record = record_with(Some("goose-bundled"), None, None); + assert_eq!(record_agent_command(&record, &[]), "goose-acp"); + let default_env = crate::managed_agents::readiness::resolve_effective_agent_env( + &record, + &[], + Some(bundled), + &Default::default(), + ); + for (key, value) in bundled.configuration_defaults() { + assert_eq!(default_env.env.get(&key), Some(&value)); + } + assert!(known_acp_runtime("goose") + .unwrap() + .configuration_defaults() + .is_empty()); + record.provider = Some("anthropic".into()); + record.model = Some("explicit-model".into()); + record + .env_vars + .insert("ANTHROPIC_API_KEY".into(), "test-key".into()); + let env = crate::managed_agents::readiness::resolve_effective_agent_env( + &record, + &[], + Some(bundled), + &Default::default(), + ); + assert_eq!( + env.env.get("GOOSE_PROVIDER").map(String::as_str), + Some("anthropic") + ); + assert_eq!( + env.env.get("GOOSE_MODEL").map(String::as_str), + Some("explicit-model") + ); + assert!(crate::managed_agents::readiness::agent_readiness(&env).is_ready()); + record + .env_vars + .insert("GOOSE_MODEL".into(), "env-model".into()); + let env = crate::managed_agents::readiness::resolve_effective_agent_env( + &record, + &[], + Some(bundled), + &Default::default(), + ); + assert_eq!( + env.env.get("GOOSE_MODEL").map(String::as_str), + Some("env-model") + ); +} + +#[test] +fn bundled_goose_display_defaults_match_launch_precedence() { + use crate::managed_agents::config_bridge::{reader::read_config_surface, InheritedConfigTiers}; + let runtime = known_acp_runtime("goose-bundled").unwrap(); + let mut record = record_with(Some("goose-bundled"), None, None); + let tiers = InheritedConfigTiers::default(); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); + let defaults = runtime.configuration_defaults(); + if let Some(model) = defaults.get("GOOSE_MODEL") { + assert_eq!( + surface.normalized.model.unwrap().value.as_ref(), + Some(model) + ); + } + record.provider = Some("anthropic".into()); + record.model = Some("chosen-model".into()); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); + assert_eq!( + surface.normalized.model.unwrap().value.as_deref(), + Some("chosen-model") + ); + assert_eq!( + surface.normalized.provider.unwrap().value.as_deref(), + Some("anthropic") + ); + record.model = None; + record.provider = None; + let tiers = InheritedConfigTiers { + persona_model: Some("persona-model".into()), + persona_provider: Some("openai".into()), + ..Default::default() + }; + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); + assert_eq!( + surface.normalized.model.unwrap().value.as_deref(), + Some("persona-model") + ); + assert_eq!( + surface.normalized.provider.unwrap().value.as_deref(), + Some("openai") + ); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs index fecf792f214..ff1d78c1256 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs @@ -10,42 +10,18 @@ use super::runtime_metadata::{ use super::{BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL}; pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ + GOOSE_RUNTIME, + #[cfg(all(feature = "bundled-goose", target_os = "macos"))] KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: GOOSE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("goose"), - cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], - // Goose's stable release currently publishes only the Unix installer; - // its official Windows instructions intentionally point at this main-branch script. - cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], - adapter_install_commands: &[], - cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", - adapter_install_instructions_url: "", - cli_install_hint: "Buzz talks to Goose through the Goose CLI.", - adapter_install_hint: "", - skill_dir: Some(".goose/skills"), - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[("GOOSE_MODE", "auto")], - config_file_path: Some("~/.config/goose/config.yaml"), - config_file_format: Some("yaml"), - supports_acp_native_config: true, - thinking_env_var: Some("GOOSE_THINKING_EFFORT"), - effort_normalization: Some(&GOOSE_EFFORT_NORMALIZATION), - effort_accepted_values: None, // goose: validated via effort_normalization - max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), - context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), - max_rounds_env_var: None, - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, + id: "goose-bundled", + label: "Goose (bundled)", + commands: &["goose-acp"], + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + cli_install_hint: "Ships with the internal Buzz macOS app.", + default_env: BUNDLED_GOOSE_DEFAULT_ENV, + ..GOOSE_RUNTIME }, KnownAcpRuntime { id: "claude", @@ -154,3 +130,54 @@ pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ auth_probe_args: None, }, ]; + +const GOOSE_RUNTIME: KnownAcpRuntime = KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: GOOSE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("goose"), + cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], + // Goose's stable release currently publishes only the Unix installer; + // its official Windows instructions intentionally point at this main-branch script. + cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], + adapter_install_commands: &[], + cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz talks to Goose through the Goose CLI.", + adapter_install_hint: "", + skill_dir: Some(".goose/skills"), + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[("GOOSE_MODE", "auto")], + config_file_path: Some("~/.config/goose/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: true, + thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + effort_normalization: Some(&GOOSE_EFFORT_NORMALIZATION), + effort_accepted_values: None, // goose: validated via effort_normalization + max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), + context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + }; + +#[cfg(all(feature = "bundled-goose", target_os = "macos"))] +const BUNDLED_GOOSE_DEFAULT_ENV: &[(&str, &str)] = match ( + option_env!("BUZZ_DESKTOP_BUILD_BUNDLED_GOOSE_PROVIDER"), + option_env!("BUZZ_DESKTOP_BUILD_BUNDLED_GOOSE_MODEL"), +) { + (Some(provider), Some(model)) => &[ + ("GOOSE_MODE", "auto"), + ("GOOSE_PROVIDER", provider), + ("GOOSE_MODEL", model), + ], + _ => &[("GOOSE_MODE", "auto")], +}; diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index b68bc84c23f..1fbf9273c0e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -162,6 +162,19 @@ pub(crate) struct KnownAcpRuntime { } impl KnownAcpRuntime { + /// Build-provided settings for the bundled pilot, below explicit user choices. + /// External runtimes retain their existing configuration and environment policy. + pub(crate) fn configuration_defaults(&self) -> std::collections::BTreeMap { + if self.id != "goose-bundled" { + return Default::default(); + } + self.default_env + .iter() + .filter(|(key, _)| *key != "GOOSE_MODE") + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + } + /// Return the CLI install commands for the current platform. /// /// On Windows, returns `cli_install_commands_windows` when non-empty, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 7121151cd47..20b57c4a794 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -205,7 +205,7 @@ fn effective_agent_command_explicit_override_wins() { ); } /// Minimal record for `record_agent_command` tests; only resolution inputs vary. -fn record_with( +pub(super) fn record_with( runtime: Option<&str>, persona_id: Option<&str>, override_cmd: Option<&str>, diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 7178b20a085..bdde39ea07c 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -26,16 +26,13 @@ //! //! 1. Baked build defaults (`baked_build_env()`) — injected first so the //! layers above can override them. -//! 2. Runtime metadata env vars (`runtime_metadata_env_vars`) — provider / -//! model env keys derived from the record's `model`/`provider` fields and -//! the runtime's `model_env_var`/`provider_env_var`. +//! 2. Runtime defaults, then structured provider/model selections. //! 3. Merged user env (`merged_user_env`) — live persona env under the //! record's `env_vars` overrides, after reserved-key and malformed-key //! filtering. Last-wins on collision. //! -//! The config-file tier (Goose `~/.config/goose/config.yaml`) is tracked -//! separately because it is not part of the process env — the harness reads -//! it at startup. We do not evaluate it here; it is exposed for future +//! Runtime file configuration is read separately from process environment. +//! It is exposed for future //! UI display only. use serde::{Deserialize, Serialize}; @@ -230,6 +227,7 @@ fn resolve_effective_agent_env_with_def( super::global_config::resolve_effective_model_provider(record, personas, global); if let Some(rt) = runtime { + env.extend(rt.configuration_defaults()); for (key, value) in super::runtime::runtime_metadata_env_vars( rt.model_env_var, rt.provider_env_var, @@ -443,7 +441,7 @@ fn collect_missing_requirements( match rt.id { "buzz-agent" => buzz_agent_requirements(effective), - "goose" => { + "goose" | "goose-bundled" => { // Read the file config once at the call site so the inner fn is // pure and unit-testable by injection. let file_cfg = read_goose_file_config(); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 9d3da397307..e63621f2087 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -408,3 +408,14 @@ matches the code is worse than no rule; a new pattern that isn't written down here will be broken by the next agent that never learns it existed. Reviewers: treat a config-behavior diff without a matching AGENTS.md diff (or an explicit "no rules changed" note) as incomplete. + +## Bundled Goose pilot + +The internal macOS `bundled-goose` Cargo feature adds `goose-bundled` / "Goose +(bundled)" to the Rust catalog. It runs the pinned `goose-acp` sidecar directly; +`goose` remains the external CLI with its `acp` argument. Buzz Agent remains the +default. Do not alias or migrate existing Goose records to the bundled runtime. +Bundled provider/model defaults belong to that runtime's catalog metadata and +are below explicit structured and user environment settings. File configuration +and credentials follow Goose's existing configuration paths. The pilot is local +only; remote images do not yet contain this executable. diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index e640e84ec12..ab504c7e117 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -274,8 +274,13 @@ export function AgentConfigFields({ ]); const bakedEnvMap = Object.fromEntries(bakedEnv.map((e) => [e.key, e.value])); const bakedProvider = React.useMemo( - () => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null, - [bakedEnv], + () => + (selectedRuntime?.providerEnvVar + ? selectedRuntime.definitionEnv?.[selectedRuntime.providerEnvVar] + : undefined) ?? + bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? + null, + [bakedEnv, selectedRuntime], ); const selectedRuntimeId = selectedRuntime?.id ?? ""; const providerFieldVisible = hasRenderableAgentConfigField( @@ -285,10 +290,20 @@ export function AgentConfigFields({ const effectiveProvider = providerFieldVisible ? config.provider?.trim() || bakedProvider || "" : ""; - const fallbackModel = React.useMemo( - () => getGlobalModelFallback(bakedEnv, effectiveProvider, config.env_vars), - [bakedEnv, config.env_vars, effectiveProvider], - ); + const fallbackModel = React.useMemo(() => { + const key = selectedRuntime?.modelEnvVar; + const providerKey = selectedRuntime?.providerEnvVar; + const defaults = selectedRuntime?.definitionEnv; + if ( + key && + providerKey && + defaults?.[providerKey] === effectiveProvider && + defaults[key] + ) { + return defaults[key]; + } + return getGlobalModelFallback(bakedEnv, effectiveProvider, config.env_vars); + }, [bakedEnv, config.env_vars, effectiveProvider, selectedRuntime]); const modelField = fieldModel.fields.find( (field) => field.kind === "model" && field.render === "control", ); diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index eb24d88084a..849e47a6a61 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -7,6 +7,7 @@ import { getPersonaProviderOptions, getProviderApiKeyLabel, resetConfigForHarnessChange, + requiredCredentialEnvKeys, runtimeSupportsLlmProviderSelection, } from "./agentConfigOptions.tsx"; import { formatModelDiscoveryErrorStatus } from "./personaModelDiscoveryStatus.ts"; @@ -294,3 +295,11 @@ test("getProviderApiKeyLabel_provider_id_trimmed_and_lowercased", () => { // Mirrors getProviderApiKeyEnvVar normalisation behaviour. assert.equal(getProviderApiKeyLabel(" Anthropic "), "Anthropic API Key"); }); + +test("bundled Goose uses Goose provider credentials", () => { + assert.equal(runtimeSupportsLlmProviderSelection("goose-bundled"), true); + assert.deepEqual( + requiredCredentialEnvKeys("goose-bundled", "databricks_v2"), + requiredCredentialEnvKeys("goose", "databricks_v2"), + ); +}); diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 5c515a05073..213e380c8a8 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -186,7 +186,7 @@ export function requiredCredentialEnvKeys( provider: string, ): readonly string[] { const normalizedRuntime = runtimeId.trim(); - if (normalizedRuntime !== "buzz-agent" && normalizedRuntime !== "goose") { + if (!runtimeSupportsLlmProviderSelection(normalizedRuntime)) { return []; } const config = PROVIDER_CREDENTIAL_CONFIG[provider.trim().toLowerCase()]; @@ -201,7 +201,11 @@ export function isMissingRequiredDropdownField( } export function runtimeSupportsLlmProviderSelection(runtimeId: string) { - return runtimeId === "buzz-agent" || runtimeId === "goose"; + return ( + runtimeId === "buzz-agent" || + runtimeId === "goose" || + runtimeId === "goose-bundled" + ); } /** Clears values whose meaning or support changes with the selected harness. */ @@ -555,6 +559,7 @@ function runtimePreferenceSortRank(runtimeId: string) { case "buzz-agent": return 0; case "goose": + case "goose-bundled": return 1; default: return 2; diff --git a/desktop/src/features/onboarding/ui/agentReadiness.ts b/desktop/src/features/onboarding/ui/agentReadiness.ts index 86b9721af35..727c332a507 100644 --- a/desktop/src/features/onboarding/ui/agentReadiness.ts +++ b/desktop/src/features/onboarding/ui/agentReadiness.ts @@ -58,7 +58,11 @@ export function resolveAgentReadiness( }; } - if (preferredRuntime.id !== "buzz-agent" && preferredRuntime.id !== "goose") { + if ( + preferredRuntime.id !== "buzz-agent" && + preferredRuntime.id !== "goose" && + preferredRuntime.id !== "goose-bundled" + ) { return { ready: false }; } diff --git a/desktop/src/features/onboarding/ui/harnessConnectionOptions.ts b/desktop/src/features/onboarding/ui/harnessConnectionOptions.ts index 944620b015e..dae86d6f7e3 100644 --- a/desktop/src/features/onboarding/ui/harnessConnectionOptions.ts +++ b/desktop/src/features/onboarding/ui/harnessConnectionOptions.ts @@ -13,6 +13,7 @@ const SUBSCRIPTION_RUNTIME_IDS = new Set([ const API_RUNTIME_IDS = new Set([ "buzz-agent", "goose", + "goose-bundled", "omp", "grok", "opencode", @@ -59,7 +60,7 @@ export function orderRuntimesForConnectionMethod( const priority = (runtime: AcpRuntimeCatalogEntry) => { if (method !== "api") return 0; if (runtime.id === "buzz-agent") return 0; - if (runtime.id === "goose") return 1; + if (runtime.id === "goose" || runtime.id === "goose-bundled") return 1; return 2; }; diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 74e24d5f6e1..079e68009a2 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -4,6 +4,7 @@ export const ONBOARDING_RUNTIME_ORDER = [ "claude", "codex", "goose", + "goose-bundled", "buzz-agent", "cursor", "devin", diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 9ff2414f3bf..90f82be0d7f 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -1,9 +1,10 @@ import { expect, test } from "@playwright/test"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; import { passThroughBackupStep } from "../helpers/onboarding"; function runtime( - id: "buzz-agent" | "claude" | "codex" | "goose", + id: "buzz-agent" | "claude" | "codex" | "goose" | "goose-bundled", availability: string, authStatus: Record, overrides: Record = {}, @@ -102,6 +103,19 @@ test("setup filters the bundled harnesses by connection method", async ({ acpRuntimesCatalog: [ runtime("buzz-agent", "available", { status: "not_applicable" }), runtime("goose", "available", { status: "not_applicable" }), + runtime( + "goose-bundled", + "available", + { status: "not_applicable" }, + { + label: "Goose (bundled)", + command: "goose-acp", + requires_external_cli: false, + provider_env_var: "GOOSE_PROVIDER", + model_env_var: "GOOSE_MODEL", + can_auto_install: false, + }, + ), runtime("codex", "available", { status: "logged_in" }), runtime("claude", "available", { status: "logged_in" }), ], @@ -177,7 +191,14 @@ test("setup filters the bundled harnesses by connection method", async ({ page.getByRole("heading", { name: "Choose a harness" }), ).toBeVisible(); await expect(page.getByTestId("onboarding-runtime-goose")).toBeVisible(); + await expect( + page.getByTestId("onboarding-runtime-goose-bundled"), + ).toContainText("Goose (bundled)"); await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toBeVisible(); + await waitForAnimations(page); + await page.getByTestId("onboarding-page-2").screenshot({ + path: "test-results/screenshots/bundled-goose.png", + }); await expect(page.getByTestId("onboarding-runtime-claude")).toHaveCount(0); await expect(page.getByTestId("onboarding-runtime-codex")).toHaveCount(0); await expect(page.getByRole("checkbox")).toHaveCount(0); @@ -1212,3 +1233,53 @@ test("baked build config keeps Finish enabled without manual provider setup", as await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); }); + +test("bundled Goose displays its own provider and model defaults", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("buzz-agent", "available", { status: "not_applicable" }), + runtime( + "goose-bundled", + "available", + { status: "not_applicable" }, + { + label: "Goose (bundled)", + command: "goose-acp", + requires_external_cli: false, + provider_env_var: "GOOSE_PROVIDER", + model_env_var: "GOOSE_MODEL", + definition_env: { + GOOSE_PROVIDER: "databricks_v2", + GOOSE_MODEL: "bundled-pilot-model", + }, + can_auto_install: false, + }, + ), + ], + bakedBuildEnv: [ + { key: "BUZZ_AGENT_PROVIDER", masked: false, value: "anthropic" }, + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page, "api"); + await page.getByTestId("onboarding-use-different-harness").click(); + await page.getByTestId("onboarding-runtime-details-goose-bundled").click(); + await expect(page.getByTestId("global-agent-provider")).toContainText( + "Databricks", + ); + await expect(page.getByTestId("global-agent-model")).toContainText( + "bundled-pilot-model", + ); +}); diff --git a/docs/bundled-goose.md b/docs/bundled-goose.md new file mode 100644 index 00000000000..d0ea54a6e8b --- /dev/null +++ b/docs/bundled-goose.md @@ -0,0 +1,66 @@ +# Bundled Goose macOS pilot + +Internal macOS builds may enable the desktop `bundled-goose` Cargo feature. +This adds **Goose (bundled)** (`goose-bundled`) alongside the existing **Goose** +CLI runtime. Buzz Agent remains the default. No saved agent or persona is +migrated. The pilot supports local agents only. + +## Build and package + +Activate Hermit, then run `just bundled-goose`. The source revision, profile, +and feature list live in `scripts/goose-build.json`. The script fetches that +exact commit, activates its toolchain, builds with the upstream Cargo lockfile, +and stages `desktop/src-tauri/binaries/goose-acp-` plus a JSON provenance +manifest. It uses an isolated `.cache/bundled-goose` checkout and target cache; +no changes to an installed Goose are needed. Supported targets are Apple Silicon +and Intel macOS. The binary is replaced atomically and checked for non-system +dynamic libraries before staging. + +The lean profile includes native TLS and system-keyring so existing Goose +keychain credentials remain usable. It excludes optional bundled MCP servers, +scheduler, HTTP serving, and other extensions disabled by the upstream lean +configuration. Native developer tools and external MCP remain available. + +`squareup/buzz-releases` enables `BUZZ_BUNDLE_GOOSE=1`, builds the sidecar, adds +it to its Tauri release configuration, and enables `bundled-goose`. It supplies +`BUZZ_BUILD_BUNDLED_GOOSE_PROVIDER` and `BUZZ_BUILD_BUNDLED_GOOSE_MODEL` together. +These are defaults only for the bundled runtime; structured agent/persona/global +selections and user environment values take precedence. OSS builds don't enable +the feature or require the additional artifact. The internal pipeline must +select a Buzz desktop tag containing this support. + +For source-tree UI development, build the sidecar and start Tauri with +`--features bundled-goose`. The development resolver finds the staged artifact; +installed apps use the executable beside Buzz, never an external PATH match. +`goose-acp` takes no `acp` subcommand. External Goose continues to use `goose acp`. +Both use Goose's existing configuration and credential locations; incompatible +extensions in an existing Goose config can still cause startup errors. + +## Qualification + +- Run `just ci` and Tauri tests with `--features bundled-goose`. +- Test first launch with no Goose CLI installed and with an existing external + Goose. Both entries must be distinct and Buzz Agent must stay the default. +- Verify Databricks OAuth, model discovery, explicit provider/model/effort + changes, and restart using the exact packaged artifact. +- Mention the agent through a real relay, perform shell/file work, and verify + its reply lands in the right thread. Exercise cancel and a subsequent turn. +- Run the existing harness Git tests with `BUZZ_TEST_GOOSE_ACP` pointing to the + staged executable and `BUZZ_TEST_BIN_DIR` pointing to built Buzz binaries: + + ```sh + cargo test -p buzz-acp git_runtime_tests -- --ignored --nocapture + ``` + + With `BUZZ_TEST_GOOSE_ACP`, the Goose test uses a deterministic local provider. + The tests verify local signed commits/tags, identity, credential scoping, and + key cleanup. Also qualify + authenticated relay clone/push/readback through the installed app. +- Inspect `Contents/Resources/goose-build.json` for source/build provenance. Its + checksum is for the artifact before signing; signing changes binary bytes. + The release pipeline checks the packaged binary before and after signing. + +The pilot does not resolve upstream empty-final-response warnings after a +successful Buzz publication, shell process-tree cancellation, or unbounded +shell capture. Track these against the pinned build when collecting feedback; +shipping this option is not a default migration or a parity claim. diff --git a/scripts/build-bundled-goose.sh b/scripts/build-bundled-goose.sh new file mode 100755 index 00000000000..0d9da4082c5 --- /dev/null +++ b/scripts/build-bundled-goose.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Build the internal macOS Goose sidecar from the reviewed source pin. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" +source ./bin/activate-hermit +[[ $(uname -s) == Darwin ]] || { echo 'Bundled Goose currently supports macOS only' >&2; exit 1; } +TARGET=${1:-$(rustc -vV | sed -n 's/^host: //p')} +case "$TARGET" in aarch64-apple-darwin|x86_64-apple-darwin) ;; *) echo "Unsupported target: $TARGET" >&2; exit 1 ;; esac +PIN="$ROOT/scripts/goose-build.json" +REPOSITORY=$(node -p 'require(process.argv[1]).repository' "$PIN") +REVISION=$(node -p 'require(process.argv[1]).revision' "$PIN") +PROFILE=$(node -p 'require(process.argv[1]).profile' "$PIN") +FEATURES=$(node -p 'require(process.argv[1]).features' "$PIN") +[[ "$REVISION" =~ ^[0-9a-f]{40}$ && "$PROFILE" == lean ]] || { echo 'Invalid Goose source pin' >&2; exit 1; } +CACHE="$ROOT/.cache/bundled-goose/$REVISION" +SOURCE="$CACHE/source" +mkdir -p "$CACHE" +# Never share a mutable checkout or target directory with the developer's Goose. +if [[ ! -d "$SOURCE/.git" ]]; then + git init -q "$SOURCE" +fi +if ! git -C "$SOURCE" cat-file -e "$REVISION^{commit}" 2>/dev/null; then + git -C "$SOURCE" -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=60 fetch --depth=1 "$REPOSITORY" "$REVISION" +fi +[[ -z $(git -C "$SOURCE" status --porcelain) ]] || { echo "Dirty Goose build checkout: $SOURCE" >&2; exit 1; } +git -C "$SOURCE" checkout --detach "$REVISION" +[[ $(git -C "$SOURCE" rev-parse HEAD) == "$REVISION" ]] +( + cd "$SOURCE" + source ./bin/activate-hermit + TOOLCHAIN=$(rustc -vV | shasum -a 256 | cut -d ' ' -f 1) + export CARGO_TARGET_DIR="$CACHE/target/$TOOLCHAIN/$FEATURES" + # Avoid a runtime dependency on a build machine's Homebrew libssl/libcrypto. + export OPENSSL_STATIC=1 + cargo build --locked -p goose --bin goose-acp --profile "$PROFILE" \ + --no-default-features --features "$FEATURES" --target "$TARGET" + BINARY="$CARGO_TARGET_DIR/$TARGET/$PROFILE/goose-acp" + "$ROOT/scripts/verify-bundled-goose.sh" "$BINARY" + DEST="$ROOT/desktop/src-tauri/binaries/goose-acp-$TARGET" + mkdir -p "$(dirname "$DEST")" + # Atomic replacement keeps an already-running signed executable valid. + STAGED=$(mktemp "$DEST.XXXXXX") + trap 'rm -f "$STAGED"' EXIT + cp "$BINARY" "$STAGED" + chmod 755 "$STAGED" + mv -f "$STAGED" "$DEST" + node - "$PIN" "$DEST" "$TARGET" "$(rustc --version)" <<'JS' +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const [pin, binary, target, rustc] = process.argv.slice(2); +const manifest = { + ...JSON.parse(fs.readFileSync(pin, 'utf8')), target, rustc, + unsigned_sha256: crypto.createHash('sha256').update(fs.readFileSync(binary)).digest('hex'), +}; +fs.writeFileSync(`${binary}.json`, `${JSON.stringify(manifest, null, 2)}\n`); +JS + echo "Staged $DEST" +) diff --git a/scripts/goose-build.json b/scripts/goose-build.json new file mode 100644 index 00000000000..bc30f3d62e6 --- /dev/null +++ b/scripts/goose-build.json @@ -0,0 +1,6 @@ +{ + "repository": "https://github.com/aaif-goose/goose.git", + "revision": "6a2c3dadf6a8866aeb434f4d3c41ea58b278d6c0", + "profile": "lean", + "features": "native-tls,system-keyring" +} diff --git a/scripts/verify-bundled-goose.sh b/scripts/verify-bundled-goose.sh new file mode 100755 index 00000000000..db94ce3d37e --- /dev/null +++ b/scripts/verify-bundled-goose.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Run against both the staged binary and the signed .app executable. +set -euo pipefail +BINARY=${1:?usage: verify-bundled-goose.sh /path/to/goose-acp} +[[ -x "$BINARY" ]] || { echo "Missing executable: $BINARY" >&2; exit 1; } +# Only system dylibs are permitted: no unshipped Homebrew or @rpath libraries. +DEPENDENCIES=$(otool -L "$BINARY" | tail -n +2 | sed -E 's/^[[:space:]]*//; s/ \(compatibility version.*$//') +while IFS= read -r dependency; do + case "$dependency" in + /usr/lib/*|/System/Library/*) ;; + *) echo "Unbundled Goose dependency: $dependency" >&2; exit 1 ;; + esac +done <<< "$DEPENDENCIES" +"$BINARY" --version +"$BINARY" --help | grep -Fq 'Usage: goose-acp' From 48c13b5ac9a7fbb6654eecb34f0a50bf2f220f18 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Wed, 23 Sep 2026 22:07:51 -0400 Subject: [PATCH 2/2] Preserve model defaults after discovery Signed-off-by: Salman Mohammed --- .../src/features/agents/ui/AgentConfigFields.tsx | 4 +++- desktop/tests/e2e/onboarding-agent-defaults.spec.ts | 13 ++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index ab504c7e117..fd853932fa9 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -425,7 +425,8 @@ export function AgentConfigFields({ autoSelectedModelScopeRef.current = null; return; } - if ((config.model ?? "").trim().length > 0) return; + // Discovery fills an unset model; it must not replace an inherited default. + if ((config.model ?? "").trim() || fallbackModel?.trim()) return; if (modelDiscoveryLoading || discoveredModelOptions === null) return; const selectionScope = `${selectedRuntimeId}:${trimmedProvider}`; if (autoSelectedModelScopeRef.current === selectionScope) return; @@ -441,6 +442,7 @@ export function AgentConfigFields({ }, [ config, discoveredModelOptions, + fallbackModel, isCustomProvider, modelDiscoveryLoading, onConfigChange, diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 90f82be0d7f..1e4a88dc6dc 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -1234,7 +1234,7 @@ test("baked build config keeps Finish enabled without manual provider setup", as await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); }); -test("bundled Goose displays its own provider and model defaults", async ({ +test("bundled Goose preserves its defaults after model discovery", async ({ page, }) => { await installMockBridge( @@ -1263,6 +1263,10 @@ test("bundled Goose displays its own provider and model defaults", async ({ bakedBuildEnv: [ { key: "BUZZ_AGENT_PROVIDER", masked: false, value: "anthropic" }, ], + discoverAgentModels: { + models: [{ id: "discovered-model", name: "Discovered Model" }], + supportsSwitching: true, + }, globalAgentConfig: { env_vars: {}, provider: null, @@ -1279,6 +1283,13 @@ test("bundled Goose displays its own provider and model defaults", async ({ await expect(page.getByTestId("global-agent-provider")).toContainText( "Databricks", ); + // Wait for the competing catalog entry to render so this assertion cannot + // pass before the discovery effect has had a chance to replace the default. + await page.getByTestId("global-agent-model").click(); + await expect( + page.getByTestId("global-agent-model-option-discovered-model"), + ).toBeVisible(); + await page.keyboard.press("Escape"); await expect(page.getByTestId("global-agent-model")).toContainText( "bundled-pilot-model", );