From 8c706efd62049a178204bd9d4aa82503309b4850 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 16 Jul 2026 11:53:40 -0400 Subject: [PATCH 1/3] WIP connect ACP runtime auth Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- crates/buzz-acp/src/acp.rs | 73 ++- crates/buzz-acp/src/config.rs | 38 ++ crates/buzz-acp/src/lib.rs | 173 ++++++- desktop/src-tauri/src/commands/agent_auth.rs | 424 ++++++++++++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 2 + desktop/src/features/agents/hooks.ts | 28 ++ .../settings/ui/DoctorSettingsPanel.tsx | 130 +++++- desktop/src/shared/api/tauri.ts | 52 +++ desktop/src/shared/api/types.ts | 18 + desktop/src/testing/e2eBridge.ts | 43 ++ desktop/tests/e2e/doctor-states.spec.ts | 89 ++++ desktop/tests/helpers/bridge.ts | 4 + 13 files changed, 1037 insertions(+), 39 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_auth.rs diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index ebfd8fb4a8..4b8d043ab3 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -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 { // 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 { + 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), diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index e8e8fdd51d..befb7aa6aa 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -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, +} + +/// 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", diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 77fb4a6138..8aa0dbb3de 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -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 = 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 = 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 = 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")), @@ -3327,12 +3354,131 @@ async fn spawn_and_init( /// `buzz-acp models` — spawn an agent, query its available models, exit. /// + +async fn spawn_auth_client(agent: &AuthAgentArgs) -> Result { + 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 { + 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 +3486,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). diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs new file mode 100644 index 0000000000..f9b1dac964 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -0,0 +1,424 @@ +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, + #[serde(rename = "type")] + pub method_type: Option, + #[serde(default)] + pub args: Vec, + /// 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, + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AcpAuthMethodsResult { + pub methods: Vec, +} + +#[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 { + 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 { + 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 { + 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::(&output.stdout) + .map_err(|error| format!("failed to parse auth methods JSON: {error}")) +} + +fn connect_acp_runtime_blocking( + request: &ConnectAcpRuntimeRequest, +) -> Result { + 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( + runtime_id: &str, + args: [&str; N], +) -> Result { + 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, 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>, 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> { + let mut command = Command::new("cmd"); + command + .args(["/C", "start", "", "cmd", "/K"]) + .arg(shell_join(argv)); + spawn_without_stdio(command) +} + +#[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::>() + .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, 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 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()] + ); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index bf87faac7e..3e39efb990 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -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::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 99fe6f1983..c046bf3c19 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -775,9 +775,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, diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 7672b74b19..6bc36c04f3 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -14,8 +14,10 @@ import { } from "@/features/channels/hooks"; import { evictUsersBatchEntries } from "@/features/profile/hooks"; import { + connectAcpRuntime, createManagedAgent, deleteManagedAgent, + discoverAcpAuthMethods, discoverAcpRuntimes, discoverBackendProviders, discoverGitBashPrerequisite, @@ -101,6 +103,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 +199,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({ diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 2baa44d096..a8c4a95128 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -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 ( +

+ Looking for account connection options... +

+ ); + } + + if (authMethodsQuery.error instanceof Error) { + return ( +

+ Couldn't load account connection options:{" "} + {authMethodsQuery.error.message} +

+ ); + } + + if (methods.length === 0) { + return ( +

+ This adapter did not advertise a built-in login flow. Use the manual + instructions above, then click Re-run. +

+ ); + } + + return ( +
+
+ {methods.map((method) => { + const pending = + isConnecting && connectMutation.variables?.methodId === method.id; + return ( + + ); + })} +
+

+ Buzz launches the adapter's own login flow and then re-checks the{" "} + {runtime.label} CLI. Credentials stay with {runtime.label}. +

+ {terminalLaunchMethodId ? ( +

+ Finish signing in from the Terminal window, then click Re-run to + re-check {runtime.label}. +

+ ) : null} + {methods.map((method) => + method.description ? ( +

+ + {method.name || method.id}: + {" "} + {method.description} +

+ ) : null, + )} + {connectMutation.error instanceof Error ? ( +

+ {connectMutation.error.message} +

+ ) : null} +
+ ); +} + function InstallActions({ hasError, isInstalling, @@ -258,6 +383,7 @@ function RuntimeRow({ : runtime.loginHint}

) : null} + ) : runtime.availability === "adapter_missing" ? ( <> diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 085ebd777e..1b3fd2825d 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1,5 +1,7 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import type { + AcpAuthMethod, + AcpAuthMethodsResult, AddChannelMembersInput, AddChannelMembersResult, BackendProviderCandidate, @@ -39,6 +41,7 @@ import type { AcpRuntimeCatalogEntry, AuthStatus, CommandAvailability, + ConnectAcpRuntimeResult, InstallRuntimeResult, GitBashPrerequisite, OpenDmInput, @@ -235,6 +238,24 @@ export type RawAcpRuntimeCatalogEntry = { login_hint?: string; }; +export 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; +}; + export type RawInstallStepResult = { step: string; command: string; @@ -915,6 +936,18 @@ function fromRawAcpRuntimeCatalogEntry( }; } +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, + }; +} + function fromRawInstallRuntimeResult( raw: RawInstallRuntimeResult, ): InstallRuntimeResult { @@ -1087,6 +1120,25 @@ export async function discoverGitBashPrerequisite(): Promise { + const raw = await invokeTauri( + "discover_acp_auth_methods", + { runtimeId }, + ); + return { methods: raw.methods.map(fromRawAcpAuthMethod) }; +} + +export async function connectAcpRuntime( + runtimeId: string, + methodId: string, +): Promise { + return invokeTauri("connect_acp_runtime", { + request: { runtimeId, methodId }, + }); +} + export async function discoverAcpRuntimes(): Promise { return ( await invokeTauri("discover_acp_providers") diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index b0f82a765a..b82fa06d3a 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -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; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index c428f61956..1228fcecfb 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -41,7 +41,9 @@ import { KIND_USER_STATUS, } from "@/shared/constants/kinds"; import type { + RawAcpAuthMethodsResult, RawAcpRuntimeCatalogEntry, + RawConnectAcpRuntimeResult, RawInstallRuntimeResult, } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -118,6 +120,10 @@ type E2eConfig = { mode?: "mock" | "relay"; mock?: { acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; + acpAuthMethods?: Record; + connectAcpRuntimeResult?: RawConnectAcpRuntimeResult; + connectAcpRuntimeDelayMs?: number; + connectAcpRuntimeError?: string; activePersonaIds?: string[]; installAcpRuntimeResult?: RawInstallRuntimeResult; /** Sequence of results for successive `install_acp_runtime` calls. @@ -6506,6 +6512,33 @@ async function handleDiscoverAcpRuntimes( ]; } +async function handleDiscoverAcpAuthMethods( + args: { runtimeId?: string }, + config: E2eConfig | undefined, +): Promise { + 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 { + 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; @@ -8851,6 +8884,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 }, diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index a1aed04900..213b4c1bdf 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -328,4 +328,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"); + }); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 22121ea818..5f2472ac74 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -116,6 +116,10 @@ export type MockAgentMemoryListing = { type MockBridgeOptions = { acpRuntimesCatalog?: Record[]; + acpAuthMethods?: Record[] }>; + 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?: { From 52dfa556bf3458fae1aa6068791c3cdf3b9cd59e Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 16 Jul 2026 12:57:02 -0400 Subject: [PATCH 2/3] Polish guided CLI installation Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- desktop/src/features/agents/hooks.ts | 6 +- .../settings/ui/DoctorSettingsPanel.tsx | 73 ++++++++++++------- desktop/src/shared/api/tauri.ts | 52 ------------- desktop/src/shared/api/tauriAgentAuth.ts | 55 ++++++++++++++ desktop/src/testing/e2eBridge.ts | 4 +- desktop/tests/e2e/doctor-states.spec.ts | 8 +- 6 files changed, 115 insertions(+), 83 deletions(-) create mode 100644 desktop/src/shared/api/tauriAgentAuth.ts diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 6bc36c04f3..43146e9932 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -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, @@ -14,10 +18,8 @@ import { } from "@/features/channels/hooks"; import { evictUsersBatchEntries } from "@/features/profile/hooks"; import { - connectAcpRuntime, createManagedAgent, deleteManagedAgent, - discoverAcpAuthMethods, discoverAcpRuntimes, discoverBackendProviders, discoverGitBashPrerequisite, diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index a8c4a95128..baae562cd4 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -208,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 ( -
+
{showInstall ? ( - + ) : null} + - ) : null} - + + View instructions + +
); } @@ -474,7 +497,7 @@ function RuntimeRow({ {installSuccess && runtime.availability !== "available" ? (

- Installed successfully! + {runtime.label} installed. Checking for sign-in options...

) : null} {installError ? ( diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 1b3fd2825d..085ebd777e 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1,7 +1,5 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import type { - AcpAuthMethod, - AcpAuthMethodsResult, AddChannelMembersInput, AddChannelMembersResult, BackendProviderCandidate, @@ -41,7 +39,6 @@ import type { AcpRuntimeCatalogEntry, AuthStatus, CommandAvailability, - ConnectAcpRuntimeResult, InstallRuntimeResult, GitBashPrerequisite, OpenDmInput, @@ -238,24 +235,6 @@ export type RawAcpRuntimeCatalogEntry = { login_hint?: string; }; -export 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; -}; - export type RawInstallStepResult = { step: string; command: string; @@ -936,18 +915,6 @@ function fromRawAcpRuntimeCatalogEntry( }; } -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, - }; -} - function fromRawInstallRuntimeResult( raw: RawInstallRuntimeResult, ): InstallRuntimeResult { @@ -1120,25 +1087,6 @@ export async function discoverGitBashPrerequisite(): Promise { - const raw = await invokeTauri( - "discover_acp_auth_methods", - { runtimeId }, - ); - return { methods: raw.methods.map(fromRawAcpAuthMethod) }; -} - -export async function connectAcpRuntime( - runtimeId: string, - methodId: string, -): Promise { - return invokeTauri("connect_acp_runtime", { - request: { runtimeId, methodId }, - }); -} - export async function discoverAcpRuntimes(): Promise { return ( await invokeTauri("discover_acp_providers") diff --git a/desktop/src/shared/api/tauriAgentAuth.ts b/desktop/src/shared/api/tauriAgentAuth.ts new file mode 100644 index 0000000000..93442270c8 --- /dev/null +++ b/desktop/src/shared/api/tauriAgentAuth.ts @@ -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 { + const raw = await invokeTauri( + "discover_acp_auth_methods", + { runtimeId }, + ); + return { methods: raw.methods.map(fromRawAcpAuthMethod) }; +} + +export async function connectAcpRuntime( + runtimeId: string, + methodId: string, +): Promise { + return invokeTauri("connect_acp_runtime", { + request: { runtimeId, methodId }, + }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1228fcecfb..e7a395a71c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -42,8 +42,10 @@ import { } from "@/shared/constants/kinds"; import type { RawAcpAuthMethodsResult, - RawAcpRuntimeCatalogEntry, RawConnectAcpRuntimeResult, +} from "@/shared/api/tauriAgentAuth"; +import type { + RawAcpRuntimeCatalogEntry, RawInstallRuntimeResult, } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 213b4c1bdf..6675ef23b3 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -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, }); From 2370ef0507141ed1c4a74011772fb056a6a57128 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 16 Jul 2026 13:10:24 -0400 Subject: [PATCH 3/3] Fix auth terminal launch on Windows Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- crates/buzz-acp/src/lib.rs | 3 -- desktop/src-tauri/src/commands/agent_auth.rs | 39 ++++++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 8aa0dbb3de..2b6e8d45d1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3352,9 +3352,6 @@ async fn spawn_and_init( } } -/// `buzz-acp models` — spawn an agent, query its available models, exit. -/// - async fn spawn_auth_client(agent: &AuthAgentArgs) -> Result { let agent_args = config::normalize_agent_args(&agent.agent_command, agent.agent_args.clone()); AcpClient::spawn(&agent.agent_command, &agent_args, &[], false).await diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs index f9b1dac964..7b1d84a564 100644 --- a/desktop/src-tauri/src/commands/agent_auth.rs +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -289,13 +289,26 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), 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(["/C", "start", "", "cmd", "/K"]) - .arg(shell_join(argv)); + .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 { + 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()) @@ -326,7 +339,9 @@ fn applescript_string(value: &str) -> String { #[cfg(test)] mod tests { - use super::{adapter_terminal_argv, shell_escape, shell_join, AcpAuthMethod}; + use super::{ + adapter_terminal_argv, shell_escape, shell_join, windows_terminal_args, AcpAuthMethod, + }; #[test] fn shell_join_escapes_spaces_and_quotes() { @@ -341,6 +356,24 @@ mod tests { 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"]}"#;