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        // Insert extension agents before custom/registry so registry entries override extensions.
 727        for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() {
 728            let name = ExternalAgentServerName(agent_name.clone().into());
 729            let mut env = env.clone();
 730            if let Some(settings_env) =
 731                new_settings
 732                    .custom
 733                    .get(agent_name.as_ref())
 734                    .and_then(|settings| match settings {
 735                        CustomAgentServerSettings::Extension { env, .. } => Some(env.clone()),
 736                        _ => None,
 737                    })
 738            {
 739                env.extend(settings_env);
 740            }
 741            let icon = icon_path
 742                .as_ref()
 743                .map(|path| SharedString::from(path.clone()));
 744
 745            self.external_agents.insert(
 746                name.clone(),
 747                ExternalAgentEntry::new(
 748                    Box::new(LocalExtensionArchiveAgent {
 749                        fs: fs.clone(),
 750                        http_client: http_client.clone(),
 751                        node_runtime: node_runtime.clone(),
 752                        project_environment: project_environment.clone(),
 753                        extension_id: Arc::from(&**ext_id),
 754                        targets: targets.clone(),
 755                        env,
 756                        agent_id: agent_name.clone(),
 757                    }) as Box<dyn ExternalAgentServer>,
 758                    ExternalAgentSource::Extension,
 759                    icon,
 760                    display_name.clone(),
 761                ),
 762            );
 763        }
 764
 765        for (name, settings) in &new_settings.custom {
 766            match settings {
 767                CustomAgentServerSettings::Custom { command, .. } => {
 768                    let agent_name = ExternalAgentServerName(name.clone().into());
 769                    self.external_agents.insert(
 770                        agent_name.clone(),
 771                        ExternalAgentEntry::new(
 772                            Box::new(LocalCustomAgent {
 773                                command: command.clone(),
 774                                project_environment: project_environment.clone(),
 775                            }) as Box<dyn ExternalAgentServer>,
 776                            ExternalAgentSource::Custom,
 777                            None,
 778                            None,
 779                        ),
 780                    );
 781                }
 782                CustomAgentServerSettings::Registry { env, .. } => {
 783                    let Some(agent) = registry_agents_by_id.get(name) else {
 784                        if registry_store.is_some() {
 785                            log::warn!("Registry agent '{}' not found in ACP registry", name);
 786                        }
 787                        continue;
 788                    };
 789
 790                    let agent_name = ExternalAgentServerName(name.clone().into());
 791                    match agent {
 792                        RegistryAgent::Binary(agent) => {
 793                            if !agent.supports_current_platform {
 794                                log::warn!(
 795                                    "Registry agent '{}' has no compatible binary for this platform",
 796                                    name
 797                                );
 798                                continue;
 799                            }
 800
 801                            self.external_agents.insert(
 802                                agent_name.clone(),
 803                                ExternalAgentEntry::new(
 804                                    Box::new(LocalRegistryArchiveAgent {
 805                                        fs: fs.clone(),
 806                                        http_client: http_client.clone(),
 807                                        node_runtime: node_runtime.clone(),
 808                                        project_environment: project_environment.clone(),
 809                                        registry_id: Arc::from(name.as_str()),
 810                                        targets: agent.targets.clone(),
 811                                        env: env.clone(),
 812                                    })
 813                                        as Box<dyn ExternalAgentServer>,
 814                                    ExternalAgentSource::Registry,
 815                                    agent.metadata.icon_path.clone(),
 816                                    Some(agent.metadata.name.clone()),
 817                                ),
 818                            );
 819                        }
 820                        RegistryAgent::Npx(agent) => {
 821                            self.external_agents.insert(
 822                                agent_name.clone(),
 823                                ExternalAgentEntry::new(
 824                                    Box::new(LocalRegistryNpxAgent {
 825                                        node_runtime: node_runtime.clone(),
 826                                        project_environment: project_environment.clone(),
 827                                        package: agent.package.clone(),
 828                                        args: agent.args.clone(),
 829                                        distribution_env: agent.env.clone(),
 830                                        settings_env: env.clone(),
 831                                    })
 832                                        as Box<dyn ExternalAgentServer>,
 833                                    ExternalAgentSource::Registry,
 834                                    agent.metadata.icon_path.clone(),
 835                                    Some(agent.metadata.name.clone()),
 836                                ),
 837                            );
 838                        }
 839                    }
 840                }
 841                CustomAgentServerSettings::Extension { .. } => {}
 842            }
 843        }
 844
 845        *old_settings = Some(new_settings);
 846
 847        if let Some((project_id, downstream_client)) = downstream_client {
 848            downstream_client
 849                .send(proto::ExternalAgentsUpdated {
 850                    project_id: *project_id,
 851                    names: self
 852                        .external_agents
 853                        .keys()
 854                        .map(|name| name.to_string())
 855                        .collect(),
 856                })
 857                .log_err();
 858        }
 859        cx.emit(AgentServersUpdated);
 860    }
 861
 862    pub fn node_runtime(&self) -> Option<NodeRuntime> {
 863        match &self.state {
 864            AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
 865            _ => None,
 866        }
 867    }
 868
 869    pub fn local(
 870        node_runtime: NodeRuntime,
 871        fs: Arc<dyn Fs>,
 872        project_environment: Entity<ProjectEnvironment>,
 873        http_client: Arc<dyn HttpClient>,
 874        cx: &mut Context<Self>,
 875    ) -> Self {
 876        let mut subscriptions = vec![cx.observe_global::<SettingsStore>(|this, cx| {
 877            this.agent_servers_settings_changed(cx);
 878        })];
 879        if let Some(registry_store) = AgentRegistryStore::try_global(cx) {
 880            subscriptions.push(cx.observe(&registry_store, |this, _, cx| {
 881                this.reregister_agents(cx);
 882            }));
 883        }
 884        let mut this = Self {
 885            state: AgentServerStoreState::Local {
 886                node_runtime,
 887                fs,
 888                project_environment,
 889                http_client,
 890                downstream_client: None,
 891                settings: None,
 892                extension_agents: vec![],
 893                _subscriptions: subscriptions,
 894            },
 895            external_agents: Default::default(),
 896        };
 897        if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
 898        this.agent_servers_settings_changed(cx);
 899        this
 900    }
 901
 902    pub(crate) fn remote(project_id: u64, upstream_client: Entity<RemoteClient>) -> Self {
 903        // Set up the builtin agents here so they're immediately available in
 904        // remote projects--we know that the HeadlessProject on the other end
 905        // will have them.
 906        let external_agents: [(ExternalAgentServerName, ExternalAgentEntry); 3] = [
 907            (
 908                CLAUDE_CODE_NAME.into(),
 909                ExternalAgentEntry::new(
 910                    Box::new(RemoteExternalAgentServer {
 911                        project_id,
 912                        upstream_client: upstream_client.clone(),
 913                        name: CLAUDE_CODE_NAME.into(),
 914                        status_tx: None,
 915                        new_version_available_tx: None,
 916                    }) as Box<dyn ExternalAgentServer>,
 917                    ExternalAgentSource::Builtin,
 918                    None,
 919                    None,
 920                ),
 921            ),
 922            (
 923                CODEX_NAME.into(),
 924                ExternalAgentEntry::new(
 925                    Box::new(RemoteExternalAgentServer {
 926                        project_id,
 927                        upstream_client: upstream_client.clone(),
 928                        name: CODEX_NAME.into(),
 929                        status_tx: None,
 930                        new_version_available_tx: None,
 931                    }) as Box<dyn ExternalAgentServer>,
 932                    ExternalAgentSource::Builtin,
 933                    None,
 934                    None,
 935                ),
 936            ),
 937            (
 938                GEMINI_NAME.into(),
 939                ExternalAgentEntry::new(
 940                    Box::new(RemoteExternalAgentServer {
 941                        project_id,
 942                        upstream_client: upstream_client.clone(),
 943                        name: GEMINI_NAME.into(),
 944                        status_tx: None,
 945                        new_version_available_tx: None,
 946                    }) as Box<dyn ExternalAgentServer>,
 947                    ExternalAgentSource::Builtin,
 948                    None,
 949                    None,
 950                ),
 951            ),
 952        ];
 953
 954        Self {
 955            state: AgentServerStoreState::Remote {
 956                project_id,
 957                upstream_client,
 958            },
 959            external_agents: external_agents.into_iter().collect(),
 960        }
 961    }
 962
 963    pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
 964        Self {
 965            state: AgentServerStoreState::Collab,
 966            external_agents: Default::default(),
 967        }
 968    }
 969
 970    pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
 971        match &mut self.state {
 972            AgentServerStoreState::Local {
 973                downstream_client, ..
 974            } => {
 975                *downstream_client = Some((project_id, client.clone()));
 976                // Send the current list of external agents downstream, but only after a delay,
 977                // to avoid having the message arrive before the downstream project's agent server store
 978                // sets up its handlers.
 979                cx.spawn(async move |this, cx| {
 980                    cx.background_executor().timer(Duration::from_secs(1)).await;
 981                    let names = this.update(cx, |this, _| {
 982                        this.external_agents()
 983                            .map(|name| name.to_string())
 984                            .collect()
 985                    })?;
 986                    client
 987                        .send(proto::ExternalAgentsUpdated { project_id, names })
 988                        .log_err();
 989                    anyhow::Ok(())
 990                })
 991                .detach();
 992            }
 993            AgentServerStoreState::Remote { .. } => {
 994                debug_panic!(
 995                    "external agents over collab not implemented, remote project should not be shared"
 996                );
 997            }
 998            AgentServerStoreState::Collab => {
 999                debug_panic!("external agents over collab not implemented, should not be shared");
1000            }
1001        }
1002    }
1003
1004    pub fn get_external_agent(
1005        &mut self,
1006        name: &ExternalAgentServerName,
1007    ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
1008        self.external_agents
1009            .get_mut(name)
1010            .map(|entry| entry.server.as_mut())
1011    }
1012
1013    pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
1014        self.external_agents.keys()
1015    }
1016
1017    async fn handle_get_agent_server_command(
1018        this: Entity<Self>,
1019        envelope: TypedEnvelope<proto::GetAgentServerCommand>,
1020        mut cx: AsyncApp,
1021    ) -> Result<proto::AgentServerCommand> {
1022        let (command, root_dir, login_command) = this
1023            .update(&mut cx, |this, cx| {
1024                let AgentServerStoreState::Local {
1025                    downstream_client, ..
1026                } = &this.state
1027                else {
1028                    debug_panic!("should not receive GetAgentServerCommand in a non-local project");
1029                    bail!("unexpected GetAgentServerCommand request in a non-local project");
1030                };
1031                let agent = this
1032                    .external_agents
1033                    .get_mut(&*envelope.payload.name)
1034                    .map(|entry| entry.server.as_mut())
1035                    .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
1036                let (status_tx, new_version_available_tx) = downstream_client
1037                    .clone()
1038                    .map(|(project_id, downstream_client)| {
1039                        let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
1040                        let (new_version_available_tx, mut new_version_available_rx) =
1041                            watch::channel(None);
1042                        cx.spawn({
1043                            let downstream_client = downstream_client.clone();
1044                            let name = envelope.payload.name.clone();
1045                            async move |_, _| {
1046                                while let Some(status) = status_rx.recv().await.ok() {
1047                                    downstream_client.send(
1048                                        proto::ExternalAgentLoadingStatusUpdated {
1049                                            project_id,
1050                                            name: name.clone(),
1051                                            status: status.to_string(),
1052                                        },
1053                                    )?;
1054                                }
1055                                anyhow::Ok(())
1056                            }
1057                        })
1058                        .detach_and_log_err(cx);
1059                        cx.spawn({
1060                            let name = envelope.payload.name.clone();
1061                            async move |_, _| {
1062                                if let Some(version) =
1063                                    new_version_available_rx.recv().await.ok().flatten()
1064                                {
1065                                    downstream_client.send(
1066                                        proto::NewExternalAgentVersionAvailable {
1067                                            project_id,
1068                                            name: name.clone(),
1069                                            version,
1070                                        },
1071                                    )?;
1072                                }
1073                                anyhow::Ok(())
1074                            }
1075                        })
1076                        .detach_and_log_err(cx);
1077                        (status_tx, new_version_available_tx)
1078                    })
1079                    .unzip();
1080                anyhow::Ok(agent.get_command(
1081                    envelope.payload.root_dir.as_deref(),
1082                    HashMap::default(),
1083                    status_tx,
1084                    new_version_available_tx,
1085                    &mut cx.to_async(),
1086                ))
1087            })?
1088            .await?;
1089        Ok(proto::AgentServerCommand {
1090            path: command.path.to_string_lossy().into_owned(),
1091            args: command.args,
1092            env: command
1093                .env
1094                .map(|env| env.into_iter().collect())
1095                .unwrap_or_default(),
1096            root_dir: root_dir,
1097            login: login_command.map(|cmd| cmd.to_proto()),
1098        })
1099    }
1100
1101    async fn handle_external_agents_updated(
1102        this: Entity<Self>,
1103        envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
1104        mut cx: AsyncApp,
1105    ) -> Result<()> {
1106        this.update(&mut cx, |this, cx| {
1107            let AgentServerStoreState::Remote {
1108                project_id,
1109                upstream_client,
1110            } = &this.state
1111            else {
1112                debug_panic!(
1113                    "handle_external_agents_updated should not be called for a non-remote project"
1114                );
1115                bail!("unexpected ExternalAgentsUpdated message")
1116            };
1117
1118            let mut previous_entries = std::mem::take(&mut this.external_agents);
1119            let mut status_txs = HashMap::default();
1120            let mut new_version_available_txs = HashMap::default();
1121            let mut metadata = HashMap::default();
1122
1123            for (name, mut entry) in previous_entries.drain() {
1124                if let Some(agent) = entry.server.downcast_mut::<RemoteExternalAgentServer>() {
1125                    status_txs.insert(name.clone(), agent.status_tx.take());
1126                    new_version_available_txs
1127                        .insert(name.clone(), agent.new_version_available_tx.take());
1128                }
1129
1130                metadata.insert(name, (entry.icon, entry.display_name, entry.source));
1131            }
1132
1133            this.external_agents = envelope
1134                .payload
1135                .names
1136                .into_iter()
1137                .map(|name| {
1138                    let agent_name = ExternalAgentServerName(name.clone().into());
1139                    let fallback_source =
1140                        if name == GEMINI_NAME || name == CLAUDE_CODE_NAME || name == CODEX_NAME {
1141                            ExternalAgentSource::Builtin
1142                        } else {
1143                            ExternalAgentSource::Custom
1144                        };
1145                    let (icon, display_name, source) =
1146                        metadata
1147                            .remove(&agent_name)
1148                            .unwrap_or((None, None, fallback_source));
1149                    let source = if fallback_source == ExternalAgentSource::Builtin {
1150                        ExternalAgentSource::Builtin
1151                    } else {
1152                        source
1153                    };
1154                    let agent = RemoteExternalAgentServer {
1155                        project_id: *project_id,
1156                        upstream_client: upstream_client.clone(),
1157                        name: agent_name.clone(),
1158                        status_tx: status_txs.remove(&agent_name).flatten(),
1159                        new_version_available_tx: new_version_available_txs
1160                            .remove(&agent_name)
1161                            .flatten(),
1162                    };
1163                    (
1164                        agent_name,
1165                        ExternalAgentEntry::new(
1166                            Box::new(agent) as Box<dyn ExternalAgentServer>,
1167                            source,
1168                            icon,
1169                            display_name,
1170                        ),
1171                    )
1172                })
1173                .collect();
1174            cx.emit(AgentServersUpdated);
1175            Ok(())
1176        })
1177    }
1178
1179    async fn handle_external_extension_agents_updated(
1180        this: Entity<Self>,
1181        envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
1182        mut cx: AsyncApp,
1183    ) -> Result<()> {
1184        this.update(&mut cx, |this, cx| {
1185            let AgentServerStoreState::Local {
1186                extension_agents, ..
1187            } = &mut this.state
1188            else {
1189                panic!(
1190                    "handle_external_extension_agents_updated \
1191                    should not be called for a non-remote project"
1192                );
1193            };
1194
1195            for ExternalExtensionAgent {
1196                name,
1197                icon_path,
1198                extension_id,
1199                targets,
1200                env,
1201            } in envelope.payload.agents
1202            {
1203                extension_agents.push((
1204                    Arc::from(&*name),
1205                    extension_id,
1206                    targets
1207                        .into_iter()
1208                        .map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
1209                        .collect(),
1210                    env.into_iter().collect(),
1211                    icon_path,
1212                    None,
1213                ));
1214            }
1215
1216            this.reregister_agents(cx);
1217            cx.emit(AgentServersUpdated);
1218            Ok(())
1219        })
1220    }
1221
1222    async fn handle_loading_status_updated(
1223        this: Entity<Self>,
1224        envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
1225        mut cx: AsyncApp,
1226    ) -> Result<()> {
1227        this.update(&mut cx, |this, _| {
1228            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
1229                && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
1230                && let Some(status_tx) = &mut agent.status_tx
1231            {
1232                status_tx.send(envelope.payload.status.into()).ok();
1233            }
1234        });
1235        Ok(())
1236    }
1237
1238    async fn handle_new_version_available(
1239        this: Entity<Self>,
1240        envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
1241        mut cx: AsyncApp,
1242    ) -> Result<()> {
1243        this.update(&mut cx, |this, _| {
1244            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
1245                && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
1246                && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
1247            {
1248                new_version_available_tx
1249                    .send(Some(envelope.payload.version))
1250                    .ok();
1251            }
1252        });
1253        Ok(())
1254    }
1255
1256    pub fn get_extension_id_for_agent(
1257        &mut self,
1258        name: &ExternalAgentServerName,
1259    ) -> Option<Arc<str>> {
1260        self.external_agents.get_mut(name).and_then(|entry| {
1261            entry
1262                .server
1263                .as_any_mut()
1264                .downcast_ref::<LocalExtensionArchiveAgent>()
1265                .map(|ext_agent| ext_agent.extension_id.clone())
1266        })
1267    }
1268}
1269
1270fn get_or_npm_install_builtin_agent(
1271    binary_name: SharedString,
1272    package_name: SharedString,
1273    entrypoint_path: PathBuf,
1274    minimum_version: Option<semver::Version>,
1275    status_tx: Option<watch::Sender<SharedString>>,
1276    new_version_available: Option<watch::Sender<Option<String>>>,
1277    fs: Arc<dyn Fs>,
1278    node_runtime: NodeRuntime,
1279    cx: &mut AsyncApp,
1280) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
1281    cx.spawn(async move |cx| {
1282        let node_path = node_runtime.binary_path().await?;
1283        let dir = paths::external_agents_dir().join(binary_name.as_str());
1284        fs.create_dir(&dir).await?;
1285
1286        let mut stream = fs.read_dir(&dir).await?;
1287        let mut versions = Vec::new();
1288        let mut to_delete = Vec::new();
1289        while let Some(entry) = stream.next().await {
1290            let Ok(entry) = entry else { continue };
1291            let Some(file_name) = entry.file_name() else {
1292                continue;
1293            };
1294
1295            if let Some(name) = file_name.to_str()
1296                && let Some(version) = semver::Version::from_str(name).ok()
1297                && fs
1298                    .is_file(&dir.join(file_name).join(&entrypoint_path))
1299                    .await
1300            {
1301                versions.push((version, file_name.to_owned()));
1302            } else {
1303                to_delete.push(file_name.to_owned())
1304            }
1305        }
1306
1307        versions.sort();
1308        let newest_version = if let Some((version, _)) = versions.last().cloned()
1309            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
1310        {
1311            versions.pop()
1312        } else {
1313            None
1314        };
1315        log::debug!("existing version of {package_name}: {newest_version:?}");
1316        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
1317
1318        cx.background_spawn({
1319            let fs = fs.clone();
1320            let dir = dir.clone();
1321            async move {
1322                for file_name in to_delete {
1323                    fs.remove_dir(
1324                        &dir.join(file_name),
1325                        RemoveOptions {
1326                            recursive: true,
1327                            ignore_if_not_exists: false,
1328                        },
1329                    )
1330                    .await
1331                    .ok();
1332                }
1333            }
1334        })
1335        .detach();
1336
1337        let version = if let Some((version, file_name)) = newest_version {
1338            cx.background_spawn({
1339                let dir = dir.clone();
1340                let fs = fs.clone();
1341                async move {
1342                    let latest_version = node_runtime
1343                        .npm_package_latest_version(&package_name)
1344                        .await
1345                        .ok();
1346                    if let Some(latest_version) = latest_version
1347                        && latest_version != version
1348                    {
1349                        let download_result = download_latest_version(
1350                            fs,
1351                            dir.clone(),
1352                            node_runtime,
1353                            package_name.clone(),
1354                        )
1355                        .await
1356                        .log_err();
1357                        if let Some(mut new_version_available) = new_version_available
1358                            && download_result.is_some()
1359                        {
1360                            new_version_available
1361                                .send(Some(latest_version.to_string()))
1362                                .ok();
1363                        }
1364                    }
1365                }
1366            })
1367            .detach();
1368            file_name
1369        } else {
1370            if let Some(mut status_tx) = status_tx {
1371                status_tx.send("Installing…".into()).ok();
1372            }
1373            let dir = dir.clone();
1374            cx.background_spawn(download_latest_version(
1375                fs.clone(),
1376                dir.clone(),
1377                node_runtime,
1378                package_name.clone(),
1379            ))
1380            .await?
1381            .to_string()
1382            .into()
1383        };
1384
1385        let agent_server_path = dir.join(version).join(entrypoint_path);
1386        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
1387        anyhow::ensure!(
1388            agent_server_path_exists,
1389            "Missing entrypoint path {} after installation",
1390            agent_server_path.to_string_lossy()
1391        );
1392
1393        anyhow::Ok(AgentServerCommand {
1394            path: node_path,
1395            args: vec![agent_server_path.to_string_lossy().into_owned()],
1396            env: None,
1397        })
1398    })
1399}
1400
1401fn find_bin_in_path(
1402    bin_name: SharedString,
1403    root_dir: PathBuf,
1404    env: HashMap<String, String>,
1405    cx: &mut AsyncApp,
1406) -> Task<Option<PathBuf>> {
1407    cx.background_executor().spawn(async move {
1408        let which_result = if cfg!(windows) {
1409            which::which(bin_name.as_str())
1410        } else {
1411            let shell_path = env.get("PATH").cloned();
1412            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
1413        };
1414
1415        if let Err(which::Error::CannotFindBinaryPath) = which_result {
1416            return None;
1417        }
1418
1419        which_result.log_err()
1420    })
1421}
1422
1423async fn download_latest_version(
1424    fs: Arc<dyn Fs>,
1425    dir: PathBuf,
1426    node_runtime: NodeRuntime,
1427    package_name: SharedString,
1428) -> Result<Version> {
1429    log::debug!("downloading latest version of {package_name}");
1430
1431    let tmp_dir = tempfile::tempdir_in(&dir)?;
1432
1433    node_runtime
1434        .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
1435        .await?;
1436
1437    let version = node_runtime
1438        .npm_package_installed_version(tmp_dir.path(), &package_name)
1439        .await?
1440        .context("expected package to be installed")?;
1441
1442    fs.rename(
1443        &tmp_dir.keep(),
1444        &dir.join(version.to_string()),
1445        RenameOptions {
1446            ignore_if_exists: true,
1447            overwrite: true,
1448            create_parents: false,
1449        },
1450    )
1451    .await?;
1452
1453    anyhow::Ok(version)
1454}
1455
1456struct RemoteExternalAgentServer {
1457    project_id: u64,
1458    upstream_client: Entity<RemoteClient>,
1459    name: ExternalAgentServerName,
1460    status_tx: Option<watch::Sender<SharedString>>,
1461    new_version_available_tx: Option<watch::Sender<Option<String>>>,
1462}
1463
1464impl ExternalAgentServer for RemoteExternalAgentServer {
1465    fn get_command(
1466        &mut self,
1467        root_dir: Option<&str>,
1468        extra_env: HashMap<String, String>,
1469        status_tx: Option<watch::Sender<SharedString>>,
1470        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1471        cx: &mut AsyncApp,
1472    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1473        let project_id = self.project_id;
1474        let name = self.name.to_string();
1475        let upstream_client = self.upstream_client.downgrade();
1476        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
1477        self.status_tx = status_tx;
1478        self.new_version_available_tx = new_version_available_tx;
1479        cx.spawn(async move |cx| {
1480            let mut response = upstream_client
1481                .update(cx, |upstream_client, _| {
1482                    upstream_client
1483                        .proto_client()
1484                        .request(proto::GetAgentServerCommand {
1485                            project_id,
1486                            name,
1487                            root_dir: root_dir.clone(),
1488                        })
1489                })?
1490                .await?;
1491            let root_dir = response.root_dir;
1492            response.env.extend(extra_env);
1493            let command = upstream_client.update(cx, |client, _| {
1494                client.build_command_with_options(
1495                    Some(response.path),
1496                    &response.args,
1497                    &response.env.into_iter().collect(),
1498                    Some(root_dir.clone()),
1499                    None,
1500                    Interactive::No,
1501                )
1502            })??;
1503            Ok((
1504                AgentServerCommand {
1505                    path: command.program.into(),
1506                    args: command.args,
1507                    env: Some(command.env),
1508                },
1509                root_dir,
1510                response.login.map(SpawnInTerminal::from_proto),
1511            ))
1512        })
1513    }
1514
1515    fn as_any_mut(&mut self) -> &mut dyn Any {
1516        self
1517    }
1518}
1519
1520struct LocalGemini {
1521    fs: Arc<dyn Fs>,
1522    node_runtime: NodeRuntime,
1523    project_environment: Entity<ProjectEnvironment>,
1524    custom_command: Option<AgentServerCommand>,
1525    settings_env: Option<HashMap<String, String>>,
1526    ignore_system_version: bool,
1527}
1528
1529impl ExternalAgentServer for LocalGemini {
1530    fn get_command(
1531        &mut self,
1532        root_dir: Option<&str>,
1533        extra_env: HashMap<String, String>,
1534        status_tx: Option<watch::Sender<SharedString>>,
1535        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1536        cx: &mut AsyncApp,
1537    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1538        let fs = self.fs.clone();
1539        let node_runtime = self.node_runtime.clone();
1540        let project_environment = self.project_environment.downgrade();
1541        let custom_command = self.custom_command.clone();
1542        let settings_env = self.settings_env.clone();
1543        let ignore_system_version = self.ignore_system_version;
1544        let root_dir: Arc<Path> = root_dir
1545            .map(|root_dir| Path::new(root_dir))
1546            .unwrap_or(paths::home_dir())
1547            .into();
1548
1549        cx.spawn(async move |cx| {
1550            let mut env = project_environment
1551                .update(cx, |project_environment, cx| {
1552                    project_environment.local_directory_environment(
1553                        &Shell::System,
1554                        root_dir.clone(),
1555                        cx,
1556                    )
1557                })?
1558                .await
1559                .unwrap_or_default();
1560
1561            env.extend(settings_env.unwrap_or_default());
1562
1563            let mut command = if let Some(mut custom_command) = custom_command {
1564                custom_command.env = Some(env);
1565                custom_command
1566            } else if !ignore_system_version
1567                && let Some(bin) =
1568                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1569            {
1570                AgentServerCommand {
1571                    path: bin,
1572                    args: Vec::new(),
1573                    env: Some(env),
1574                }
1575            } else {
1576                let mut command = get_or_npm_install_builtin_agent(
1577                    GEMINI_NAME.into(),
1578                    "@google/gemini-cli".into(),
1579                    "node_modules/@google/gemini-cli/dist/index.js".into(),
1580                    if cfg!(windows) {
1581                        // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1582                        Some("0.9.0".parse().unwrap())
1583                    } else {
1584                        Some("0.2.1".parse().unwrap())
1585                    },
1586                    status_tx,
1587                    new_version_available_tx,
1588                    fs,
1589                    node_runtime,
1590                    cx,
1591                )
1592                .await?;
1593                command.env = Some(env);
1594                command
1595            };
1596
1597            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1598            let login = task::SpawnInTerminal {
1599                command: Some(command.path.to_string_lossy().into_owned()),
1600                args: command.args.clone(),
1601                env: command.env.clone().unwrap_or_default(),
1602                label: "gemini /auth".into(),
1603                ..Default::default()
1604            };
1605
1606            command.env.get_or_insert_default().extend(extra_env);
1607            command.args.push("--experimental-acp".into());
1608            Ok((
1609                command,
1610                root_dir.to_string_lossy().into_owned(),
1611                Some(login),
1612            ))
1613        })
1614    }
1615
1616    fn as_any_mut(&mut self) -> &mut dyn Any {
1617        self
1618    }
1619}
1620
1621struct LocalClaudeCode {
1622    fs: Arc<dyn Fs>,
1623    node_runtime: NodeRuntime,
1624    project_environment: Entity<ProjectEnvironment>,
1625    custom_command: Option<AgentServerCommand>,
1626    settings_env: Option<HashMap<String, String>>,
1627}
1628
1629impl ExternalAgentServer for LocalClaudeCode {
1630    fn get_command(
1631        &mut self,
1632        root_dir: Option<&str>,
1633        extra_env: HashMap<String, String>,
1634        status_tx: Option<watch::Sender<SharedString>>,
1635        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1636        cx: &mut AsyncApp,
1637    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1638        let fs = self.fs.clone();
1639        let node_runtime = self.node_runtime.clone();
1640        let project_environment = self.project_environment.downgrade();
1641        let custom_command = self.custom_command.clone();
1642        let settings_env = self.settings_env.clone();
1643        let root_dir: Arc<Path> = root_dir
1644            .map(|root_dir| Path::new(root_dir))
1645            .unwrap_or(paths::home_dir())
1646            .into();
1647
1648        cx.spawn(async move |cx| {
1649            let mut env = project_environment
1650                .update(cx, |project_environment, cx| {
1651                    project_environment.local_directory_environment(
1652                        &Shell::System,
1653                        root_dir.clone(),
1654                        cx,
1655                    )
1656                })?
1657                .await
1658                .unwrap_or_default();
1659            env.insert("ANTHROPIC_API_KEY".into(), "".into());
1660
1661            env.extend(settings_env.unwrap_or_default());
1662
1663            let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1664                custom_command.env = Some(env);
1665                (custom_command, None)
1666            } else {
1667                let mut command = get_or_npm_install_builtin_agent(
1668                    "claude-code-acp".into(),
1669                    "@zed-industries/claude-code-acp".into(),
1670                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1671                    Some("0.5.2".parse().unwrap()),
1672                    status_tx,
1673                    new_version_available_tx,
1674                    fs,
1675                    node_runtime,
1676                    cx,
1677                )
1678                .await?;
1679                command.env = Some(env);
1680                let login = command
1681                    .args
1682                    .first()
1683                    .and_then(|path| {
1684                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1685                    })
1686                    .map(|path_prefix| task::SpawnInTerminal {
1687                        command: Some(command.path.to_string_lossy().into_owned()),
1688                        args: vec![
1689                            Path::new(path_prefix)
1690                                .join("@anthropic-ai/claude-agent-sdk/cli.js")
1691                                .to_string_lossy()
1692                                .to_string(),
1693                            "/login".into(),
1694                        ],
1695                        env: command.env.clone().unwrap_or_default(),
1696                        label: "claude /login".into(),
1697                        ..Default::default()
1698                    });
1699                (command, login)
1700            };
1701
1702            command.env.get_or_insert_default().extend(extra_env);
1703            Ok((
1704                command,
1705                root_dir.to_string_lossy().into_owned(),
1706                login_command,
1707            ))
1708        })
1709    }
1710
1711    fn as_any_mut(&mut self) -> &mut dyn Any {
1712        self
1713    }
1714}
1715
1716struct LocalCodex {
1717    fs: Arc<dyn Fs>,
1718    project_environment: Entity<ProjectEnvironment>,
1719    http_client: Arc<dyn HttpClient>,
1720    custom_command: Option<AgentServerCommand>,
1721    settings_env: Option<HashMap<String, String>>,
1722    no_browser: bool,
1723}
1724
1725impl ExternalAgentServer for LocalCodex {
1726    fn get_command(
1727        &mut self,
1728        root_dir: Option<&str>,
1729        extra_env: HashMap<String, String>,
1730        mut status_tx: Option<watch::Sender<SharedString>>,
1731        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1732        cx: &mut AsyncApp,
1733    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1734        let fs = self.fs.clone();
1735        let project_environment = self.project_environment.downgrade();
1736        let http = self.http_client.clone();
1737        let custom_command = self.custom_command.clone();
1738        let settings_env = self.settings_env.clone();
1739        let root_dir: Arc<Path> = root_dir
1740            .map(|root_dir| Path::new(root_dir))
1741            .unwrap_or(paths::home_dir())
1742            .into();
1743        let no_browser = self.no_browser;
1744
1745        cx.spawn(async move |cx| {
1746            let mut env = project_environment
1747                .update(cx, |project_environment, cx| {
1748                    project_environment.local_directory_environment(
1749                        &Shell::System,
1750                        root_dir.clone(),
1751                        cx,
1752                    )
1753                })?
1754                .await
1755                .unwrap_or_default();
1756            if no_browser {
1757                env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1758            }
1759
1760            env.extend(settings_env.unwrap_or_default());
1761
1762            let mut command = if let Some(mut custom_command) = custom_command {
1763                custom_command.env = Some(env);
1764                custom_command
1765            } else {
1766                let dir = paths::external_agents_dir().join(CODEX_NAME);
1767                fs.create_dir(&dir).await?;
1768
1769                let bin_name = if cfg!(windows) {
1770                    "codex-acp.exe"
1771                } else {
1772                    "codex-acp"
1773                };
1774
1775                let find_latest_local_version = async || -> Option<PathBuf> {
1776                    let mut local_versions: Vec<(semver::Version, String)> = Vec::new();
1777                    let mut stream = fs.read_dir(&dir).await.ok()?;
1778                    while let Some(entry) = stream.next().await {
1779                        let Ok(entry) = entry else { continue };
1780                        let Some(file_name) = entry.file_name() else {
1781                            continue;
1782                        };
1783                        let version_path = dir.join(&file_name);
1784                        if fs.is_file(&version_path.join(bin_name)).await {
1785                            let version_str = file_name.to_string_lossy();
1786                            if let Ok(version) =
1787                                semver::Version::from_str(version_str.trim_start_matches('v'))
1788                            {
1789                                local_versions.push((version, version_str.into_owned()));
1790                            }
1791                        }
1792                    }
1793                    local_versions.sort_by(|(a, _), (b, _)| a.cmp(b));
1794                    local_versions.last().map(|(_, v)| dir.join(v))
1795                };
1796
1797                let fallback_to_latest_local_version =
1798                    async |err: anyhow::Error| -> Result<PathBuf, anyhow::Error> {
1799                        if let Some(local) = find_latest_local_version().await {
1800                            log::info!(
1801                                "Falling back to locally installed Codex version: {}",
1802                                local.display()
1803                            );
1804                            Ok(local)
1805                        } else {
1806                            Err(err)
1807                        }
1808                    };
1809
1810                let version_dir = match ::http_client::github::latest_github_release(
1811                    CODEX_ACP_REPO,
1812                    true,
1813                    false,
1814                    http.clone(),
1815                )
1816                .await
1817                {
1818                    Ok(release) => {
1819                        let version_dir = dir.join(&release.tag_name);
1820                        if !fs.is_dir(&version_dir).await {
1821                            if let Some(ref mut status_tx) = status_tx {
1822                                status_tx.send("Installing…".into()).ok();
1823                            }
1824
1825                            let tag = release.tag_name.clone();
1826                            let version_number = tag.trim_start_matches('v');
1827                            let asset_name = asset_name(version_number)
1828                                .context("codex acp is not supported for this architecture")?;
1829                            let asset = release
1830                                .assets
1831                                .into_iter()
1832                                .find(|asset| asset.name == asset_name)
1833                                .with_context(|| {
1834                                    format!("no asset found matching `{asset_name:?}`")
1835                                })?;
1836                            // Strip "sha256:" prefix from digest if present (GitHub API format)
1837                            let digest = asset
1838                                .digest
1839                                .as_deref()
1840                                .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1841                            match ::http_client::github_download::download_server_binary(
1842                                &*http,
1843                                &asset.browser_download_url,
1844                                digest,
1845                                &version_dir,
1846                                if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1847                                    AssetKind::Zip
1848                                } else {
1849                                    AssetKind::TarGz
1850                                },
1851                            )
1852                            .await
1853                            {
1854                                Ok(()) => {
1855                                    // remove older versions
1856                                    util::fs::remove_matching(&dir, |entry| entry != version_dir)
1857                                        .await;
1858                                    version_dir
1859                                }
1860                                Err(err) => {
1861                                    log::error!(
1862                                        "Failed to download Codex release {}: {err:#}",
1863                                        release.tag_name
1864                                    );
1865                                    fallback_to_latest_local_version(err).await?
1866                                }
1867                            }
1868                        } else {
1869                            version_dir
1870                        }
1871                    }
1872                    Err(err) => {
1873                        log::error!("Failed to fetch Codex latest release: {err:#}");
1874                        fallback_to_latest_local_version(err).await?
1875                    }
1876                };
1877
1878                let bin_path = version_dir.join(bin_name);
1879                anyhow::ensure!(
1880                    fs.is_file(&bin_path).await,
1881                    "Missing Codex binary at {} after installation",
1882                    bin_path.to_string_lossy()
1883                );
1884
1885                let mut cmd = AgentServerCommand {
1886                    path: bin_path,
1887                    args: Vec::new(),
1888                    env: None,
1889                };
1890                cmd.env = Some(env);
1891                cmd
1892            };
1893
1894            command.env.get_or_insert_default().extend(extra_env);
1895            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1896        })
1897    }
1898
1899    fn as_any_mut(&mut self) -> &mut dyn Any {
1900        self
1901    }
1902}
1903
1904pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1905
1906fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1907    let arch = if cfg!(target_arch = "x86_64") {
1908        "x86_64"
1909    } else if cfg!(target_arch = "aarch64") {
1910        "aarch64"
1911    } else {
1912        return None;
1913    };
1914
1915    let platform = if cfg!(target_os = "macos") {
1916        "apple-darwin"
1917    } else if cfg!(target_os = "windows") {
1918        "pc-windows-msvc"
1919    } else if cfg!(target_os = "linux") {
1920        "unknown-linux-gnu"
1921    } else {
1922        return None;
1923    };
1924
1925    // Windows uses .zip in release assets
1926    let ext = if cfg!(target_os = "windows") {
1927        "zip"
1928    } else {
1929        "tar.gz"
1930    };
1931
1932    Some((arch, platform, ext))
1933}
1934
1935fn asset_name(version: &str) -> Option<String> {
1936    let (arch, platform, ext) = get_platform_info()?;
1937    Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1938}
1939
1940struct LocalExtensionArchiveAgent {
1941    fs: Arc<dyn Fs>,
1942    http_client: Arc<dyn HttpClient>,
1943    node_runtime: NodeRuntime,
1944    project_environment: Entity<ProjectEnvironment>,
1945    extension_id: Arc<str>,
1946    agent_id: Arc<str>,
1947    targets: HashMap<String, extension::TargetConfig>,
1948    env: HashMap<String, String>,
1949}
1950
1951impl ExternalAgentServer for LocalExtensionArchiveAgent {
1952    fn get_command(
1953        &mut self,
1954        root_dir: Option<&str>,
1955        extra_env: HashMap<String, String>,
1956        _status_tx: Option<watch::Sender<SharedString>>,
1957        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1958        cx: &mut AsyncApp,
1959    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1960        let fs = self.fs.clone();
1961        let http_client = self.http_client.clone();
1962        let node_runtime = self.node_runtime.clone();
1963        let project_environment = self.project_environment.downgrade();
1964        let extension_id = self.extension_id.clone();
1965        let agent_id = self.agent_id.clone();
1966        let targets = self.targets.clone();
1967        let base_env = self.env.clone();
1968
1969        let root_dir: Arc<Path> = root_dir
1970            .map(|root_dir| Path::new(root_dir))
1971            .unwrap_or(paths::home_dir())
1972            .into();
1973
1974        cx.spawn(async move |cx| {
1975            // Get project environment
1976            let mut env = project_environment
1977                .update(cx, |project_environment, cx| {
1978                    project_environment.local_directory_environment(
1979                        &Shell::System,
1980                        root_dir.clone(),
1981                        cx,
1982                    )
1983                })?
1984                .await
1985                .unwrap_or_default();
1986
1987            // Merge manifest env and extra env
1988            env.extend(base_env);
1989            env.extend(extra_env);
1990
1991            let cache_key = format!("{}/{}", extension_id, agent_id);
1992            let dir = paths::external_agents_dir().join(&cache_key);
1993            fs.create_dir(&dir).await?;
1994
1995            // Determine platform key
1996            let os = if cfg!(target_os = "macos") {
1997                "darwin"
1998            } else if cfg!(target_os = "linux") {
1999                "linux"
2000            } else if cfg!(target_os = "windows") {
2001                "windows"
2002            } else {
2003                anyhow::bail!("unsupported OS");
2004            };
2005
2006            let arch = if cfg!(target_arch = "aarch64") {
2007                "aarch64"
2008            } else if cfg!(target_arch = "x86_64") {
2009                "x86_64"
2010            } else {
2011                anyhow::bail!("unsupported architecture");
2012            };
2013
2014            let platform_key = format!("{}-{}", os, arch);
2015            let target_config = targets.get(&platform_key).with_context(|| {
2016                format!(
2017                    "no target specified for platform '{}'. Available platforms: {}",
2018                    platform_key,
2019                    targets
2020                        .keys()
2021                        .map(|k| k.as_str())
2022                        .collect::<Vec<_>>()
2023                        .join(", ")
2024                )
2025            })?;
2026
2027            let archive_url = &target_config.archive;
2028
2029            // Use URL as version identifier for caching
2030            // Hash the URL to get a stable directory name
2031            use std::collections::hash_map::DefaultHasher;
2032            use std::hash::{Hash, Hasher};
2033            let mut hasher = DefaultHasher::new();
2034            archive_url.hash(&mut hasher);
2035            let url_hash = hasher.finish();
2036            let version_dir = dir.join(format!("v_{:x}", url_hash));
2037
2038            if !fs.is_dir(&version_dir).await {
2039                // Determine SHA256 for verification
2040                let sha256 = if let Some(provided_sha) = &target_config.sha256 {
2041                    // Use provided SHA256
2042                    Some(provided_sha.clone())
2043                } else if archive_url.starts_with("https://github.com/") {
2044                    // Try to fetch SHA256 from GitHub API
2045                    // Parse URL to extract repo and tag/file info
2046                    // Format: https://github.com/owner/repo/releases/download/tag/file.zip
2047                    if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
2048                        let parts: Vec<&str> = caps.split('/').collect();
2049                        if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
2050                            let repo = format!("{}/{}", parts[0], parts[1]);
2051                            let tag = parts[4];
2052                            let filename = parts[5..].join("/");
2053
2054                            // Try to get release info from GitHub
2055                            if let Ok(release) = ::http_client::github::get_release_by_tag_name(
2056                                &repo,
2057                                tag,
2058                                http_client.clone(),
2059                            )
2060                            .await
2061                            {
2062                                // Find matching asset
2063                                if let Some(asset) =
2064                                    release.assets.iter().find(|a| a.name == filename)
2065                                {
2066                                    // Strip "sha256:" prefix if present
2067                                    asset.digest.as_ref().and_then(|d| {
2068                                        d.strip_prefix("sha256:")
2069                                            .map(|s| s.to_string())
2070                                            .or_else(|| Some(d.clone()))
2071                                    })
2072                                } else {
2073                                    None
2074                                }
2075                            } else {
2076                                None
2077                            }
2078                        } else {
2079                            None
2080                        }
2081                    } else {
2082                        None
2083                    }
2084                } else {
2085                    None
2086                };
2087
2088                // Determine archive type from URL
2089                let asset_kind = if archive_url.ends_with(".zip") {
2090                    AssetKind::Zip
2091                } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
2092                    AssetKind::TarGz
2093                } else {
2094                    anyhow::bail!("unsupported archive type in URL: {}", archive_url);
2095                };
2096
2097                // Download and extract
2098                ::http_client::github_download::download_server_binary(
2099                    &*http_client,
2100                    archive_url,
2101                    sha256.as_deref(),
2102                    &version_dir,
2103                    asset_kind,
2104                )
2105                .await?;
2106            }
2107
2108            // Validate and resolve cmd path
2109            let cmd = &target_config.cmd;
2110
2111            let cmd_path = if cmd == "node" {
2112                // Use Zed's managed Node.js runtime
2113                node_runtime.binary_path().await?
2114            } else {
2115                if cmd.contains("..") {
2116                    anyhow::bail!("command path cannot contain '..': {}", cmd);
2117                }
2118
2119                if cmd.starts_with("./") || cmd.starts_with(".\\") {
2120                    // Relative to extraction directory
2121                    let cmd_path = version_dir.join(&cmd[2..]);
2122                    anyhow::ensure!(
2123                        fs.is_file(&cmd_path).await,
2124                        "Missing command {} after extraction",
2125                        cmd_path.to_string_lossy()
2126                    );
2127                    cmd_path
2128                } else {
2129                    // On PATH
2130                    anyhow::bail!("command must be relative (start with './'): {}", cmd);
2131                }
2132            };
2133
2134            let command = AgentServerCommand {
2135                path: cmd_path,
2136                args: target_config.args.clone(),
2137                env: Some(env),
2138            };
2139
2140            Ok((command, version_dir.to_string_lossy().into_owned(), None))
2141        })
2142    }
2143
2144    fn as_any_mut(&mut self) -> &mut dyn Any {
2145        self
2146    }
2147}
2148
2149struct LocalRegistryArchiveAgent {
2150    fs: Arc<dyn Fs>,
2151    http_client: Arc<dyn HttpClient>,
2152    node_runtime: NodeRuntime,
2153    project_environment: Entity<ProjectEnvironment>,
2154    registry_id: Arc<str>,
2155    targets: HashMap<String, RegistryTargetConfig>,
2156    env: HashMap<String, String>,
2157}
2158
2159impl ExternalAgentServer for LocalRegistryArchiveAgent {
2160    fn get_command(
2161        &mut self,
2162        root_dir: Option<&str>,
2163        extra_env: HashMap<String, String>,
2164        _status_tx: Option<watch::Sender<SharedString>>,
2165        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2166        cx: &mut AsyncApp,
2167    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2168        let fs = self.fs.clone();
2169        let http_client = self.http_client.clone();
2170        let node_runtime = self.node_runtime.clone();
2171        let project_environment = self.project_environment.downgrade();
2172        let registry_id = self.registry_id.clone();
2173        let targets = self.targets.clone();
2174        let settings_env = self.env.clone();
2175
2176        let root_dir: Arc<Path> = root_dir
2177            .map(|root_dir| Path::new(root_dir))
2178            .unwrap_or(paths::home_dir())
2179            .into();
2180
2181        cx.spawn(async move |cx| {
2182            let mut env = project_environment
2183                .update(cx, |project_environment, cx| {
2184                    project_environment.local_directory_environment(
2185                        &Shell::System,
2186                        root_dir.clone(),
2187                        cx,
2188                    )
2189                })?
2190                .await
2191                .unwrap_or_default();
2192
2193            let dir = paths::external_agents_dir()
2194                .join("registry")
2195                .join(registry_id.as_ref());
2196            fs.create_dir(&dir).await?;
2197
2198            let os = if cfg!(target_os = "macos") {
2199                "darwin"
2200            } else if cfg!(target_os = "linux") {
2201                "linux"
2202            } else if cfg!(target_os = "windows") {
2203                "windows"
2204            } else {
2205                anyhow::bail!("unsupported OS");
2206            };
2207
2208            let arch = if cfg!(target_arch = "aarch64") {
2209                "aarch64"
2210            } else if cfg!(target_arch = "x86_64") {
2211                "x86_64"
2212            } else {
2213                anyhow::bail!("unsupported architecture");
2214            };
2215
2216            let platform_key = format!("{}-{}", os, arch);
2217            let target_config = targets.get(&platform_key).with_context(|| {
2218                format!(
2219                    "no target specified for platform '{}'. Available platforms: {}",
2220                    platform_key,
2221                    targets
2222                        .keys()
2223                        .map(|k| k.as_str())
2224                        .collect::<Vec<_>>()
2225                        .join(", ")
2226                )
2227            })?;
2228
2229            env.extend(target_config.env.clone());
2230            env.extend(extra_env);
2231            env.extend(settings_env);
2232
2233            let archive_url = &target_config.archive;
2234
2235            use std::collections::hash_map::DefaultHasher;
2236            use std::hash::{Hash, Hasher};
2237            let mut hasher = DefaultHasher::new();
2238            archive_url.hash(&mut hasher);
2239            let url_hash = hasher.finish();
2240            let version_dir = dir.join(format!("v_{:x}", url_hash));
2241
2242            if !fs.is_dir(&version_dir).await {
2243                let sha256 = if let Some(provided_sha) = &target_config.sha256 {
2244                    Some(provided_sha.clone())
2245                } else if archive_url.starts_with("https://github.com/") {
2246                    if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
2247                        let parts: Vec<&str> = caps.split('/').collect();
2248                        if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
2249                            let repo = format!("{}/{}", parts[0], parts[1]);
2250                            let tag = parts[4];
2251                            let filename = parts[5..].join("/");
2252
2253                            if let Ok(release) = ::http_client::github::get_release_by_tag_name(
2254                                &repo,
2255                                tag,
2256                                http_client.clone(),
2257                            )
2258                            .await
2259                            {
2260                                if let Some(asset) =
2261                                    release.assets.iter().find(|a| a.name == filename)
2262                                {
2263                                    asset.digest.as_ref().and_then(|d| {
2264                                        d.strip_prefix("sha256:")
2265                                            .map(|s| s.to_string())
2266                                            .or_else(|| Some(d.clone()))
2267                                    })
2268                                } else {
2269                                    None
2270                                }
2271                            } else {
2272                                None
2273                            }
2274                        } else {
2275                            None
2276                        }
2277                    } else {
2278                        None
2279                    }
2280                } else {
2281                    None
2282                };
2283
2284                let asset_kind = if archive_url.ends_with(".zip") {
2285                    AssetKind::Zip
2286                } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
2287                    AssetKind::TarGz
2288                } else {
2289                    anyhow::bail!("unsupported archive type in URL: {}", archive_url);
2290                };
2291
2292                ::http_client::github_download::download_server_binary(
2293                    &*http_client,
2294                    archive_url,
2295                    sha256.as_deref(),
2296                    &version_dir,
2297                    asset_kind,
2298                )
2299                .await?;
2300            }
2301
2302            let cmd = &target_config.cmd;
2303
2304            let cmd_path = if cmd == "node" {
2305                node_runtime.binary_path().await?
2306            } else {
2307                if cmd.contains("..") {
2308                    anyhow::bail!("command path cannot contain '..': {}", cmd);
2309                }
2310
2311                if cmd.starts_with("./") || cmd.starts_with(".\\") {
2312                    let cmd_path = version_dir.join(&cmd[2..]);
2313                    anyhow::ensure!(
2314                        fs.is_file(&cmd_path).await,
2315                        "Missing command {} after extraction",
2316                        cmd_path.to_string_lossy()
2317                    );
2318                    cmd_path
2319                } else {
2320                    anyhow::bail!("command must be relative (start with './'): {}", cmd);
2321                }
2322            };
2323
2324            let command = AgentServerCommand {
2325                path: cmd_path,
2326                args: target_config.args.clone(),
2327                env: Some(env),
2328            };
2329
2330            Ok((command, version_dir.to_string_lossy().into_owned(), None))
2331        })
2332    }
2333
2334    fn as_any_mut(&mut self) -> &mut dyn Any {
2335        self
2336    }
2337}
2338
2339struct LocalRegistryNpxAgent {
2340    node_runtime: NodeRuntime,
2341    project_environment: Entity<ProjectEnvironment>,
2342    package: SharedString,
2343    args: Vec<String>,
2344    distribution_env: HashMap<String, String>,
2345    settings_env: HashMap<String, String>,
2346}
2347
2348impl ExternalAgentServer for LocalRegistryNpxAgent {
2349    fn get_command(
2350        &mut self,
2351        root_dir: Option<&str>,
2352        extra_env: HashMap<String, String>,
2353        _status_tx: Option<watch::Sender<SharedString>>,
2354        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2355        cx: &mut AsyncApp,
2356    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2357        let node_runtime = self.node_runtime.clone();
2358        let project_environment = self.project_environment.downgrade();
2359        let package = self.package.clone();
2360        let args = self.args.clone();
2361        let distribution_env = self.distribution_env.clone();
2362        let settings_env = self.settings_env.clone();
2363
2364        let env_root_dir: Arc<Path> = root_dir
2365            .map(|root_dir| Path::new(root_dir))
2366            .unwrap_or(paths::home_dir())
2367            .into();
2368
2369        cx.spawn(async move |cx| {
2370            let mut env = project_environment
2371                .update(cx, |project_environment, cx| {
2372                    project_environment.local_directory_environment(
2373                        &Shell::System,
2374                        env_root_dir.clone(),
2375                        cx,
2376                    )
2377                })?
2378                .await
2379                .unwrap_or_default();
2380
2381            let mut exec_args = Vec::new();
2382            exec_args.push("--yes".to_string());
2383            exec_args.push(package.to_string());
2384            if !args.is_empty() {
2385                exec_args.push("--".to_string());
2386                exec_args.extend(args);
2387            }
2388
2389            let npm_command = node_runtime
2390                .npm_command(
2391                    "exec",
2392                    &exec_args.iter().map(|a| a.as_str()).collect::<Vec<_>>(),
2393                )
2394                .await?;
2395
2396            env.extend(npm_command.env);
2397            env.extend(distribution_env);
2398            env.extend(extra_env);
2399            env.extend(settings_env);
2400
2401            let command = AgentServerCommand {
2402                path: npm_command.path,
2403                args: npm_command.args,
2404                env: Some(env),
2405            };
2406
2407            Ok((command, env_root_dir.to_string_lossy().into_owned(), None))
2408        })
2409    }
2410
2411    fn as_any_mut(&mut self) -> &mut dyn Any {
2412        self
2413    }
2414}
2415
2416struct LocalCustomAgent {
2417    project_environment: Entity<ProjectEnvironment>,
2418    command: AgentServerCommand,
2419}
2420
2421impl ExternalAgentServer for LocalCustomAgent {
2422    fn get_command(
2423        &mut self,
2424        root_dir: Option<&str>,
2425        extra_env: HashMap<String, String>,
2426        _status_tx: Option<watch::Sender<SharedString>>,
2427        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2428        cx: &mut AsyncApp,
2429    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2430        let mut command = self.command.clone();
2431        let root_dir: Arc<Path> = root_dir
2432            .map(|root_dir| Path::new(root_dir))
2433            .unwrap_or(paths::home_dir())
2434            .into();
2435        let project_environment = self.project_environment.downgrade();
2436        cx.spawn(async move |cx| {
2437            let mut env = project_environment
2438                .update(cx, |project_environment, cx| {
2439                    project_environment.local_directory_environment(
2440                        &Shell::System,
2441                        root_dir.clone(),
2442                        cx,
2443                    )
2444                })?
2445                .await
2446                .unwrap_or_default();
2447            env.extend(command.env.unwrap_or_default());
2448            env.extend(extra_env);
2449            command.env = Some(env);
2450            Ok((command, root_dir.to_string_lossy().into_owned(), None))
2451        })
2452    }
2453
2454    fn as_any_mut(&mut self) -> &mut dyn Any {
2455        self
2456    }
2457}
2458
2459pub const GEMINI_NAME: &'static str = "gemini";
2460pub const CLAUDE_CODE_NAME: &'static str = "claude";
2461pub const CODEX_NAME: &'static str = "codex";
2462
2463#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
2464pub struct AllAgentServersSettings {
2465    pub gemini: Option<BuiltinAgentServerSettings>,
2466    pub claude: Option<BuiltinAgentServerSettings>,
2467    pub codex: Option<BuiltinAgentServerSettings>,
2468    pub custom: HashMap<String, CustomAgentServerSettings>,
2469}
2470#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
2471pub struct BuiltinAgentServerSettings {
2472    pub path: Option<PathBuf>,
2473    pub args: Option<Vec<String>>,
2474    pub env: Option<HashMap<String, String>>,
2475    pub ignore_system_version: Option<bool>,
2476    pub default_mode: Option<String>,
2477    pub default_model: Option<String>,
2478    pub favorite_models: Vec<String>,
2479    pub default_config_options: HashMap<String, String>,
2480    pub favorite_config_option_values: HashMap<String, Vec<String>>,
2481}
2482
2483impl BuiltinAgentServerSettings {
2484    fn custom_command(self) -> Option<AgentServerCommand> {
2485        self.path.map(|path| AgentServerCommand {
2486            path,
2487            args: self.args.unwrap_or_default(),
2488            // Settings env are always applied, so we don't need to supply them here as well
2489            env: None,
2490        })
2491    }
2492}
2493
2494impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
2495    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
2496        BuiltinAgentServerSettings {
2497            path: value
2498                .path
2499                .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
2500            args: value.args,
2501            env: value.env,
2502            ignore_system_version: value.ignore_system_version,
2503            default_mode: value.default_mode,
2504            default_model: value.default_model,
2505            favorite_models: value.favorite_models,
2506            default_config_options: value.default_config_options,
2507            favorite_config_option_values: value.favorite_config_option_values,
2508        }
2509    }
2510}
2511
2512impl From<AgentServerCommand> for BuiltinAgentServerSettings {
2513    fn from(value: AgentServerCommand) -> Self {
2514        BuiltinAgentServerSettings {
2515            path: Some(value.path),
2516            args: Some(value.args),
2517            env: value.env,
2518            ..Default::default()
2519        }
2520    }
2521}
2522
2523#[derive(Clone, JsonSchema, Debug, PartialEq)]
2524pub enum CustomAgentServerSettings {
2525    Custom {
2526        command: AgentServerCommand,
2527        /// The default mode to use for this agent.
2528        ///
2529        /// Note: Not only all agents support modes.
2530        ///
2531        /// Default: None
2532        default_mode: Option<String>,
2533        /// The default model to use for this agent.
2534        ///
2535        /// This should be the model ID as reported by the agent.
2536        ///
2537        /// Default: None
2538        default_model: Option<String>,
2539        /// The favorite models for this agent.
2540        ///
2541        /// Default: []
2542        favorite_models: Vec<String>,
2543        /// Default values for session config options.
2544        ///
2545        /// This is a map from config option ID to value ID.
2546        ///
2547        /// Default: {}
2548        default_config_options: HashMap<String, String>,
2549        /// Favorited values for session config options.
2550        ///
2551        /// This is a map from config option ID to a list of favorited value IDs.
2552        ///
2553        /// Default: {}
2554        favorite_config_option_values: HashMap<String, Vec<String>>,
2555    },
2556    Extension {
2557        /// Additional environment variables to pass to the agent.
2558        ///
2559        /// Default: {}
2560        env: HashMap<String, String>,
2561        /// The default mode to use for this agent.
2562        ///
2563        /// Note: Not only all agents support modes.
2564        ///
2565        /// Default: None
2566        default_mode: Option<String>,
2567        /// The default model to use for this agent.
2568        ///
2569        /// This should be the model ID as reported by the agent.
2570        ///
2571        /// Default: None
2572        default_model: Option<String>,
2573        /// The favorite models for this agent.
2574        ///
2575        /// Default: []
2576        favorite_models: Vec<String>,
2577        /// Default values for session config options.
2578        ///
2579        /// This is a map from config option ID to value ID.
2580        ///
2581        /// Default: {}
2582        default_config_options: HashMap<String, String>,
2583        /// Favorited values for session config options.
2584        ///
2585        /// This is a map from config option ID to a list of favorited value IDs.
2586        ///
2587        /// Default: {}
2588        favorite_config_option_values: HashMap<String, Vec<String>>,
2589    },
2590    Registry {
2591        /// Additional environment variables to pass to the agent.
2592        ///
2593        /// Default: {}
2594        env: HashMap<String, String>,
2595        /// The default mode to use for this agent.
2596        ///
2597        /// Note: Not only all agents support modes.
2598        ///
2599        /// Default: None
2600        default_mode: Option<String>,
2601        /// The default model to use for this agent.
2602        ///
2603        /// This should be the model ID as reported by the agent.
2604        ///
2605        /// Default: None
2606        default_model: Option<String>,
2607        /// The favorite models for this agent.
2608        ///
2609        /// Default: []
2610        favorite_models: Vec<String>,
2611        /// Default values for session config options.
2612        ///
2613        /// This is a map from config option ID to value ID.
2614        ///
2615        /// Default: {}
2616        default_config_options: HashMap<String, String>,
2617        /// Favorited values for session config options.
2618        ///
2619        /// This is a map from config option ID to a list of favorited value IDs.
2620        ///
2621        /// Default: {}
2622        favorite_config_option_values: HashMap<String, Vec<String>>,
2623    },
2624}
2625
2626impl CustomAgentServerSettings {
2627    pub fn command(&self) -> Option<&AgentServerCommand> {
2628        match self {
2629            CustomAgentServerSettings::Custom { command, .. } => Some(command),
2630            CustomAgentServerSettings::Extension { .. }
2631            | CustomAgentServerSettings::Registry { .. } => None,
2632        }
2633    }
2634
2635    pub fn default_mode(&self) -> Option<&str> {
2636        match self {
2637            CustomAgentServerSettings::Custom { default_mode, .. }
2638            | CustomAgentServerSettings::Extension { default_mode, .. }
2639            | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
2640        }
2641    }
2642
2643    pub fn default_model(&self) -> Option<&str> {
2644        match self {
2645            CustomAgentServerSettings::Custom { default_model, .. }
2646            | CustomAgentServerSettings::Extension { default_model, .. }
2647            | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(),
2648        }
2649    }
2650
2651    pub fn favorite_models(&self) -> &[String] {
2652        match self {
2653            CustomAgentServerSettings::Custom {
2654                favorite_models, ..
2655            }
2656            | CustomAgentServerSettings::Extension {
2657                favorite_models, ..
2658            }
2659            | CustomAgentServerSettings::Registry {
2660                favorite_models, ..
2661            } => favorite_models,
2662        }
2663    }
2664
2665    pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
2666        match self {
2667            CustomAgentServerSettings::Custom {
2668                default_config_options,
2669                ..
2670            }
2671            | CustomAgentServerSettings::Extension {
2672                default_config_options,
2673                ..
2674            }
2675            | CustomAgentServerSettings::Registry {
2676                default_config_options,
2677                ..
2678            } => default_config_options.get(config_id).map(|s| s.as_str()),
2679        }
2680    }
2681
2682    pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
2683        match self {
2684            CustomAgentServerSettings::Custom {
2685                favorite_config_option_values,
2686                ..
2687            }
2688            | CustomAgentServerSettings::Extension {
2689                favorite_config_option_values,
2690                ..
2691            }
2692            | CustomAgentServerSettings::Registry {
2693                favorite_config_option_values,
2694                ..
2695            } => favorite_config_option_values
2696                .get(config_id)
2697                .map(|v| v.as_slice()),
2698        }
2699    }
2700}
2701
2702impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
2703    fn from(value: settings::CustomAgentServerSettings) -> Self {
2704        match value {
2705            settings::CustomAgentServerSettings::Custom {
2706                path,
2707                args,
2708                env,
2709                default_mode,
2710                default_model,
2711                favorite_models,
2712                default_config_options,
2713                favorite_config_option_values,
2714            } => CustomAgentServerSettings::Custom {
2715                command: AgentServerCommand {
2716                    path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
2717                    args,
2718                    env: Some(env),
2719                },
2720                default_mode,
2721                default_model,
2722                favorite_models,
2723                default_config_options,
2724                favorite_config_option_values,
2725            },
2726            settings::CustomAgentServerSettings::Extension {
2727                env,
2728                default_mode,
2729                default_model,
2730                default_config_options,
2731                favorite_models,
2732                favorite_config_option_values,
2733            } => CustomAgentServerSettings::Extension {
2734                env,
2735                default_mode,
2736                default_model,
2737                default_config_options,
2738                favorite_models,
2739                favorite_config_option_values,
2740            },
2741            settings::CustomAgentServerSettings::Registry {
2742                env,
2743                default_mode,
2744                default_model,
2745                default_config_options,
2746                favorite_models,
2747                favorite_config_option_values,
2748            } => CustomAgentServerSettings::Registry {
2749                env,
2750                default_mode,
2751                default_model,
2752                default_config_options,
2753                favorite_models,
2754                favorite_config_option_values,
2755            },
2756        }
2757    }
2758}
2759
2760impl settings::Settings for AllAgentServersSettings {
2761    fn from_settings(content: &settings::SettingsContent) -> Self {
2762        let agent_settings = content.agent_servers.clone().unwrap();
2763        Self {
2764            gemini: agent_settings.gemini.map(Into::into),
2765            claude: agent_settings.claude.map(Into::into),
2766            codex: agent_settings.codex.map(Into::into),
2767            custom: agent_settings
2768                .custom
2769                .into_iter()
2770                .map(|(k, v)| (k, v.into()))
2771                .collect(),
2772        }
2773    }
2774}
2775
2776#[cfg(test)]
2777mod extension_agent_tests {
2778    use crate::worktree_store::WorktreeStore;
2779
2780    use super::*;
2781    use gpui::TestAppContext;
2782    use std::sync::Arc;
2783
2784    #[test]
2785    fn extension_agent_constructs_proper_display_names() {
2786        // Verify the display name format for extension-provided agents
2787        let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
2788        assert!(name1.0.contains(": "));
2789
2790        let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
2791        assert_eq!(name2.0, "MyExt: MyAgent");
2792
2793        // Non-extension agents shouldn't have the separator
2794        let custom = ExternalAgentServerName(SharedString::from("custom"));
2795        assert!(!custom.0.contains(": "));
2796    }
2797
2798    struct NoopExternalAgent;
2799
2800    impl ExternalAgentServer for NoopExternalAgent {
2801        fn get_command(
2802            &mut self,
2803            _root_dir: Option<&str>,
2804            _extra_env: HashMap<String, String>,
2805            _status_tx: Option<watch::Sender<SharedString>>,
2806            _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2807            _cx: &mut AsyncApp,
2808        ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2809            Task::ready(Ok((
2810                AgentServerCommand {
2811                    path: PathBuf::from("noop"),
2812                    args: Vec::new(),
2813                    env: None,
2814                },
2815                "".to_string(),
2816                None,
2817            )))
2818        }
2819
2820        fn as_any_mut(&mut self) -> &mut dyn Any {
2821            self
2822        }
2823    }
2824
2825    #[test]
2826    fn sync_removes_only_extension_provided_agents() {
2827        let mut store = AgentServerStore {
2828            state: AgentServerStoreState::Collab,
2829            external_agents: HashMap::default(),
2830        };
2831
2832        // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
2833        store.external_agents.insert(
2834            ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
2835            ExternalAgentEntry::new(
2836                Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2837                ExternalAgentSource::Extension,
2838                None,
2839                None,
2840            ),
2841        );
2842        store.external_agents.insert(
2843            ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
2844            ExternalAgentEntry::new(
2845                Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2846                ExternalAgentSource::Extension,
2847                None,
2848                None,
2849            ),
2850        );
2851        store.external_agents.insert(
2852            ExternalAgentServerName(SharedString::from("custom-agent")),
2853            ExternalAgentEntry::new(
2854                Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2855                ExternalAgentSource::Custom,
2856                None,
2857                None,
2858            ),
2859        );
2860
2861        // Simulate removal phase
2862        store
2863            .external_agents
2864            .retain(|_, entry| entry.source != ExternalAgentSource::Extension);
2865
2866        // Only custom-agent should remain
2867        assert_eq!(store.external_agents.len(), 1);
2868        assert!(
2869            store
2870                .external_agents
2871                .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
2872        );
2873    }
2874
2875    #[test]
2876    fn archive_launcher_constructs_with_all_fields() {
2877        use extension::AgentServerManifestEntry;
2878
2879        let mut env = HashMap::default();
2880        env.insert("GITHUB_TOKEN".into(), "secret".into());
2881
2882        let mut targets = HashMap::default();
2883        targets.insert(
2884            "darwin-aarch64".to_string(),
2885            extension::TargetConfig {
2886                archive:
2887                    "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
2888                        .into(),
2889                cmd: "./agent".into(),
2890                args: vec![],
2891                sha256: None,
2892                env: Default::default(),
2893            },
2894        );
2895
2896        let _entry = AgentServerManifestEntry {
2897            name: "GitHub Agent".into(),
2898            targets,
2899            env,
2900            icon: None,
2901        };
2902
2903        // Verify display name construction
2904        let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
2905        assert_eq!(expected_name.0, "GitHub Agent");
2906    }
2907
2908    #[gpui::test]
2909    async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
2910        let fs = fs::FakeFs::new(cx.background_executor.clone());
2911        let http_client = http_client::FakeHttpClient::with_404_response();
2912        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2913        let project_environment = cx.new(|cx| {
2914            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2915        });
2916
2917        let agent = LocalExtensionArchiveAgent {
2918            fs,
2919            http_client,
2920            node_runtime: node_runtime::NodeRuntime::unavailable(),
2921            project_environment,
2922            extension_id: Arc::from("my-extension"),
2923            agent_id: Arc::from("my-agent"),
2924            targets: {
2925                let mut map = HashMap::default();
2926                map.insert(
2927                    "darwin-aarch64".to_string(),
2928                    extension::TargetConfig {
2929                        archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
2930                        cmd: "./my-agent".into(),
2931                        args: vec!["--serve".into()],
2932                        sha256: None,
2933                        env: Default::default(),
2934                    },
2935                );
2936                map
2937            },
2938            env: {
2939                let mut map = HashMap::default();
2940                map.insert("PORT".into(), "8080".into());
2941                map
2942            },
2943        };
2944
2945        // Verify agent is properly constructed
2946        assert_eq!(agent.extension_id.as_ref(), "my-extension");
2947        assert_eq!(agent.agent_id.as_ref(), "my-agent");
2948        assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
2949        assert!(agent.targets.contains_key("darwin-aarch64"));
2950    }
2951
2952    #[test]
2953    fn sync_extension_agents_registers_archive_launcher() {
2954        use extension::AgentServerManifestEntry;
2955
2956        let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
2957        assert_eq!(expected_name.0, "Release Agent");
2958
2959        // Verify the manifest entry structure for archive-based installation
2960        let mut env = HashMap::default();
2961        env.insert("API_KEY".into(), "secret".into());
2962
2963        let mut targets = HashMap::default();
2964        targets.insert(
2965            "linux-x86_64".to_string(),
2966            extension::TargetConfig {
2967                archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
2968                cmd: "./release-agent".into(),
2969                args: vec!["serve".into()],
2970                sha256: None,
2971                env: Default::default(),
2972            },
2973        );
2974
2975        let manifest_entry = AgentServerManifestEntry {
2976            name: "Release Agent".into(),
2977            targets: targets.clone(),
2978            env,
2979            icon: None,
2980        };
2981
2982        // Verify target config is present
2983        assert!(manifest_entry.targets.contains_key("linux-x86_64"));
2984        let target = manifest_entry.targets.get("linux-x86_64").unwrap();
2985        assert_eq!(target.cmd, "./release-agent");
2986    }
2987
2988    #[gpui::test]
2989    async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
2990        let fs = fs::FakeFs::new(cx.background_executor.clone());
2991        let http_client = http_client::FakeHttpClient::with_404_response();
2992        let node_runtime = NodeRuntime::unavailable();
2993        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2994        let project_environment = cx.new(|cx| {
2995            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2996        });
2997
2998        let agent = LocalExtensionArchiveAgent {
2999            fs: fs.clone(),
3000            http_client,
3001            node_runtime,
3002            project_environment,
3003            extension_id: Arc::from("node-extension"),
3004            agent_id: Arc::from("node-agent"),
3005            targets: {
3006                let mut map = HashMap::default();
3007                map.insert(
3008                    "darwin-aarch64".to_string(),
3009                    extension::TargetConfig {
3010                        archive: "https://example.com/node-agent.zip".into(),
3011                        cmd: "node".into(),
3012                        args: vec!["index.js".into()],
3013                        sha256: None,
3014                        env: Default::default(),
3015                    },
3016                );
3017                map
3018            },
3019            env: HashMap::default(),
3020        };
3021
3022        // Verify that when cmd is "node", it attempts to use the node runtime
3023        assert_eq!(agent.extension_id.as_ref(), "node-extension");
3024        assert_eq!(agent.agent_id.as_ref(), "node-agent");
3025
3026        let target = agent.targets.get("darwin-aarch64").unwrap();
3027        assert_eq!(target.cmd, "node");
3028        assert_eq!(target.args, vec!["index.js"]);
3029    }
3030
3031    #[gpui::test]
3032    async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
3033        let fs = fs::FakeFs::new(cx.background_executor.clone());
3034        let http_client = http_client::FakeHttpClient::with_404_response();
3035        let node_runtime = NodeRuntime::unavailable();
3036        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
3037        let project_environment = cx.new(|cx| {
3038            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
3039        });
3040
3041        let agent = LocalExtensionArchiveAgent {
3042            fs: fs.clone(),
3043            http_client,
3044            node_runtime,
3045            project_environment,
3046            extension_id: Arc::from("test-ext"),
3047            agent_id: Arc::from("test-agent"),
3048            targets: {
3049                let mut map = HashMap::default();
3050                map.insert(
3051                    "darwin-aarch64".to_string(),
3052                    extension::TargetConfig {
3053                        archive: "https://example.com/test.zip".into(),
3054                        cmd: "node".into(),
3055                        args: vec![
3056                            "server.js".into(),
3057                            "--config".into(),
3058                            "./config.json".into(),
3059                        ],
3060                        sha256: None,
3061                        env: Default::default(),
3062                    },
3063                );
3064                map
3065            },
3066            env: HashMap::default(),
3067        };
3068
3069        // Verify the agent is configured with relative paths in args
3070        let target = agent.targets.get("darwin-aarch64").unwrap();
3071        assert_eq!(target.args[0], "server.js");
3072        assert_eq!(target.args[2], "./config.json");
3073        // These relative paths will resolve relative to the extraction directory
3074        // when the command is executed
3075    }
3076
3077    #[test]
3078    fn test_tilde_expansion_in_settings() {
3079        let settings = settings::BuiltinAgentServerSettings {
3080            path: Some(PathBuf::from("~/bin/agent")),
3081            args: Some(vec!["--flag".into()]),
3082            env: None,
3083            ignore_system_version: None,
3084            default_mode: None,
3085            default_model: None,
3086            favorite_models: vec![],
3087            default_config_options: Default::default(),
3088            favorite_config_option_values: Default::default(),
3089        };
3090
3091        let BuiltinAgentServerSettings { path, .. } = settings.into();
3092
3093        let path = path.unwrap();
3094        assert!(
3095            !path.to_string_lossy().starts_with("~"),
3096            "Tilde should be expanded for builtin agent path"
3097        );
3098
3099        let settings = settings::CustomAgentServerSettings::Custom {
3100            path: PathBuf::from("~/custom/agent"),
3101            args: vec!["serve".into()],
3102            env: Default::default(),
3103            default_mode: None,
3104            default_model: None,
3105            favorite_models: vec![],
3106            default_config_options: Default::default(),
3107            favorite_config_option_values: Default::default(),
3108        };
3109
3110        let converted: CustomAgentServerSettings = settings.into();
3111        let CustomAgentServerSettings::Custom {
3112            command: AgentServerCommand { path, .. },
3113            ..
3114        } = converted
3115        else {
3116            panic!("Expected Custom variant");
3117        };
3118
3119        assert!(
3120            !path.to_string_lossy().starts_with("~"),
3121            "Tilde should be expanded for custom agent path"
3122        );
3123    }
3124}