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