agent_server_store.rs

   1use std::{
   2    any::Any,
   3    borrow::Borrow,
   4    collections::HashSet,
   5    path::{Path, PathBuf},
   6    str::FromStr as _,
   7    sync::Arc,
   8    time::Duration,
   9};
  10
  11use anyhow::{Context as _, Result, bail};
  12use collections::HashMap;
  13use fs::{Fs, RemoveOptions, RenameOptions};
  14use futures::StreamExt as _;
  15use gpui::{
  16    AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
  17};
  18use http_client::{HttpClient, github::AssetKind};
  19use node_runtime::NodeRuntime;
  20use remote::RemoteClient;
  21use rpc::{AnyProtoClient, TypedEnvelope, proto};
  22use schemars::JsonSchema;
  23use serde::{Deserialize, Serialize};
  24use settings::SettingsStore;
  25use task::Shell;
  26use util::{ResultExt as _, debug_panic};
  27
  28use crate::ProjectEnvironment;
  29
  30#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
  31pub struct AgentServerCommand {
  32    #[serde(rename = "command")]
  33    pub path: PathBuf,
  34    #[serde(default)]
  35    pub args: Vec<String>,
  36    pub env: Option<HashMap<String, String>>,
  37}
  38
  39impl std::fmt::Debug for AgentServerCommand {
  40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  41        let filtered_env = self.env.as_ref().map(|env| {
  42            env.iter()
  43                .map(|(k, v)| {
  44                    (
  45                        k,
  46                        if util::redact::should_redact(k) {
  47                            "[REDACTED]"
  48                        } else {
  49                            v
  50                        },
  51                    )
  52                })
  53                .collect::<Vec<_>>()
  54        });
  55
  56        f.debug_struct("AgentServerCommand")
  57            .field("path", &self.path)
  58            .field("args", &self.args)
  59            .field("env", &filtered_env)
  60            .finish()
  61    }
  62}
  63
  64#[derive(Clone, Debug, PartialEq, Eq, Hash)]
  65pub struct ExternalAgentServerName(pub SharedString);
  66
  67impl std::fmt::Display for ExternalAgentServerName {
  68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  69        write!(f, "{}", self.0)
  70    }
  71}
  72
  73impl From<&'static str> for ExternalAgentServerName {
  74    fn from(value: &'static str) -> Self {
  75        ExternalAgentServerName(value.into())
  76    }
  77}
  78
  79impl From<ExternalAgentServerName> for SharedString {
  80    fn from(value: ExternalAgentServerName) -> Self {
  81        value.0
  82    }
  83}
  84
  85impl Borrow<str> for ExternalAgentServerName {
  86    fn borrow(&self) -> &str {
  87        &self.0
  88    }
  89}
  90
  91pub trait ExternalAgentServer {
  92    fn get_command(
  93        &mut self,
  94        root_dir: Option<&str>,
  95        extra_env: HashMap<String, String>,
  96        status_tx: Option<watch::Sender<SharedString>>,
  97        new_version_available_tx: Option<watch::Sender<Option<String>>>,
  98        cx: &mut AsyncApp,
  99    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>>;
 100
 101    fn as_any_mut(&mut self) -> &mut dyn Any;
 102}
 103
 104impl dyn ExternalAgentServer {
 105    fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
 106        self.as_any_mut().downcast_mut()
 107    }
 108}
 109
 110enum AgentServerStoreState {
 111    Local {
 112        node_runtime: NodeRuntime,
 113        fs: Arc<dyn Fs>,
 114        project_environment: Entity<ProjectEnvironment>,
 115        downstream_client: Option<(u64, AnyProtoClient)>,
 116        settings: Option<AllAgentServersSettings>,
 117        http_client: Arc<dyn HttpClient>,
 118        _subscriptions: [Subscription; 1],
 119    },
 120    Remote {
 121        project_id: u64,
 122        upstream_client: Entity<RemoteClient>,
 123    },
 124    Collab,
 125}
 126
 127pub struct AgentServerStore {
 128    state: AgentServerStoreState,
 129    external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
 130    agent_icons: HashMap<ExternalAgentServerName, SharedString>,
 131}
 132
 133pub struct AgentServersUpdated;
 134
 135impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
 136
 137#[cfg(test)]
 138mod ext_agent_tests {
 139    use super::*;
 140    use std::fmt::Write as _;
 141
 142    // Helper to build a store in Collab mode so we can mutate internal maps without
 143    // needing to spin up a full project environment.
 144    fn collab_store() -> AgentServerStore {
 145        AgentServerStore {
 146            state: AgentServerStoreState::Collab,
 147            external_agents: HashMap::default(),
 148            agent_icons: HashMap::default(),
 149        }
 150    }
 151
 152    // A simple fake that implements ExternalAgentServer without needing async plumbing.
 153    struct NoopExternalAgent;
 154
 155    impl ExternalAgentServer for NoopExternalAgent {
 156        fn get_command(
 157            &mut self,
 158            _root_dir: Option<&str>,
 159            _extra_env: HashMap<String, String>,
 160            _status_tx: Option<watch::Sender<SharedString>>,
 161            _new_version_available_tx: Option<watch::Sender<Option<String>>>,
 162            _cx: &mut AsyncApp,
 163        ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 164            Task::ready(Ok((
 165                AgentServerCommand {
 166                    path: PathBuf::from("noop"),
 167                    args: Vec::new(),
 168                    env: None,
 169                },
 170                "".to_string(),
 171                None,
 172            )))
 173        }
 174
 175        fn as_any_mut(&mut self) -> &mut dyn Any {
 176            self
 177        }
 178    }
 179
 180    #[test]
 181    fn external_agent_server_name_display() {
 182        let name = ExternalAgentServerName(SharedString::from("Ext: Tool"));
 183        let mut s = String::new();
 184        write!(&mut s, "{name}").unwrap();
 185        assert_eq!(s, "Ext: Tool");
 186    }
 187
 188    #[test]
 189    fn sync_extension_agents_removes_previous_extension_entries() {
 190        let mut store = collab_store();
 191
 192        // Seed with a couple of agents that will be replaced by extensions
 193        store.external_agents.insert(
 194            ExternalAgentServerName(SharedString::from("foo-agent")),
 195            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 196        );
 197        store.external_agents.insert(
 198            ExternalAgentServerName(SharedString::from("bar-agent")),
 199            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 200        );
 201        store.external_agents.insert(
 202            ExternalAgentServerName(SharedString::from("custom")),
 203            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 204        );
 205
 206        // Simulate the removal phase: if we're syncing extensions that provide
 207        // "foo-agent" and "bar-agent", those should be removed first
 208        let extension_agent_names: HashSet<String> =
 209            ["foo-agent".to_string(), "bar-agent".to_string()]
 210                .into_iter()
 211                .collect();
 212
 213        let keys_to_remove: Vec<_> = store
 214            .external_agents
 215            .keys()
 216            .filter(|name| extension_agent_names.contains(name.0.as_ref()))
 217            .cloned()
 218            .collect();
 219
 220        for key in keys_to_remove {
 221            store.external_agents.remove(&key);
 222        }
 223
 224        // Only the custom entry should remain.
 225        let remaining: Vec<_> = store
 226            .external_agents
 227            .keys()
 228            .map(|k| k.0.to_string())
 229            .collect();
 230        assert_eq!(remaining, vec!["custom".to_string()]);
 231    }
 232}
 233
 234impl AgentServerStore {
 235    /// Synchronizes extension-provided agent servers with the store.
 236    pub fn sync_extension_agents<'a, I>(
 237        &mut self,
 238        manifests: I,
 239        extensions_dir: PathBuf,
 240        cx: &mut Context<Self>,
 241    ) where
 242        I: IntoIterator<Item = (&'a str, &'a extension::ExtensionManifest)>,
 243    {
 244        // Collect manifests first so we can iterate twice
 245        let manifests: Vec<_> = manifests.into_iter().collect();
 246
 247        // Remove existing extension-provided agents by tracking which ones we're about to add
 248        let extension_agent_names: HashSet<_> = manifests
 249            .iter()
 250            .flat_map(|(_, manifest)| manifest.agent_servers.keys().map(|k| k.to_string()))
 251            .collect();
 252
 253        let keys_to_remove: Vec<_> = self
 254            .external_agents
 255            .keys()
 256            .filter(|name| {
 257                // Remove if it matches an extension agent name from any extension
 258                extension_agent_names.contains(name.0.as_ref())
 259            })
 260            .cloned()
 261            .collect();
 262        for key in &keys_to_remove {
 263            self.external_agents.remove(key);
 264            self.agent_icons.remove(key);
 265        }
 266
 267        // Insert agent servers from extension manifests
 268        match &self.state {
 269            AgentServerStoreState::Local {
 270                node_runtime,
 271                project_environment,
 272                fs,
 273                http_client,
 274                ..
 275            } => {
 276                for (ext_id, manifest) in manifests {
 277                    for (agent_name, agent_entry) in &manifest.agent_servers {
 278                        let display = SharedString::from(agent_entry.name.clone());
 279
 280                        // Store absolute icon path if provided, resolving symlinks for dev extensions
 281                        if let Some(icon) = &agent_entry.icon {
 282                            let icon_path = extensions_dir.join(ext_id).join(icon);
 283                            // Canonicalize to resolve symlinks (dev extensions are symlinked)
 284                            let absolute_icon_path = icon_path
 285                                .canonicalize()
 286                                .unwrap_or(icon_path)
 287                                .to_string_lossy()
 288                                .to_string();
 289                            self.agent_icons.insert(
 290                                ExternalAgentServerName(display.clone()),
 291                                SharedString::from(absolute_icon_path),
 292                            );
 293                        }
 294
 295                        // Archive-based launcher (download from URL)
 296                        self.external_agents.insert(
 297                            ExternalAgentServerName(display),
 298                            Box::new(LocalExtensionArchiveAgent {
 299                                fs: fs.clone(),
 300                                http_client: http_client.clone(),
 301                                node_runtime: node_runtime.clone(),
 302                                project_environment: project_environment.clone(),
 303                                extension_id: Arc::from(ext_id),
 304                                agent_id: agent_name.clone(),
 305                                targets: agent_entry.targets.clone(),
 306                                env: agent_entry.env.clone(),
 307                            }) as Box<dyn ExternalAgentServer>,
 308                        );
 309                    }
 310                }
 311            }
 312            _ => {
 313                // Only local projects support local extension agents
 314            }
 315        }
 316
 317        cx.emit(AgentServersUpdated);
 318    }
 319
 320    pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
 321        self.agent_icons.get(name).cloned()
 322    }
 323
 324    pub fn init_remote(session: &AnyProtoClient) {
 325        session.add_entity_message_handler(Self::handle_external_agents_updated);
 326        session.add_entity_message_handler(Self::handle_loading_status_updated);
 327        session.add_entity_message_handler(Self::handle_new_version_available);
 328    }
 329
 330    pub fn init_headless(session: &AnyProtoClient) {
 331        session.add_entity_request_handler(Self::handle_get_agent_server_command);
 332    }
 333
 334    fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
 335        let AgentServerStoreState::Local {
 336            settings: old_settings,
 337            ..
 338        } = &mut self.state
 339        else {
 340            debug_panic!(
 341                "should not be subscribed to agent server settings changes in non-local project"
 342            );
 343            return;
 344        };
 345
 346        let new_settings = cx
 347            .global::<SettingsStore>()
 348            .get::<AllAgentServersSettings>(None)
 349            .clone();
 350        if Some(&new_settings) == old_settings.as_ref() {
 351            return;
 352        }
 353
 354        self.reregister_agents(cx);
 355    }
 356
 357    fn reregister_agents(&mut self, cx: &mut Context<Self>) {
 358        let AgentServerStoreState::Local {
 359            node_runtime,
 360            fs,
 361            project_environment,
 362            downstream_client,
 363            settings: old_settings,
 364            http_client,
 365            ..
 366        } = &mut self.state
 367        else {
 368            debug_panic!("Non-local projects should never attempt to reregister. This is a bug!");
 369
 370            return;
 371        };
 372
 373        let new_settings = cx
 374            .global::<SettingsStore>()
 375            .get::<AllAgentServersSettings>(None)
 376            .clone();
 377
 378        self.external_agents.clear();
 379        self.external_agents.insert(
 380            GEMINI_NAME.into(),
 381            Box::new(LocalGemini {
 382                fs: fs.clone(),
 383                node_runtime: node_runtime.clone(),
 384                project_environment: project_environment.clone(),
 385                custom_command: new_settings
 386                    .gemini
 387                    .clone()
 388                    .and_then(|settings| settings.custom_command()),
 389                ignore_system_version: new_settings
 390                    .gemini
 391                    .as_ref()
 392                    .and_then(|settings| settings.ignore_system_version)
 393                    .unwrap_or(false),
 394            }),
 395        );
 396        self.external_agents.insert(
 397            CODEX_NAME.into(),
 398            Box::new(LocalCodex {
 399                fs: fs.clone(),
 400                project_environment: project_environment.clone(),
 401                custom_command: new_settings
 402                    .codex
 403                    .clone()
 404                    .and_then(|settings| settings.custom_command()),
 405                http_client: http_client.clone(),
 406                is_remote: downstream_client.is_some(),
 407            }),
 408        );
 409        self.external_agents.insert(
 410            CLAUDE_CODE_NAME.into(),
 411            Box::new(LocalClaudeCode {
 412                fs: fs.clone(),
 413                node_runtime: node_runtime.clone(),
 414                project_environment: project_environment.clone(),
 415                custom_command: new_settings
 416                    .claude
 417                    .clone()
 418                    .and_then(|settings| settings.custom_command()),
 419            }),
 420        );
 421        self.external_agents
 422            .extend(new_settings.custom.iter().map(|(name, settings)| {
 423                (
 424                    ExternalAgentServerName(name.clone()),
 425                    Box::new(LocalCustomAgent {
 426                        command: settings.command.clone(),
 427                        project_environment: project_environment.clone(),
 428                    }) as Box<dyn ExternalAgentServer>,
 429                )
 430            }));
 431
 432        *old_settings = Some(new_settings.clone());
 433
 434        if let Some((project_id, downstream_client)) = downstream_client {
 435            downstream_client
 436                .send(proto::ExternalAgentsUpdated {
 437                    project_id: *project_id,
 438                    names: self
 439                        .external_agents
 440                        .keys()
 441                        .map(|name| name.to_string())
 442                        .collect(),
 443                })
 444                .log_err();
 445        }
 446        cx.emit(AgentServersUpdated);
 447    }
 448
 449    pub fn node_runtime(&self) -> Option<NodeRuntime> {
 450        match &self.state {
 451            AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
 452            _ => None,
 453        }
 454    }
 455
 456    pub fn local(
 457        node_runtime: NodeRuntime,
 458        fs: Arc<dyn Fs>,
 459        project_environment: Entity<ProjectEnvironment>,
 460        http_client: Arc<dyn HttpClient>,
 461        cx: &mut Context<Self>,
 462    ) -> Self {
 463        let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
 464            this.agent_servers_settings_changed(cx);
 465        });
 466        let mut this = Self {
 467            state: AgentServerStoreState::Local {
 468                node_runtime,
 469                fs,
 470                project_environment,
 471                http_client,
 472                downstream_client: None,
 473                settings: None,
 474                _subscriptions: [subscription],
 475            },
 476            external_agents: Default::default(),
 477            agent_icons: Default::default(),
 478        };
 479        if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
 480        this.agent_servers_settings_changed(cx);
 481        this
 482    }
 483
 484    pub(crate) fn remote(project_id: u64, upstream_client: Entity<RemoteClient>) -> Self {
 485        // Set up the builtin agents here so they're immediately available in
 486        // remote projects--we know that the HeadlessProject on the other end
 487        // will have them.
 488        let external_agents: [(ExternalAgentServerName, Box<dyn ExternalAgentServer>); 3] = [
 489            (
 490                CLAUDE_CODE_NAME.into(),
 491                Box::new(RemoteExternalAgentServer {
 492                    project_id,
 493                    upstream_client: upstream_client.clone(),
 494                    name: CLAUDE_CODE_NAME.into(),
 495                    status_tx: None,
 496                    new_version_available_tx: None,
 497                }) as Box<dyn ExternalAgentServer>,
 498            ),
 499            (
 500                CODEX_NAME.into(),
 501                Box::new(RemoteExternalAgentServer {
 502                    project_id,
 503                    upstream_client: upstream_client.clone(),
 504                    name: CODEX_NAME.into(),
 505                    status_tx: None,
 506                    new_version_available_tx: None,
 507                }) as Box<dyn ExternalAgentServer>,
 508            ),
 509            (
 510                GEMINI_NAME.into(),
 511                Box::new(RemoteExternalAgentServer {
 512                    project_id,
 513                    upstream_client: upstream_client.clone(),
 514                    name: GEMINI_NAME.into(),
 515                    status_tx: None,
 516                    new_version_available_tx: None,
 517                }) as Box<dyn ExternalAgentServer>,
 518            ),
 519        ];
 520
 521        Self {
 522            state: AgentServerStoreState::Remote {
 523                project_id,
 524                upstream_client,
 525            },
 526            external_agents: external_agents.into_iter().collect(),
 527            agent_icons: HashMap::default(),
 528        }
 529    }
 530
 531    pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
 532        Self {
 533            state: AgentServerStoreState::Collab,
 534            external_agents: Default::default(),
 535            agent_icons: Default::default(),
 536        }
 537    }
 538
 539    pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
 540        match &mut self.state {
 541            AgentServerStoreState::Local {
 542                downstream_client, ..
 543            } => {
 544                *downstream_client = Some((project_id, client.clone()));
 545                // Send the current list of external agents downstream, but only after a delay,
 546                // to avoid having the message arrive before the downstream project's agent server store
 547                // sets up its handlers.
 548                cx.spawn(async move |this, cx| {
 549                    cx.background_executor().timer(Duration::from_secs(1)).await;
 550                    let names = this.update(cx, |this, _| {
 551                        this.external_agents
 552                            .keys()
 553                            .map(|name| name.to_string())
 554                            .collect()
 555                    })?;
 556                    client
 557                        .send(proto::ExternalAgentsUpdated { project_id, names })
 558                        .log_err();
 559                    anyhow::Ok(())
 560                })
 561                .detach();
 562            }
 563            AgentServerStoreState::Remote { .. } => {
 564                debug_panic!(
 565                    "external agents over collab not implemented, remote project should not be shared"
 566                );
 567            }
 568            AgentServerStoreState::Collab => {
 569                debug_panic!("external agents over collab not implemented, should not be shared");
 570            }
 571        }
 572    }
 573
 574    pub fn get_external_agent(
 575        &mut self,
 576        name: &ExternalAgentServerName,
 577    ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
 578        self.external_agents
 579            .get_mut(name)
 580            .map(|agent| agent.as_mut())
 581    }
 582
 583    pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
 584        self.external_agents.keys()
 585    }
 586
 587    async fn handle_get_agent_server_command(
 588        this: Entity<Self>,
 589        envelope: TypedEnvelope<proto::GetAgentServerCommand>,
 590        mut cx: AsyncApp,
 591    ) -> Result<proto::AgentServerCommand> {
 592        let (command, root_dir, login_command) = this
 593            .update(&mut cx, |this, cx| {
 594                let AgentServerStoreState::Local {
 595                    downstream_client, ..
 596                } = &this.state
 597                else {
 598                    debug_panic!("should not receive GetAgentServerCommand in a non-local project");
 599                    bail!("unexpected GetAgentServerCommand request in a non-local project");
 600                };
 601                let agent = this
 602                    .external_agents
 603                    .get_mut(&*envelope.payload.name)
 604                    .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
 605                let (status_tx, new_version_available_tx) = downstream_client
 606                    .clone()
 607                    .map(|(project_id, downstream_client)| {
 608                        let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
 609                        let (new_version_available_tx, mut new_version_available_rx) =
 610                            watch::channel(None);
 611                        cx.spawn({
 612                            let downstream_client = downstream_client.clone();
 613                            let name = envelope.payload.name.clone();
 614                            async move |_, _| {
 615                                while let Some(status) = status_rx.recv().await.ok() {
 616                                    downstream_client.send(
 617                                        proto::ExternalAgentLoadingStatusUpdated {
 618                                            project_id,
 619                                            name: name.clone(),
 620                                            status: status.to_string(),
 621                                        },
 622                                    )?;
 623                                }
 624                                anyhow::Ok(())
 625                            }
 626                        })
 627                        .detach_and_log_err(cx);
 628                        cx.spawn({
 629                            let name = envelope.payload.name.clone();
 630                            async move |_, _| {
 631                                if let Some(version) =
 632                                    new_version_available_rx.recv().await.ok().flatten()
 633                                {
 634                                    downstream_client.send(
 635                                        proto::NewExternalAgentVersionAvailable {
 636                                            project_id,
 637                                            name: name.clone(),
 638                                            version,
 639                                        },
 640                                    )?;
 641                                }
 642                                anyhow::Ok(())
 643                            }
 644                        })
 645                        .detach_and_log_err(cx);
 646                        (status_tx, new_version_available_tx)
 647                    })
 648                    .unzip();
 649                anyhow::Ok(agent.get_command(
 650                    envelope.payload.root_dir.as_deref(),
 651                    HashMap::default(),
 652                    status_tx,
 653                    new_version_available_tx,
 654                    &mut cx.to_async(),
 655                ))
 656            })??
 657            .await?;
 658        Ok(proto::AgentServerCommand {
 659            path: command.path.to_string_lossy().into_owned(),
 660            args: command.args,
 661            env: command
 662                .env
 663                .map(|env| env.into_iter().collect())
 664                .unwrap_or_default(),
 665            root_dir: root_dir,
 666            login: login_command.map(|cmd| cmd.to_proto()),
 667        })
 668    }
 669
 670    async fn handle_external_agents_updated(
 671        this: Entity<Self>,
 672        envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
 673        mut cx: AsyncApp,
 674    ) -> Result<()> {
 675        this.update(&mut cx, |this, cx| {
 676            let AgentServerStoreState::Remote {
 677                project_id,
 678                upstream_client,
 679            } = &this.state
 680            else {
 681                debug_panic!(
 682                    "handle_external_agents_updated should not be called for a non-remote project"
 683                );
 684                bail!("unexpected ExternalAgentsUpdated message")
 685            };
 686
 687            let mut status_txs = this
 688                .external_agents
 689                .iter_mut()
 690                .filter_map(|(name, agent)| {
 691                    Some((
 692                        name.clone(),
 693                        agent
 694                            .downcast_mut::<RemoteExternalAgentServer>()?
 695                            .status_tx
 696                            .take(),
 697                    ))
 698                })
 699                .collect::<HashMap<_, _>>();
 700            let mut new_version_available_txs = this
 701                .external_agents
 702                .iter_mut()
 703                .filter_map(|(name, agent)| {
 704                    Some((
 705                        name.clone(),
 706                        agent
 707                            .downcast_mut::<RemoteExternalAgentServer>()?
 708                            .new_version_available_tx
 709                            .take(),
 710                    ))
 711                })
 712                .collect::<HashMap<_, _>>();
 713
 714            this.external_agents = envelope
 715                .payload
 716                .names
 717                .into_iter()
 718                .map(|name| {
 719                    let agent = RemoteExternalAgentServer {
 720                        project_id: *project_id,
 721                        upstream_client: upstream_client.clone(),
 722                        name: ExternalAgentServerName(name.clone().into()),
 723                        status_tx: status_txs.remove(&*name).flatten(),
 724                        new_version_available_tx: new_version_available_txs
 725                            .remove(&*name)
 726                            .flatten(),
 727                    };
 728                    (
 729                        ExternalAgentServerName(name.into()),
 730                        Box::new(agent) as Box<dyn ExternalAgentServer>,
 731                    )
 732                })
 733                .collect();
 734            cx.emit(AgentServersUpdated);
 735            Ok(())
 736        })?
 737    }
 738
 739    async fn handle_loading_status_updated(
 740        this: Entity<Self>,
 741        envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
 742        mut cx: AsyncApp,
 743    ) -> Result<()> {
 744        this.update(&mut cx, |this, _| {
 745            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 746                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 747                && let Some(status_tx) = &mut agent.status_tx
 748            {
 749                status_tx.send(envelope.payload.status.into()).ok();
 750            }
 751        })
 752    }
 753
 754    async fn handle_new_version_available(
 755        this: Entity<Self>,
 756        envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
 757        mut cx: AsyncApp,
 758    ) -> Result<()> {
 759        this.update(&mut cx, |this, _| {
 760            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 761                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 762                && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
 763            {
 764                new_version_available_tx
 765                    .send(Some(envelope.payload.version))
 766                    .ok();
 767            }
 768        })
 769    }
 770}
 771
 772fn get_or_npm_install_builtin_agent(
 773    binary_name: SharedString,
 774    package_name: SharedString,
 775    entrypoint_path: PathBuf,
 776    minimum_version: Option<semver::Version>,
 777    status_tx: Option<watch::Sender<SharedString>>,
 778    new_version_available: Option<watch::Sender<Option<String>>>,
 779    fs: Arc<dyn Fs>,
 780    node_runtime: NodeRuntime,
 781    cx: &mut AsyncApp,
 782) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
 783    cx.spawn(async move |cx| {
 784        let node_path = node_runtime.binary_path().await?;
 785        let dir = paths::external_agents_dir().join(binary_name.as_str());
 786        fs.create_dir(&dir).await?;
 787
 788        let mut stream = fs.read_dir(&dir).await?;
 789        let mut versions = Vec::new();
 790        let mut to_delete = Vec::new();
 791        while let Some(entry) = stream.next().await {
 792            let Ok(entry) = entry else { continue };
 793            let Some(file_name) = entry.file_name() else {
 794                continue;
 795            };
 796
 797            if let Some(name) = file_name.to_str()
 798                && let Some(version) = semver::Version::from_str(name).ok()
 799                && fs
 800                    .is_file(&dir.join(file_name).join(&entrypoint_path))
 801                    .await
 802            {
 803                versions.push((version, file_name.to_owned()));
 804            } else {
 805                to_delete.push(file_name.to_owned())
 806            }
 807        }
 808
 809        versions.sort();
 810        let newest_version = if let Some((version, file_name)) = versions.last().cloned()
 811            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
 812        {
 813            versions.pop();
 814            Some(file_name)
 815        } else {
 816            None
 817        };
 818        log::debug!("existing version of {package_name}: {newest_version:?}");
 819        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
 820
 821        cx.background_spawn({
 822            let fs = fs.clone();
 823            let dir = dir.clone();
 824            async move {
 825                for file_name in to_delete {
 826                    fs.remove_dir(
 827                        &dir.join(file_name),
 828                        RemoveOptions {
 829                            recursive: true,
 830                            ignore_if_not_exists: false,
 831                        },
 832                    )
 833                    .await
 834                    .ok();
 835                }
 836            }
 837        })
 838        .detach();
 839
 840        let version = if let Some(file_name) = newest_version {
 841            cx.background_spawn({
 842                let file_name = file_name.clone();
 843                let dir = dir.clone();
 844                let fs = fs.clone();
 845                async move {
 846                    let latest_version = node_runtime
 847                        .npm_package_latest_version(&package_name)
 848                        .await
 849                        .ok();
 850                    if let Some(latest_version) = latest_version
 851                        && &latest_version != &file_name.to_string_lossy()
 852                    {
 853                        let download_result = download_latest_version(
 854                            fs,
 855                            dir.clone(),
 856                            node_runtime,
 857                            package_name.clone(),
 858                        )
 859                        .await
 860                        .log_err();
 861                        if let Some(mut new_version_available) = new_version_available
 862                            && download_result.is_some()
 863                        {
 864                            new_version_available.send(Some(latest_version)).ok();
 865                        }
 866                    }
 867                }
 868            })
 869            .detach();
 870            file_name
 871        } else {
 872            if let Some(mut status_tx) = status_tx {
 873                status_tx.send("Installing…".into()).ok();
 874            }
 875            let dir = dir.clone();
 876            cx.background_spawn(download_latest_version(
 877                fs.clone(),
 878                dir.clone(),
 879                node_runtime,
 880                package_name.clone(),
 881            ))
 882            .await?
 883            .into()
 884        };
 885
 886        let agent_server_path = dir.join(version).join(entrypoint_path);
 887        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
 888        anyhow::ensure!(
 889            agent_server_path_exists,
 890            "Missing entrypoint path {} after installation",
 891            agent_server_path.to_string_lossy()
 892        );
 893
 894        anyhow::Ok(AgentServerCommand {
 895            path: node_path,
 896            args: vec![agent_server_path.to_string_lossy().into_owned()],
 897            env: None,
 898        })
 899    })
 900}
 901
 902fn find_bin_in_path(
 903    bin_name: SharedString,
 904    root_dir: PathBuf,
 905    env: HashMap<String, String>,
 906    cx: &mut AsyncApp,
 907) -> Task<Option<PathBuf>> {
 908    cx.background_executor().spawn(async move {
 909        let which_result = if cfg!(windows) {
 910            which::which(bin_name.as_str())
 911        } else {
 912            let shell_path = env.get("PATH").cloned();
 913            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
 914        };
 915
 916        if let Err(which::Error::CannotFindBinaryPath) = which_result {
 917            return None;
 918        }
 919
 920        which_result.log_err()
 921    })
 922}
 923
 924async fn download_latest_version(
 925    fs: Arc<dyn Fs>,
 926    dir: PathBuf,
 927    node_runtime: NodeRuntime,
 928    package_name: SharedString,
 929) -> Result<String> {
 930    log::debug!("downloading latest version of {package_name}");
 931
 932    let tmp_dir = tempfile::tempdir_in(&dir)?;
 933
 934    node_runtime
 935        .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
 936        .await?;
 937
 938    let version = node_runtime
 939        .npm_package_installed_version(tmp_dir.path(), &package_name)
 940        .await?
 941        .context("expected package to be installed")?;
 942
 943    fs.rename(
 944        &tmp_dir.keep(),
 945        &dir.join(&version),
 946        RenameOptions {
 947            ignore_if_exists: true,
 948            overwrite: true,
 949        },
 950    )
 951    .await?;
 952
 953    anyhow::Ok(version)
 954}
 955
 956struct RemoteExternalAgentServer {
 957    project_id: u64,
 958    upstream_client: Entity<RemoteClient>,
 959    name: ExternalAgentServerName,
 960    status_tx: Option<watch::Sender<SharedString>>,
 961    new_version_available_tx: Option<watch::Sender<Option<String>>>,
 962}
 963
 964impl ExternalAgentServer for RemoteExternalAgentServer {
 965    fn get_command(
 966        &mut self,
 967        root_dir: Option<&str>,
 968        extra_env: HashMap<String, String>,
 969        status_tx: Option<watch::Sender<SharedString>>,
 970        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 971        cx: &mut AsyncApp,
 972    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 973        let project_id = self.project_id;
 974        let name = self.name.to_string();
 975        let upstream_client = self.upstream_client.downgrade();
 976        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
 977        self.status_tx = status_tx;
 978        self.new_version_available_tx = new_version_available_tx;
 979        cx.spawn(async move |cx| {
 980            let mut response = upstream_client
 981                .update(cx, |upstream_client, _| {
 982                    upstream_client
 983                        .proto_client()
 984                        .request(proto::GetAgentServerCommand {
 985                            project_id,
 986                            name,
 987                            root_dir: root_dir.clone(),
 988                        })
 989                })?
 990                .await?;
 991            let root_dir = response.root_dir;
 992            response.env.extend(extra_env);
 993            let command = upstream_client.update(cx, |client, _| {
 994                client.build_command(
 995                    Some(response.path),
 996                    &response.args,
 997                    &response.env.into_iter().collect(),
 998                    Some(root_dir.clone()),
 999                    None,
1000                )
1001            })??;
1002            Ok((
1003                AgentServerCommand {
1004                    path: command.program.into(),
1005                    args: command.args,
1006                    env: Some(command.env),
1007                },
1008                root_dir,
1009                None,
1010            ))
1011        })
1012    }
1013
1014    fn as_any_mut(&mut self) -> &mut dyn Any {
1015        self
1016    }
1017}
1018
1019struct LocalGemini {
1020    fs: Arc<dyn Fs>,
1021    node_runtime: NodeRuntime,
1022    project_environment: Entity<ProjectEnvironment>,
1023    custom_command: Option<AgentServerCommand>,
1024    ignore_system_version: bool,
1025}
1026
1027impl ExternalAgentServer for LocalGemini {
1028    fn get_command(
1029        &mut self,
1030        root_dir: Option<&str>,
1031        extra_env: HashMap<String, String>,
1032        status_tx: Option<watch::Sender<SharedString>>,
1033        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1034        cx: &mut AsyncApp,
1035    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1036        let fs = self.fs.clone();
1037        let node_runtime = self.node_runtime.clone();
1038        let project_environment = self.project_environment.downgrade();
1039        let custom_command = self.custom_command.clone();
1040        let ignore_system_version = self.ignore_system_version;
1041        let root_dir: Arc<Path> = root_dir
1042            .map(|root_dir| Path::new(root_dir))
1043            .unwrap_or(paths::home_dir())
1044            .into();
1045
1046        cx.spawn(async move |cx| {
1047            let mut env = project_environment
1048                .update(cx, |project_environment, cx| {
1049                    project_environment.get_local_directory_environment(
1050                        &Shell::System,
1051                        root_dir.clone(),
1052                        cx,
1053                    )
1054                })?
1055                .await
1056                .unwrap_or_default();
1057
1058            let mut command = if let Some(mut custom_command) = custom_command {
1059                env.extend(custom_command.env.unwrap_or_default());
1060                custom_command.env = Some(env);
1061                custom_command
1062            } else if !ignore_system_version
1063                && let Some(bin) =
1064                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1065            {
1066                AgentServerCommand {
1067                    path: bin,
1068                    args: Vec::new(),
1069                    env: Some(env),
1070                }
1071            } else {
1072                let mut command = get_or_npm_install_builtin_agent(
1073                    GEMINI_NAME.into(),
1074                    "@google/gemini-cli".into(),
1075                    "node_modules/@google/gemini-cli/dist/index.js".into(),
1076                    if cfg!(windows) {
1077                        // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1078                        Some("0.9.0".parse().unwrap())
1079                    } else {
1080                        Some("0.2.1".parse().unwrap())
1081                    },
1082                    status_tx,
1083                    new_version_available_tx,
1084                    fs,
1085                    node_runtime,
1086                    cx,
1087                )
1088                .await?;
1089                command.env = Some(env);
1090                command
1091            };
1092
1093            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1094            let login = task::SpawnInTerminal {
1095                command: Some(command.path.to_string_lossy().into_owned()),
1096                args: command.args.clone(),
1097                env: command.env.clone().unwrap_or_default(),
1098                label: "gemini /auth".into(),
1099                ..Default::default()
1100            };
1101
1102            command.env.get_or_insert_default().extend(extra_env);
1103            command.args.push("--experimental-acp".into());
1104            Ok((
1105                command,
1106                root_dir.to_string_lossy().into_owned(),
1107                Some(login),
1108            ))
1109        })
1110    }
1111
1112    fn as_any_mut(&mut self) -> &mut dyn Any {
1113        self
1114    }
1115}
1116
1117struct LocalClaudeCode {
1118    fs: Arc<dyn Fs>,
1119    node_runtime: NodeRuntime,
1120    project_environment: Entity<ProjectEnvironment>,
1121    custom_command: Option<AgentServerCommand>,
1122}
1123
1124impl ExternalAgentServer for LocalClaudeCode {
1125    fn get_command(
1126        &mut self,
1127        root_dir: Option<&str>,
1128        extra_env: HashMap<String, String>,
1129        status_tx: Option<watch::Sender<SharedString>>,
1130        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1131        cx: &mut AsyncApp,
1132    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1133        let fs = self.fs.clone();
1134        let node_runtime = self.node_runtime.clone();
1135        let project_environment = self.project_environment.downgrade();
1136        let custom_command = self.custom_command.clone();
1137        let root_dir: Arc<Path> = root_dir
1138            .map(|root_dir| Path::new(root_dir))
1139            .unwrap_or(paths::home_dir())
1140            .into();
1141
1142        cx.spawn(async move |cx| {
1143            let mut env = project_environment
1144                .update(cx, |project_environment, cx| {
1145                    project_environment.get_local_directory_environment(
1146                        &Shell::System,
1147                        root_dir.clone(),
1148                        cx,
1149                    )
1150                })?
1151                .await
1152                .unwrap_or_default();
1153            env.insert("ANTHROPIC_API_KEY".into(), "".into());
1154
1155            let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1156                env.extend(custom_command.env.unwrap_or_default());
1157                custom_command.env = Some(env);
1158                (custom_command, None)
1159            } else {
1160                let mut command = get_or_npm_install_builtin_agent(
1161                    "claude-code-acp".into(),
1162                    "@zed-industries/claude-code-acp".into(),
1163                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1164                    Some("0.5.2".parse().unwrap()),
1165                    status_tx,
1166                    new_version_available_tx,
1167                    fs,
1168                    node_runtime,
1169                    cx,
1170                )
1171                .await?;
1172                command.env = Some(env);
1173                let login = command
1174                    .args
1175                    .first()
1176                    .and_then(|path| {
1177                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1178                    })
1179                    .map(|path_prefix| task::SpawnInTerminal {
1180                        command: Some(command.path.to_string_lossy().into_owned()),
1181                        args: vec![
1182                            Path::new(path_prefix)
1183                                .join("@anthropic-ai/claude-agent-sdk/cli.js")
1184                                .to_string_lossy()
1185                                .to_string(),
1186                            "/login".into(),
1187                        ],
1188                        env: command.env.clone().unwrap_or_default(),
1189                        label: "claude /login".into(),
1190                        ..Default::default()
1191                    });
1192                (command, login)
1193            };
1194
1195            command.env.get_or_insert_default().extend(extra_env);
1196            Ok((
1197                command,
1198                root_dir.to_string_lossy().into_owned(),
1199                login_command,
1200            ))
1201        })
1202    }
1203
1204    fn as_any_mut(&mut self) -> &mut dyn Any {
1205        self
1206    }
1207}
1208
1209struct LocalCodex {
1210    fs: Arc<dyn Fs>,
1211    project_environment: Entity<ProjectEnvironment>,
1212    http_client: Arc<dyn HttpClient>,
1213    custom_command: Option<AgentServerCommand>,
1214    is_remote: bool,
1215}
1216
1217impl ExternalAgentServer for LocalCodex {
1218    fn get_command(
1219        &mut self,
1220        root_dir: Option<&str>,
1221        extra_env: HashMap<String, String>,
1222        status_tx: Option<watch::Sender<SharedString>>,
1223        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1224        cx: &mut AsyncApp,
1225    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1226        let fs = self.fs.clone();
1227        let project_environment = self.project_environment.downgrade();
1228        let http = self.http_client.clone();
1229        let custom_command = self.custom_command.clone();
1230        let root_dir: Arc<Path> = root_dir
1231            .map(|root_dir| Path::new(root_dir))
1232            .unwrap_or(paths::home_dir())
1233            .into();
1234        let is_remote = self.is_remote;
1235
1236        cx.spawn(async move |cx| {
1237            let mut env = project_environment
1238                .update(cx, |project_environment, cx| {
1239                    project_environment.get_local_directory_environment(
1240                        &Shell::System,
1241                        root_dir.clone(),
1242                        cx,
1243                    )
1244                })?
1245                .await
1246                .unwrap_or_default();
1247            if is_remote {
1248                env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1249            }
1250
1251            let mut command = if let Some(mut custom_command) = custom_command {
1252                env.extend(custom_command.env.unwrap_or_default());
1253                custom_command.env = Some(env);
1254                custom_command
1255            } else {
1256                let dir = paths::external_agents_dir().join(CODEX_NAME);
1257                fs.create_dir(&dir).await?;
1258
1259                // Find or install the latest Codex release (no update checks for now).
1260                let release = ::http_client::github::latest_github_release(
1261                    CODEX_ACP_REPO,
1262                    true,
1263                    false,
1264                    http.clone(),
1265                )
1266                .await
1267                .context("fetching Codex latest release")?;
1268
1269                let version_dir = dir.join(&release.tag_name);
1270                if !fs.is_dir(&version_dir).await {
1271                    if let Some(mut status_tx) = status_tx {
1272                        status_tx.send("Installing…".into()).ok();
1273                    }
1274
1275                    let tag = release.tag_name.clone();
1276                    let version_number = tag.trim_start_matches('v');
1277                    let asset_name = asset_name(version_number)
1278                        .context("codex acp is not supported for this architecture")?;
1279                    let asset = release
1280                        .assets
1281                        .into_iter()
1282                        .find(|asset| asset.name == asset_name)
1283                        .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1284                    // Strip "sha256:" prefix from digest if present (GitHub API format)
1285                    let digest = asset
1286                        .digest
1287                        .as_deref()
1288                        .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1289                    ::http_client::github_download::download_server_binary(
1290                        &*http,
1291                        &asset.browser_download_url,
1292                        digest,
1293                        &version_dir,
1294                        if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1295                            AssetKind::Zip
1296                        } else {
1297                            AssetKind::TarGz
1298                        },
1299                    )
1300                    .await?;
1301
1302                    // remove older versions
1303                    util::fs::remove_matching(&dir, |entry| entry != version_dir).await;
1304                }
1305
1306                let bin_name = if cfg!(windows) {
1307                    "codex-acp.exe"
1308                } else {
1309                    "codex-acp"
1310                };
1311                let bin_path = version_dir.join(bin_name);
1312                anyhow::ensure!(
1313                    fs.is_file(&bin_path).await,
1314                    "Missing Codex binary at {} after installation",
1315                    bin_path.to_string_lossy()
1316                );
1317
1318                let mut cmd = AgentServerCommand {
1319                    path: bin_path,
1320                    args: Vec::new(),
1321                    env: None,
1322                };
1323                cmd.env = Some(env);
1324                cmd
1325            };
1326
1327            command.env.get_or_insert_default().extend(extra_env);
1328            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1329        })
1330    }
1331
1332    fn as_any_mut(&mut self) -> &mut dyn Any {
1333        self
1334    }
1335}
1336
1337pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1338
1339fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1340    let arch = if cfg!(target_arch = "x86_64") {
1341        "x86_64"
1342    } else if cfg!(target_arch = "aarch64") {
1343        "aarch64"
1344    } else {
1345        return None;
1346    };
1347
1348    let platform = if cfg!(target_os = "macos") {
1349        "apple-darwin"
1350    } else if cfg!(target_os = "windows") {
1351        "pc-windows-msvc"
1352    } else if cfg!(target_os = "linux") {
1353        "unknown-linux-gnu"
1354    } else {
1355        return None;
1356    };
1357
1358    // Only Windows x86_64 uses .zip in release assets
1359    let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1360        "zip"
1361    } else {
1362        "tar.gz"
1363    };
1364
1365    Some((arch, platform, ext))
1366}
1367
1368fn asset_name(version: &str) -> Option<String> {
1369    let (arch, platform, ext) = get_platform_info()?;
1370    Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1371}
1372
1373struct LocalExtensionArchiveAgent {
1374    fs: Arc<dyn Fs>,
1375    http_client: Arc<dyn HttpClient>,
1376    node_runtime: NodeRuntime,
1377    project_environment: Entity<ProjectEnvironment>,
1378    extension_id: Arc<str>,
1379    agent_id: Arc<str>,
1380    targets: HashMap<String, extension::TargetConfig>,
1381    env: HashMap<String, String>,
1382}
1383
1384struct LocalCustomAgent {
1385    project_environment: Entity<ProjectEnvironment>,
1386    command: AgentServerCommand,
1387}
1388
1389impl ExternalAgentServer for LocalExtensionArchiveAgent {
1390    fn get_command(
1391        &mut self,
1392        root_dir: Option<&str>,
1393        extra_env: HashMap<String, String>,
1394        _status_tx: Option<watch::Sender<SharedString>>,
1395        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1396        cx: &mut AsyncApp,
1397    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1398        let fs = self.fs.clone();
1399        let http_client = self.http_client.clone();
1400        let node_runtime = self.node_runtime.clone();
1401        let project_environment = self.project_environment.downgrade();
1402        let extension_id = self.extension_id.clone();
1403        let agent_id = self.agent_id.clone();
1404        let targets = self.targets.clone();
1405        let base_env = self.env.clone();
1406
1407        let root_dir: Arc<Path> = root_dir
1408            .map(|root_dir| Path::new(root_dir))
1409            .unwrap_or(paths::home_dir())
1410            .into();
1411
1412        cx.spawn(async move |cx| {
1413            // Get project environment
1414            let mut env = project_environment
1415                .update(cx, |project_environment, cx| {
1416                    project_environment.get_local_directory_environment(
1417                        &Shell::System,
1418                        root_dir.clone(),
1419                        cx,
1420                    )
1421                })?
1422                .await
1423                .unwrap_or_default();
1424
1425            // Merge manifest env and extra env
1426            env.extend(base_env);
1427            env.extend(extra_env);
1428
1429            let cache_key = format!("{}/{}", extension_id, agent_id);
1430            let dir = paths::external_agents_dir().join(&cache_key);
1431            fs.create_dir(&dir).await?;
1432
1433            // Determine platform key
1434            let os = if cfg!(target_os = "macos") {
1435                "darwin"
1436            } else if cfg!(target_os = "linux") {
1437                "linux"
1438            } else if cfg!(target_os = "windows") {
1439                "windows"
1440            } else {
1441                anyhow::bail!("unsupported OS");
1442            };
1443
1444            let arch = if cfg!(target_arch = "aarch64") {
1445                "aarch64"
1446            } else if cfg!(target_arch = "x86_64") {
1447                "x86_64"
1448            } else {
1449                anyhow::bail!("unsupported architecture");
1450            };
1451
1452            let platform_key = format!("{}-{}", os, arch);
1453            let target_config = targets.get(&platform_key).with_context(|| {
1454                format!(
1455                    "no target specified for platform '{}'. Available platforms: {}",
1456                    platform_key,
1457                    targets
1458                        .keys()
1459                        .map(|k| k.as_str())
1460                        .collect::<Vec<_>>()
1461                        .join(", ")
1462                )
1463            })?;
1464
1465            let archive_url = &target_config.archive;
1466
1467            // Use URL as version identifier for caching
1468            // Hash the URL to get a stable directory name
1469            use std::collections::hash_map::DefaultHasher;
1470            use std::hash::{Hash, Hasher};
1471            let mut hasher = DefaultHasher::new();
1472            archive_url.hash(&mut hasher);
1473            let url_hash = hasher.finish();
1474            let version_dir = dir.join(format!("v_{:x}", url_hash));
1475
1476            if !fs.is_dir(&version_dir).await {
1477                // Determine SHA256 for verification
1478                let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1479                    // Use provided SHA256
1480                    Some(provided_sha.clone())
1481                } else if archive_url.starts_with("https://github.com/") {
1482                    // Try to fetch SHA256 from GitHub API
1483                    // Parse URL to extract repo and tag/file info
1484                    // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1485                    if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1486                        let parts: Vec<&str> = caps.split('/').collect();
1487                        if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1488                            let repo = format!("{}/{}", parts[0], parts[1]);
1489                            let tag = parts[4];
1490                            let filename = parts[5..].join("/");
1491
1492                            // Try to get release info from GitHub
1493                            if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1494                                &repo,
1495                                tag,
1496                                http_client.clone(),
1497                            )
1498                            .await
1499                            {
1500                                // Find matching asset
1501                                if let Some(asset) =
1502                                    release.assets.iter().find(|a| a.name == filename)
1503                                {
1504                                    // Strip "sha256:" prefix if present
1505                                    asset.digest.as_ref().and_then(|d| {
1506                                        d.strip_prefix("sha256:")
1507                                            .map(|s| s.to_string())
1508                                            .or_else(|| Some(d.clone()))
1509                                    })
1510                                } else {
1511                                    None
1512                                }
1513                            } else {
1514                                None
1515                            }
1516                        } else {
1517                            None
1518                        }
1519                    } else {
1520                        None
1521                    }
1522                } else {
1523                    None
1524                };
1525
1526                // Determine archive type from URL
1527                let asset_kind = if archive_url.ends_with(".zip") {
1528                    AssetKind::Zip
1529                } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1530                    AssetKind::TarGz
1531                } else {
1532                    anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1533                };
1534
1535                // Download and extract
1536                ::http_client::github_download::download_server_binary(
1537                    &*http_client,
1538                    archive_url,
1539                    sha256.as_deref(),
1540                    &version_dir,
1541                    asset_kind,
1542                )
1543                .await?;
1544            }
1545
1546            // Validate and resolve cmd path
1547            let cmd = &target_config.cmd;
1548
1549            let cmd_path = if cmd == "node" {
1550                // Use Zed's managed Node.js runtime
1551                node_runtime.binary_path().await?
1552            } else {
1553                if cmd.contains("..") {
1554                    anyhow::bail!("command path cannot contain '..': {}", cmd);
1555                }
1556
1557                if cmd.starts_with("./") || cmd.starts_with(".\\") {
1558                    // Relative to extraction directory
1559                    let cmd_path = version_dir.join(&cmd[2..]);
1560                    anyhow::ensure!(
1561                        fs.is_file(&cmd_path).await,
1562                        "Missing command {} after extraction",
1563                        cmd_path.to_string_lossy()
1564                    );
1565                    cmd_path
1566                } else {
1567                    // On PATH
1568                    anyhow::bail!("command must be relative (start with './'): {}", cmd);
1569                }
1570            };
1571
1572            let command = AgentServerCommand {
1573                path: cmd_path,
1574                args: target_config.args.clone(),
1575                env: Some(env),
1576            };
1577
1578            Ok((command, version_dir.to_string_lossy().into_owned(), None))
1579        })
1580    }
1581
1582    fn as_any_mut(&mut self) -> &mut dyn Any {
1583        self
1584    }
1585}
1586
1587impl ExternalAgentServer for LocalCustomAgent {
1588    fn get_command(
1589        &mut self,
1590        root_dir: Option<&str>,
1591        extra_env: HashMap<String, String>,
1592        _status_tx: Option<watch::Sender<SharedString>>,
1593        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1594        cx: &mut AsyncApp,
1595    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1596        let mut command = self.command.clone();
1597        let root_dir: Arc<Path> = root_dir
1598            .map(|root_dir| Path::new(root_dir))
1599            .unwrap_or(paths::home_dir())
1600            .into();
1601        let project_environment = self.project_environment.downgrade();
1602        cx.spawn(async move |cx| {
1603            let mut env = project_environment
1604                .update(cx, |project_environment, cx| {
1605                    project_environment.get_local_directory_environment(
1606                        &Shell::System,
1607                        root_dir.clone(),
1608                        cx,
1609                    )
1610                })?
1611                .await
1612                .unwrap_or_default();
1613            env.extend(command.env.unwrap_or_default());
1614            env.extend(extra_env);
1615            command.env = Some(env);
1616            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1617        })
1618    }
1619
1620    fn as_any_mut(&mut self) -> &mut dyn Any {
1621        self
1622    }
1623}
1624
1625pub const GEMINI_NAME: &'static str = "gemini";
1626pub const CLAUDE_CODE_NAME: &'static str = "claude";
1627pub const CODEX_NAME: &'static str = "codex";
1628
1629#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1630pub struct AllAgentServersSettings {
1631    pub gemini: Option<BuiltinAgentServerSettings>,
1632    pub claude: Option<BuiltinAgentServerSettings>,
1633    pub codex: Option<BuiltinAgentServerSettings>,
1634    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1635}
1636#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1637pub struct BuiltinAgentServerSettings {
1638    pub path: Option<PathBuf>,
1639    pub args: Option<Vec<String>>,
1640    pub env: Option<HashMap<String, String>>,
1641    pub ignore_system_version: Option<bool>,
1642    pub default_mode: Option<String>,
1643}
1644
1645impl BuiltinAgentServerSettings {
1646    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1647        self.path.map(|path| AgentServerCommand {
1648            path,
1649            args: self.args.unwrap_or_default(),
1650            env: self.env,
1651        })
1652    }
1653}
1654
1655impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1656    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1657        BuiltinAgentServerSettings {
1658            path: value.path,
1659            args: value.args,
1660            env: value.env,
1661            ignore_system_version: value.ignore_system_version,
1662            default_mode: value.default_mode,
1663        }
1664    }
1665}
1666
1667impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1668    fn from(value: AgentServerCommand) -> Self {
1669        BuiltinAgentServerSettings {
1670            path: Some(value.path),
1671            args: Some(value.args),
1672            env: value.env,
1673            ..Default::default()
1674        }
1675    }
1676}
1677
1678#[derive(Clone, JsonSchema, Debug, PartialEq)]
1679pub struct CustomAgentServerSettings {
1680    pub command: AgentServerCommand,
1681    /// The default mode to use for this agent.
1682    ///
1683    /// Note: Not only all agents support modes.
1684    ///
1685    /// Default: None
1686    pub default_mode: Option<String>,
1687}
1688
1689impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1690    fn from(value: settings::CustomAgentServerSettings) -> Self {
1691        CustomAgentServerSettings {
1692            command: AgentServerCommand {
1693                path: value.path,
1694                args: value.args,
1695                env: value.env,
1696            },
1697            default_mode: value.default_mode,
1698        }
1699    }
1700}
1701
1702impl settings::Settings for AllAgentServersSettings {
1703    fn from_settings(content: &settings::SettingsContent) -> Self {
1704        let agent_settings = content.agent_servers.clone().unwrap();
1705        Self {
1706            gemini: agent_settings.gemini.map(Into::into),
1707            claude: agent_settings.claude.map(Into::into),
1708            codex: agent_settings.codex.map(Into::into),
1709            custom: agent_settings
1710                .custom
1711                .into_iter()
1712                .map(|(k, v)| (k, v.into()))
1713                .collect(),
1714        }
1715    }
1716}
1717
1718#[cfg(test)]
1719mod extension_agent_tests {
1720    use super::*;
1721    use gpui::TestAppContext;
1722    use std::sync::Arc;
1723
1724    #[test]
1725    fn extension_agent_constructs_proper_display_names() {
1726        // Verify the display name format for extension-provided agents
1727        let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
1728        assert!(name1.0.contains(": "));
1729
1730        let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
1731        assert_eq!(name2.0, "MyExt: MyAgent");
1732
1733        // Non-extension agents shouldn't have the separator
1734        let custom = ExternalAgentServerName(SharedString::from("custom"));
1735        assert!(!custom.0.contains(": "));
1736    }
1737
1738    struct NoopExternalAgent;
1739
1740    impl ExternalAgentServer for NoopExternalAgent {
1741        fn get_command(
1742            &mut self,
1743            _root_dir: Option<&str>,
1744            _extra_env: HashMap<String, String>,
1745            _status_tx: Option<watch::Sender<SharedString>>,
1746            _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1747            _cx: &mut AsyncApp,
1748        ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1749            Task::ready(Ok((
1750                AgentServerCommand {
1751                    path: PathBuf::from("noop"),
1752                    args: Vec::new(),
1753                    env: None,
1754                },
1755                "".to_string(),
1756                None,
1757            )))
1758        }
1759
1760        fn as_any_mut(&mut self) -> &mut dyn Any {
1761            self
1762        }
1763    }
1764
1765    #[test]
1766    fn sync_removes_only_extension_provided_agents() {
1767        let mut store = AgentServerStore {
1768            state: AgentServerStoreState::Collab,
1769            external_agents: HashMap::default(),
1770            agent_icons: HashMap::default(),
1771        };
1772
1773        // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
1774        store.external_agents.insert(
1775            ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
1776            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1777        );
1778        store.external_agents.insert(
1779            ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
1780            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1781        );
1782        store.external_agents.insert(
1783            ExternalAgentServerName(SharedString::from("custom-agent")),
1784            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1785        );
1786
1787        // Simulate removal phase
1788        let keys_to_remove: Vec<_> = store
1789            .external_agents
1790            .keys()
1791            .filter(|name| name.0.contains(": "))
1792            .cloned()
1793            .collect();
1794
1795        for key in keys_to_remove {
1796            store.external_agents.remove(&key);
1797        }
1798
1799        // Only custom-agent should remain
1800        assert_eq!(store.external_agents.len(), 1);
1801        assert!(
1802            store
1803                .external_agents
1804                .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
1805        );
1806    }
1807
1808    #[test]
1809    fn archive_launcher_constructs_with_all_fields() {
1810        use extension::AgentServerManifestEntry;
1811
1812        let mut env = HashMap::default();
1813        env.insert("GITHUB_TOKEN".into(), "secret".into());
1814
1815        let mut targets = HashMap::default();
1816        targets.insert(
1817            "darwin-aarch64".to_string(),
1818            extension::TargetConfig {
1819                archive:
1820                    "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
1821                        .into(),
1822                cmd: "./agent".into(),
1823                args: vec![],
1824                sha256: None,
1825            },
1826        );
1827
1828        let _entry = AgentServerManifestEntry {
1829            name: "GitHub Agent".into(),
1830            targets,
1831            env,
1832            icon: None,
1833        };
1834
1835        // Verify display name construction
1836        let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
1837        assert_eq!(expected_name.0, "GitHub Agent");
1838    }
1839
1840    #[gpui::test]
1841    async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
1842        let fs = fs::FakeFs::new(cx.background_executor.clone());
1843        let http_client = http_client::FakeHttpClient::with_404_response();
1844        let project_environment = cx.new(|cx| crate::ProjectEnvironment::new(None, cx));
1845
1846        let agent = LocalExtensionArchiveAgent {
1847            fs,
1848            http_client,
1849            node_runtime: node_runtime::NodeRuntime::unavailable(),
1850            project_environment,
1851            extension_id: Arc::from("my-extension"),
1852            agent_id: Arc::from("my-agent"),
1853            targets: {
1854                let mut map = HashMap::default();
1855                map.insert(
1856                    "darwin-aarch64".to_string(),
1857                    extension::TargetConfig {
1858                        archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
1859                        cmd: "./my-agent".into(),
1860                        args: vec!["--serve".into()],
1861                        sha256: None,
1862                    },
1863                );
1864                map
1865            },
1866            env: {
1867                let mut map = HashMap::default();
1868                map.insert("PORT".into(), "8080".into());
1869                map
1870            },
1871        };
1872
1873        // Verify agent is properly constructed
1874        assert_eq!(agent.extension_id.as_ref(), "my-extension");
1875        assert_eq!(agent.agent_id.as_ref(), "my-agent");
1876        assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
1877        assert!(agent.targets.contains_key("darwin-aarch64"));
1878    }
1879
1880    #[test]
1881    fn sync_extension_agents_registers_archive_launcher() {
1882        use extension::AgentServerManifestEntry;
1883
1884        let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
1885        assert_eq!(expected_name.0, "Release Agent");
1886
1887        // Verify the manifest entry structure for archive-based installation
1888        let mut env = HashMap::default();
1889        env.insert("API_KEY".into(), "secret".into());
1890
1891        let mut targets = HashMap::default();
1892        targets.insert(
1893            "linux-x86_64".to_string(),
1894            extension::TargetConfig {
1895                archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
1896                cmd: "./release-agent".into(),
1897                args: vec!["serve".into()],
1898                sha256: None,
1899            },
1900        );
1901
1902        let manifest_entry = AgentServerManifestEntry {
1903            name: "Release Agent".into(),
1904            targets: targets.clone(),
1905            env,
1906            icon: None,
1907        };
1908
1909        // Verify target config is present
1910        assert!(manifest_entry.targets.contains_key("linux-x86_64"));
1911        let target = manifest_entry.targets.get("linux-x86_64").unwrap();
1912        assert_eq!(target.cmd, "./release-agent");
1913    }
1914
1915    #[gpui::test]
1916    async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
1917        let fs = fs::FakeFs::new(cx.background_executor.clone());
1918        let http_client = http_client::FakeHttpClient::with_404_response();
1919        let node_runtime = NodeRuntime::unavailable();
1920        let project_environment = cx.new(|cx| crate::ProjectEnvironment::new(None, cx));
1921
1922        let agent = LocalExtensionArchiveAgent {
1923            fs,
1924            http_client,
1925            node_runtime,
1926            project_environment,
1927            extension_id: Arc::from("node-extension"),
1928            agent_id: Arc::from("node-agent"),
1929            targets: {
1930                let mut map = HashMap::default();
1931                map.insert(
1932                    "darwin-aarch64".to_string(),
1933                    extension::TargetConfig {
1934                        archive: "https://example.com/node-agent.zip".into(),
1935                        cmd: "node".into(),
1936                        args: vec!["index.js".into()],
1937                        sha256: None,
1938                    },
1939                );
1940                map
1941            },
1942            env: HashMap::default(),
1943        };
1944
1945        // Verify that when cmd is "node", it attempts to use the node runtime
1946        assert_eq!(agent.extension_id.as_ref(), "node-extension");
1947        assert_eq!(agent.agent_id.as_ref(), "node-agent");
1948
1949        let target = agent.targets.get("darwin-aarch64").unwrap();
1950        assert_eq!(target.cmd, "node");
1951        assert_eq!(target.args, vec!["index.js"]);
1952    }
1953
1954    #[gpui::test]
1955    async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
1956        let fs = fs::FakeFs::new(cx.background_executor.clone());
1957        let http_client = http_client::FakeHttpClient::with_404_response();
1958        let node_runtime = NodeRuntime::unavailable();
1959        let project_environment = cx.new(|cx| crate::ProjectEnvironment::new(None, cx));
1960
1961        let agent = LocalExtensionArchiveAgent {
1962            fs,
1963            http_client,
1964            node_runtime,
1965            project_environment,
1966            extension_id: Arc::from("test-ext"),
1967            agent_id: Arc::from("test-agent"),
1968            targets: {
1969                let mut map = HashMap::default();
1970                map.insert(
1971                    "darwin-aarch64".to_string(),
1972                    extension::TargetConfig {
1973                        archive: "https://example.com/test.zip".into(),
1974                        cmd: "node".into(),
1975                        args: vec![
1976                            "server.js".into(),
1977                            "--config".into(),
1978                            "./config.json".into(),
1979                        ],
1980                        sha256: None,
1981                    },
1982                );
1983                map
1984            },
1985            env: HashMap::default(),
1986        };
1987
1988        // Verify the agent is configured with relative paths in args
1989        let target = agent.targets.get("darwin-aarch64").unwrap();
1990        assert_eq!(target.args[0], "server.js");
1991        assert_eq!(target.args[2], "./config.json");
1992        // These relative paths will resolve relative to the extraction directory
1993        // when the command is executed
1994    }
1995}