mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent
d52dedb06f
commit
8d3666c5f8
+49
-24
@@ -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),
|
||||
|
||||
@@ -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
@@ -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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'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'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'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 ? (
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user