agent_server_store.rs

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