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