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