agent_server_store.rs

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