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    }
 909
 910    async fn handle_new_version_available(
 911        this: Entity<Self>,
 912        envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
 913        mut cx: AsyncApp,
 914    ) -> Result<()> {
 915        this.update(&mut cx, |this, _| {
 916            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 917                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 918                && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
 919            {
 920                new_version_available_tx
 921                    .send(Some(envelope.payload.version))
 922                    .ok();
 923            }
 924        })
 925    }
 926
 927    pub fn get_extension_id_for_agent(
 928        &mut self,
 929        name: &ExternalAgentServerName,
 930    ) -> Option<Arc<str>> {
 931        self.external_agents.get_mut(name).and_then(|agent| {
 932            agent
 933                .as_any_mut()
 934                .downcast_ref::<LocalExtensionArchiveAgent>()
 935                .map(|ext_agent| ext_agent.extension_id.clone())
 936        })
 937    }
 938}
 939
 940fn get_or_npm_install_builtin_agent(
 941    binary_name: SharedString,
 942    package_name: SharedString,
 943    entrypoint_path: PathBuf,
 944    minimum_version: Option<semver::Version>,
 945    status_tx: Option<watch::Sender<SharedString>>,
 946    new_version_available: Option<watch::Sender<Option<String>>>,
 947    fs: Arc<dyn Fs>,
 948    node_runtime: NodeRuntime,
 949    cx: &mut AsyncApp,
 950) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
 951    cx.spawn(async move |cx| {
 952        let node_path = node_runtime.binary_path().await?;
 953        let dir = paths::external_agents_dir().join(binary_name.as_str());
 954        fs.create_dir(&dir).await?;
 955
 956        let mut stream = fs.read_dir(&dir).await?;
 957        let mut versions = Vec::new();
 958        let mut to_delete = Vec::new();
 959        while let Some(entry) = stream.next().await {
 960            let Ok(entry) = entry else { continue };
 961            let Some(file_name) = entry.file_name() else {
 962                continue;
 963            };
 964
 965            if let Some(name) = file_name.to_str()
 966                && let Some(version) = semver::Version::from_str(name).ok()
 967                && fs
 968                    .is_file(&dir.join(file_name).join(&entrypoint_path))
 969                    .await
 970            {
 971                versions.push((version, file_name.to_owned()));
 972            } else {
 973                to_delete.push(file_name.to_owned())
 974            }
 975        }
 976
 977        versions.sort();
 978        let newest_version = if let Some((version, _)) = versions.last().cloned()
 979            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
 980        {
 981            versions.pop()
 982        } else {
 983            None
 984        };
 985        log::debug!("existing version of {package_name}: {newest_version:?}");
 986        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
 987
 988        cx.background_spawn({
 989            let fs = fs.clone();
 990            let dir = dir.clone();
 991            async move {
 992                for file_name in to_delete {
 993                    fs.remove_dir(
 994                        &dir.join(file_name),
 995                        RemoveOptions {
 996                            recursive: true,
 997                            ignore_if_not_exists: false,
 998                        },
 999                    )
1000                    .await
1001                    .ok();
1002                }
1003            }
1004        })
1005        .detach();
1006
1007        let version = if let Some((version, file_name)) = newest_version {
1008            cx.background_spawn({
1009                let dir = dir.clone();
1010                let fs = fs.clone();
1011                async move {
1012                    let latest_version = node_runtime
1013                        .npm_package_latest_version(&package_name)
1014                        .await
1015                        .ok();
1016                    if let Some(latest_version) = latest_version
1017                        && latest_version != version
1018                    {
1019                        let download_result = download_latest_version(
1020                            fs,
1021                            dir.clone(),
1022                            node_runtime,
1023                            package_name.clone(),
1024                        )
1025                        .await
1026                        .log_err();
1027                        if let Some(mut new_version_available) = new_version_available
1028                            && download_result.is_some()
1029                        {
1030                            new_version_available
1031                                .send(Some(latest_version.to_string()))
1032                                .ok();
1033                        }
1034                    }
1035                }
1036            })
1037            .detach();
1038            file_name
1039        } else {
1040            if let Some(mut status_tx) = status_tx {
1041                status_tx.send("Installing…".into()).ok();
1042            }
1043            let dir = dir.clone();
1044            cx.background_spawn(download_latest_version(
1045                fs.clone(),
1046                dir.clone(),
1047                node_runtime,
1048                package_name.clone(),
1049            ))
1050            .await?
1051            .to_string()
1052            .into()
1053        };
1054
1055        let agent_server_path = dir.join(version).join(entrypoint_path);
1056        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
1057        anyhow::ensure!(
1058            agent_server_path_exists,
1059            "Missing entrypoint path {} after installation",
1060            agent_server_path.to_string_lossy()
1061        );
1062
1063        anyhow::Ok(AgentServerCommand {
1064            path: node_path,
1065            args: vec![agent_server_path.to_string_lossy().into_owned()],
1066            env: None,
1067        })
1068    })
1069}
1070
1071fn find_bin_in_path(
1072    bin_name: SharedString,
1073    root_dir: PathBuf,
1074    env: HashMap<String, String>,
1075    cx: &mut AsyncApp,
1076) -> Task<Option<PathBuf>> {
1077    cx.background_executor().spawn(async move {
1078        let which_result = if cfg!(windows) {
1079            which::which(bin_name.as_str())
1080        } else {
1081            let shell_path = env.get("PATH").cloned();
1082            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
1083        };
1084
1085        if let Err(which::Error::CannotFindBinaryPath) = which_result {
1086            return None;
1087        }
1088
1089        which_result.log_err()
1090    })
1091}
1092
1093async fn download_latest_version(
1094    fs: Arc<dyn Fs>,
1095    dir: PathBuf,
1096    node_runtime: NodeRuntime,
1097    package_name: SharedString,
1098) -> Result<Version> {
1099    log::debug!("downloading latest version of {package_name}");
1100
1101    let tmp_dir = tempfile::tempdir_in(&dir)?;
1102
1103    node_runtime
1104        .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
1105        .await?;
1106
1107    let version = node_runtime
1108        .npm_package_installed_version(tmp_dir.path(), &package_name)
1109        .await?
1110        .context("expected package to be installed")?;
1111
1112    fs.rename(
1113        &tmp_dir.keep(),
1114        &dir.join(version.to_string()),
1115        RenameOptions {
1116            ignore_if_exists: true,
1117            overwrite: true,
1118            create_parents: false,
1119        },
1120    )
1121    .await?;
1122
1123    anyhow::Ok(version)
1124}
1125
1126struct RemoteExternalAgentServer {
1127    project_id: u64,
1128    upstream_client: Entity<RemoteClient>,
1129    name: ExternalAgentServerName,
1130    status_tx: Option<watch::Sender<SharedString>>,
1131    new_version_available_tx: Option<watch::Sender<Option<String>>>,
1132}
1133
1134impl ExternalAgentServer for RemoteExternalAgentServer {
1135    fn get_command(
1136        &mut self,
1137        root_dir: Option<&str>,
1138        extra_env: HashMap<String, String>,
1139        status_tx: Option<watch::Sender<SharedString>>,
1140        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1141        cx: &mut AsyncApp,
1142    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1143        let project_id = self.project_id;
1144        let name = self.name.to_string();
1145        let upstream_client = self.upstream_client.downgrade();
1146        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
1147        self.status_tx = status_tx;
1148        self.new_version_available_tx = new_version_available_tx;
1149        cx.spawn(async move |cx| {
1150            let mut response = upstream_client
1151                .update(cx, |upstream_client, _| {
1152                    upstream_client
1153                        .proto_client()
1154                        .request(proto::GetAgentServerCommand {
1155                            project_id,
1156                            name,
1157                            root_dir: root_dir.clone(),
1158                        })
1159                })?
1160                .await?;
1161            let root_dir = response.root_dir;
1162            response.env.extend(extra_env);
1163            let command = upstream_client.update(cx, |client, _| {
1164                client.build_command(
1165                    Some(response.path),
1166                    &response.args,
1167                    &response.env.into_iter().collect(),
1168                    Some(root_dir.clone()),
1169                    None,
1170                )
1171            })??;
1172            Ok((
1173                AgentServerCommand {
1174                    path: command.program.into(),
1175                    args: command.args,
1176                    env: Some(command.env),
1177                },
1178                root_dir,
1179                response.login.map(SpawnInTerminal::from_proto),
1180            ))
1181        })
1182    }
1183
1184    fn as_any_mut(&mut self) -> &mut dyn Any {
1185        self
1186    }
1187}
1188
1189struct LocalGemini {
1190    fs: Arc<dyn Fs>,
1191    node_runtime: NodeRuntime,
1192    project_environment: Entity<ProjectEnvironment>,
1193    custom_command: Option<AgentServerCommand>,
1194    ignore_system_version: bool,
1195}
1196
1197impl ExternalAgentServer for LocalGemini {
1198    fn get_command(
1199        &mut self,
1200        root_dir: Option<&str>,
1201        extra_env: HashMap<String, String>,
1202        status_tx: Option<watch::Sender<SharedString>>,
1203        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1204        cx: &mut AsyncApp,
1205    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1206        let fs = self.fs.clone();
1207        let node_runtime = self.node_runtime.clone();
1208        let project_environment = self.project_environment.downgrade();
1209        let custom_command = self.custom_command.clone();
1210        let ignore_system_version = self.ignore_system_version;
1211        let root_dir: Arc<Path> = root_dir
1212            .map(|root_dir| Path::new(root_dir))
1213            .unwrap_or(paths::home_dir())
1214            .into();
1215
1216        cx.spawn(async move |cx| {
1217            let mut env = project_environment
1218                .update(cx, |project_environment, cx| {
1219                    project_environment.local_directory_environment(
1220                        &Shell::System,
1221                        root_dir.clone(),
1222                        cx,
1223                    )
1224                })?
1225                .await
1226                .unwrap_or_default();
1227
1228            let mut command = if let Some(mut custom_command) = custom_command {
1229                env.extend(custom_command.env.unwrap_or_default());
1230                custom_command.env = Some(env);
1231                custom_command
1232            } else if !ignore_system_version
1233                && let Some(bin) =
1234                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1235            {
1236                AgentServerCommand {
1237                    path: bin,
1238                    args: Vec::new(),
1239                    env: Some(env),
1240                }
1241            } else {
1242                let mut command = get_or_npm_install_builtin_agent(
1243                    GEMINI_NAME.into(),
1244                    "@google/gemini-cli".into(),
1245                    "node_modules/@google/gemini-cli/dist/index.js".into(),
1246                    if cfg!(windows) {
1247                        // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1248                        Some("0.9.0".parse().unwrap())
1249                    } else {
1250                        Some("0.2.1".parse().unwrap())
1251                    },
1252                    status_tx,
1253                    new_version_available_tx,
1254                    fs,
1255                    node_runtime,
1256                    cx,
1257                )
1258                .await?;
1259                command.env = Some(env);
1260                command
1261            };
1262
1263            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1264            let login = task::SpawnInTerminal {
1265                command: Some(command.path.to_string_lossy().into_owned()),
1266                args: command.args.clone(),
1267                env: command.env.clone().unwrap_or_default(),
1268                label: "gemini /auth".into(),
1269                ..Default::default()
1270            };
1271
1272            command.env.get_or_insert_default().extend(extra_env);
1273            command.args.push("--experimental-acp".into());
1274            Ok((
1275                command,
1276                root_dir.to_string_lossy().into_owned(),
1277                Some(login),
1278            ))
1279        })
1280    }
1281
1282    fn as_any_mut(&mut self) -> &mut dyn Any {
1283        self
1284    }
1285}
1286
1287struct LocalClaudeCode {
1288    fs: Arc<dyn Fs>,
1289    node_runtime: NodeRuntime,
1290    project_environment: Entity<ProjectEnvironment>,
1291    custom_command: Option<AgentServerCommand>,
1292}
1293
1294impl ExternalAgentServer for LocalClaudeCode {
1295    fn get_command(
1296        &mut self,
1297        root_dir: Option<&str>,
1298        extra_env: HashMap<String, String>,
1299        status_tx: Option<watch::Sender<SharedString>>,
1300        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1301        cx: &mut AsyncApp,
1302    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1303        let fs = self.fs.clone();
1304        let node_runtime = self.node_runtime.clone();
1305        let project_environment = self.project_environment.downgrade();
1306        let custom_command = self.custom_command.clone();
1307        let root_dir: Arc<Path> = root_dir
1308            .map(|root_dir| Path::new(root_dir))
1309            .unwrap_or(paths::home_dir())
1310            .into();
1311
1312        cx.spawn(async move |cx| {
1313            let mut env = project_environment
1314                .update(cx, |project_environment, cx| {
1315                    project_environment.local_directory_environment(
1316                        &Shell::System,
1317                        root_dir.clone(),
1318                        cx,
1319                    )
1320                })?
1321                .await
1322                .unwrap_or_default();
1323            env.insert("ANTHROPIC_API_KEY".into(), "".into());
1324
1325            let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1326                env.extend(custom_command.env.unwrap_or_default());
1327                custom_command.env = Some(env);
1328                (custom_command, None)
1329            } else {
1330                let mut command = get_or_npm_install_builtin_agent(
1331                    "claude-code-acp".into(),
1332                    "@zed-industries/claude-code-acp".into(),
1333                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1334                    Some("0.5.2".parse().unwrap()),
1335                    status_tx,
1336                    new_version_available_tx,
1337                    fs,
1338                    node_runtime,
1339                    cx,
1340                )
1341                .await?;
1342                command.env = Some(env);
1343                let login = command
1344                    .args
1345                    .first()
1346                    .and_then(|path| {
1347                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1348                    })
1349                    .map(|path_prefix| task::SpawnInTerminal {
1350                        command: Some(command.path.to_string_lossy().into_owned()),
1351                        args: vec![
1352                            Path::new(path_prefix)
1353                                .join("@anthropic-ai/claude-agent-sdk/cli.js")
1354                                .to_string_lossy()
1355                                .to_string(),
1356                            "/login".into(),
1357                        ],
1358                        env: command.env.clone().unwrap_or_default(),
1359                        label: "claude /login".into(),
1360                        ..Default::default()
1361                    });
1362                (command, login)
1363            };
1364
1365            command.env.get_or_insert_default().extend(extra_env);
1366            Ok((
1367                command,
1368                root_dir.to_string_lossy().into_owned(),
1369                login_command,
1370            ))
1371        })
1372    }
1373
1374    fn as_any_mut(&mut self) -> &mut dyn Any {
1375        self
1376    }
1377}
1378
1379struct LocalCodex {
1380    fs: Arc<dyn Fs>,
1381    project_environment: Entity<ProjectEnvironment>,
1382    http_client: Arc<dyn HttpClient>,
1383    custom_command: Option<AgentServerCommand>,
1384    no_browser: bool,
1385}
1386
1387impl ExternalAgentServer for LocalCodex {
1388    fn get_command(
1389        &mut self,
1390        root_dir: Option<&str>,
1391        extra_env: HashMap<String, String>,
1392        mut status_tx: Option<watch::Sender<SharedString>>,
1393        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1394        cx: &mut AsyncApp,
1395    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1396        let fs = self.fs.clone();
1397        let project_environment = self.project_environment.downgrade();
1398        let http = self.http_client.clone();
1399        let custom_command = self.custom_command.clone();
1400        let root_dir: Arc<Path> = root_dir
1401            .map(|root_dir| Path::new(root_dir))
1402            .unwrap_or(paths::home_dir())
1403            .into();
1404        let no_browser = self.no_browser;
1405
1406        cx.spawn(async move |cx| {
1407            let mut env = project_environment
1408                .update(cx, |project_environment, cx| {
1409                    project_environment.local_directory_environment(
1410                        &Shell::System,
1411                        root_dir.clone(),
1412                        cx,
1413                    )
1414                })?
1415                .await
1416                .unwrap_or_default();
1417            if no_browser {
1418                env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1419            }
1420
1421            let mut command = if let Some(mut custom_command) = custom_command {
1422                env.extend(custom_command.env.unwrap_or_default());
1423                custom_command.env = Some(env);
1424                custom_command
1425            } else {
1426                let dir = paths::external_agents_dir().join(CODEX_NAME);
1427                fs.create_dir(&dir).await?;
1428
1429                let bin_name = if cfg!(windows) {
1430                    "codex-acp.exe"
1431                } else {
1432                    "codex-acp"
1433                };
1434
1435                let find_latest_local_version = async || -> Option<PathBuf> {
1436                    let mut local_versions: Vec<(semver::Version, String)> = Vec::new();
1437                    let mut stream = fs.read_dir(&dir).await.ok()?;
1438                    while let Some(entry) = stream.next().await {
1439                        let Ok(entry) = entry else { continue };
1440                        let Some(file_name) = entry.file_name() else {
1441                            continue;
1442                        };
1443                        let version_path = dir.join(&file_name);
1444                        if fs.is_file(&version_path.join(bin_name)).await {
1445                            let version_str = file_name.to_string_lossy();
1446                            if let Ok(version) =
1447                                semver::Version::from_str(version_str.trim_start_matches('v'))
1448                            {
1449                                local_versions.push((version, version_str.into_owned()));
1450                            }
1451                        }
1452                    }
1453                    local_versions.sort_by(|(a, _), (b, _)| a.cmp(b));
1454                    local_versions.last().map(|(_, v)| dir.join(v))
1455                };
1456
1457                let fallback_to_latest_local_version =
1458                    async |err: anyhow::Error| -> Result<PathBuf, anyhow::Error> {
1459                        if let Some(local) = find_latest_local_version().await {
1460                            log::info!(
1461                                "Falling back to locally installed Codex version: {}",
1462                                local.display()
1463                            );
1464                            Ok(local)
1465                        } else {
1466                            Err(err)
1467                        }
1468                    };
1469
1470                let version_dir = match ::http_client::github::latest_github_release(
1471                    CODEX_ACP_REPO,
1472                    true,
1473                    false,
1474                    http.clone(),
1475                )
1476                .await
1477                {
1478                    Ok(release) => {
1479                        let version_dir = dir.join(&release.tag_name);
1480                        if !fs.is_dir(&version_dir).await {
1481                            if let Some(ref mut status_tx) = status_tx {
1482                                status_tx.send("Installing…".into()).ok();
1483                            }
1484
1485                            let tag = release.tag_name.clone();
1486                            let version_number = tag.trim_start_matches('v');
1487                            let asset_name = asset_name(version_number)
1488                                .context("codex acp is not supported for this architecture")?;
1489                            let asset = release
1490                                .assets
1491                                .into_iter()
1492                                .find(|asset| asset.name == asset_name)
1493                                .with_context(|| {
1494                                    format!("no asset found matching `{asset_name:?}`")
1495                                })?;
1496                            // Strip "sha256:" prefix from digest if present (GitHub API format)
1497                            let digest = asset
1498                                .digest
1499                                .as_deref()
1500                                .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1501                            match ::http_client::github_download::download_server_binary(
1502                                &*http,
1503                                &asset.browser_download_url,
1504                                digest,
1505                                &version_dir,
1506                                if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1507                                    AssetKind::Zip
1508                                } else {
1509                                    AssetKind::TarGz
1510                                },
1511                            )
1512                            .await
1513                            {
1514                                Ok(()) => {
1515                                    // remove older versions
1516                                    util::fs::remove_matching(&dir, |entry| entry != version_dir)
1517                                        .await;
1518                                    version_dir
1519                                }
1520                                Err(err) => {
1521                                    log::error!(
1522                                        "Failed to download Codex release {}: {err:#}",
1523                                        release.tag_name
1524                                    );
1525                                    fallback_to_latest_local_version(err).await?
1526                                }
1527                            }
1528                        } else {
1529                            version_dir
1530                        }
1531                    }
1532                    Err(err) => {
1533                        log::error!("Failed to fetch Codex latest release: {err:#}");
1534                        fallback_to_latest_local_version(err).await?
1535                    }
1536                };
1537
1538                let bin_path = version_dir.join(bin_name);
1539                anyhow::ensure!(
1540                    fs.is_file(&bin_path).await,
1541                    "Missing Codex binary at {} after installation",
1542                    bin_path.to_string_lossy()
1543                );
1544
1545                let mut cmd = AgentServerCommand {
1546                    path: bin_path,
1547                    args: Vec::new(),
1548                    env: None,
1549                };
1550                cmd.env = Some(env);
1551                cmd
1552            };
1553
1554            command.env.get_or_insert_default().extend(extra_env);
1555            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1556        })
1557    }
1558
1559    fn as_any_mut(&mut self) -> &mut dyn Any {
1560        self
1561    }
1562}
1563
1564pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1565
1566fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1567    let arch = if cfg!(target_arch = "x86_64") {
1568        "x86_64"
1569    } else if cfg!(target_arch = "aarch64") {
1570        "aarch64"
1571    } else {
1572        return None;
1573    };
1574
1575    let platform = if cfg!(target_os = "macos") {
1576        "apple-darwin"
1577    } else if cfg!(target_os = "windows") {
1578        "pc-windows-msvc"
1579    } else if cfg!(target_os = "linux") {
1580        "unknown-linux-gnu"
1581    } else {
1582        return None;
1583    };
1584
1585    // Windows uses .zip in release assets
1586    let ext = if cfg!(target_os = "windows") {
1587        "zip"
1588    } else {
1589        "tar.gz"
1590    };
1591
1592    Some((arch, platform, ext))
1593}
1594
1595fn asset_name(version: &str) -> Option<String> {
1596    let (arch, platform, ext) = get_platform_info()?;
1597    Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1598}
1599
1600struct LocalExtensionArchiveAgent {
1601    fs: Arc<dyn Fs>,
1602    http_client: Arc<dyn HttpClient>,
1603    node_runtime: NodeRuntime,
1604    project_environment: Entity<ProjectEnvironment>,
1605    extension_id: Arc<str>,
1606    agent_id: Arc<str>,
1607    targets: HashMap<String, extension::TargetConfig>,
1608    env: HashMap<String, String>,
1609}
1610
1611struct LocalCustomAgent {
1612    project_environment: Entity<ProjectEnvironment>,
1613    command: AgentServerCommand,
1614}
1615
1616impl ExternalAgentServer for LocalExtensionArchiveAgent {
1617    fn get_command(
1618        &mut self,
1619        root_dir: Option<&str>,
1620        extra_env: HashMap<String, String>,
1621        _status_tx: Option<watch::Sender<SharedString>>,
1622        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1623        cx: &mut AsyncApp,
1624    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1625        let fs = self.fs.clone();
1626        let http_client = self.http_client.clone();
1627        let node_runtime = self.node_runtime.clone();
1628        let project_environment = self.project_environment.downgrade();
1629        let extension_id = self.extension_id.clone();
1630        let agent_id = self.agent_id.clone();
1631        let targets = self.targets.clone();
1632        let base_env = self.env.clone();
1633
1634        let root_dir: Arc<Path> = root_dir
1635            .map(|root_dir| Path::new(root_dir))
1636            .unwrap_or(paths::home_dir())
1637            .into();
1638
1639        cx.spawn(async move |cx| {
1640            // Get project environment
1641            let mut env = project_environment
1642                .update(cx, |project_environment, cx| {
1643                    project_environment.local_directory_environment(
1644                        &Shell::System,
1645                        root_dir.clone(),
1646                        cx,
1647                    )
1648                })?
1649                .await
1650                .unwrap_or_default();
1651
1652            // Merge manifest env and extra env
1653            env.extend(base_env);
1654            env.extend(extra_env);
1655
1656            let cache_key = format!("{}/{}", extension_id, agent_id);
1657            let dir = paths::external_agents_dir().join(&cache_key);
1658            fs.create_dir(&dir).await?;
1659
1660            // Determine platform key
1661            let os = if cfg!(target_os = "macos") {
1662                "darwin"
1663            } else if cfg!(target_os = "linux") {
1664                "linux"
1665            } else if cfg!(target_os = "windows") {
1666                "windows"
1667            } else {
1668                anyhow::bail!("unsupported OS");
1669            };
1670
1671            let arch = if cfg!(target_arch = "aarch64") {
1672                "aarch64"
1673            } else if cfg!(target_arch = "x86_64") {
1674                "x86_64"
1675            } else {
1676                anyhow::bail!("unsupported architecture");
1677            };
1678
1679            let platform_key = format!("{}-{}", os, arch);
1680            let target_config = targets.get(&platform_key).with_context(|| {
1681                format!(
1682                    "no target specified for platform '{}'. Available platforms: {}",
1683                    platform_key,
1684                    targets
1685                        .keys()
1686                        .map(|k| k.as_str())
1687                        .collect::<Vec<_>>()
1688                        .join(", ")
1689                )
1690            })?;
1691
1692            let archive_url = &target_config.archive;
1693
1694            // Use URL as version identifier for caching
1695            // Hash the URL to get a stable directory name
1696            use std::collections::hash_map::DefaultHasher;
1697            use std::hash::{Hash, Hasher};
1698            let mut hasher = DefaultHasher::new();
1699            archive_url.hash(&mut hasher);
1700            let url_hash = hasher.finish();
1701            let version_dir = dir.join(format!("v_{:x}", url_hash));
1702
1703            if !fs.is_dir(&version_dir).await {
1704                // Determine SHA256 for verification
1705                let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1706                    // Use provided SHA256
1707                    Some(provided_sha.clone())
1708                } else if archive_url.starts_with("https://github.com/") {
1709                    // Try to fetch SHA256 from GitHub API
1710                    // Parse URL to extract repo and tag/file info
1711                    // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1712                    if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1713                        let parts: Vec<&str> = caps.split('/').collect();
1714                        if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1715                            let repo = format!("{}/{}", parts[0], parts[1]);
1716                            let tag = parts[4];
1717                            let filename = parts[5..].join("/");
1718
1719                            // Try to get release info from GitHub
1720                            if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1721                                &repo,
1722                                tag,
1723                                http_client.clone(),
1724                            )
1725                            .await
1726                            {
1727                                // Find matching asset
1728                                if let Some(asset) =
1729                                    release.assets.iter().find(|a| a.name == filename)
1730                                {
1731                                    // Strip "sha256:" prefix if present
1732                                    asset.digest.as_ref().and_then(|d| {
1733                                        d.strip_prefix("sha256:")
1734                                            .map(|s| s.to_string())
1735                                            .or_else(|| Some(d.clone()))
1736                                    })
1737                                } else {
1738                                    None
1739                                }
1740                            } else {
1741                                None
1742                            }
1743                        } else {
1744                            None
1745                        }
1746                    } else {
1747                        None
1748                    }
1749                } else {
1750                    None
1751                };
1752
1753                // Determine archive type from URL
1754                let asset_kind = if archive_url.ends_with(".zip") {
1755                    AssetKind::Zip
1756                } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1757                    AssetKind::TarGz
1758                } else {
1759                    anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1760                };
1761
1762                // Download and extract
1763                ::http_client::github_download::download_server_binary(
1764                    &*http_client,
1765                    archive_url,
1766                    sha256.as_deref(),
1767                    &version_dir,
1768                    asset_kind,
1769                )
1770                .await?;
1771            }
1772
1773            // Validate and resolve cmd path
1774            let cmd = &target_config.cmd;
1775
1776            let cmd_path = if cmd == "node" {
1777                // Use Zed's managed Node.js runtime
1778                node_runtime.binary_path().await?
1779            } else {
1780                if cmd.contains("..") {
1781                    anyhow::bail!("command path cannot contain '..': {}", cmd);
1782                }
1783
1784                if cmd.starts_with("./") || cmd.starts_with(".\\") {
1785                    // Relative to extraction directory
1786                    let cmd_path = version_dir.join(&cmd[2..]);
1787                    anyhow::ensure!(
1788                        fs.is_file(&cmd_path).await,
1789                        "Missing command {} after extraction",
1790                        cmd_path.to_string_lossy()
1791                    );
1792                    cmd_path
1793                } else {
1794                    // On PATH
1795                    anyhow::bail!("command must be relative (start with './'): {}", cmd);
1796                }
1797            };
1798
1799            let command = AgentServerCommand {
1800                path: cmd_path,
1801                args: target_config.args.clone(),
1802                env: Some(env),
1803            };
1804
1805            Ok((command, version_dir.to_string_lossy().into_owned(), None))
1806        })
1807    }
1808
1809    fn as_any_mut(&mut self) -> &mut dyn Any {
1810        self
1811    }
1812}
1813
1814impl ExternalAgentServer for LocalCustomAgent {
1815    fn get_command(
1816        &mut self,
1817        root_dir: Option<&str>,
1818        extra_env: HashMap<String, String>,
1819        _status_tx: Option<watch::Sender<SharedString>>,
1820        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1821        cx: &mut AsyncApp,
1822    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1823        let mut command = self.command.clone();
1824        let root_dir: Arc<Path> = root_dir
1825            .map(|root_dir| Path::new(root_dir))
1826            .unwrap_or(paths::home_dir())
1827            .into();
1828        let project_environment = self.project_environment.downgrade();
1829        cx.spawn(async move |cx| {
1830            let mut env = project_environment
1831                .update(cx, |project_environment, cx| {
1832                    project_environment.local_directory_environment(
1833                        &Shell::System,
1834                        root_dir.clone(),
1835                        cx,
1836                    )
1837                })?
1838                .await
1839                .unwrap_or_default();
1840            env.extend(command.env.unwrap_or_default());
1841            env.extend(extra_env);
1842            command.env = Some(env);
1843            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1844        })
1845    }
1846
1847    fn as_any_mut(&mut self) -> &mut dyn Any {
1848        self
1849    }
1850}
1851
1852pub const GEMINI_NAME: &'static str = "gemini";
1853pub const CLAUDE_CODE_NAME: &'static str = "claude";
1854pub const CODEX_NAME: &'static str = "codex";
1855
1856#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1857pub struct AllAgentServersSettings {
1858    pub gemini: Option<BuiltinAgentServerSettings>,
1859    pub claude: Option<BuiltinAgentServerSettings>,
1860    pub codex: Option<BuiltinAgentServerSettings>,
1861    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1862}
1863#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1864pub struct BuiltinAgentServerSettings {
1865    pub path: Option<PathBuf>,
1866    pub args: Option<Vec<String>>,
1867    pub env: Option<HashMap<String, String>>,
1868    pub ignore_system_version: Option<bool>,
1869    pub default_mode: Option<String>,
1870    pub default_model: Option<String>,
1871    pub favorite_models: Vec<String>,
1872    pub default_config_options: HashMap<String, String>,
1873    pub favorite_config_option_values: HashMap<String, Vec<String>>,
1874}
1875
1876impl BuiltinAgentServerSettings {
1877    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1878        self.path.map(|path| AgentServerCommand {
1879            path,
1880            args: self.args.unwrap_or_default(),
1881            env: self.env,
1882        })
1883    }
1884}
1885
1886impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1887    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1888        BuiltinAgentServerSettings {
1889            path: value
1890                .path
1891                .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
1892            args: value.args,
1893            env: value.env,
1894            ignore_system_version: value.ignore_system_version,
1895            default_mode: value.default_mode,
1896            default_model: value.default_model,
1897            favorite_models: value.favorite_models,
1898            default_config_options: value.default_config_options,
1899            favorite_config_option_values: value.favorite_config_option_values,
1900        }
1901    }
1902}
1903
1904impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1905    fn from(value: AgentServerCommand) -> Self {
1906        BuiltinAgentServerSettings {
1907            path: Some(value.path),
1908            args: Some(value.args),
1909            env: value.env,
1910            ..Default::default()
1911        }
1912    }
1913}
1914
1915#[derive(Clone, JsonSchema, Debug, PartialEq)]
1916pub enum CustomAgentServerSettings {
1917    Custom {
1918        command: AgentServerCommand,
1919        /// The default mode to use for this agent.
1920        ///
1921        /// Note: Not only all agents support modes.
1922        ///
1923        /// Default: None
1924        default_mode: Option<String>,
1925        /// The default model to use for this agent.
1926        ///
1927        /// This should be the model ID as reported by the agent.
1928        ///
1929        /// Default: None
1930        default_model: Option<String>,
1931        /// The favorite models for this agent.
1932        ///
1933        /// Default: []
1934        favorite_models: Vec<String>,
1935        /// Default values for session config options.
1936        ///
1937        /// This is a map from config option ID to value ID.
1938        ///
1939        /// Default: {}
1940        default_config_options: HashMap<String, String>,
1941        /// Favorited values for session config options.
1942        ///
1943        /// This is a map from config option ID to a list of favorited value IDs.
1944        ///
1945        /// Default: {}
1946        favorite_config_option_values: HashMap<String, Vec<String>>,
1947    },
1948    Extension {
1949        /// The default mode to use for this agent.
1950        ///
1951        /// Note: Not only all agents support modes.
1952        ///
1953        /// Default: None
1954        default_mode: Option<String>,
1955        /// The default model to use for this agent.
1956        ///
1957        /// This should be the model ID as reported by the agent.
1958        ///
1959        /// Default: None
1960        default_model: Option<String>,
1961        /// The favorite models for this agent.
1962        ///
1963        /// Default: []
1964        favorite_models: Vec<String>,
1965        /// Default values for session config options.
1966        ///
1967        /// This is a map from config option ID to value ID.
1968        ///
1969        /// Default: {}
1970        default_config_options: HashMap<String, String>,
1971        /// Favorited values for session config options.
1972        ///
1973        /// This is a map from config option ID to a list of favorited value IDs.
1974        ///
1975        /// Default: {}
1976        favorite_config_option_values: HashMap<String, Vec<String>>,
1977    },
1978}
1979
1980impl CustomAgentServerSettings {
1981    pub fn command(&self) -> Option<&AgentServerCommand> {
1982        match self {
1983            CustomAgentServerSettings::Custom { command, .. } => Some(command),
1984            CustomAgentServerSettings::Extension { .. } => None,
1985        }
1986    }
1987
1988    pub fn default_mode(&self) -> Option<&str> {
1989        match self {
1990            CustomAgentServerSettings::Custom { default_mode, .. }
1991            | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(),
1992        }
1993    }
1994
1995    pub fn default_model(&self) -> Option<&str> {
1996        match self {
1997            CustomAgentServerSettings::Custom { default_model, .. }
1998            | CustomAgentServerSettings::Extension { default_model, .. } => {
1999                default_model.as_deref()
2000            }
2001        }
2002    }
2003
2004    pub fn favorite_models(&self) -> &[String] {
2005        match self {
2006            CustomAgentServerSettings::Custom {
2007                favorite_models, ..
2008            }
2009            | CustomAgentServerSettings::Extension {
2010                favorite_models, ..
2011            } => favorite_models,
2012        }
2013    }
2014
2015    pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
2016        match self {
2017            CustomAgentServerSettings::Custom {
2018                default_config_options,
2019                ..
2020            }
2021            | CustomAgentServerSettings::Extension {
2022                default_config_options,
2023                ..
2024            } => default_config_options.get(config_id).map(|s| s.as_str()),
2025        }
2026    }
2027
2028    pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
2029        match self {
2030            CustomAgentServerSettings::Custom {
2031                favorite_config_option_values,
2032                ..
2033            }
2034            | CustomAgentServerSettings::Extension {
2035                favorite_config_option_values,
2036                ..
2037            } => favorite_config_option_values
2038                .get(config_id)
2039                .map(|v| v.as_slice()),
2040        }
2041    }
2042}
2043
2044impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
2045    fn from(value: settings::CustomAgentServerSettings) -> Self {
2046        match value {
2047            settings::CustomAgentServerSettings::Custom {
2048                path,
2049                args,
2050                env,
2051                default_mode,
2052                default_model,
2053                favorite_models,
2054                default_config_options,
2055                favorite_config_option_values,
2056            } => CustomAgentServerSettings::Custom {
2057                command: AgentServerCommand {
2058                    path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
2059                    args,
2060                    env,
2061                },
2062                default_mode,
2063                default_model,
2064                favorite_models,
2065                default_config_options,
2066                favorite_config_option_values,
2067            },
2068            settings::CustomAgentServerSettings::Extension {
2069                default_mode,
2070                default_model,
2071                default_config_options,
2072                favorite_models,
2073                favorite_config_option_values,
2074            } => CustomAgentServerSettings::Extension {
2075                default_mode,
2076                default_model,
2077                default_config_options,
2078                favorite_models,
2079                favorite_config_option_values,
2080            },
2081        }
2082    }
2083}
2084
2085impl settings::Settings for AllAgentServersSettings {
2086    fn from_settings(content: &settings::SettingsContent) -> Self {
2087        let agent_settings = content.agent_servers.clone().unwrap();
2088        Self {
2089            gemini: agent_settings.gemini.map(Into::into),
2090            claude: agent_settings.claude.map(Into::into),
2091            codex: agent_settings.codex.map(Into::into),
2092            custom: agent_settings
2093                .custom
2094                .into_iter()
2095                .map(|(k, v)| (k, v.into()))
2096                .collect(),
2097        }
2098    }
2099}
2100
2101#[cfg(test)]
2102mod extension_agent_tests {
2103    use crate::worktree_store::WorktreeStore;
2104
2105    use super::*;
2106    use gpui::TestAppContext;
2107    use std::sync::Arc;
2108
2109    #[test]
2110    fn extension_agent_constructs_proper_display_names() {
2111        // Verify the display name format for extension-provided agents
2112        let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
2113        assert!(name1.0.contains(": "));
2114
2115        let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
2116        assert_eq!(name2.0, "MyExt: MyAgent");
2117
2118        // Non-extension agents shouldn't have the separator
2119        let custom = ExternalAgentServerName(SharedString::from("custom"));
2120        assert!(!custom.0.contains(": "));
2121    }
2122
2123    struct NoopExternalAgent;
2124
2125    impl ExternalAgentServer for NoopExternalAgent {
2126        fn get_command(
2127            &mut self,
2128            _root_dir: Option<&str>,
2129            _extra_env: HashMap<String, String>,
2130            _status_tx: Option<watch::Sender<SharedString>>,
2131            _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2132            _cx: &mut AsyncApp,
2133        ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2134            Task::ready(Ok((
2135                AgentServerCommand {
2136                    path: PathBuf::from("noop"),
2137                    args: Vec::new(),
2138                    env: None,
2139                },
2140                "".to_string(),
2141                None,
2142            )))
2143        }
2144
2145        fn as_any_mut(&mut self) -> &mut dyn Any {
2146            self
2147        }
2148    }
2149
2150    #[test]
2151    fn sync_removes_only_extension_provided_agents() {
2152        let mut store = AgentServerStore {
2153            state: AgentServerStoreState::Collab,
2154            external_agents: HashMap::default(),
2155            agent_icons: HashMap::default(),
2156            agent_display_names: HashMap::default(),
2157        };
2158
2159        // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
2160        store.external_agents.insert(
2161            ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
2162            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2163        );
2164        store.external_agents.insert(
2165            ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
2166            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2167        );
2168        store.external_agents.insert(
2169            ExternalAgentServerName(SharedString::from("custom-agent")),
2170            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2171        );
2172
2173        // Simulate removal phase
2174        let keys_to_remove: Vec<_> = store
2175            .external_agents
2176            .keys()
2177            .filter(|name| name.0.contains(": "))
2178            .cloned()
2179            .collect();
2180
2181        for key in keys_to_remove {
2182            store.external_agents.remove(&key);
2183        }
2184
2185        // Only custom-agent should remain
2186        assert_eq!(store.external_agents.len(), 1);
2187        assert!(
2188            store
2189                .external_agents
2190                .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
2191        );
2192    }
2193
2194    #[test]
2195    fn archive_launcher_constructs_with_all_fields() {
2196        use extension::AgentServerManifestEntry;
2197
2198        let mut env = HashMap::default();
2199        env.insert("GITHUB_TOKEN".into(), "secret".into());
2200
2201        let mut targets = HashMap::default();
2202        targets.insert(
2203            "darwin-aarch64".to_string(),
2204            extension::TargetConfig {
2205                archive:
2206                    "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
2207                        .into(),
2208                cmd: "./agent".into(),
2209                args: vec![],
2210                sha256: None,
2211                env: Default::default(),
2212            },
2213        );
2214
2215        let _entry = AgentServerManifestEntry {
2216            name: "GitHub Agent".into(),
2217            targets,
2218            env,
2219            icon: None,
2220        };
2221
2222        // Verify display name construction
2223        let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
2224        assert_eq!(expected_name.0, "GitHub Agent");
2225    }
2226
2227    #[gpui::test]
2228    async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
2229        let fs = fs::FakeFs::new(cx.background_executor.clone());
2230        let http_client = http_client::FakeHttpClient::with_404_response();
2231        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2232        let project_environment = cx.new(|cx| {
2233            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2234        });
2235
2236        let agent = LocalExtensionArchiveAgent {
2237            fs,
2238            http_client,
2239            node_runtime: node_runtime::NodeRuntime::unavailable(),
2240            project_environment,
2241            extension_id: Arc::from("my-extension"),
2242            agent_id: Arc::from("my-agent"),
2243            targets: {
2244                let mut map = HashMap::default();
2245                map.insert(
2246                    "darwin-aarch64".to_string(),
2247                    extension::TargetConfig {
2248                        archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
2249                        cmd: "./my-agent".into(),
2250                        args: vec!["--serve".into()],
2251                        sha256: None,
2252                        env: Default::default(),
2253                    },
2254                );
2255                map
2256            },
2257            env: {
2258                let mut map = HashMap::default();
2259                map.insert("PORT".into(), "8080".into());
2260                map
2261            },
2262        };
2263
2264        // Verify agent is properly constructed
2265        assert_eq!(agent.extension_id.as_ref(), "my-extension");
2266        assert_eq!(agent.agent_id.as_ref(), "my-agent");
2267        assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
2268        assert!(agent.targets.contains_key("darwin-aarch64"));
2269    }
2270
2271    #[test]
2272    fn sync_extension_agents_registers_archive_launcher() {
2273        use extension::AgentServerManifestEntry;
2274
2275        let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
2276        assert_eq!(expected_name.0, "Release Agent");
2277
2278        // Verify the manifest entry structure for archive-based installation
2279        let mut env = HashMap::default();
2280        env.insert("API_KEY".into(), "secret".into());
2281
2282        let mut targets = HashMap::default();
2283        targets.insert(
2284            "linux-x86_64".to_string(),
2285            extension::TargetConfig {
2286                archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
2287                cmd: "./release-agent".into(),
2288                args: vec!["serve".into()],
2289                sha256: None,
2290                env: Default::default(),
2291            },
2292        );
2293
2294        let manifest_entry = AgentServerManifestEntry {
2295            name: "Release Agent".into(),
2296            targets: targets.clone(),
2297            env,
2298            icon: None,
2299        };
2300
2301        // Verify target config is present
2302        assert!(manifest_entry.targets.contains_key("linux-x86_64"));
2303        let target = manifest_entry.targets.get("linux-x86_64").unwrap();
2304        assert_eq!(target.cmd, "./release-agent");
2305    }
2306
2307    #[gpui::test]
2308    async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
2309        let fs = fs::FakeFs::new(cx.background_executor.clone());
2310        let http_client = http_client::FakeHttpClient::with_404_response();
2311        let node_runtime = NodeRuntime::unavailable();
2312        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2313        let project_environment = cx.new(|cx| {
2314            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2315        });
2316
2317        let agent = LocalExtensionArchiveAgent {
2318            fs: fs.clone(),
2319            http_client,
2320            node_runtime,
2321            project_environment,
2322            extension_id: Arc::from("node-extension"),
2323            agent_id: Arc::from("node-agent"),
2324            targets: {
2325                let mut map = HashMap::default();
2326                map.insert(
2327                    "darwin-aarch64".to_string(),
2328                    extension::TargetConfig {
2329                        archive: "https://example.com/node-agent.zip".into(),
2330                        cmd: "node".into(),
2331                        args: vec!["index.js".into()],
2332                        sha256: None,
2333                        env: Default::default(),
2334                    },
2335                );
2336                map
2337            },
2338            env: HashMap::default(),
2339        };
2340
2341        // Verify that when cmd is "node", it attempts to use the node runtime
2342        assert_eq!(agent.extension_id.as_ref(), "node-extension");
2343        assert_eq!(agent.agent_id.as_ref(), "node-agent");
2344
2345        let target = agent.targets.get("darwin-aarch64").unwrap();
2346        assert_eq!(target.cmd, "node");
2347        assert_eq!(target.args, vec!["index.js"]);
2348    }
2349
2350    #[gpui::test]
2351    async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
2352        let fs = fs::FakeFs::new(cx.background_executor.clone());
2353        let http_client = http_client::FakeHttpClient::with_404_response();
2354        let node_runtime = NodeRuntime::unavailable();
2355        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2356        let project_environment = cx.new(|cx| {
2357            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2358        });
2359
2360        let agent = LocalExtensionArchiveAgent {
2361            fs: fs.clone(),
2362            http_client,
2363            node_runtime,
2364            project_environment,
2365            extension_id: Arc::from("test-ext"),
2366            agent_id: Arc::from("test-agent"),
2367            targets: {
2368                let mut map = HashMap::default();
2369                map.insert(
2370                    "darwin-aarch64".to_string(),
2371                    extension::TargetConfig {
2372                        archive: "https://example.com/test.zip".into(),
2373                        cmd: "node".into(),
2374                        args: vec![
2375                            "server.js".into(),
2376                            "--config".into(),
2377                            "./config.json".into(),
2378                        ],
2379                        sha256: None,
2380                        env: Default::default(),
2381                    },
2382                );
2383                map
2384            },
2385            env: HashMap::default(),
2386        };
2387
2388        // Verify the agent is configured with relative paths in args
2389        let target = agent.targets.get("darwin-aarch64").unwrap();
2390        assert_eq!(target.args[0], "server.js");
2391        assert_eq!(target.args[2], "./config.json");
2392        // These relative paths will resolve relative to the extraction directory
2393        // when the command is executed
2394    }
2395
2396    #[test]
2397    fn test_tilde_expansion_in_settings() {
2398        let settings = settings::BuiltinAgentServerSettings {
2399            path: Some(PathBuf::from("~/bin/agent")),
2400            args: Some(vec!["--flag".into()]),
2401            env: None,
2402            ignore_system_version: None,
2403            default_mode: None,
2404            default_model: None,
2405            favorite_models: vec![],
2406            default_config_options: Default::default(),
2407            favorite_config_option_values: Default::default(),
2408        };
2409
2410        let BuiltinAgentServerSettings { path, .. } = settings.into();
2411
2412        let path = path.unwrap();
2413        assert!(
2414            !path.to_string_lossy().starts_with("~"),
2415            "Tilde should be expanded for builtin agent path"
2416        );
2417
2418        let settings = settings::CustomAgentServerSettings::Custom {
2419            path: PathBuf::from("~/custom/agent"),
2420            args: vec!["serve".into()],
2421            env: None,
2422            default_mode: None,
2423            default_model: None,
2424            favorite_models: vec![],
2425            default_config_options: Default::default(),
2426            favorite_config_option_values: Default::default(),
2427        };
2428
2429        let converted: CustomAgentServerSettings = settings.into();
2430        let CustomAgentServerSettings::Custom {
2431            command: AgentServerCommand { path, .. },
2432            ..
2433        } = converted
2434        else {
2435            panic!("Expected Custom variant");
2436        };
2437
2438        assert!(
2439            !path.to_string_lossy().starts_with("~"),
2440            "Tilde should be expanded for custom agent path"
2441        );
2442    }
2443}