Skip to content
Open
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
6 changes: 5 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,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" | "openclaw-acp" | "buzz-agent" => Some(Vec::new()),
_ => None,
}
}
Expand Down Expand Up @@ -1575,6 +1575,10 @@ mod tests {
normalize_agent_args("claude-agent-acp", vec!["acp".into()]),
Vec::<String>::new()
);
assert_eq!(
normalize_agent_args("openclaw-acp", vec!["acp".into()]),
Vec::<String>::new()
);
}

#[test]
Expand Down
52 changes: 39 additions & 13 deletions desktop/src-tauri/src/commands/agent_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ fn launch_terminal_auth(runtime_id: &str, method: &AcpAuthMethod) -> Result<(),
.ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?;
let fallback_command = adapter_command.1.display().to_string();
let argv = adapter_terminal_argv(runtime.label, method, &fallback_command)?;
launch_visible_terminal(&argv)
let augmented_path = auth_command_path();
launch_visible_terminal(&argv, augmented_path.as_deref())
}

fn adapter_terminal_argv(
Expand Down Expand Up @@ -361,17 +362,13 @@ fn spawn_without_stdio(mut command: Command) -> Result<(), String> {
}

#[cfg(target_os = "macos")]
fn launch_visible_terminal(argv: &[String]) -> Result<(), String> {
fn launch_visible_terminal(argv: &[String], augmented_path: Option<&str>) -> Result<(), String> {
let mut script = tempfile::Builder::new()
.prefix("buzz-auth-")
.suffix(".command")
.tempfile()
.map_err(|error| format!("failed to create terminal login script: {error}"))?;
writeln!(
script,
"#!/bin/sh\ntrap 'rm -f -- \"$0\"' EXIT\n{}",
shell_join(argv)
)
writeln!(script, "{}", terminal_shell_script(argv, augmented_path))
.map_err(|error| format!("failed to write terminal login script: {error}"))?;
fs::set_permissions(script.path(), fs::Permissions::from_mode(0o700))
.map_err(|error| format!("failed to prepare terminal login script: {error}"))?;
Expand All @@ -395,8 +392,8 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), String> {
}

#[cfg(target_os = "linux")]
fn launch_visible_terminal(argv: &[String]) -> Result<(), String> {
let command = shell_join(argv);
fn launch_visible_terminal(argv: &[String], augmented_path: Option<&str>) -> Result<(), String> {
let command = terminal_shell_command(argv, augmented_path);
let candidates: [(&str, &[&str]); 4] = [
("x-terminal-emulator", &["-e", "sh", "-lc"]),
("gnome-terminal", &["--", "sh", "-lc"]),
Expand All @@ -414,7 +411,7 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), String> {
}

#[cfg(target_os = "windows")]
fn launch_visible_terminal(argv: &[String]) -> Result<(), String> {
fn launch_visible_terminal(argv: &[String], augmented_path: Option<&str>) -> Result<(), String> {
use std::os::windows::process::CommandExt;

const CREATE_NEW_CONSOLE: u32 = 0x0000_0010;
Expand All @@ -425,6 +422,9 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), String> {
command
.args(windows_terminal_args(argv))
.creation_flags(CREATE_NEW_CONSOLE);
if let Some(path) = augmented_path {
command.env("PATH", path);
}
spawn_without_stdio(command)
}

Expand All @@ -436,10 +436,25 @@ fn windows_terminal_args(argv: &[String]) -> Vec<String> {
}

#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn launch_visible_terminal(_argv: &[String]) -> Result<(), String> {
fn launch_visible_terminal(_argv: &[String], _augmented_path: Option<&str>) -> Result<(), String> {
Err("opening a terminal is not supported on this platform".to_string())
}

fn terminal_shell_command(argv: &[String], augmented_path: Option<&str>) -> String {
match augmented_path {
Some(path) => format!("export PATH={}; exec {}", shell_escape(path), shell_join(argv)),
None => format!("exec {}", shell_join(argv)),
}
}

#[cfg(any(target_os = "macos", test))]
fn terminal_shell_script(argv: &[String], augmented_path: Option<&str>) -> String {
format!(
"#!/bin/sh\ntrap 'rm -f -- \"$0\"' EXIT\n{}",
terminal_shell_command(argv, augmented_path)
)
}

fn shell_join(argv: &[String]) -> String {
argv.iter()
.map(|arg| shell_escape(arg))
Expand All @@ -462,8 +477,8 @@ fn shell_escape(arg: &str) -> String {
mod tests {
use super::{
adapter_terminal_argv, append_inherited_path, is_claude_subscription_login,
run_buzz_acp_auth_command_with_paths, shell_escape, shell_join, uses_terminal_auth,
windows_terminal_args, AcpAuthMethod,
run_buzz_acp_auth_command_with_paths, shell_escape, shell_join, terminal_shell_script,
uses_terminal_auth, windows_terminal_args, AcpAuthMethod,
};

/// Windows regression: the augmented PATH there holds only Buzz-managed
Expand Down Expand Up @@ -571,6 +586,17 @@ mod tests {
assert_eq!(shell_escape("--claudeai"), "--claudeai");
}

#[test]
fn terminal_auth_script_exports_augmented_path() {
assert_eq!(
terminal_shell_script(
&["/managed/openclaw-acp".into(), "--configure-model".into()],
Some("/managed node/bin:/usr/bin"),
),
"#!/bin/sh\ntrap 'rm -f -- \"$0\"' EXIT\nexport PATH='/managed node/bin:/usr/bin'; exec /managed/openclaw-acp --configure-model"
);
}

#[test]
fn windows_terminal_keeps_argv_separate() {
let argv = vec![
Expand Down
46 changes: 45 additions & 1 deletion desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub(crate) use runtime_metadata::KnownAcpRuntime;
const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default";
const OPENCLAW_AVATAR_URL: &str =
"https://raw.githubusercontent.com/openclaw/openclaw/refs/heads/main/ui/public/favicon.svg";
const BUZZ_AGENT_AVATAR_URL: &str =
"https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png";
fn common_binary_paths() -> &'static [PathBuf] {
Expand Down Expand Up @@ -175,6 +177,48 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
// Verified: `codex login status` exits 0 when logged in, non-zero otherwise.
auth_probe_args: Some(&["codex", "login", "status"]),
},
KnownAcpRuntime {
id: "openclaw",
label: "OpenClaw",
commands: &["openclaw-acp"],
aliases: &[],
avatar_url: OPENCLAW_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: None,
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &["npm install -g openclaw@latest"],
cli_install_instructions_url: "https://docs.openclaw.ai/install",
adapter_install_instructions_url: "https://docs.openclaw.ai/cli/acp",
cli_install_hint: "",
adapter_install_hint:
"Install OpenClaw to add its self-contained native ACP runtime.",
skill_dir: None,
supports_acp_model_switching: false,
model_env_var: None,
provider_env_var: None,
provider_locked: false,
default_env: &[],
config_file_path: Some("~/.openclaw/openclaw.json"),
config_file_format: Some("json"),
supports_acp_native_config: false,
thinking_env_var: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,
required_normalized_fields: &[],
login_hint: Some(
"Run `openclaw-acp --configure-model` to authenticate a provider and choose a model.",
),
auth_probe_args: Some(&[
"openclaw",
"models",
"status",
"--check",
"--json",
]),
},
KnownAcpRuntime {
id: "buzz-agent",
label: "Buzz Agent",
Expand Down Expand Up @@ -460,7 +504,7 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
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" | "openclaw-acp" | "buzz-agent" => Some(Vec::new()),
_ => None,
}
}
Expand Down
16 changes: 0 additions & 16 deletions desktop/src-tauri/src/managed_agents/discovery/presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,22 +158,6 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.",
underlying_cli: None,
},
PresetHarness {
id: "openclaw",
label: "OpenClaw",
command: "openclaw",
args: &["acp"],
install_instructions_url: "https://docs.openclaw.ai/start/getting-started",
install_hint: "Buzz talks to OpenClaw through its ACP mode (openclaw acp), which relies on the OpenClaw Gateway daemon. Follow the setup guide to install both.\n\n\
⚠️ Execution-locus note: `openclaw acp` runs tools inside the \
OpenClaw Gateway daemon, not in the Desktop process. \
Desktop-injected BUZZ_* env vars are visible to the `openclaw` \
harness process itself, but do NOT automatically reach the \
Gateway's execution environment. If your tools or agent logic \
needs BUZZ_* credentials at execution time, set them on the \
Gateway's own environment separately.",
underlying_cli: None,
},
];

/// Return preset definitions for the spawn/readiness registry.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,24 @@ mod tests {
);
assert!(codex.adapter_install_instructions_url.contains("codex-acp"));
assert!(codex.cli_install_hint.contains("Codex CLI"));

let openclaw = known_acp_runtime_exact("openclaw").unwrap();
assert_eq!(openclaw.commands, &["openclaw-acp"]);
assert_eq!(
openclaw.adapter_install_commands,
&["npm install -g openclaw@latest"]
);
assert_eq!(
openclaw.auth_probe_args,
Some(
&[
"openclaw",
"models",
"status",
"--check",
"--json"
][..]
)
);
}
}
14 changes: 13 additions & 1 deletion desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use super::{
is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args,
parse_semver_tag, probe_codex_acp_version, record_agent_command, refresh_login_shell_path,
try_record_agent_command, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL,
GOOSE_AVATAR_URL,
GOOSE_AVATAR_URL, OPENCLAW_AVATAR_URL,
};
use crate::managed_agents::AcpAvailabilityStatus;

Expand Down Expand Up @@ -82,6 +82,18 @@ fn resolves_buzz_agent_avatar() {
);
}

#[test]
fn resolves_openclaw_native_runtime() {
assert_eq!(
managed_agent_avatar_url("openclaw-acp"),
Some(OPENCLAW_AVATAR_URL.to_string())
);
assert_eq!(
normalize_agent_args("openclaw-acp", vec!["acp".into()]),
Vec::<String>::new()
);
}

#[test]
fn normalizes_buzz_agent_args_to_empty() {
assert_eq!(
Expand Down
12 changes: 12 additions & 0 deletions desktop/src-tauri/src/managed_agents/readiness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ impl AgentReadiness {
/// * **claude**: a successful `claude auth status` probe.
/// * **codex**: a successful `codex login status` probe (checks the codex
/// credential store — NOT `OPENAI_API_KEY`).
/// * **openclaw**: a configured model route with usable authentication.
/// * **unknown / custom command**: always `Ready` (no requirements known).
///
/// Databricks note: `DATABRICKS_TOKEN` is `.unwrap_or_default()` in
Expand Down Expand Up @@ -441,6 +442,17 @@ fn collect_missing_requirements(
rt,
),
"codex" => cli_login::requirements(&["codex", "login", "status"], "run `codex login`", rt),
"openclaw" => cli_login::requirements(
&[
"openclaw",
"models",
"status",
"--check",
"--json",
],
"run `openclaw-acp --configure-model`",
rt,
),
_ => vec![],
}
}
Expand Down
73 changes: 73 additions & 0 deletions desktop/src-tauri/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
}
migrate_persona_provider_to_runtime(app);
reconcile_legacy_command_names(app);
reconcile_openclaw_native_runtime(app);
// Fold personas.json into the unified store HERE: after the JSON-level
// personas.json migrations above (which must see the legacy file), and
// before every consumer of the load/save_personas shims below —
Expand Down Expand Up @@ -1099,6 +1100,57 @@ fn reconcile_legacy_command_names_in_file(path: &Path) {
});
}

fn reconcile_openclaw_native_runtime_in_file(path: &Path) {
patch_json_records(path, |obj| {
let legacy_args = obj
.get("agent_args")
.and_then(serde_json::Value::as_array)
.is_some_and(|args| args.len() == 1 && args[0].as_str() == Some("acp"));
if !legacy_args {
return false;
}

let command_is_legacy =
obj.get("agent_command").and_then(serde_json::Value::as_str) == Some("openclaw");
let command_override = obj
.get("agent_command_override")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if command_override.is_some_and(|value| value != "openclaw") {
return false;
}
let override_is_legacy = command_override == Some("openclaw");
if !command_is_legacy && !override_is_legacy {
return false;
}

if command_is_legacy {
obj.insert(
"agent_command".to_string(),
serde_json::Value::String("openclaw-acp".to_string()),
);
}
if override_is_legacy {
obj.insert(
"agent_command_override".to_string(),
serde_json::Value::String("openclaw-acp".to_string()),
);
}
obj.insert(
"agent_args".to_string(),
serde_json::Value::Array(Vec::new()),
);
eprintln!(
"buzz-desktop: runtime-reconcile: {:?}: migrated OpenClaw to native ACP",
obj.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or("?"),
);
true
});
}

fn reconcile_legacy_persona_runtimes_in_file(path: &Path) {
patch_json_records(path, |obj| {
let Some(runtime) = obj.get("runtime").and_then(|v| v.as_str()) else {
Expand Down Expand Up @@ -1213,6 +1265,27 @@ pub fn reconcile_legacy_command_names(app: &tauri::AppHandle) {
}
}

/// Upgrade the exact OpenClaw command/argument shape shipped by Buzz v0.5.2.
///
/// Custom commands and any non-standard arguments remain untouched.
pub fn reconcile_openclaw_native_runtime(app: &tauri::AppHandle) {
let Ok(current_dir) = app.path().app_data_dir() else {
return;
};
let mut dirs = vec![current_dir.clone()];
if let Some(canonical) = canonical_dev_data_dir(&current_dir) {
if canonical.exists() && canonical != current_dir {
dirs.push(canonical);
}
}
for dir in dirs {
let path = dir.join("agents/managed-agents.json");
if path.exists() {
reconcile_openclaw_native_runtime_in_file(&path);
}
}
}

/// Reconcile `mcp_command` values in managed-agents.json against the
/// discovery table. Known runtimes get their canonical mcp_command;
/// unknown/custom agents are left untouched. Covers both the current
Expand Down
Loading