Guide CLI installation and subscription sign-in (#1980)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-16 14:21:03 -04:00
committed by GitHub
co-authored by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent d52dedb06f
commit 8d3666c5f8
13 changed files with 1130 additions and 70 deletions
+49 -24
View File
@@ -121,6 +121,17 @@ fn agent_error_from_json(error: &serde_json::Value) -> AcpError {
AcpError::AgentError { code, message }
}
fn build_initialize_params() -> serde_json::Value {
serde_json::json!({
"protocolVersion": 2,
"clientCapabilities": build_client_capabilities(),
"clientInfo": {
"name": "buzz-acp",
"version": env!("CARGO_PKG_VERSION")
},
})
}
/// ACP client that owns an agent subprocess and communicates over its stdio.
///
/// One `AcpClient` per agent process. Multiple sessions can be created on the
@@ -333,6 +344,29 @@ pub(crate) fn build_codex_config_env(
Ok(Some(serde_json::Value::Object(base).to_string()))
}
fn build_client_capabilities() -> serde_json::Value {
serde_json::json!({
// Signal to ACP adapters that Buzz can hand users to terminal-native
// auth flows. Adapters decide which auth methods to expose; Buzz does
// not hardcode vendor login commands from this capability.
"auth": {
"terminal": true
},
// Signal to goose that we handle `_goose/unstable/session/update`
// notifications. Without this the custom notification is suppressed
// on goose's side and usage data is never emitted.
"_meta": {
"goose": {
"customNotifications": true
},
// Non-standard extension used by claude-agent-acp to advertise the
// exact terminal login argv for subscription auth. Unknown `_meta`
// keys are ignored by other adapters.
"terminal-auth": true
}
})
}
impl AcpClient {
/// Kill the agent subprocess and wait for it to exit (no zombies).
///
@@ -501,28 +535,20 @@ impl AcpClient {
pub async fn initialize(&mut self) -> Result<serde_json::Value, AcpError> {
// Requesting version 2 is an intentional temporary pin — we are squatting
// on ACP v2 ahead of the upstream ACP RFD. Revisit when that RFD merges.
let params = serde_json::json!({
"protocolVersion": 2,
"clientCapabilities": {
// Signal to goose that we handle `_goose/unstable/session/update`
// notifications. Without this the custom notification is suppressed
// on goose's side and usage data is never emitted.
"_meta": {
"goose": {
"customNotifications": true
}
}
},
"clientInfo": {
"name": "buzz-acp",
"version": env!("CARGO_PKG_VERSION")
}
});
let params = build_initialize_params();
let result = self.send_request("initialize", params).await?;
tracing::debug!(target: "acp::init", "initialize response: {result}");
Ok(result)
}
/// Send the ACP `authenticate` request for an adapter-advertised method.
pub async fn authenticate(&mut self, method_id: &str) -> Result<serde_json::Value, AcpError> {
let params = serde_json::json!({
"methodId": method_id,
});
self.send_request("authenticate", params).await
}
/// Send `session/new` and return the full response alongside the session ID.
///
/// `cwd` must be an absolute path. `mcp_servers` may be empty.
@@ -2067,13 +2093,7 @@ mod tests {
"method": "initialize",
"params": {
"protocolVersion": 2,
"clientCapabilities": {
"_meta": {
"goose": {
"customNotifications": true
}
}
},
"clientCapabilities": build_client_capabilities(),
"clientInfo": {
"name": "buzz-acp",
"version": "0.1.0"
@@ -2086,6 +2106,11 @@ mod tests {
Some("buzz-acp")
);
assert!(msg["params"]["clientCapabilities"].is_object());
assert_eq!(
msg["params"]["clientCapabilities"]["auth"]["terminal"].as_bool(),
Some(true),
"terminal auth capability must be advertised so adapters can expose terminal login methods"
);
assert_eq!(
msg["params"]["clientCapabilities"]["_meta"]["goose"]["customNotifications"].as_bool(),
Some(true),
+38
View File
@@ -175,6 +175,18 @@ impl std::fmt::Display for PermissionMode {
about = "Query available models from the configured agent"
)]
pub struct ModelsArgs {
/// Agent binary to spawn (e.g. "goose", "claude-agent-acp", "codex-acp").
#[command(flatten)]
pub agent: AuthAgentArgs,
/// Output structured JSON instead of human-readable text.
#[arg(long)]
pub json: bool,
}
/// Shared agent-spawn flags for lightweight local ACP helper subcommands.
#[derive(Debug, Parser)]
pub struct AuthAgentArgs {
/// Agent binary to spawn (e.g. "goose", "claude-agent-acp", "codex-acp").
#[arg(long, env = "BUZZ_ACP_AGENT_COMMAND", default_value = "goose")]
pub agent_command: String,
@@ -187,12 +199,38 @@ pub struct ModelsArgs {
value_delimiter = ','
)]
pub agent_args: Vec<String>,
}
/// CLI args for `buzz-acp auth-methods` — query adapter-advertised login methods.
#[derive(Debug, Parser)]
#[command(
name = "buzz-acp auth-methods",
about = "Query adapter-advertised ACP authentication methods"
)]
pub struct AuthMethodsArgs {
#[command(flatten)]
pub agent: AuthAgentArgs,
/// Output structured JSON instead of human-readable text.
#[arg(long)]
pub json: bool,
}
/// CLI args for `buzz-acp authenticate` — start an adapter-owned login flow.
#[derive(Debug, Parser)]
#[command(
name = "buzz-acp authenticate",
about = "Start an adapter-owned ACP authentication flow"
)]
pub struct AuthenticateArgs {
#[command(flatten)]
pub agent: AuthAgentArgs,
/// Adapter-advertised auth method id to invoke.
#[arg(long)]
pub method_id: String,
}
#[derive(Debug, Parser)]
#[command(
name = "buzz-acp",
+159 -15
View File
@@ -28,7 +28,10 @@ use buzz_core::observer::{
OBSERVER_MAX_PLAINTEXT_LEN,
};
use clap::Parser;
use config::{Config, DedupMode, ModelsArgs, MultipleEventHandling, RespondTo, SubscribeMode};
use config::{
AuthAgentArgs, AuthMethodsArgs, AuthenticateArgs, Config, DedupMode, ModelsArgs,
MultipleEventHandling, RespondTo, SubscribeMode,
};
use filter::SubscriptionRule;
use futures_util::FutureExt;
use nostr::{PublicKey, ToBech32};
@@ -46,7 +49,7 @@ use uuid::Uuid;
///
/// This avoids clap rejecting harness flags (like `--private-key`) that aren't
/// declared on the subcommand's `Parser`. The `models` path has its own
/// `ModelsArgs` parser; the default path uses the existing `CliArgs`.
/// dedicated parser; the default path uses the existing `CliArgs`.
///
/// **Constraint**: subcommand must be argv[1] — flags before the subcommand
/// name (e.g., `buzz-acp --verbose models`) are not supported.
@@ -54,9 +57,13 @@ fn is_subcommand(name: &str) -> bool {
std::env::args().nth(1).map(|a| a == name).unwrap_or(false)
}
/// Timeout for the `buzz-acp models` subcommand (spawn + init + session/new).
/// Timeout for lightweight helper subcommands (spawn + initialize + model/method probes).
const MODELS_TIMEOUT: Duration = Duration::from_secs(10);
/// Timeout for `buzz-acp authenticate`. Browser-based vendor auth can require
/// human interaction, so it must not share the short probe timeout.
const AUTHENTICATE_TIMEOUT: Duration = Duration::from_secs(10 * 60);
/// Publish a kind:20001 presence update event via the WebSocket connection.
///
/// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence
@@ -1076,8 +1083,8 @@ async fn tokio_main() -> Result<()> {
.install_default()
.expect("failed to install rustls crypto provider");
if is_subcommand("models") {
// Strip the "models" token so clap doesn't reject it as a positional.
// Keeps argv[0] (binary name) and passes everything after "models".
// Strip the subcommand token so clap doesn't reject it as a positional.
// Keeps argv[0] (binary name) and passes everything after the subcommand.
let filtered: Vec<String> = std::env::args()
.enumerate()
.filter(|(i, _)| *i != 1)
@@ -1087,6 +1094,26 @@ async fn tokio_main() -> Result<()> {
return run_models(args).await;
}
if is_subcommand("auth-methods") {
let filtered: Vec<String> = std::env::args()
.enumerate()
.filter(|(i, _)| *i != 1)
.map(|(_, a)| a)
.collect();
let args = AuthMethodsArgs::parse_from(&filtered);
return run_auth_methods(args).await;
}
if is_subcommand("authenticate") {
let filtered: Vec<String> = std::env::args()
.enumerate()
.filter(|(i, _)| *i != 1)
.map(|(_, a)| a)
.collect();
let args = AuthenticateArgs::parse_from(&filtered);
return run_authenticate(args).await;
}
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("buzz_acp=info")),
@@ -3325,14 +3352,130 @@ async fn spawn_and_init(
}
}
/// `buzz-acp models` — spawn an agent, query its available models, exit.
///
async fn spawn_auth_client(agent: &AuthAgentArgs) -> Result<AcpClient, acp::AcpError> {
let agent_args = config::normalize_agent_args(&agent.agent_command, agent.agent_args.clone());
AcpClient::spawn(&agent.agent_command, &agent_args, &[], false).await
}
fn extract_auth_methods(init_result: &serde_json::Value) -> Vec<serde_json::Value> {
init_result
.get("authMethods")
.and_then(|methods| methods.as_array())
.cloned()
.unwrap_or_default()
}
/// `buzz-acp auth-methods` — spawn an adapter, initialize it, print authMethods.
async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> {
let mut client = match spawn_auth_client(&args.agent).await {
Ok(c) => c,
Err(e) => {
eprintln!("error: failed to spawn agent: {e}");
std::process::exit(1);
}
};
let init_result = match tokio::time::timeout(MODELS_TIMEOUT, client.initialize()).await {
Ok(Ok(result)) => result,
Ok(Err(e)) => {
client.shutdown().await;
eprintln!("error: agent initialize failed: {e}");
std::process::exit(1);
}
Err(_) => {
client.shutdown().await;
eprintln!("error: agent timed out ({MODELS_TIMEOUT:?})");
std::process::exit(1);
}
};
let methods = extract_auth_methods(&init_result);
client.shutdown().await;
if args.json {
let output = serde_json::json!({ "methods": methods });
println!("{}", serde_json::to_string_pretty(&output)?);
} else if methods.is_empty() {
println!("No auth methods advertised.");
} else {
for method in methods {
let id = method
.get("id")
.and_then(|value| value.as_str())
.unwrap_or("unknown");
let name = method
.get("name")
.and_then(|value| value.as_str())
.unwrap_or(id);
println!("{id}\t{name}");
}
}
Ok(())
}
/// `buzz-acp authenticate` — invoke one adapter-owned auth method.
async fn run_authenticate(args: AuthenticateArgs) -> Result<()> {
let mut client = match spawn_auth_client(&args.agent).await {
Ok(c) => c,
Err(e) => {
eprintln!("error: failed to spawn agent: {e}");
std::process::exit(1);
}
};
let init_result = match tokio::time::timeout(MODELS_TIMEOUT, client.initialize()).await {
Ok(Ok(result)) => result,
Ok(Err(e)) => {
client.shutdown().await;
eprintln!("error: agent initialize failed: {e}");
std::process::exit(1);
}
Err(_) => {
client.shutdown().await;
eprintln!("error: agent initialize timed out ({MODELS_TIMEOUT:?})");
std::process::exit(1);
}
};
let supports_method = extract_auth_methods(&init_result)
.iter()
.any(|method| method.get("id").and_then(|id| id.as_str()) == Some(args.method_id.as_str()));
if !supports_method {
client.shutdown().await;
eprintln!(
"error: auth method '{}' is not advertised by this adapter",
args.method_id
);
std::process::exit(1);
}
let result =
tokio::time::timeout(AUTHENTICATE_TIMEOUT, client.authenticate(&args.method_id)).await;
match result {
Ok(Ok(_)) => {
client.shutdown().await;
Ok(())
}
Ok(Err(e)) => {
client.shutdown().await;
eprintln!("error: authenticate failed: {e}");
std::process::exit(1);
}
Err(_) => {
client.shutdown().await;
eprintln!("error: authenticate timed out ({AUTHENTICATE_TIMEOUT:?})");
std::process::exit(1);
}
}
}
/// Flow: spawn → initialize → session/new → print models → shutdown.
/// No relay connection, no MCP servers, no subscriptions. ~2-5s total.
async fn run_models(args: ModelsArgs) -> Result<()> {
use acp::{extract_model_config_options, extract_model_state};
let agent_args = config::normalize_agent_args(&args.agent_command, args.agent_args);
let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args);
let cwd = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("/"))
.to_string_lossy()
@@ -3340,13 +3483,14 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
// Spawn outside the timeout so we always own the child for cleanup.
// `models` subcommand doesn't use persona packs — no extra env, no codex config.
let mut client = match AcpClient::spawn(&args.agent_command, &agent_args, &[], false).await {
Ok(c) => c,
Err(e) => {
eprintln!("error: failed to spawn agent: {e}");
std::process::exit(1);
}
};
let mut client =
match AcpClient::spawn(&args.agent.agent_command, &agent_args, &[], false).await {
Ok(c) => c,
Err(e) => {
eprintln!("error: failed to spawn agent: {e}");
std::process::exit(1);
}
};
// Initialize + session/new under a timeout. Client is owned above,
// so shutdown() runs on all paths (success, error, timeout).
@@ -0,0 +1,457 @@
use std::process::{Command, Stdio};
use serde_json::Value;
use serde::{Deserialize, Serialize};
use crate::managed_agents::{
default_agent_workdir, known_acp_runtime_exact, normalize_agent_args, resolve_command,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AcpAuthMethod {
pub id: String,
pub name: String,
pub description: Option<String>,
#[serde(rename = "type")]
pub method_type: Option<String>,
#[serde(default)]
pub args: Vec<String>,
/// Full terminal command advertised by the adapter. Buzz never guesses
/// vendor login commands; when present, this argv is the source of truth.
#[serde(default)]
pub command: Vec<String>,
#[serde(default, rename = "_meta")]
pub meta: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AcpAuthMethodsResult {
pub methods: Vec<AcpAuthMethod>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectAcpRuntimeRequest {
pub runtime_id: String,
pub method_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConnectAcpRuntimeResult {
pub launched: bool,
}
#[tauri::command]
pub async fn discover_acp_auth_methods(runtime_id: String) -> Result<AcpAuthMethodsResult, String> {
tokio::task::spawn_blocking(move || discover_acp_auth_methods_blocking(&runtime_id))
.await
.map_err(|error| format!("auth-method discovery task failed: {error}"))?
}
#[tauri::command]
pub async fn connect_acp_runtime(
request: ConnectAcpRuntimeRequest,
) -> Result<ConnectAcpRuntimeResult, String> {
tokio::task::spawn_blocking(move || connect_acp_runtime_blocking(&request))
.await
.map_err(|error| format!("connect-account task failed: {error}"))?
}
fn discover_acp_auth_methods_blocking(runtime_id: &str) -> Result<AcpAuthMethodsResult, String> {
let output = run_buzz_acp_auth_command(runtime_id, ["auth-methods", "--json"])?;
if !output.status.success() {
return Err(command_error("buzz-acp auth-methods", &output));
}
serde_json::from_slice::<AcpAuthMethodsResult>(&output.stdout)
.map_err(|error| format!("failed to parse auth methods JSON: {error}"))
}
fn connect_acp_runtime_blocking(
request: &ConnectAcpRuntimeRequest,
) -> Result<ConnectAcpRuntimeResult, String> {
let methods = discover_acp_auth_methods_blocking(&request.runtime_id)?;
let method = methods
.methods
.iter()
.find(|candidate| candidate.id == request.method_id)
.ok_or_else(|| "auth method is no longer advertised by this adapter".to_string())?;
if method.method_type.as_deref() == Some("terminal") {
launch_terminal_auth(&request.runtime_id, method)?;
return Ok(ConnectAcpRuntimeResult { launched: true });
}
let output = run_buzz_acp_auth_command(
&request.runtime_id,
["authenticate", "--method-id", request.method_id.as_str()],
)?;
if !output.status.success() {
return Err(command_error("buzz-acp authenticate", &output));
}
Ok(ConnectAcpRuntimeResult { launched: true })
}
fn run_buzz_acp_auth_command<const N: usize>(
runtime_id: &str,
args: [&str; N],
) -> Result<std::process::Output, String> {
let runtime = known_acp_runtime_exact(runtime_id)
.ok_or_else(|| format!("unknown ACP runtime: {runtime_id}"))?;
let adapter_command = runtime
.commands
.iter()
.find_map(|command| resolve_command(command).map(|path| (*command, path)))
.ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?;
let acp_path = std::env::current_exe()
.map(|path| path.with_file_name(format!("buzz-acp{}", std::env::consts::EXE_SUFFIX)))
.ok()
.filter(|path| path.exists())
.or_else(|| resolve_command("buzz-acp"))
.ok_or_else(|| "buzz-acp helper not found".to_string())?;
let agent_args = normalize_agent_args(adapter_command.0, Vec::new());
let mut command = Command::new(acp_path);
command
.args(args)
.env("BUZZ_ACP_AGENT_COMMAND", adapter_command.1.as_os_str())
.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(","))
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(workdir) = default_agent_workdir() {
command.current_dir(workdir);
}
if let Some(ref path) = crate::managed_agents::login_shell_path() {
command.env("PATH", path);
}
command
.output()
.map_err(|error| format!("failed to run buzz-acp auth helper: {error}"))
}
fn command_error(label: &str, output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
format!(
"{label} failed (exit {})",
output.status.code().unwrap_or(-1)
)
} else {
format!(
"{label} failed (exit {}): {stderr}",
output.status.code().unwrap_or(-1)
)
}
}
fn launch_terminal_auth(runtime_id: &str, method: &AcpAuthMethod) -> Result<(), String> {
let runtime = known_acp_runtime_exact(runtime_id)
.ok_or_else(|| format!("unknown ACP runtime: {runtime_id}"))?;
let adapter_command = runtime
.commands
.iter()
.find_map(|command| resolve_command(command).map(|path| (*command, path)))
.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)
}
fn adapter_terminal_argv(
runtime_label: &str,
method: &AcpAuthMethod,
fallback_command: &str,
) -> Result<Vec<String>, String> {
let meta_command = terminal_auth_meta_command(method)?;
let (command, args): (&str, &[String]) =
match meta_command.as_deref().and_then(|argv| argv.split_first()) {
Some((command, args)) => (command.as_str(), args),
None => match method.command.split_first() {
Some((command, args)) => (command.as_str(), args),
None => (fallback_command, method.args.as_slice()),
},
};
if command.trim().is_empty() {
return Err(format!(
"{} did not provide a terminal login command for {}",
runtime_label, method.name
));
}
let command_path = resolve_command(command)
.map(|path| path.display().to_string())
.unwrap_or_else(|| command.to_string());
let mut argv = vec![command_path];
argv.extend(args.iter().cloned());
Ok(argv)
}
fn terminal_auth_meta_command(method: &AcpAuthMethod) -> Result<Option<Vec<String>>, String> {
let Some(meta) = method.meta.as_ref() else {
return Ok(None);
};
let Some(terminal_auth) = meta.get("terminal-auth") else {
return Ok(None);
};
let Some(command) = terminal_auth.get("command") else {
return Ok(None);
};
if let Some(command) = command.as_str() {
let mut argv = vec![command.to_string()];
if let Some(args) = terminal_auth.get("args") {
let args = args.as_array().ok_or_else(|| {
format!(
"terminal auth metadata for {} has non-array args",
method.name
)
})?;
for value in args {
let Some(arg) = value.as_str() else {
return Err(format!(
"terminal auth metadata for {} has a non-string arg",
method.name
));
};
argv.push(arg.to_string());
}
}
return Ok((!argv.is_empty()).then_some(argv));
}
let command = command.as_array().ok_or_else(|| {
format!(
"terminal auth metadata for {} has a non-string/non-array command",
method.name
)
})?;
let mut argv = Vec::with_capacity(command.len());
for value in command {
let Some(arg) = value.as_str() else {
return Err(format!(
"terminal auth metadata for {} has a non-string command argument",
method.name
));
};
argv.push(arg.to_string());
}
Ok((!argv.is_empty()).then_some(argv))
}
fn spawn_without_stdio(mut command: Command) -> Result<(), String> {
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|error| format!("failed to open terminal: {error}"))?;
Ok(())
}
#[cfg(target_os = "macos")]
fn launch_visible_terminal(argv: &[String]) -> Result<(), String> {
let command = shell_join(argv);
let script = format!(
"tell application \"Terminal\"\n activate\n do script {}\nend tell",
applescript_string(&command)
);
let mut command = Command::new("osascript");
command.arg("-e").arg(script);
spawn_without_stdio(command)
}
#[cfg(target_os = "linux")]
fn launch_visible_terminal(argv: &[String]) -> Result<(), String> {
let command = shell_join(argv);
let candidates: [(&str, &[&str]); 4] = [
("x-terminal-emulator", &["-e", "sh", "-lc"]),
("gnome-terminal", &["--", "sh", "-lc"]),
("konsole", &["-e", "sh", "-lc"]),
("xterm", &["-e", "sh", "-lc"]),
];
for (terminal, prefix) in candidates {
let mut terminal_command = Command::new(terminal);
terminal_command.args(prefix).arg(&command);
if spawn_without_stdio(terminal_command).is_ok() {
return Ok(());
}
}
Err("no terminal emulator found".to_string())
}
#[cfg(target_os = "windows")]
fn launch_visible_terminal(argv: &[String]) -> Result<(), String> {
use std::os::windows::process::CommandExt;
const CREATE_NEW_CONSOLE: u32 = 0x0000_0010;
let mut command = Command::new("cmd");
// Keep argv separate so Rust applies Windows command-line quoting. Joining
// with POSIX shell escaping breaks paths such as `C:\Program Files\...`.
command
.args(windows_terminal_args(argv))
.creation_flags(CREATE_NEW_CONSOLE);
spawn_without_stdio(command)
}
#[cfg(any(target_os = "windows", test))]
fn windows_terminal_args(argv: &[String]) -> Vec<String> {
std::iter::once("/K".to_string())
.chain(argv.iter().cloned())
.collect()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn launch_visible_terminal(_argv: &[String]) -> Result<(), String> {
Err("opening a terminal is not supported on this platform".to_string())
}
fn shell_join(argv: &[String]) -> String {
argv.iter()
.map(|arg| shell_escape(arg))
.collect::<Vec<_>>()
.join(" ")
}
fn shell_escape(arg: &str) -> String {
if !arg.is_empty()
&& arg
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '_' | '-' | '.' | ':' | '='))
{
return arg.to_string();
}
format!("'{}'", arg.replace('\'', "'\\''"))
}
#[cfg(target_os = "macos")]
fn applescript_string(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
#[cfg(test)]
mod tests {
use super::{
adapter_terminal_argv, shell_escape, shell_join, windows_terminal_args, AcpAuthMethod,
};
#[test]
fn shell_join_escapes_spaces_and_quotes() {
assert_eq!(
shell_join(&["/bin/claude".into(), "auth login".into(), "it's".into()]),
"/bin/claude 'auth login' 'it'\\''s'"
);
}
#[test]
fn shell_escape_leaves_simple_args_unquoted() {
assert_eq!(shell_escape("--claudeai"), "--claudeai");
}
#[test]
fn windows_terminal_keeps_argv_separate() {
let argv = vec![
r"C:\Program Files\Codex\codex.exe".to_string(),
"login".to_string(),
"subscription name".to_string(),
];
assert_eq!(
windows_terminal_args(&argv),
vec![
"/K",
r"C:\Program Files\Codex\codex.exe",
"login",
"subscription name"
]
);
}
#[test]
fn auth_method_parses_terminal_command() {
let raw = r#"{"id":"claude-ai-login","name":"Claude Subscription","description":"Use Claude subscription","type":"terminal","command":["claude","auth","login","--claudeai"]}"#;
let method: AcpAuthMethod = serde_json::from_str(raw).unwrap();
assert_eq!(method.method_type.as_deref(), Some("terminal"));
assert_eq!(method.command[0], "claude");
}
#[test]
fn terminal_argv_uses_adapter_declared_command() {
let _guard = crate::managed_agents::lock_path_mutex();
let method = AcpAuthMethod {
id: "claude-ai-login".into(),
name: "Claude Subscription".into(),
description: None,
method_type: Some("terminal".into()),
args: vec!["should-not".into(), "be-used".into()],
command: vec![
"definitely-not-on-path-buzz-test".into(),
"auth".into(),
"login".into(),
],
meta: None,
};
assert_eq!(
adapter_terminal_argv("Claude Code", &method, "claude-agent-acp").unwrap(),
vec![
"definitely-not-on-path-buzz-test".to_string(),
"auth".to_string(),
"login".to_string()
]
);
}
#[test]
fn terminal_argv_prefers_terminal_auth_meta_command() {
let _guard = crate::managed_agents::lock_path_mutex();
let method = AcpAuthMethod {
id: "claude-ai-login".into(),
name: "Claude Subscription".into(),
description: None,
method_type: Some("terminal".into()),
args: vec!["fallback-arg".into()],
command: vec!["fallback-command".into()],
meta: Some(serde_json::json!({
"terminal-auth": {
"command": "definitely-not-on-path-meta",
"args": ["auth", "login", "--claudeai"]
}
})),
};
assert_eq!(
adapter_terminal_argv("Claude Code", &method, "adapter-fallback").unwrap(),
vec![
"definitely-not-on-path-meta".to_string(),
"auth".to_string(),
"login".to_string(),
"--claudeai".to_string()
]
);
}
#[test]
fn terminal_argv_falls_back_to_adapter_command() {
let _guard = crate::managed_agents::lock_path_mutex();
let method = AcpAuthMethod {
id: "claude-ai-login".into(),
name: "Claude Subscription".into(),
description: None,
method_type: Some("terminal".into()),
args: vec![],
command: vec![],
meta: None,
};
assert_eq!(
adapter_terminal_argv("Claude Code", &method, "definitely-not-on-path-buzz-test")
.unwrap(),
vec!["definitely-not-on-path-buzz-test".to_string()]
);
}
}
+2
View File
@@ -1,3 +1,4 @@
mod agent_auth;
mod agent_config;
mod agent_discovery;
mod agent_logs;
@@ -47,6 +48,7 @@ mod window_vibrancy;
mod workflows;
mod workspace;
pub use agent_auth::*;
pub use agent_config::*;
pub use agent_discovery::*;
pub use agent_logs::*;
+2
View File
@@ -773,9 +773,11 @@ pub fn run() {
get_relay_http_url,
get_media_proxy_port,
fetch_link_preview_title,
discover_acp_auth_methods,
discover_acp_providers,
discover_git_bash_prerequisite,
install_acp_runtime,
connect_acp_runtime,
discover_managed_agent_prereqs,
sign_event,
sign_nostr_identity_binding,
+30
View File
@@ -1,6 +1,10 @@
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
connectAcpRuntime,
discoverAcpAuthMethods,
} from "@/shared/api/tauriAgentAuth";
import {
attachManagedAgentToChannel,
createChannelManagedAgents,
@@ -101,6 +105,7 @@ export const managedAgentsQueryKey = ["managed-agents"] as const;
export const personasQueryKey = ["personas"] as const;
export const teamsQueryKey = ["teams"] as const;
export const acpRuntimesQueryKey = ["acp-runtimes"] as const;
export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const;
export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const;
export const backendProvidersQueryKey = ["backend-providers"] as const;
export const gitBashPrerequisiteQueryKey = ["git-bash-prerequisite"] as const;
@@ -196,6 +201,31 @@ export function useAvailableAcpRuntimes(options?: { enabled?: boolean }) {
return { ...query, data: available };
}
export function useAcpAuthMethodsQuery(
runtimeId: string,
options?: { enabled?: boolean },
) {
return useQuery({
enabled: (options?.enabled ?? true) && runtimeId.trim().length > 0,
queryKey: [...acpAuthMethodsQueryKey, runtimeId],
queryFn: () => discoverAcpAuthMethods(runtimeId),
staleTime: 30_000,
});
}
export function useConnectAcpRuntimeMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { runtimeId: string; methodId: string }) =>
connectAcpRuntime(input.runtimeId, input.methodId),
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey });
void queryClient.invalidateQueries({ queryKey: acpAuthMethodsQueryKey });
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
},
});
}
export function useInstallAcpRuntimeMutation() {
const queryClient = useQueryClient();
return useMutation({
@@ -11,12 +11,18 @@ import {
import { openUrl } from "@tauri-apps/plugin-opener";
import {
useAcpAuthMethodsQuery,
useAcpRuntimesQuery,
useInstallAcpRuntimeMutation,
useConnectAcpRuntimeMutation,
useGitBashPrerequisiteQuery,
useInstallAcpRuntimeMutation,
} from "@/features/agents/hooks";
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
import type { AcpRuntimeCatalogEntry, AuthStatus } from "@/shared/api/types";
import type {
AcpAuthMethod,
AcpRuntimeCatalogEntry,
AuthStatus,
} from "@/shared/api/types";
import { getInstallErrorMessage } from "@/shared/lib/installError";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
@@ -71,6 +77,125 @@ function AuthStatusBadge({ authStatus }: { authStatus: AuthStatus }) {
}
}
function AuthMethodButtonLabel({ method }: { method: AcpAuthMethod }) {
return <>{method.name || method.id}</>;
}
function ConnectAccountActions({
runtime,
}: {
runtime: AcpRuntimeCatalogEntry;
}) {
const authMethodsQuery = useAcpAuthMethodsQuery(runtime.id, {
enabled:
runtime.availability === "available" &&
runtime.authStatus.status === "logged_out",
});
const connectMutation = useConnectAcpRuntimeMutation();
const [terminalLaunchMethodId, setTerminalLaunchMethodId] = React.useState<
string | null
>(null);
if (runtime.authStatus.status !== "logged_out") {
return null;
}
const methods = authMethodsQuery.data?.methods ?? [];
const isConnecting = connectMutation.isPending;
function connect(method: AcpAuthMethod) {
connectMutation.mutate(
{ runtimeId: runtime.id, methodId: method.id },
{
onSuccess: (result) => {
if (result.launched && method.type === "terminal") {
setTerminalLaunchMethodId(method.id);
}
},
},
);
}
if (authMethodsQuery.isLoading) {
return (
<p className="mt-2 text-sm text-muted-foreground">
Looking for account connection options...
</p>
);
}
if (authMethodsQuery.error instanceof Error) {
return (
<p className="mt-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive">
Couldn&apos;t load account connection options:{" "}
{authMethodsQuery.error.message}
</p>
);
}
if (methods.length === 0) {
return (
<p className="mt-2 text-sm text-muted-foreground">
This adapter did not advertise a built-in login flow. Use the manual
instructions above, then click Re-run.
</p>
);
}
return (
<div className="mt-2 space-y-2">
<div className="flex flex-wrap items-center gap-2">
{methods.map((method) => {
const pending =
isConnecting && connectMutation.variables?.methodId === method.id;
return (
<Button
disabled={isConnecting}
key={method.id}
onClick={() => connect(method)}
size="sm"
type="button"
variant="outline"
>
{pending ? <RefreshCw className="h-4 w-4 animate-spin" /> : null}
{pending ? (
"Connecting..."
) : (
<AuthMethodButtonLabel method={method} />
)}
</Button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Buzz launches the adapter&apos;s own login flow and then re-checks the{" "}
{runtime.label} CLI. Credentials stay with {runtime.label}.
</p>
{terminalLaunchMethodId ? (
<p className="text-xs text-muted-foreground">
Finish signing in from the Terminal window, then click Re-run to
re-check {runtime.label}.
</p>
) : null}
{methods.map((method) =>
method.description ? (
<p className="text-xs text-muted-foreground" key={method.id}>
<span className="font-medium text-foreground/80">
{method.name || method.id}:
</span>{" "}
{method.description}
</p>
) : null,
)}
{connectMutation.error instanceof Error ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive">
{connectMutation.error.message}
</p>
) : null}
</div>
);
}
function InstallActions({
hasError,
isInstalling,
@@ -83,35 +208,58 @@ function InstallActions({
runtime: AcpRuntimeCatalogEntry;
}) {
const showInstall = runtime.canAutoInstall && !runtime.nodeRequired;
const installLabel =
runtime.availability === "adapter_missing"
? "Install ACP adapter"
: runtime.availability === "adapter_outdated"
? "Update ACP adapter"
: `Install ${runtime.label}`;
const pendingLabel =
runtime.availability === "adapter_missing" ||
runtime.availability === "adapter_outdated"
? "Installing adapter..."
: `Installing ${runtime.label}...`;
return (
<div className="mt-2 flex items-center gap-2">
<div className="mt-2 space-y-2">
{showInstall ? (
<Button
disabled={isInstalling}
onClick={onInstall}
size="sm"
type="button"
variant="outline"
>
{isInstalling ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : hasError ? (
<RefreshCw className="h-4 w-4" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling ? "Installing..." : hasError ? "Retry" : "Install"}
</Button>
<p className="text-xs text-muted-foreground">
Buzz uses the official installer and adds the ACP adapter. After it
finishes, Buzz will show the vendor&apos;s sign-in flow here.
</p>
) : null}
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onClick={() => void openUrl(runtime.installInstructionsUrl)}
type="button"
>
<ExternalLink className="h-4 w-4" />
View instructions
</button>
<div className="flex items-center gap-2">
{showInstall ? (
<Button
disabled={isInstalling}
onClick={onInstall}
size="sm"
type="button"
variant="outline"
>
{isInstalling ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : hasError ? (
<RefreshCw className="h-4 w-4" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling
? pendingLabel
: hasError
? `Retry ${installLabel}`
: installLabel}
</Button>
) : null}
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onClick={() => void openUrl(runtime.installInstructionsUrl)}
type="button"
>
<ExternalLink className="h-4 w-4" />
View instructions
</button>
</div>
</div>
);
}
@@ -258,6 +406,7 @@ function RuntimeRow({
: runtime.loginHint}
</p>
) : null}
<ConnectAccountActions runtime={runtime} />
</>
) : runtime.availability === "adapter_missing" ? (
<>
@@ -348,7 +497,7 @@ function RuntimeRow({
{installSuccess && runtime.availability !== "available" ? (
<p className="mt-2 rounded-lg border border-green-500/30 bg-green-500/10 px-3 py-1.5 text-sm text-green-700 dark:text-green-400">
Installed successfully!
{runtime.label} installed. Checking for sign-in options...
</p>
) : null}
{installError ? (
+55
View File
@@ -0,0 +1,55 @@
import type {
AcpAuthMethod,
AcpAuthMethodsResult,
ConnectAcpRuntimeResult,
} from "@/shared/api/types";
import { invokeTauri } from "@/shared/api/tauri";
type RawAcpAuthMethod = {
id: string;
name: string;
description?: string | null;
type?: string | null;
args?: string[];
command?: string[];
_meta?: unknown;
};
export type RawAcpAuthMethodsResult = {
methods: RawAcpAuthMethod[];
};
export type RawConnectAcpRuntimeResult = {
launched: boolean;
};
function fromRawAcpAuthMethod(method: RawAcpAuthMethod): AcpAuthMethod {
return {
id: method.id,
name: method.name,
description: method.description ?? null,
type: method.type ?? null,
args: method.args ?? [],
command: method.command ?? [],
meta: method._meta ?? null,
};
}
export async function discoverAcpAuthMethods(
runtimeId: string,
): Promise<AcpAuthMethodsResult> {
const raw = await invokeTauri<RawAcpAuthMethodsResult>(
"discover_acp_auth_methods",
{ runtimeId },
);
return { methods: raw.methods.map(fromRawAcpAuthMethod) };
}
export async function connectAcpRuntime(
runtimeId: string,
methodId: string,
): Promise<ConnectAcpRuntimeResult> {
return invokeTauri<RawConnectAcpRuntimeResult>("connect_acp_runtime", {
request: { runtimeId, methodId },
});
}
+18
View File
@@ -550,6 +550,24 @@ export type InstallRuntimeResult = {
failedRestartCount: number;
};
export type AcpAuthMethod = {
id: string;
name: string;
description: string | null;
type: string | null;
args: string[];
command: string[];
meta: unknown | null;
};
export type AcpAuthMethodsResult = {
methods: AcpAuthMethod[];
};
export type ConnectAcpRuntimeResult = {
launched: boolean;
};
export type CommandAvailability = {
command: string;
resolvedPath: string | null;
+45
View File
@@ -40,6 +40,10 @@ import {
KIND_SYSTEM_MESSAGE,
KIND_USER_STATUS,
} from "@/shared/constants/kinds";
import type {
RawAcpAuthMethodsResult,
RawConnectAcpRuntimeResult,
} from "@/shared/api/tauriAgentAuth";
import type {
RawAcpRuntimeCatalogEntry,
RawInstallRuntimeResult,
@@ -118,6 +122,10 @@ type E2eConfig = {
mode?: "mock" | "relay";
mock?: {
acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[];
acpAuthMethods?: Record<string, RawAcpAuthMethodsResult>;
connectAcpRuntimeResult?: RawConnectAcpRuntimeResult;
connectAcpRuntimeDelayMs?: number;
connectAcpRuntimeError?: string;
activePersonaIds?: string[];
installAcpRuntimeResult?: RawInstallRuntimeResult;
/** Sequence of results for successive `install_acp_runtime` calls.
@@ -6507,6 +6515,33 @@ async function handleDiscoverAcpRuntimes(
];
}
async function handleDiscoverAcpAuthMethods(
args: { runtimeId?: string },
config: E2eConfig | undefined,
): Promise<RawAcpAuthMethodsResult> {
const runtimeId = args.runtimeId ?? "";
const configured = config?.mock?.acpAuthMethods?.[runtimeId];
if (configured) {
return configured;
}
return { methods: [] };
}
async function handleConnectAcpRuntime(
_args: { request?: { runtimeId?: string; methodId?: string } },
config: E2eConfig | undefined,
): Promise<RawConnectAcpRuntimeResult> {
const error = config?.mock?.connectAcpRuntimeError;
if (error) {
throw new Error(error);
}
const delayMs = config?.mock?.connectAcpRuntimeDelayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
return config?.mock?.connectAcpRuntimeResult ?? { launched: true };
}
// Per-page install call counter. Reset each test run because this module is
// re-evaluated via addInitScript, so the counter starts at 0 for every test.
let installCallCount = 0;
@@ -8857,6 +8892,16 @@ export function maybeInstallE2eTauriMocks() {
return getRelayHttpUrl(activeConfig);
case "discover_acp_providers":
return handleDiscoverAcpRuntimes(activeConfig);
case "discover_acp_auth_methods":
return handleDiscoverAcpAuthMethods(
payload as { runtimeId?: string },
activeConfig,
);
case "connect_acp_runtime":
return handleConnectAcpRuntime(
payload as { request?: { runtimeId?: string; methodId?: string } },
activeConfig,
);
case "install_acp_runtime":
return handleInstallAcpRuntime(
payload as { runtimeId?: string },
+94 -3
View File
@@ -300,12 +300,12 @@ test.describe("Doctor panel state screenshots", () => {
await expect(row).toBeVisible({ timeout: 10_000 });
// Trigger the first install — the mock returns a failure.
const installBtn = row.getByRole("button", { name: "Install" });
const installBtn = row.getByRole("button", { name: "Install Codex" });
await expect(installBtn).toBeVisible({ timeout: 5_000 });
await installBtn.click();
// After failure: Retry button appears and the error message is visible.
const retryBtn = row.getByRole("button", { name: "Retry" });
const retryBtn = row.getByRole("button", { name: "Retry Install Codex" });
await expect(retryBtn).toBeVisible({ timeout: 5_000 });
await expect(row).toContainText("Step");
await expect(row).toContainText("failed");
@@ -320,7 +320,9 @@ test.describe("Doctor panel state screenshots", () => {
// Error paragraph must disappear and per-runtime spinner must appear,
// then the success banner must render.
await expect(row).not.toContainText("failed", { timeout: 5_000 });
await expect(row.getByText("Installed successfully!")).toBeVisible({
await expect(
row.getByText("Codex installed. Checking for sign-in options..."),
).toBeVisible({
timeout: 10_000,
});
@@ -328,4 +330,93 @@ test.describe("Doctor panel state screenshots", () => {
await waitForAnimations(page);
await row.screenshot({ path: `${SHOTS}/05-retry-success.png` });
});
/**
* 06 — logged-out runtime with adapter-advertised auth methods: Doctor shows
* adapter-provided labels/descriptions and clicking one launches the
* vendor-owned flow through the mocked connect command.
*/
test("06-connect-account-methods", async ({ page }) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
GOOSE_AVAILABLE,
CLAUDE_AVAILABLE_LOGGED_IN,
{
...CODEX_NOT_INSTALLED,
availability: "available",
command: "codex-acp",
binary_path: "/usr/local/bin/codex-acp",
underlying_cli_path: "/usr/local/bin/codex",
auth_status: { status: "logged_out" },
login_hint: "Run `codex login` to authenticate.",
},
BUZZ_AGENT_AVAILABLE,
],
connectAcpRuntimeDelayMs: 250,
acpAuthMethods: {
codex: {
methods: [
{
id: "chat-gpt",
name: "Sign in with ChatGPT",
description: "Use your Codex subscription in the browser.",
type: "browser",
},
],
},
},
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "doctor");
const row = page.getByTestId("doctor-runtime-codex");
await expect(row).toBeVisible({ timeout: 10_000 });
await expect(row).toContainText("Not authenticated");
await expect(row).toContainText("Sign in with ChatGPT");
await expect(row).toContainText(
"Use your Codex subscription in the browser.",
);
await expect(row).toContainText("Credentials stay with Codex.");
await row.getByRole("button", { name: "Sign in with ChatGPT" }).click();
await expect(
row.getByRole("button", { name: "Connecting..." }),
).toBeVisible({
timeout: 5_000,
});
});
/**
* 07 — old or constrained adapter with no advertised auth methods: Doctor
* falls back to manual instructions instead of inventing a login command.
*/
test("07-connect-account-no-methods", async ({ page }) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
GOOSE_AVAILABLE,
{
...CLAUDE_AVAILABLE_LOGGED_IN,
auth_status: { status: "logged_out" },
login_hint: "Run the Claude CLI to complete authentication.",
},
CODEX_NOT_INSTALLED,
BUZZ_AGENT_AVAILABLE,
],
acpAuthMethods: {
claude: { methods: [] },
},
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "doctor");
const row = page.getByTestId("doctor-runtime-claude");
await expect(row).toBeVisible({ timeout: 10_000 });
await expect(row).toContainText("Not authenticated");
await expect(row).toContainText(
"This adapter did not advertise a built-in login flow.",
);
await expect(row).not.toContainText("Connect account");
});
});
+4
View File
@@ -116,6 +116,10 @@ export type MockAgentMemoryListing = {
type MockBridgeOptions = {
acpRuntimesCatalog?: Record<string, unknown>[];
acpAuthMethods?: Record<string, { methods: Record<string, unknown>[] }>;
connectAcpRuntimeResult?: { launched: boolean };
connectAcpRuntimeDelayMs?: number;
connectAcpRuntimeError?: string;
/** Override the result returned by the `install_acp_runtime` mock command.
* Pass `{ success: false, steps: [...] }` to exercise error/Retry states. */
installAcpRuntimeResult?: {