diff --git a/.github/workflows/_ci-desktop-macos.yml b/.github/workflows/_ci-desktop-macos.yml index 728f17f324..dd4c07fdf9 100644 --- a/.github/workflows/_ci-desktop-macos.yml +++ b/.github/workflows/_ci-desktop-macos.yml @@ -96,6 +96,17 @@ jobs: LLAMA_STAGE_BACKEND: metal LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal SKIPPY_LLAMA_AUTO_BUILD: "0" + - name: Test bundled Goose desktop feature + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --features bundled-goose --lib + env: + BUZZ_BUILD_BUNDLED_GOOSE_PROVIDER: databricks_v2 + BUZZ_BUILD_BUNDLED_GOOSE_MODEL: test-model + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + MACOSX_DEPLOYMENT_TARGET: "10.15" + CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" + LLAMA_STAGE_BACKEND: metal + LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal + SKIPPY_LLAMA_AUTO_BUILD: "0" results: name: Results diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 99dcc98145..6f25783a80 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -62,10 +62,8 @@ pub async fn discover_acp_providers( /// Write a user-defined harness definition to `/custom_harnesses/.json`. /// -/// Validates the definition (id regex, builtin-id collision, non-empty command -/// and label, env well-formedness) before touching the filesystem. Returns the -/// merged catalog entry so the UI can update the provider list without triggering -/// a full re-discover. +/// Validates the definition before touching the filesystem. Returns the updated +/// catalog entry so the UI can refresh without a full rediscovery. /// /// `original_id` handles the rename case: when the user edits an existing /// harness and changes its id, pass the old id here so the old file is removed @@ -163,6 +161,7 @@ pub async fn save_custom_harness( login_hint: None, source: HarnessSource::Custom, definition_env: definition.env, + configuration_defaults: Default::default(), max_parallelism: crate::managed_agents::harness_max_parallelism(&definition.command), }) } diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index f671983bbc..c71e39e987 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -41,7 +41,7 @@ pub(super) async fn run_agent_models_command( .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); if let Some(meta) = known_acp_runtime(&agent_command) { - for (key, value) in meta.default_env { + for (key, value) in meta.process_defaults() { if std::env::var(key).is_err() { cmd.env(key, value); } diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index c887b25148..bb7278a971 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -229,7 +229,6 @@ pub async fn discover_agent_models( &input.definition_env, &input.env_vars, ); - let merged_env = discovery_env_with_baked_floor(merged_env); // Recover a build-provided provider when the form has none, so the create // dialog discovers live models instead of falling through to the subprocess. let effective_provider = effective_discovery_provider( @@ -786,3 +785,7 @@ pub(super) fn normalize_agent_models( #[cfg(test)] #[path = "agent_models_tests.rs"] mod tests; + +#[cfg(all(test, feature = "bundled-goose", target_os = "macos"))] +#[path = "agent_models_goose_tests.rs"] +mod goose_tests; diff --git a/desktop/src-tauri/src/commands/agent_models_discovery_config.rs b/desktop/src-tauri/src/commands/agent_models_discovery_config.rs index e43f09495b..777ce17387 100644 --- a/desktop/src-tauri/src/commands/agent_models_discovery_config.rs +++ b/desktop/src-tauri/src/commands/agent_models_discovery_config.rs @@ -3,8 +3,7 @@ //! Two entry points, one per call shape: [`agent_model_discovery_config`] //! resolves a *saved* agent through the same descriptor/model resolvers spawn //! uses, and [`draft_agent_model_discovery_env`] derives the env for an unsaved -//! form. Both are pure so the regression tests can bind the exact values the -//! commands consume. +//! form. Both use the same Goose file-aware fallback layer as local launch. //! //! Included from `agent_models.rs` via `#[path]`, so `super::*` resolves //! against that module (the `agent_models_tests.rs` convention). @@ -108,5 +107,12 @@ pub(super) fn draft_agent_model_discovery_env( } let merged_with_def = crate::managed_agents::merged_user_env(&derived_env, &filtered_definition_env); - crate::managed_agents::merged_user_env(&merged_with_def, env_vars) + let mut env = crate::managed_agents::discovery_env_with_baked_floor( + crate::managed_agents::merged_user_env(&merged_with_def, env_vars), + ); + crate::managed_agents::config_bridge::apply_bundled_goose_defaults( + &mut env, + known_acp_runtime(agent_command), + ); + env } diff --git a/desktop/src-tauri/src/commands/agent_models_goose_tests.rs b/desktop/src-tauri/src/commands/agent_models_goose_tests.rs new file mode 100644 index 0000000000..260a9c8a64 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_goose_tests.rs @@ -0,0 +1,87 @@ +use super::*; +use std::os::unix::fs::PermissionsExt; + +#[tokio::test] +async fn bundled_goose_discovery_preserves_file_and_selected_provider() { + const CHILD: &str = "BUZZ_TEST_GOOSE_DISCOVERY_CHILD"; + if std::env::var_os(CHILD).is_none() { + let root = tempfile::tempdir().unwrap(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "commands::agent_models::goose_tests::bundled_goose_discovery_preserves_file_and_selected_provider", "--nocapture"]) + .env(CHILD, "1") + .env("GOOSE_PATH_ROOT", root.path()) + .env_remove("GOOSE_PROVIDER") + .env_remove("GOOSE_MODEL") + .status().unwrap(); + assert!(status.success()); + return; + } + let root = std::path::PathBuf::from(std::env::var_os("GOOSE_PATH_ROOT").unwrap()); + std::fs::create_dir_all(root.join("config")).unwrap(); + std::fs::write( + root.join("config/config.yaml"), + "GOOSE_PROVIDER: anthropic\nGOOSE_MODEL: file-model\n", + ) + .unwrap(); + let record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "test", "name": "test", "runtime": "goose", + "private_key_nsec": "", "relay_url": "", "auth_tag": "", + "acp_command": "buzz-acp", "agent_command": "goose", + "agent_args": [], "mcp_command": "", "turn_timeout_seconds": 300, + "parallelism": 1, "created_at": "", "updated_at": "" + })) + .unwrap(); + let saved = agent_model_discovery_config(&record, &[], &Default::default()).unwrap(); + let draft = draft_agent_model_discovery_env("goose", None, &BTreeMap::new(), &BTreeMap::new()); + for env in [&saved.env, &draft] { + assert!( + !env.contains_key("GOOSE_PROVIDER"), + "file provider must remain authoritative" + ); + assert!( + !env.contains_key("GOOSE_MODEL"), + "file model must remain authoritative" + ); + } + // Exercise the actual discovery child environment, where raw metadata used + // to reinsert build defaults after descriptor resolution. No network needed. + let probe = root.join("buzz-acp"); + std::fs::write( + &probe, + r#"#!/bin/sh +if [ "${GOOSE_PROVIDER+x}" ] || [ "${GOOSE_MODEL+x}" ]; then + echo 'discovery injected provider/model above file configuration' >&2 + exit 1 +fi +printf '%s\n' '{"unstable":{"availableModels":[{"modelId":"file-model"}]}}' +"#, + ) + .unwrap(); + std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o755)).unwrap(); + let models = run_agent_models_command(probe, saved.command, saved.args, None, saved.env) + .await + .unwrap(); + assert_eq!(models.models[0].id, "file-model"); + + std::fs::write(root.join("config/config.yaml"), "{}\n").unwrap(); + let draft = draft_agent_model_discovery_env( + "goose", + Some("anthropic"), + &BTreeMap::new(), + &BTreeMap::new(), + ); + assert_eq!( + draft.get("GOOSE_PROVIDER").map(String::as_str), + Some("anthropic") + ); + assert!(!draft.contains_key("GOOSE_MODEL")); + let defaults = known_acp_runtime("goose").unwrap().configuration_defaults(); + let draft = draft_agent_model_discovery_env("goose", None, &BTreeMap::new(), &BTreeMap::new()); + for (key, value) in defaults { + assert_eq!( + draft.get(&key), + Some(&value), + "fresh discovery uses build defaults" + ); + } +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 94c4460378..aaceb793d3 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -14,15 +14,6 @@ use crate::{ relay::relay_ws_url_with_override, }; -fn ensure_remote_harness_supported(command: &str) -> Result<(), String> { - if crate::managed_agents::known_acp_runtime(command) - .is_some_and(|rt| rt.commands == ["goose-acp"]) - { - return Err("This build includes Goose for local use only. Remote Goose deployment is not supported.".into()); - } - Ok(()) -} - /// Effective projection fields for the deploy payload — all derived from the /// resolved descriptor and effective config so that the serialised payload and /// the `launch` block are always internally consistent. @@ -74,9 +65,8 @@ fn build_launch_block_for_policy( if let Some(runtime) = runtime { policy_env.extend( runtime - .default_env - .iter() - .map(|(key, value)| ((*key).to_string(), (*value).to_string())), + .process_defaults() + .map(|(key, value)| (key.to_string(), value.to_string())), ); if runtime.mcp_hooks { policy_env.insert("MCP_HOOK_SERVERS".into(), "*".into()); @@ -221,10 +211,10 @@ pub(crate) fn build_deploy_payload( ensure_remote_provider_supported(effective.provider.value.as_deref())?; - let descriptor = - crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) - .map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?; - ensure_remote_harness_supported(&descriptor.command)?; + let descriptor = crate::managed_agents::readiness::resolve_remote_harness_descriptor( + record, &personas, &global, + ) + .map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?; let owner_pubkey = super::workspace_owner_hex(state)?; let launch = build_launch_block_for_policy( record, @@ -299,19 +289,6 @@ mod tests { use super::*; use crate::managed_agents::{readiness::EffectiveHarnessDescriptor, RespondTo, TeamRecord}; - #[test] - fn remote_goose_requires_an_external_runtime_build() { - assert!(ensure_remote_harness_supported("buzz-agent").is_ok()); - assert!(ensure_remote_harness_supported("claude-agent-acp").is_ok()); - assert_eq!( - ensure_remote_harness_supported("goose").is_err(), - cfg!(all(feature = "bundled-goose", target_os = "macos")) - ); - if cfg!(all(feature = "bundled-goose", target_os = "macos")) { - assert!(ensure_remote_harness_supported("goose-acp").is_err()); - } - } - fn record() -> ManagedAgentRecord { serde_json::from_value(serde_json::json!({ "pubkey": "abcd1234", @@ -336,6 +313,57 @@ mod tests { .unwrap() } + #[test] + fn remote_goose_preserves_external_command_and_user_configuration() { + for pin in [None, Some("goose"), Some("/opt/homebrew/bin/goose")] { + let mut record = record(); + record.runtime = Some("goose".into()); + record.agent_command_override = pin.map(str::to_string); + let resolve = |record: &ManagedAgentRecord| { + crate::managed_agents::readiness::resolve_remote_harness_descriptor( + record, + &[], + &Default::default(), + ) + .unwrap() + }; + let descriptor = resolve(&record); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner"); + assert_eq!(launch["command"], pin.unwrap_or("goose")); + assert_eq!(launch["args"], serde_json::json!(["acp"])); + for key in ["GOOSE_PROVIDER", "GOOSE_MODEL"] { + assert!(launch["env"].get(key).is_none()); + assert!(launch["policy_env"].get(key).is_none()); + } + // Preserve explicit values even when they equal the build defaults; + // removing defaults after assembly would incorrectly erase these. + let defaults = crate::managed_agents::known_acp_runtime("goose") + .unwrap() + .configuration_defaults(); + record.env_vars.insert( + "GOOSE_PROVIDER".into(), + defaults + .get("GOOSE_PROVIDER") + .cloned() + .unwrap_or("openai".into()), + ); + record.env_vars.insert( + "GOOSE_MODEL".into(), + defaults + .get("GOOSE_MODEL") + .cloned() + .unwrap_or("user-model".into()), + ); + let descriptor = resolve(&record); + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner"); + assert_eq!( + launch["env"]["GOOSE_PROVIDER"], + record.env_vars["GOOSE_PROVIDER"] + ); + assert_eq!(launch["env"]["GOOSE_MODEL"], record.env_vars["GOOSE_MODEL"]); + } + } + #[test] fn launch_block_preserves_descriptor_and_spawn_policy() { let record = record(); diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 31b5da67eb..09f00b2899 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -566,9 +566,6 @@ fn deploy_payload_for_policy( /// The shared provider fixture is the contract arbiter: it must be the exact /// richest deploy request produced by the real desktop serializers. -/// Bundled-Goose builds reject this external-Goose deployment; that guard is -/// covered by `remote_goose_requires_an_external_runtime_build`. -#[cfg(not(all(feature = "bundled-goose", target_os = "macos")))] #[test] fn deploy_payload_matches_the_shared_full_launch_fixture() { let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join( @@ -604,7 +601,7 @@ fn deploy_payload_matches_the_shared_full_launch_fixture() { "updated_at": "2026-01-01T00:00:00Z" })) .expect("fixture source record"); - let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + let descriptor = crate::managed_agents::readiness::resolve_remote_harness_descriptor( &record, &[], &crate::managed_agents::GlobalAgentConfig::default(), diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs index df677a8c5b..15f470c88f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -21,7 +21,19 @@ pub(crate) fn apply_bundled_defaults( return; } let file = read_config_file(); + let inherited_provider = std::env::var("GOOSE_PROVIDER").ok(); + let effective_provider = env + .get("GOOSE_PROVIDER") + .map(String::as_str) + .or(inherited_provider.as_deref()) + .or_else(|| file.as_ref().and_then(|f| f.provider.as_deref())) + .or_else(|| defaults.get("GOOSE_PROVIDER").map(String::as_str)); + let model_matches_provider = effective_provider.is_some() + && effective_provider == defaults.get("GOOSE_PROVIDER").map(String::as_str); for (key, value) in defaults { + if key == "GOOSE_MODEL" && !model_matches_provider { + continue; + } let file_value = match key.as_str() { "GOOSE_PROVIDER" => file.as_ref().and_then(|f| f.provider.as_deref()), "GOOSE_MODEL" => file.as_ref().and_then(|f| f.model.as_deref()), 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 cb9d35f66b..c0ed400540 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -73,6 +73,30 @@ pub(crate) fn read_config_surface( let model_overridden = session_cache.is_some_and(|c| c.model_overridden); + let provider = build_provider_field( + record, + &file_config.provider, + provider_env_var, + provider_locked, + required_fields.contains(&"provider"), + tiers, + provider_env_var + .and_then(|key| defaults.get(key)) + .map(String::as_str), + ); + // Only the bundled model is provider-scoped; existing user/file values keep + // their established precedence, even when the user changes providers. + let bundled_model = defaults.get("GOOSE_MODEL").filter(|_| { + provider + .as_ref() + .and_then(|field| field.value.as_deref()) + .is_some_and(|value| { + defaults + .get("GOOSE_PROVIDER") + .is_some_and(|expected| expected == value) + }) + }); + let normalized = NormalizedConfig { model: Some(build_model_field( record, @@ -85,21 +109,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), + bundled_model.map(String::as_str), )), - provider: build_provider_field( - record, - &file_config.provider, - provider_env_var, - provider_locked, - required_fields.contains(&"provider"), - tiers, - provider_env_var - .and_then(|key| defaults.get(key)) - .map(String::as_str), - ), + provider, mode: build_mode_field(&file_config.mode, &acp_mode, is_pre_spawn, session_cache), thinking_effort: build_thinking_field( record, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 724512c4eb..d101c4aef3 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1095,7 +1095,8 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime, force: bool) - auth_status: AuthStatus::Unknown, login_hint: None, source: HarnessSource::Builtin, - definition_env: runtime.configuration_defaults(), + definition_env: Default::default(), + configuration_defaults: runtime.configuration_defaults(), max_parallelism: super::parallelism::harness_max_parallelism(runtime.id), }, } @@ -1237,6 +1238,7 @@ pub fn discover_acp_runtimes_from( login_hint: None, source: HarnessSource::Custom, definition_env: def.env.clone(), // preserve for edit round-trip + configuration_defaults: Default::default(), max_parallelism: super::parallelism::harness_max_parallelism(&def.command), }); } 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 index 70ec156130..9e9861bd1b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/bundled_goose_tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/bundled_goose_tests.rs @@ -203,6 +203,25 @@ fn bundled_goose_file_and_env_precedence() { Some(expected_model) ); } + // A provider-only choice must not inherit the bundled provider's model. + std::fs::write(&path, "GOOSE_PROVIDER: anthropic\n").unwrap(); + let env = resolve_effective_agent_env(&record, &[], Some(&runtime), &global); + assert!(!env.env.contains_key("GOOSE_MODEL")); + let surface = read_config_surface(&record, Some(&runtime), None, &tiers, None); + assert_eq!(surface.normalized.model.unwrap().value, None); + + std::fs::write(&path, "{}\n").unwrap(); + record.provider = Some("anthropic".into()); + let env = resolve_effective_agent_env(&record, &[], Some(&runtime), &global); + assert!(!env.env.contains_key("GOOSE_MODEL")); + let surface = read_config_surface(&record, Some(&runtime), None, &tiers, None); + assert_eq!(surface.normalized.model.unwrap().value, None); + record.provider = None; + std::env::set_var("GOOSE_PROVIDER", "anthropic"); + let env = resolve_effective_agent_env(&record, &[], Some(&runtime), &global); + assert!(!env.env.contains_key("GOOSE_MODEL")); + std::env::remove_var("GOOSE_PROVIDER"); + // Existing exported settings must remain inherited by the child. std::fs::write(&path, "{}\n").unwrap(); std::env::set_var("GOOSE_PROVIDER", "exported-provider"); diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index e1c94c1d13..2d88af2b8d 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -96,6 +96,7 @@ pub(super) fn preset_catalog_entry( login_hint: None, source: HarnessSource::Preset, definition_env: Default::default(), + configuration_defaults: Default::default(), // Derived from the static preset command (`def.command`). This ensures // unavailable entries (command: null in JSON, None here) still carry // the cap — the harness cap is command-keyed, not availability-gated. diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index e9352fd033..35604b4c2d 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -616,6 +616,7 @@ fn update_time_override_preserves_pin_for_persona_less_agent() { // ── probe_codex_acp_version ─────────────────────────────────────────────────── +mod catalog; mod effort_clear; mod forced_discovery; mod managed_path_resolution; @@ -1632,19 +1633,6 @@ fn custom_catalog_entry_carries_definition_env_for_edit_roundtrip() { ); } -/// Builtin catalog defaults come from runtime metadata, not user-editable JSON. -#[test] -fn builtin_catalog_entries_expose_runtime_configuration_defaults() { - use crate::managed_agents::custom_harnesses::registry_test_lock; - let _path_guard = crate::managed_agents::lock_path_mutex(); - let _lock = registry_test_lock(); - let entries = discover_acp_runtimes_from(None, true); - for runtime in KNOWN_ACP_RUNTIMES { - let entry = entries.iter().find(|entry| entry.id == runtime.id).unwrap(); - assert_eq!(entry.definition_env, runtime.configuration_defaults()); - } -} - // ── Discovery publish via the PRODUCTION call path (stale-snapshot regression) ─ // // These drive `discover_acp_runtimes_from` itself and land a save/delete in diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/catalog.rs new file mode 100644 index 0000000000..dfffe9a53c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/catalog.rs @@ -0,0 +1,21 @@ +use super::*; + +/// Builtin catalog defaults come from runtime metadata, not user-editable JSON. +#[test] +fn builtin_catalog_entries_expose_runtime_configuration_defaults() { + use crate::managed_agents::custom_harnesses::registry_test_lock; + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _lock = registry_test_lock(); + let entries = discover_acp_runtimes_from(None, true); + for runtime in KNOWN_ACP_RUNTIMES { + let entry = entries.iter().find(|entry| entry.id == runtime.id).unwrap(); + assert!( + entry.definition_env.is_empty(), + "build fallbacks are not definition overrides" + ); + assert_eq!( + entry.configuration_defaults, + runtime.configuration_defaults() + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 636b10c012..67a3cd4f72 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -31,9 +31,9 @@ //! record's `env_vars` overrides, after reserved-key and malformed-key //! filtering. Last-wins on collision. //! -//! Runtime file configuration is read separately from process environment. -//! It is exposed for future -//! UI display only. +//! Goose file settings remain in the file, which the harness reads at startup. +//! Local bundled fallbacks fill missing values below those settings; the bundled +//! model applies only when the effective provider matches the bundled provider. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -44,12 +44,17 @@ use crate::managed_agents::{ discovery::{known_acp_runtime, KnownAcpRuntime}, env_vars::merged_user_env, global_config::GlobalAgentConfig, - normalize_agent_args, types::{AcpAvailabilityStatus, AgentDefinition, ManagedAgentRecord}, }; mod cli_login; pub(crate) mod cli_probe; +mod descriptor; + +pub(crate) use descriptor::{ + resolve_effective_harness_descriptor, resolve_remote_harness_descriptor, + EffectiveHarnessDescriptor, +}; // ── EffectiveAgentEnv ───────────────────────────────────────────────────────── @@ -75,101 +80,6 @@ pub(crate) struct EffectiveAgentEnv { pub effective_command: String, } -// ── Typed effective-harness descriptor ─────────────────────────────────────── -// -// A single owned type that fully describes what a spawn would run. Produced -// by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child, -// spawn_snapshot, build_managed_agent_summary, get_agent_models, and -// agent_readiness — so the harness-definition lookup and arg/env resolution -// happen exactly once, in one place. - -/// The complete effective description of a harness spawn: resolved command, -/// args, and layered env. This is the single source of truth for what will -/// actually run — computed once and shared across every consumer that needs -/// the effective values. -#[derive(Debug, Clone)] -pub(crate) struct EffectiveHarnessDescriptor { - /// The raw effective command string (e.g. `"buzz-agent"`, `"my-acp-agent"`). - /// Used for `known_acp_runtime` lookup and hashing. - pub command: String, - /// Normalized effective args. Instance args win when non-empty; otherwise - /// the harness definition's args apply. - pub args: Vec, - /// The full layered process env: baked floor → runtime metadata → definition - /// env → global → persona → agent. - pub env: BTreeMap, -} - -/// Resolve the complete harness descriptor from a record + context — the single -/// authoritative path for command, args, and env. -/// -/// This is the only place where harness-definition lookup and arg/env layering -/// happen; spawn, hash, summary, and both model-probe paths all consume this. -/// -/// Returns `Err("DANGLING_HARNESS_ID:")` when the record (or its linked -/// persona) references a runtime id that no longer exists in the registry — -/// the same typed error produced by `try_record_agent_command`. Callers that -/// cannot meaningfully continue with a dangling id (e.g. `spawn_agent_child`) -/// propagate the error; callers that degrade gracefully may use -/// `.unwrap_or_else(|_| …)`. -/// -/// Does NOT require an `AppHandle` so it is fully unit-testable. -/// -/// # Arguments -/// * `record` — the managed agent record -/// * `personas` — all current personas (for command/env resolution) -/// * `global` — global agent config defaults -pub(crate) fn resolve_effective_harness_descriptor( - record: &ManagedAgentRecord, - personas: &[crate::managed_agents::types::AgentDefinition], - global: &crate::managed_agents::GlobalAgentConfig, -) -> Result { - let effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; - let runtime_meta = known_acp_runtime(&effective_command); - - // Look up the harness definition once — used for both args and env. - // Resolution order: record.runtime → persona.runtime → "". - let harness_def = { - let runtime_id = record - .runtime - .as_deref() - .or_else(|| { - record.persona_id.as_deref().and_then(|pid| { - personas - .iter() - .find(|p| p.id == pid) - .and_then(|p| p.runtime.as_deref()) - }) - }) - .unwrap_or(""); - crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) - }; - - // Args: explicit non-empty instance args win; otherwise use definition args. - let args = { - let record_args = record.agent_args.clone(); - let instance_has_args = record_args.iter().any(|a| !a.trim().is_empty()); - if instance_has_args { - normalize_agent_args(&effective_command, record_args) - } else if let Some(ref def) = harness_def { - normalize_agent_args(&effective_command, def.args.clone()) - } else { - normalize_agent_args(&effective_command, record_args) - } - }; - - // Env: full layered resolution (same as resolve_effective_agent_env). - // Pass harness_def directly to avoid a second lookup. - let effective_env = - resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); - - Ok(EffectiveHarnessDescriptor { - command: effective_command, - args, - env: effective_env.env, - }) -} - /// Assemble the effective agent env from a record, personas, optional /// known-runtime metadata, and the global agent config defaults — without an /// `AppHandle` so it is fully unit-testable. @@ -205,7 +115,7 @@ pub(crate) fn resolve_effective_agent_env( crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) }; - resolve_effective_agent_env_with_def(record, personas, runtime, global, harness_def) + resolve_effective_agent_env_with_def(record, personas, runtime, global, harness_def, true) } /// Inner implementation that accepts a pre-fetched `harness_def` to avoid a @@ -217,6 +127,7 @@ fn resolve_effective_agent_env_with_def( runtime: Option<&KnownAcpRuntime>, global: &GlobalAgentConfig, harness_def: Option>, + local: bool, ) -> EffectiveAgentEnv { let effective_command = crate::managed_agents::record_agent_command(record, personas); @@ -266,7 +177,9 @@ fn resolve_effective_agent_env_with_def( ); env.extend(user_env); - super::config_bridge::apply_bundled_goose_defaults(&mut env, runtime); + if local { + super::config_bridge::apply_bundled_goose_defaults(&mut env, runtime); + } // Single harness-agnostic effort authority (PR #4625): resolve effective // effort over the canonical column AND all env tiers, emit one destination diff --git a/desktop/src-tauri/src/managed_agents/readiness/descriptor.rs b/desktop/src-tauri/src/managed_agents/readiness/descriptor.rs new file mode 100644 index 0000000000..edf8b82e31 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/descriptor.rs @@ -0,0 +1,142 @@ +//! Shared command, argument, and environment resolution for local and remote agents. + +use std::collections::BTreeMap; + +use crate::managed_agents::{ + discovery::known_acp_runtime, + global_config::GlobalAgentConfig, + normalize_agent_args, + types::{AgentDefinition, ManagedAgentRecord}, +}; + +use super::resolve_effective_agent_env_with_def; + +// ── Typed effective-harness descriptor ─────────────────────────────────────── +// +// A single owned type that fully describes what a spawn would run. Produced +// by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child, +// spawn_snapshot, build_managed_agent_summary, get_agent_models, and +// agent_readiness — so the harness-definition lookup and arg/env resolution +// happen exactly once, in one place. + +/// The complete effective description of a harness spawn: resolved command, +/// args, and layered env. This is the single source of truth for what will +/// actually run — computed once and shared across every consumer that needs +/// the effective values. +#[derive(Debug, Clone)] +pub(crate) struct EffectiveHarnessDescriptor { + /// The raw effective command string (e.g. `"buzz-agent"`, `"my-acp-agent"`). + /// Used for `known_acp_runtime` lookup and hashing. + pub command: String, + /// Normalized effective args. Instance args win when non-empty; otherwise + /// the harness definition's args apply. + pub args: Vec, + /// The full layered process env: baked floor → runtime metadata → definition + /// env → global → persona → agent. + pub env: BTreeMap, +} + +/// Resolve the complete harness descriptor from a record + context — the single +/// authoritative path for command, args, and env. +/// +/// This is the only place where harness-definition lookup and arg/env layering +/// happen; spawn, hash, summary, and both model-probe paths all consume this. +/// +/// Returns `Err("DANGLING_HARNESS_ID:")` when the record (or its linked +/// persona) references a runtime id that no longer exists in the registry — +/// the same typed error produced by `try_record_agent_command`. Callers that +/// cannot meaningfully continue with a dangling id (e.g. `spawn_agent_child`) +/// propagate the error; callers that degrade gracefully may use +/// `.unwrap_or_else(|_| …)`. +/// +/// Does NOT require an `AppHandle` so it is fully unit-testable. +/// +/// # Arguments +/// * `record` — the managed agent record +/// * `personas` — all current personas (for command/env resolution) +/// * `global` — global agent config defaults +pub(crate) fn resolve_effective_harness_descriptor( + record: &ManagedAgentRecord, + personas: &[crate::managed_agents::types::AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> Result { + resolve_harness_descriptor(record, personas, global, true) +} + +/// Resolve a provider deployment without local bundled executables or defaults. +/// Remote providers retain the external Goose CLI wire contract. +pub(crate) fn resolve_remote_harness_descriptor( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> Result { + resolve_harness_descriptor(record, personas, global, false) +} + +fn resolve_harness_descriptor( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + global: &GlobalAgentConfig, + local: bool, +) -> Result { + let mut effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; + let remote_goose = !local + && cfg!(all(feature = "bundled-goose", target_os = "macos")) + && matches!(effective_command.as_str(), "goose" | "goose-acp"); + let runtime_meta = known_acp_runtime(&effective_command); + + // Look up the harness definition once — used for both args and env. + // Resolution order: record.runtime → persona.runtime → "". + let harness_def = { + let runtime_id = record + .runtime + .as_deref() + .or_else(|| { + record.persona_id.as_deref().and_then(|pid| { + personas + .iter() + .find(|p| p.id == pid) + .and_then(|p| p.runtime.as_deref()) + }) + }) + .unwrap_or(""); + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) + }; + + // Args: explicit non-empty instance args win; otherwise use definition args. + let mut args = { + let record_args = record.agent_args.clone(); + let instance_has_args = record_args.iter().any(|a| !a.trim().is_empty()); + if instance_has_args { + normalize_agent_args(&effective_command, record_args) + } else if let Some(ref def) = harness_def { + normalize_agent_args(&effective_command, def.args.clone()) + } else { + normalize_agent_args(&effective_command, record_args) + } + }; + + if remote_goose { + effective_command = "goose".into(); + if args.is_empty() { + args.push("acp".into()); + } + } + + // Env: full layered resolution (same as resolve_effective_agent_env). + // Pass harness_def directly to avoid a second lookup. + let effective_env = resolve_effective_agent_env_with_def( + record, + personas, + runtime_meta, + global, + harness_def, + local, + ); + + Ok(EffectiveHarnessDescriptor { + command: effective_command, + args, + env: effective_env.env, + }) +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 75d3105d79..9d26efd8b6 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -728,6 +728,10 @@ pub struct AcpRuntimeCatalogEntry { /// Absent for builtin/preset entries. Skipped when empty in serialization. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub definition_env: BTreeMap, + /// Build-supplied provider/model fallbacks, below explicit and file settings. + /// Display metadata only; never forward these as definition env overrides. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub configuration_defaults: BTreeMap, /// Spawn-time parallelism cap; absent for uncapped harnesses. #[serde(skip_serializing_if = "Option::is_none")] pub max_parallelism: Option, diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 405139b857..e286266c74 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -442,10 +442,14 @@ entry with its existing icon. Builds without the feature use external `goose acp Buzz Agent remains the default. Existing Goose runtime selections use the bundled executable on their next local launch. Saved unpublished `goose-bundled` pilot selections deserialize to `goose` in definitions, records, and global defaults. -Bundled provider/model defaults belong to the runtime's catalog metadata and +Bundled provider/model defaults use the catalog's `configurationDefaults` field, +separate from custom harness `definitionEnv` overrides. Launch and discovery use +the same file-aware fallback layer. A bundled model applies only when the +effective provider matches the bundled provider. These defaults are below explicit structured settings, user environment settings, and existing Goose file values. `GOOSE_PROVIDER` and `GOOSE_MODEL` environment overrides win over structured values, as at launch; global settings and create-agent validation use that same precedence. File configuration and credentials follow Goose's existing configuration paths. The bundled runtime -is local only; remote images do not yet contain this executable. +is local only; remote deployments retain `goose acp` and explicit user settings +without bundled provider/model fallbacks. Absolute external Goose pins stay intact. diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 73b98683ee..b62f364c60 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -279,7 +279,7 @@ export function AgentConfigFields({ const gooseDefaults = resolveGooseConfig({ env: config.env_vars, file: runtimeFileConfig, - defaults: selectedRuntime?.definitionEnv, + defaults: selectedRuntime?.configurationDefaults, }); const inheritedProvider = selectedRuntimeId === "goose" @@ -306,7 +306,12 @@ export function AgentConfigFields({ : ""; const fallbackModel = selectedRuntimeId === "goose" - ? gooseDefaults.model.value || null + ? resolveGooseConfig({ + env: config.env_vars, + provider: effectiveProvider, + file: runtimeFileConfig, + defaults: selectedRuntime?.configurationDefaults, + }).model.value || null : ((selectedRuntime?.modelEnvVar && selectedRuntime.providerEnvVar && selectedRuntime.definitionEnv?.[selectedRuntime.providerEnvVar] === diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index f58d7e9286..73888e41d7 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -404,7 +404,7 @@ export function AgentDefinitionDialog({ provider: globalConfig.provider, model: globalConfig.model, file: runtimeFileConfig, - defaults: selectedRuntime?.definitionEnv, + defaults: selectedRuntime?.configurationDefaults, }); const effectiveInheritedProvider = runtime === "goose" ? gooseInherited.provider : inheritedProviderDefault; @@ -415,7 +415,7 @@ export function AgentDefinitionDialog({ provider: provider || globalConfig.provider, model: model || globalConfig.model, file: runtimeFileConfig, - defaults: selectedRuntime?.definitionEnv, + defaults: selectedRuntime?.configurationDefaults, }); function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) { setHasUserChanges(true); @@ -578,7 +578,7 @@ export function AgentDefinitionDialog({ const providerOptions = getPersonaProviderOptions( trimmedProvider, runtime, - inheritedProviderDefault.source === "global" + effectiveInheritedProvider.source === "global" ? effectiveInheritedProvider.value : "", hideProviderIds, diff --git a/desktop/src/features/agents/ui/gooseConfigDefaults.test.mjs b/desktop/src/features/agents/ui/gooseConfigDefaults.test.mjs index 667b9bedab..8adbf136cd 100644 --- a/desktop/src/features/agents/ui/gooseConfigDefaults.test.mjs +++ b/desktop/src/features/agents/ui/gooseConfigDefaults.test.mjs @@ -76,3 +76,15 @@ test("Goose falls back independently for missing fields", () => { assert.equal(resolveGooseConfig({}).provider.value, ""); assert.equal(resolveGooseConfig({}).model.value, ""); }); + +for (const input of [ + { provider: "anthropic" }, + { file: { provider: "anthropic" } }, + { env: { GOOSE_PROVIDER: "anthropic" } }, +]) { + test(`Goose does not inherit a model for another provider: ${JSON.stringify(input)}`, () => { + const result = resolveGooseConfig({ defaults, ...input }); + assert.equal(result.provider.value, "anthropic"); + assert.deepEqual(result.model, { value: "", source: null }); + }); +} diff --git a/desktop/src/features/agents/ui/gooseConfigDefaults.ts b/desktop/src/features/agents/ui/gooseConfigDefaults.ts index bc219ae86e..8f18f39a65 100644 --- a/desktop/src/features/agents/ui/gooseConfigDefaults.ts +++ b/desktop/src/features/agents/ui/gooseConfigDefaults.ts @@ -18,12 +18,16 @@ export function resolveGooseConfig({ key: string, structured?: string | null, fromFile?: string | null, + useBuildDefault = true, ): InheritedDefault => { const candidates: InheritedDefault[] = [ { value: env[key]?.trim() ?? "", source: "environment" }, { value: structured?.trim() ?? "", source: "global" }, { value: fromFile?.trim() ?? "", source: "file" }, - { value: defaults[key]?.trim() ?? "", source: "build" }, + { + value: useBuildDefault ? (defaults[key]?.trim() ?? "") : "", + source: "build", + }, ]; return ( candidates.find(({ value }) => value.length > 0) ?? { @@ -32,8 +36,15 @@ export function resolveGooseConfig({ } ); }; + const resolvedProvider = resolve("GOOSE_PROVIDER", provider, file?.provider); return { - provider: resolve("GOOSE_PROVIDER", provider, file?.provider), - model: resolve("GOOSE_MODEL", model, file?.model), + provider: resolvedProvider, + model: resolve( + "GOOSE_MODEL", + model, + file?.model, + !!resolvedProvider.value && + resolvedProvider.value === defaults.GOOSE_PROVIDER?.trim(), + ), }; } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 34101dc7c5..788e47a2ac 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -194,6 +194,7 @@ export type RawAcpRuntimeCatalogEntry = { source: "builtin" | "preset" | "custom"; /** Definition-level env vars for `source: custom` entries; absent for builtin/preset. */ definition_env?: Record; + configuration_defaults?: Record; max_parallelism?: number; effort_canonical_values?: string[] | null; }; @@ -660,6 +661,7 @@ export function fromRawAcpRuntimeCatalogEntry( loginHint: entry.login_hint ?? null, source: entry.source, definitionEnv: entry.definition_env ?? {}, + configurationDefaults: entry.configuration_defaults ?? {}, effortCanonicalValues: entry.effort_canonical_values ?? null, ...(entry.max_parallelism !== undefined && { maxParallelism: entry.max_parallelism, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 334735d150..1f8abda2f9 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -531,6 +531,8 @@ export type AcpRuntimeCatalogEntry = { * builtin/preset entries. */ definitionEnv?: Record; + /** Build fallbacks below explicit configuration and harness file settings. */ + configurationDefaults?: Record; /** Spawn-time parallelism cap; absent for uncapped harnesses. */ maxParallelism?: number; }; diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 6e03b959f2..742403bc51 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from "@playwright/test"; -import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; import { passThroughBackupStep } from "../helpers/onboarding"; @@ -190,14 +189,7 @@ 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"), - ).toHaveCount(0); 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); @@ -1233,7 +1225,7 @@ test("baked build config keeps Finish enabled without manual provider setup", as await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); }); -test("bundled Goose preserves its defaults after model discovery", async ({ +test("bundled Goose preserves defaults and selects a compatible model after provider change", async ({ page, }) => { await installMockBridge( @@ -1251,7 +1243,7 @@ test("bundled Goose preserves its defaults after model discovery", async ({ requires_external_cli: false, provider_env_var: "GOOSE_PROVIDER", model_env_var: "GOOSE_MODEL", - definition_env: { + configuration_defaults: { GOOSE_PROVIDER: "databricks_v2", GOOSE_MODEL: "bundled-pilot-model", }, @@ -1292,6 +1284,17 @@ test("bundled Goose preserves its defaults after model discovery", async ({ await expect(page.getByTestId("global-agent-model")).toContainText( "bundled-pilot-model", ); + await page.getByTestId("global-agent-provider").click(); + await page.getByTestId("global-agent-provider-option-anthropic").click(); + await page + .getByRole("textbox", { name: "Anthropic API Key" }) + .fill("test-anthropic-key"); + await expect(page.getByTestId("global-agent-model")).toContainText( + "Discovered Model", + ); + await expect(page.getByTestId("global-agent-model")).not.toContainText( + "bundled-pilot-model", + ); }); function bundledGooseRuntime() { @@ -1304,7 +1307,7 @@ function bundledGooseRuntime() { requires_external_cli: false, provider_env_var: "GOOSE_PROVIDER", model_env_var: "GOOSE_MODEL", - definition_env: { + configuration_defaults: { GOOSE_PROVIDER: "databricks_v2", GOOSE_MODEL: "bundled-model", }, diff --git a/docs/bundled-goose.md b/docs/bundled-goose.md index c017b35498..1de3f55433 100644 --- a/docs/bundled-goose.md +++ b/docs/bundled-goose.md @@ -4,7 +4,8 @@ Internal macOS builds may enable the desktop `bundled-goose` Cargo feature. This makes the single **Goose** (`goose`) runtime use the pinned executable included with Buzz. Buzz Agent remains the default. Existing Goose agents use the bundled executable on their next launch; builds without this feature keep -using the external Goose CLI. The bundled runtime supports local agents only. +using the external Goose CLI. Remote deployment retains the external `goose acp` +command and user configuration; it receives no bundled provider/model defaults. Saved `goose-bundled` pilot selections load as `goose`. ## Build and package @@ -36,7 +37,8 @@ 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, user environment values, and existing Goose file settings take -precedence. `GOOSE_PROVIDER` and `GOOSE_MODEL` environment overrides are +precedence. The bundled model applies only when the effective provider matches +the bundled provider. `GOOSE_PROVIDER` and `GOOSE_MODEL` environment overrides are honored in launch, settings display, and create-agent validation. OSS builds don't enable the feature or require the additional artifact. The internal pipeline must select a Buzz desktop tag containing this support. @@ -48,6 +50,10 @@ installed apps use the executable beside Buzz, never an external PATH match. Both use Goose's existing configuration and credential locations; incompatible extensions in an existing Goose config can still cause startup errors. +To keep using an external Goose in a bundled build, add a custom harness with +an absolute executable path (for example `/opt/homebrew/bin/goose`) and `acp` +as its argument. Bare `goose` and `goose-acp` commands select the bundled binary. + ## Qualification - Run `just ci` and Tauri tests with `--features bundled-goose`.