Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions crates/buzz-acp/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 20 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,7 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
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,
}
}
Expand Down Expand Up @@ -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::<String>::new()
);
assert_eq!(
normalize_agent_args("/app/goose-acp", vec!["acp".into()]),
Vec::<String>::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"]);
Expand Down
78 changes: 58 additions & 20 deletions crates/buzz-acp/src/git_runtime_tests.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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];
Expand All @@ -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| {
Expand All @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions desktop/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/commands/agent_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<RuntimeFileConfigSubset>, 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
Expand Down
26 changes: 26 additions & 0 deletions desktop/src-tauri/src/commands/agent_config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
);
}
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/commands/agents_deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ pub(crate) fn build_deploy_payload<R: tauri::Runtime>(
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,
Expand Down
17 changes: 15 additions & 2 deletions desktop/src-tauri/src/managed_agents/config_bridge/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -272,7 +281,7 @@ fn mcp_config_file_path_for_runtime(
claude_config_dir: Option<&std::path::Path>,
) -> Option<String> {
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
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -474,6 +485,7 @@ fn build_provider_field(
provider_locked: bool,
is_required: bool,
tiers: &InheritedConfigTiers,
runtime_default: Option<&str>,
) -> Option<NormalizedField> {
if provider_locked {
return Some(NormalizedField {
Expand Down Expand Up @@ -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),
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -815,7 +816,8 @@ fn missing_optional_provider_stays_hidden() {
Some("GOOSE_PROVIDER"),
false,
false,
&no_tiers()
&no_tiers(),
None,
)
.is_none());
}
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/managed_agents/custom_harnesses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = &'static str> {
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())
}
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading