acp: Support terminal auth methods (#51999)

## Context

Brings in support for the new terminal auth methods. Currently behind
AcpBeta feature flag while the RFD stabilizes

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Ben Brandt authored and GitHub committed 2026-03-20 10:12:00 +00:00
1 parent b45534afa6
commit d1177dc43c
7 files changed
+353 -143

No files matched your search

Generated
+1
View File
@@ -271,6 +271,7 @@ dependencies = [
"collections",
"credentials_provider",
"env_logger 0.11.8",
"feature_flags",
"fs",
"futures 0.3.31",
"google_ai",
+32 -1
View File
@@ -2,12 +2,13 @@ use crate::AcpThread;
use agent_client_protocol::{self as acp};
use anyhow::Result;
use chrono::{DateTime, Utc};
use collections::IndexMap;
use collections::{HashMap, IndexMap};
use gpui::{Entity, SharedString, Task};
use language_model::LanguageModelProviderId;
use project::{AgentId, Project};
use serde::{Deserialize, Serialize};
use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc, sync::Arc};
use task::{HideStrategy, SpawnInTerminal, TaskId};
use ui::{App, IconName};
use util::path_list::PathList;
use uuid::Uuid;
@@ -21,6 +22,28 @@ impl UserMessageId {
}
}
pub fn build_terminal_auth_task(
id: String,
label: String,
command: String,
args: Vec<String>,
env: HashMap<String, String>,
) -> SpawnInTerminal {
SpawnInTerminal {
id: TaskId(id),
full_label: label.clone(),
label: label.clone(),
command: Some(command),
args,
command_label: label,
env,
use_new_terminal: true,
allow_concurrent_runs: true,
hide: HideStrategy::Always,
..Default::default()
}
}
pub trait AgentConnection {
fn agent_id(&self) -> AgentId;
@@ -90,6 +113,14 @@ pub trait AgentConnection {
fn auth_methods(&self) -> &[acp::AuthMethod];
fn terminal_auth_task(
&self,
_method: &acp::AuthMethodId,
_cx: &App,
) -> Option<SpawnInTerminal> {
None
}
fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>>;
fn prompt(
+1
View File
@@ -30,6 +30,7 @@ env_logger = { workspace = true, optional = true }
fs.workspace = true
futures.workspace = true
gpui.workspace = true
feature_flags.workspace = true
gpui_tokio = { workspace = true, optional = true }
credentials_provider.workspace = true
google_ai.workspace = true
+227 -2
View File
@@ -7,13 +7,14 @@ use action_log::ActionLog;
use agent_client_protocol::{self as acp, Agent as _, ErrorCode};
use anyhow::anyhow;
use collections::HashMap;
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use futures::AsyncBufReadExt as _;
use futures::io::BufReader;
use project::agent_server_store::AgentServerCommand;
use project::{AgentId, Project};
use serde::Deserialize;
use settings::Settings as _;
use task::ShellBuilder;
use task::{ShellBuilder, SpawnInTerminal};
use util::ResultExt as _;
use util::path_list::PathList;
use util::process::Child;
@@ -33,6 +34,8 @@ use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings
use crate::GEMINI_ID;
pub const GEMINI_TERMINAL_AUTH_METHOD_ID: &str = "spawn-gemini-cli";
#[derive(Debug, Error)]
#[error("Unsupported version")]
pub struct UnsupportedVersion;
@@ -44,6 +47,7 @@ pub struct AcpConnection {
connection: Rc<acp::ClientSideConnection>,
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
auth_methods: Vec<acp::AuthMethod>,
command: AgentServerCommand,
agent_capabilities: acp::AgentCapabilities,
default_mode: Option<acp::SessionModeId>,
default_model: Option<acp::ModelId>,
@@ -286,6 +290,7 @@ impl AcpConnection {
.read_text_file(true)
.write_text_file(true))
.terminal(true)
.auth(acp::AuthCapabilities::new().terminal(true))
// Experimental: Allow for rendering terminal output from the agents
.meta(acp::Meta::from_iter([
("terminal_output".into(), true.into()),
@@ -335,7 +340,7 @@ impl AcpConnection {
});
let meta = acp::Meta::from_iter([("terminal-auth".to_string(), value)]);
vec![acp::AuthMethod::Agent(
acp::AuthMethodAgent::new("spawn-gemini-cli", "Login")
acp::AuthMethodAgent::new(GEMINI_TERMINAL_AUTH_METHOD_ID, "Login")
.description("Login with your Google or Vertex AI account")
.meta(meta),
)]
@@ -345,6 +350,7 @@ impl AcpConnection {
Ok(Self {
id: agent_id,
auth_methods,
command,
connection,
display_name,
telemetry_id,
@@ -468,6 +474,64 @@ impl Drop for AcpConnection {
}
}
fn terminal_auth_task_id(agent_id: &AgentId, method_id: &acp::AuthMethodId) -> String {
format!("external-agent-{}-{}-login", agent_id.0, method_id.0)
}
fn terminal_auth_task(
command: &AgentServerCommand,
agent_id: &AgentId,
method: &acp::AuthMethodTerminal,
) -> SpawnInTerminal {
let mut args = command.args.clone();
args.extend(method.args.clone());
let mut env = command.env.clone().unwrap_or_default();
env.extend(method.env.clone());
acp_thread::build_terminal_auth_task(
terminal_auth_task_id(agent_id, &method.id),
method.name.clone(),
command.path.to_string_lossy().into_owned(),
args,
env,
)
}
/// Used to support the _meta method prior to stabilization
fn meta_terminal_auth_task(
agent_id: &AgentId,
method_id: &acp::AuthMethodId,
method: &acp::AuthMethod,
) -> Option<SpawnInTerminal> {
#[derive(Deserialize)]
struct MetaTerminalAuth {
label: String,
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
}
let meta = match method {
acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(),
acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(),
acp::AuthMethod::Agent(agent) => agent.meta.as_ref(),
_ => None,
}?;
let terminal_auth =
serde_json::from_value::<MetaTerminalAuth>(meta.get("terminal-auth")?.clone()).ok()?;
Some(acp_thread::build_terminal_auth_task(
terminal_auth_task_id(agent_id, method_id),
terminal_auth.label.clone(),
terminal_auth.command,
terminal_auth.args,
terminal_auth.env,
))
}
impl AgentConnection for AcpConnection {
fn agent_id(&self) -> AgentId {
self.id.clone()
@@ -813,6 +877,24 @@ impl AgentConnection for AcpConnection {
&self.auth_methods
}
fn terminal_auth_task(
&self,
method_id: &acp::AuthMethodId,
cx: &App,
) -> Option<SpawnInTerminal> {
let method = self
.auth_methods
.iter()
.find(|method| method.id() == method_id)?;
match method {
acp::AuthMethod::Terminal(terminal) if cx.has_flag::<AcpBetaFeatureFlag>() => {
Some(terminal_auth_task(&self.command, &self.id, terminal))
}
_ => meta_terminal_auth_task(&self.id, method_id, method),
}
}
fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>> {
let conn = self.connection.clone();
cx.foreground_executor().spawn(async move {
@@ -979,6 +1061,149 @@ fn map_acp_error(err: acp::Error) -> anyhow::Error {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_auth_task_reuses_command_and_merges_args_and_env() {
let command = AgentServerCommand {
path: "/path/to/agent".into(),
args: vec!["--acp".into(), "--verbose".into()],
env: Some(HashMap::from_iter([
("BASE".into(), "1".into()),
("SHARED".into(), "base".into()),
])),
};
let method = acp::AuthMethodTerminal::new("login", "Login")
.args(vec!["/auth".into()])
.env(std::collections::HashMap::from_iter([
("EXTRA".into(), "2".into()),
("SHARED".into(), "override".into()),
]));
let terminal_auth_task = terminal_auth_task(&command, &AgentId::new("test-agent"), &method);
assert_eq!(
terminal_auth_task.command.as_deref(),
Some("/path/to/agent")
);
assert_eq!(terminal_auth_task.args, vec!["--acp", "--verbose", "/auth"]);
assert_eq!(
terminal_auth_task.env,
HashMap::from_iter([
("BASE".into(), "1".into()),
("SHARED".into(), "override".into()),
("EXTRA".into(), "2".into()),
])
);
assert_eq!(terminal_auth_task.label, "Login");
assert_eq!(terminal_auth_task.command_label, "Login");
}
#[test]
fn legacy_terminal_auth_task_parses_meta_and_retries_session() {
let method_id = acp::AuthMethodId::new("legacy-login");
let method = acp::AuthMethod::Agent(
acp::AuthMethodAgent::new(method_id.clone(), "Login").meta(acp::Meta::from_iter([(
"terminal-auth".to_string(),
serde_json::json!({
"label": "legacy /auth",
"command": "legacy-agent",
"args": ["auth", "--interactive"],
"env": {
"AUTH_MODE": "interactive",
},
}),
)])),
);
let terminal_auth_task =
meta_terminal_auth_task(&AgentId::new("test-agent"), &method_id, &method)
.expect("expected legacy terminal auth task");
assert_eq!(
terminal_auth_task.id.0,
"external-agent-test-agent-legacy-login-login"
);
assert_eq!(terminal_auth_task.command.as_deref(), Some("legacy-agent"));
assert_eq!(terminal_auth_task.args, vec!["auth", "--interactive"]);
assert_eq!(
terminal_auth_task.env,
HashMap::from_iter([("AUTH_MODE".into(), "interactive".into())])
);
assert_eq!(terminal_auth_task.label, "legacy /auth");
}
#[test]
fn legacy_terminal_auth_task_returns_none_for_invalid_meta() {
let method_id = acp::AuthMethodId::new("legacy-login");
let method = acp::AuthMethod::Agent(
acp::AuthMethodAgent::new(method_id.clone(), "Login").meta(acp::Meta::from_iter([(
"terminal-auth".to_string(),
serde_json::json!({
"label": "legacy /auth",
}),
)])),
);
assert!(
meta_terminal_auth_task(&AgentId::new("test-agent"), &method_id, &method).is_none()
);
}
#[test]
fn first_class_terminal_auth_takes_precedence_over_legacy_meta() {
let method_id = acp::AuthMethodId::new("login");
let method = acp::AuthMethod::Terminal(
acp::AuthMethodTerminal::new(method_id, "Login")
.args(vec!["/auth".into()])
.env(std::collections::HashMap::from_iter([(
"AUTH_MODE".into(),
"first-class".into(),
)]))
.meta(acp::Meta::from_iter([(
"terminal-auth".to_string(),
serde_json::json!({
"label": "legacy /auth",
"command": "legacy-agent",
"args": ["legacy-auth"],
"env": {
"AUTH_MODE": "legacy",
},
}),
)])),
);
let command = AgentServerCommand {
path: "/path/to/agent".into(),
args: vec!["--acp".into()],
env: Some(HashMap::from_iter([("BASE".into(), "1".into())])),
};
let terminal_auth_task = match &method {
acp::AuthMethod::Terminal(terminal) => {
terminal_auth_task(&command, &AgentId::new("test-agent"), terminal)
}
_ => unreachable!(),
};
assert_eq!(
terminal_auth_task.command.as_deref(),
Some("/path/to/agent")
);
assert_eq!(terminal_auth_task.args, vec!["--acp", "/auth"]);
assert_eq!(
terminal_auth_task.env,
HashMap::from_iter([
("BASE".into(), "1".into()),
("AUTH_MODE".into(), "first-class".into()),
])
);
assert_eq!(terminal_auth_task.label, "Login");
}
}
fn mcp_servers_for_project(project: &Entity<Project>, cx: &App) -> Vec<acp::McpServer> {
let context_server_store = project.read(cx).context_server_store().read(cx);
let is_local = project.read(cx).is_local();
+1 -1
View File
@@ -17,7 +17,7 @@ use gpui::{App, AppContext, Entity, Task};
use settings::SettingsStore;
use std::{any::Any, rc::Rc, sync::Arc};
pub use acp::AcpConnection;
pub use acp::{AcpConnection, GEMINI_TERMINAL_AUTH_METHOD_ID};
pub struct AgentServerDelegate {
store: Entity<AgentServerStore>,
+89 -132
View File
@@ -8,9 +8,9 @@ use acp_thread::{AgentConnection, Plan};
use action_log::{ActionLog, ActionLogTelemetry, DiffStats};
use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore};
use agent_client_protocol as acp;
use agent_servers::AgentServer;
#[cfg(test)]
use agent_servers::AgentServerDelegate;
use agent_servers::{AgentServer, GEMINI_TERMINAL_AUTH_METHOD_ID};
use agent_settings::{AgentProfileId, AgentSettings};
use anyhow::{Result, anyhow};
use arrayvec::ArrayVec;
@@ -1475,6 +1475,9 @@ impl ConversationView {
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let Some(connected) = self.as_connected_mut() else {
return;
};
@@ -1491,119 +1494,65 @@ impl ConversationView {
let agent_telemetry_id = connection.telemetry_id();
// Check for the experimental "terminal-auth" _meta field
let auth_method = connection.auth_methods().iter().find(|m| m.id() == &method);
if let Some(login) = connection.terminal_auth_task(&method, cx) {
configuration_view.take();
pending_auth_method.replace(method.clone());
if let Some(terminal_auth) = auth_method
.and_then(|a| match a {
acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(),
acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(),
acp::AuthMethod::Agent(agent) => agent.meta.as_ref(),
_ => None,
})
.and_then(|m| m.get("terminal-auth"))
{
// Extract terminal auth details from meta
if let (Some(command), Some(label)) = (
terminal_auth.get("command").and_then(|v| v.as_str()),
terminal_auth.get("label").and_then(|v| v.as_str()),
) {
let args = terminal_auth
.get("args")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let project = self.project.clone();
let authenticate = Self::spawn_external_agent_login(
login,
workspace,
project,
method.clone(),
false,
window,
cx,
);
cx.notify();
self.auth_task = Some(cx.spawn_in(window, {
async move |this, cx| {
let result = authenticate.await;
let env = terminal_auth
.get("env")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|val| (k.clone(), val.to_string())))
.collect::<HashMap<String, String>>()
})
.unwrap_or_default();
// Build SpawnInTerminal from _meta
let login = task::SpawnInTerminal {
id: task::TaskId(format!("external-agent-{}-login", label)),
full_label: label.to_string(),
label: label.to_string(),
command: Some(command.to_string()),
args,
command_label: label.to_string(),
env,
use_new_terminal: true,
allow_concurrent_runs: true,
hide: task::HideStrategy::Always,
..Default::default()
};
configuration_view.take();
pending_auth_method.replace(method.clone());
if let Some(workspace) = self.workspace.upgrade() {
let project = self.project.clone();
let authenticate = Self::spawn_external_agent_login(
login,
workspace,
project,
method.clone(),
false,
window,
cx,
);
cx.notify();
self.auth_task = Some(cx.spawn_in(window, {
async move |this, cx| {
let result = authenticate.await;
match &result {
Ok(_) => telemetry::event!(
"Authenticate Agent Succeeded",
agent = agent_telemetry_id
),
Err(_) => {
telemetry::event!(
"Authenticate Agent Failed",
agent = agent_telemetry_id,
)
}
}
this.update_in(cx, |this, window, cx| {
if let Err(err) = result {
if let Some(ConnectedServerState {
auth_state:
AuthState::Unauthenticated {
pending_auth_method,
..
},
..
}) = this.as_connected_mut()
{
pending_auth_method.take();
}
if let Some(active) = this.active_thread() {
active.update(cx, |active, cx| {
active.handle_thread_error(err, cx);
})
}
} else {
this.reset(window, cx);
}
this.auth_task.take()
})
.ok();
match &result {
Ok(_) => telemetry::event!(
"Authenticate Agent Succeeded",
agent = agent_telemetry_id
),
Err(_) => {
telemetry::event!(
"Authenticate Agent Failed",
agent = agent_telemetry_id,
)
}
}));
}
this.update_in(cx, |this, window, cx| {
if let Err(err) = result {
if let Some(ConnectedServerState {
auth_state:
AuthState::Unauthenticated {
pending_auth_method,
..
},
..
}) = this.as_connected_mut()
{
pending_auth_method.take();
}
if let Some(active) = this.active_thread() {
active.update(cx, |active, cx| {
active.handle_thread_error(err, cx);
})
}
} else {
this.reset(window, cx);
}
this.auth_task.take()
})
.ok();
}
return;
}
}));
return;
}
configuration_view.take();
@@ -1726,7 +1675,7 @@ impl ConversationView {
cx: &mut App,
) -> Task<Result<()>> {
let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
return Task::ready(Ok(()));
return Task::ready(Err(anyhow!("Terminal panel is unavailable")));
};
window.spawn(cx, async move |cx| {
@@ -1734,17 +1683,14 @@ impl ConversationView {
if let Some(cmd) = &task.command {
// Have "node" command use Zed's managed Node runtime by default
if cmd == "node" {
let resolved_node_runtime = project
.update(cx, |project, cx| {
let agent_server_store = project.agent_server_store().clone();
agent_server_store.update(cx, |store, cx| {
store.node_runtime().map(|node_runtime| {
cx.background_spawn(async move {
node_runtime.binary_path().await
})
})
let resolved_node_runtime = project.update(cx, |project, cx| {
let agent_server_store = project.agent_server_store().clone();
agent_server_store.update(cx, |store, cx| {
store.node_runtime().map(|node_runtime| {
cx.background_spawn(async move { node_runtime.binary_path().await })
})
});
})
});
if let Some(resolve_task) = resolved_node_runtime {
if let Ok(node_path) = resolve_task.await {
@@ -1756,14 +1702,8 @@ impl ConversationView {
task.shell = task::Shell::WithArguments {
program: task.command.take().expect("login command should be set"),
args: std::mem::take(&mut task.args),
title_override: None
title_override: None,
};
task.full_label = task.label.clone();
task.id = task::TaskId(format!("external-agent-{}-login", task.label));
task.command_label = task.label.clone();
task.use_new_terminal = true;
task.allow_concurrent_runs = true;
task.hide = task::HideStrategy::Always;
let terminal = terminal_panel
.update_in(cx, |terminal_panel, window, cx| {
@@ -1772,7 +1712,7 @@ impl ConversationView {
.await?;
let success_patterns = match method.0.as_ref() {
"claude-login" | "spawn-gemini-cli" => vec![
"claude-login" | GEMINI_TERMINAL_AUTH_METHOD_ID => vec![
"Login successful".to_string(),
"Type your message".to_string(),
],
@@ -1806,7 +1746,9 @@ impl ConversationView {
cx.background_executor().timer(Duration::from_secs(1)).await;
let content =
terminal.update(cx, |terminal, _cx| terminal.get_content())?;
if success_patterns.iter().any(|pattern| content.contains(pattern))
if success_patterns
.iter()
.any(|pattern| content.contains(pattern))
{
return anyhow::Ok(());
}
@@ -1823,8 +1765,23 @@ impl ConversationView {
}
}
_ = exit_status => {
if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server()) && login.label.contains("gemini") {
return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, project.clone(), method, true, window, cx))?.await
if !previous_attempt
&& project.read_with(cx, |project, _| project.is_via_remote_server())
&& method.0.as_ref() == GEMINI_TERMINAL_AUTH_METHOD_ID
{
return cx
.update(|window, cx| {
Self::spawn_external_agent_login(
login,
workspace,
project.clone(),
method,
true,
window,
cx,
)
})?
.await;
}
return Err(anyhow!("exited before logging in"));
}
+2 -7
View File
@@ -1374,13 +1374,8 @@ impl ExternalAgentServer for LocalRegistryNpxAgent {
.await
.unwrap_or_default();
let mut exec_args = Vec::new();
exec_args.push("--yes".to_string());
exec_args.push(package.to_string());
if !args.is_empty() {
exec_args.push("--".to_string());
exec_args.extend(args);
}
let mut exec_args = vec!["--yes".to_string(), "--".to_string(), package.to_string()];
exec_args.extend(args);
let npm_command = node_runtime
.npm_command(