Compare commits

...

5 Commits

Author SHA1 Message Date
Richard Feldman
ed41944897 Preflight auth for codex-acp 2025-10-01 13:57:06 -04:00
Richard Feldman
16007c1da6 Handle 401s on our side 2025-10-01 12:19:23 -04:00
Richard Feldman
34c97ec12c Get auth working 2025-10-01 01:57:18 -04:00
Richard Feldman
91d76b2e33 Login for Codex 2025-09-30 17:08:59 -04:00
Richard Feldman
a03817959c Add initial Codex menu item 2025-09-30 15:44:36 -04:00
12 changed files with 349 additions and 13 deletions

2
Cargo.lock generated
View File

@@ -12089,6 +12089,8 @@ dependencies = [
"aho-corasick",
"anyhow",
"askpass",
"async-compression",
"async-tar",
"async-trait",
"base64 0.22.1",
"buffer_diff",

View File

@@ -1,7 +1,7 @@
use acp_thread::AgentConnection;
use acp_tools::AcpConnectionRegistry;
use action_log::ActionLog;
use agent_client_protocol::{self as acp, Agent as _, ErrorCode};
use agent_client_protocol::{self as acp, Agent as _};
use anyhow::anyhow;
use collections::HashMap;
use futures::AsyncBufReadExt as _;
@@ -380,12 +380,35 @@ impl AgentConnection for AcpConnection {
match result {
Ok(response) => Ok(response),
Err(err) => {
if err.code != ErrorCode::INTERNAL_ERROR.code {
if err.code != acp::ErrorCode::INTERNAL_ERROR.code {
// Intercept Unauthorized to trigger auth UI instead of retrying
if let Some(data) = &err.data {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ErrorDetails {
details: Box<str>,
}
if let Ok(ErrorDetails { details }) =
serde_json::from_value::<ErrorDetails>(data.clone())
{
if details.contains("401") || details.contains("Unauthorized") {
return Err(anyhow!(acp_thread::AuthRequired::new()));
}
}
}
if err.message.contains("401") || err.message.contains("Unauthorized") {
return Err(anyhow!(acp::Error::auth_required()));
}
anyhow::bail!(err)
}
let Some(data) = &err.data else {
anyhow::bail!(err)
// INTERNAL_ERROR without data but Unauthorized in the message
if err.message.contains("401") || err.message.contains("Unauthorized") {
return Err(anyhow!(acp::Error::auth_required()));
} else {
anyhow::bail!(err)
}
};
// Temporary workaround until the following PR is generally available:
@@ -407,11 +430,20 @@ impl AgentConnection for AcpConnection {
stop_reason: acp::StopReason::Cancelled,
meta: None,
})
} else if details.contains("401") || details.contains("Unauthorized") {
Err(anyhow!(acp::Error::auth_required()))
} else {
Err(anyhow!(details))
}
}
Err(_) => Err(anyhow!(err)),
Err(_) => {
let msg = err.message.as_str();
if msg.contains("401") || msg.contains("Unauthorized") {
Err(anyhow!(acp_thread::AuthRequired::new()))
} else {
Err(anyhow!(err))
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
mod acp;
mod claude;
mod codex;
mod custom;
mod gemini;
@@ -8,6 +9,7 @@ pub mod e2e_tests;
pub use claude::*;
use client::ProxySettings;
pub use codex::*;
use collections::HashMap;
pub use custom::*;
use fs::Fs;

View File

@@ -0,0 +1,80 @@
use std::rc::Rc;
use std::{any::Any, path::Path};
use crate::{AgentServer, AgentServerDelegate, load_proxy_env};
use acp_thread::AgentConnection;
use anyhow::{Context as _, Result};
use gpui::{App, SharedString, Task};
#[derive(Clone)]
pub struct Codex;
impl AgentServer for Codex {
fn telemetry_id(&self) -> &'static str {
"codex"
}
fn name(&self) -> SharedString {
"Codex".into()
}
fn logo(&self) -> ui::IconName {
// No dedicated Codex icon yet; use the generic AI icon.
ui::IconName::AiOpenAi
}
fn connect(
&self,
root_dir: Option<&Path>,
delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
let name = self.name();
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned());
let is_remote = delegate.project.read(cx).is_via_remote_server();
let store = delegate.store.downgrade();
let extra_env = load_proxy_env(cx);
// No modes for Codex (yet).
let default_mode = self.default_mode(cx);
cx.spawn(async move |cx| {
// Look up the external agent registered under the "codex" name.
// The AgentServerStore is responsible for:
// - Downloading the correct GitHub release tar.gz for the OS/arch
// - Extracting the binary
// - Returning an AgentServerCommand to launch the binary
// - Always reporting "no updates" for now
let (command, root_dir, login) = store
.update(cx, |store, cx| {
let agent = store
.get_external_agent(&"codex".into())
.context("Codex is not registered")?;
anyhow::Ok(agent.get_command(
root_dir.as_deref(),
extra_env,
delegate.status_tx,
// For now, Codex should report that there are no updates.
// The LocalCodex implementation in AgentServerStore should not send any updates.
delegate.new_version_available,
&mut cx.to_async(),
))
})??
.await?;
let connection = crate::acp::connect(
name,
command,
root_dir.as_ref(),
default_mode,
is_remote,
cx,
)
.await?;
Ok((connection, login))
})
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
self
}
}

View File

@@ -483,6 +483,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
default_mode: None,
}),
gemini: Some(crate::gemini::tests::local_command().into()),
codex: None,
custom: collections::HashMap::default(),
},
cx,

View File

@@ -577,6 +577,31 @@ impl AcpThreadView {
AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
// Proactively surface Authentication Required if the agent advertises auth methods.
if let Some(acp_conn) = thread
.read(cx)
.connection()
.clone()
.downcast::<agent_servers::AcpConnection>()
{
let methods = acp_conn.auth_methods();
if !methods.is_empty() {
// Immediately transition to auth-required UI, but defer to avoid re-entrant update.
let err = AuthRequired {
description: None,
provider_id: None,
};
let this_weak = cx.weak_entity();
let agent = agent.clone();
let connection = thread.read(cx).connection().clone();
window.defer(cx, move |window, cx| {
Self::handle_auth_required(
this_weak, err, agent, connection, window, cx,
);
});
}
}
this.model_selector = thread
.read(cx)
.connection()
@@ -1012,11 +1037,13 @@ impl AcpThreadView {
};
let connection = thread.read(cx).connection().clone();
if !connection
.auth_methods()
.iter()
.any(|method| method.id.0.as_ref() == "claude-login")
{
let auth_methods = connection.auth_methods();
let has_supported_auth = auth_methods.iter().any(|method| {
let id = method.id.0.as_ref();
id == "claude-login" || id == "spawn-gemini-cli"
});
let can_login = has_supported_auth || auth_methods.is_empty() || self.login.is_some();
if !can_login {
return;
};
let this = cx.weak_entity();

View File

@@ -26,7 +26,7 @@ use language_model::{
};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::{
agent_server_store::{AgentServerStore, CLAUDE_CODE_NAME, GEMINI_NAME},
agent_server_store::{AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME},
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
};
use settings::{Settings, SettingsStore, update_settings_file};
@@ -1014,7 +1014,9 @@ impl AgentConfiguration {
.agent_server_store
.read(cx)
.external_agents()
.filter(|name| name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME)
.filter(|name| {
name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME && name.0 != CODEX_NAME
})
.cloned()
.collect::<Vec<_>>();

View File

@@ -7,7 +7,7 @@ use acp_thread::AcpThread;
use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use project::agent_server_store::{
AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, GEMINI_NAME,
AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME,
};
use serde::{Deserialize, Serialize};
use settings::{
@@ -216,6 +216,7 @@ pub enum AgentType {
TextThread,
Gemini,
ClaudeCode,
Codex,
NativeAgent,
Custom {
name: SharedString,
@@ -230,6 +231,7 @@ impl AgentType {
Self::NativeAgent => "Agent 2".into(),
Self::Gemini => "Gemini CLI".into(),
Self::ClaudeCode => "Claude Code".into(),
Self::Codex => "Codex".into(),
Self::Custom { name, .. } => name.into(),
}
}
@@ -239,6 +241,7 @@ impl AgentType {
Self::Zed | Self::NativeAgent | Self::TextThread => None,
Self::Gemini => Some(IconName::AiGemini),
Self::ClaudeCode => Some(IconName::AiClaude),
Self::Codex => Some(IconName::Ai),
Self::Custom { .. } => Some(IconName::Terminal),
}
}
@@ -249,6 +252,7 @@ impl From<ExternalAgent> for AgentType {
match value {
ExternalAgent::Gemini => Self::Gemini,
ExternalAgent::ClaudeCode => Self::ClaudeCode,
ExternalAgent::Codex => Self::Codex,
ExternalAgent::Custom { name, command } => Self::Custom { name, command },
ExternalAgent::NativeAgent => Self::NativeAgent,
}
@@ -1427,6 +1431,11 @@ impl AgentPanel {
cx,
)
}
AgentType::Codex => {
self.selected_agent = AgentType::Codex;
self.serialize(cx);
self.external_thread(Some(crate::ExternalAgent::Codex), None, None, window, cx)
}
AgentType::Custom { name, command } => self.external_thread(
Some(crate::ExternalAgent::Custom { name, command }),
None,
@@ -1991,12 +2000,38 @@ impl AgentPanel {
}
}),
)
.item(
ContextMenuEntry::new("New Codex Thread")
.icon(IconName::Ai)
.disabled(is_via_collab)
.icon_color(Color::Muted)
.handler({
let workspace = workspace.clone();
move |window, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
if let Some(panel) =
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
panel.new_agent_thread(
AgentType::Codex,
window,
cx,
);
});
}
});
}
}
}),
)
.map(|mut menu| {
let agent_names = agent_server_store
.read(cx)
.external_agents()
.filter(|name| {
name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME
name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME && name.0 != CODEX_NAME
})
.cloned()
.collect::<Vec<_>>();

View File

@@ -167,6 +167,7 @@ enum ExternalAgent {
#[default]
Gemini,
ClaudeCode,
Codex,
NativeAgent,
Custom {
name: SharedString,
@@ -188,6 +189,7 @@ impl ExternalAgent {
Self::NativeAgent => "zed",
Self::Gemini => "gemini-cli",
Self::ClaudeCode => "claude-code",
Self::Codex => "codex",
Self::Custom { .. } => "custom",
}
}
@@ -200,6 +202,7 @@ impl ExternalAgent {
match self {
Self::Gemini => Rc::new(agent_servers::Gemini),
Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
Self::Codex => Rc::new(agent_servers::Codex),
Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
Self::Custom { name, command: _ } => {
Rc::new(agent_servers::CustomAgentServer::new(name.clone()))

View File

@@ -30,6 +30,8 @@ test-support = [
aho-corasick.workspace = true
anyhow.workspace = true
askpass.workspace = true
async-compression.workspace = true
async-tar.workspace = true
async-trait.workspace = true
base64.workspace = true
buffer_diff.workspace = true

View File

@@ -8,6 +8,7 @@ use std::{
};
use anyhow::{Context as _, Result, bail};
use client::Client;
use collections::HashMap;
use fs::{Fs, RemoveOptions, RenameOptions};
use futures::StreamExt as _;
@@ -194,6 +195,28 @@ impl AgentServerStore {
.and_then(|settings| settings.custom_command()),
}),
);
self.external_agents.insert(
CODEX_NAME.into(),
Box::new(LocalCodex {
fs: fs.clone(),
project_environment: project_environment.clone(),
custom_command: new_settings
.codex
.clone()
.and_then(|settings| settings.custom_command()),
}),
);
self.external_agents.insert(
CODEX_NAME.into(),
Box::new(LocalCodex {
fs: fs.clone(),
project_environment: project_environment.clone(),
custom_command: new_settings
.codex
.clone()
.and_then(|settings| settings.custom_command()),
}),
);
self.external_agents
.extend(new_settings.custom.iter().map(|(name, settings)| {
(
@@ -950,6 +973,129 @@ impl ExternalAgentServer for LocalClaudeCode {
}
}
struct LocalCodex {
fs: Arc<dyn Fs>,
project_environment: Entity<ProjectEnvironment>,
custom_command: Option<AgentServerCommand>,
}
impl ExternalAgentServer for LocalCodex {
fn get_command(
&mut self,
root_dir: Option<&str>,
extra_env: HashMap<String, String>,
_status_tx: Option<watch::Sender<SharedString>>,
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
cx: &mut AsyncApp,
) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
let fs = self.fs.clone();
let project_environment = self.project_environment.downgrade();
let custom_command = self.custom_command.clone();
let root_dir: Arc<Path> = root_dir
.map(|root_dir| Path::new(root_dir))
.unwrap_or(paths::home_dir())
.into();
cx.spawn(async move |cx| {
let mut env = project_environment
.update(cx, |project_environment, cx| {
project_environment.get_directory_environment(root_dir.clone(), cx)
})?
.await
.unwrap_or_default();
let mut command = if let Some(mut custom_command) = custom_command {
env.extend(custom_command.env.unwrap_or_default());
custom_command.env = Some(env);
custom_command
} else {
let dir = paths::data_dir().join("external_agents").join(CODEX_NAME);
fs.create_dir(&dir).await?;
// Find or install the latest Codex release (no update checks for now).
let http = cx.update(|cx| Client::global(cx).http_client())?;
let release = ::http_client::github::latest_github_release(
"zed-industries/codex-acp",
true,
false,
http.clone(),
)
.await
.context("fetching Codex latest release")?;
let version_dir = dir.join(&release.tag_name);
if !fs.is_dir(&version_dir).await {
// Determine the asset name based on CPU architecture.
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
std::env::consts::ARCH
};
let asset_name = format!("{arch}.tar.gz");
let asset_url = release
.assets
.iter()
.find(|a| a.name == asset_name)
.map(|a| a.browser_download_url.clone())
.context(format!(
"no asset named {asset_name} in release {}",
release.tag_name
))?;
let http = http.clone();
let mut response = http
.get(&asset_url, Default::default(), true)
.await
.context("downloading Codex binary")?;
anyhow::ensure!(
response.status().is_success(),
"failed to download Codex release: {}",
response.status()
);
// Decompress and extract the tar.gz into the version directory.
let reader = futures::io::BufReader::new(response.body_mut());
let decoder = async_compression::futures::bufread::GzipDecoder::new(reader);
let archive = async_tar::Archive::new(decoder);
archive
.unpack(&version_dir)
.await
.context("extracting Codex binary")?;
}
let bin_name = if cfg!(windows) {
"codex-acp.exe"
} else {
"codex-acp"
};
let bin_path = version_dir.join(bin_name);
anyhow::ensure!(
fs.is_file(&bin_path).await,
"Missing Codex binary at {} after installation",
bin_path.to_string_lossy()
);
let mut cmd = AgentServerCommand {
path: bin_path,
args: Vec::new(),
env: None,
};
cmd.env = Some(env);
cmd
};
command.env.get_or_insert_default().extend(extra_env);
Ok((command, root_dir.to_string_lossy().into_owned(), None))
})
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
struct LocalCustomAgent {
project_environment: Entity<ProjectEnvironment>,
command: AgentServerCommand,
@@ -991,11 +1137,13 @@ impl ExternalAgentServer for LocalCustomAgent {
pub const GEMINI_NAME: &'static str = "gemini";
pub const CLAUDE_CODE_NAME: &'static str = "claude";
pub const CODEX_NAME: &'static str = "codex";
#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
pub struct AllAgentServersSettings {
pub gemini: Option<BuiltinAgentServerSettings>,
pub claude: Option<BuiltinAgentServerSettings>,
pub codex: Option<BuiltinAgentServerSettings>,
pub custom: HashMap<SharedString, CustomAgentServerSettings>,
}
#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
@@ -1070,6 +1218,7 @@ impl settings::Settings for AllAgentServersSettings {
Self {
gemini: agent_settings.gemini.map(Into::into),
claude: agent_settings.claude.map(Into::into),
codex: agent_settings.codex.map(Into::into),
custom: agent_settings
.custom
.into_iter()

View File

@@ -282,6 +282,7 @@ impl From<&str> for LanguageModelProviderSetting {
pub struct AllAgentServersSettings {
pub gemini: Option<BuiltinAgentServerSettings>,
pub claude: Option<BuiltinAgentServerSettings>,
pub codex: Option<BuiltinAgentServerSettings>,
/// Custom agent servers configured by the user
#[serde(flatten)]