agent_server_store.rs

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