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
) : 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 ? (
-
);
}
@@ -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"]}"#;