agent_server_store.rs

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