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