agent_server_store.rs

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