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        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                // Find or install the latest Codex release (no update checks for now).
1428                let release = ::http_client::github::latest_github_release(
1429                    CODEX_ACP_REPO,
1430                    true,
1431                    false,
1432                    http.clone(),
1433                )
1434                .await
1435                .context("fetching Codex latest release")?;
1436
1437                let version_dir = dir.join(&release.tag_name);
1438                if !fs.is_dir(&version_dir).await {
1439                    if let Some(mut status_tx) = status_tx {
1440                        status_tx.send("Installing…".into()).ok();
1441                    }
1442
1443                    let tag = release.tag_name.clone();
1444                    let version_number = tag.trim_start_matches('v');
1445                    let asset_name = asset_name(version_number)
1446                        .context("codex acp is not supported for this architecture")?;
1447                    let asset = release
1448                        .assets
1449                        .into_iter()
1450                        .find(|asset| asset.name == asset_name)
1451                        .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1452                    // Strip "sha256:" prefix from digest if present (GitHub API format)
1453                    let digest = asset
1454                        .digest
1455                        .as_deref()
1456                        .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1457                    ::http_client::github_download::download_server_binary(
1458                        &*http,
1459                        &asset.browser_download_url,
1460                        digest,
1461                        &version_dir,
1462                        if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1463                            AssetKind::Zip
1464                        } else {
1465                            AssetKind::TarGz
1466                        },
1467                    )
1468                    .await?;
1469
1470                    // remove older versions
1471                    util::fs::remove_matching(&dir, |entry| entry != version_dir).await;
1472                }
1473
1474                let bin_name = if cfg!(windows) {
1475                    "codex-acp.exe"
1476                } else {
1477                    "codex-acp"
1478                };
1479                let bin_path = version_dir.join(bin_name);
1480                anyhow::ensure!(
1481                    fs.is_file(&bin_path).await,
1482                    "Missing Codex binary at {} after installation",
1483                    bin_path.to_string_lossy()
1484                );
1485
1486                let mut cmd = AgentServerCommand {
1487                    path: bin_path,
1488                    args: Vec::new(),
1489                    env: None,
1490                };
1491                cmd.env = Some(env);
1492                cmd
1493            };
1494
1495            command.env.get_or_insert_default().extend(extra_env);
1496            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1497        })
1498    }
1499
1500    fn as_any_mut(&mut self) -> &mut dyn Any {
1501        self
1502    }
1503}
1504
1505pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1506
1507fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1508    let arch = if cfg!(target_arch = "x86_64") {
1509        "x86_64"
1510    } else if cfg!(target_arch = "aarch64") {
1511        "aarch64"
1512    } else {
1513        return None;
1514    };
1515
1516    let platform = if cfg!(target_os = "macos") {
1517        "apple-darwin"
1518    } else if cfg!(target_os = "windows") {
1519        "pc-windows-msvc"
1520    } else if cfg!(target_os = "linux") {
1521        "unknown-linux-gnu"
1522    } else {
1523        return None;
1524    };
1525
1526    // Windows uses .zip in release assets
1527    let ext = if cfg!(target_os = "windows") {
1528        "zip"
1529    } else {
1530        "tar.gz"
1531    };
1532
1533    Some((arch, platform, ext))
1534}
1535
1536fn asset_name(version: &str) -> Option<String> {
1537    let (arch, platform, ext) = get_platform_info()?;
1538    Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1539}
1540
1541struct LocalExtensionArchiveAgent {
1542    fs: Arc<dyn Fs>,
1543    http_client: Arc<dyn HttpClient>,
1544    node_runtime: NodeRuntime,
1545    project_environment: Entity<ProjectEnvironment>,
1546    extension_id: Arc<str>,
1547    agent_id: Arc<str>,
1548    targets: HashMap<String, extension::TargetConfig>,
1549    env: HashMap<String, String>,
1550}
1551
1552struct LocalCustomAgent {
1553    project_environment: Entity<ProjectEnvironment>,
1554    command: AgentServerCommand,
1555}
1556
1557impl ExternalAgentServer for LocalExtensionArchiveAgent {
1558    fn get_command(
1559        &mut self,
1560        root_dir: Option<&str>,
1561        extra_env: HashMap<String, String>,
1562        _status_tx: Option<watch::Sender<SharedString>>,
1563        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1564        cx: &mut AsyncApp,
1565    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1566        let fs = self.fs.clone();
1567        let http_client = self.http_client.clone();
1568        let node_runtime = self.node_runtime.clone();
1569        let project_environment = self.project_environment.downgrade();
1570        let extension_id = self.extension_id.clone();
1571        let agent_id = self.agent_id.clone();
1572        let targets = self.targets.clone();
1573        let base_env = self.env.clone();
1574
1575        let root_dir: Arc<Path> = root_dir
1576            .map(|root_dir| Path::new(root_dir))
1577            .unwrap_or(paths::home_dir())
1578            .into();
1579
1580        cx.spawn(async move |cx| {
1581            // Get project environment
1582            let mut env = project_environment
1583                .update(cx, |project_environment, cx| {
1584                    project_environment.local_directory_environment(
1585                        &Shell::System,
1586                        root_dir.clone(),
1587                        cx,
1588                    )
1589                })?
1590                .await
1591                .unwrap_or_default();
1592
1593            // Merge manifest env and extra env
1594            env.extend(base_env);
1595            env.extend(extra_env);
1596
1597            let cache_key = format!("{}/{}", extension_id, agent_id);
1598            let dir = paths::external_agents_dir().join(&cache_key);
1599            fs.create_dir(&dir).await?;
1600
1601            // Determine platform key
1602            let os = if cfg!(target_os = "macos") {
1603                "darwin"
1604            } else if cfg!(target_os = "linux") {
1605                "linux"
1606            } else if cfg!(target_os = "windows") {
1607                "windows"
1608            } else {
1609                anyhow::bail!("unsupported OS");
1610            };
1611
1612            let arch = if cfg!(target_arch = "aarch64") {
1613                "aarch64"
1614            } else if cfg!(target_arch = "x86_64") {
1615                "x86_64"
1616            } else {
1617                anyhow::bail!("unsupported architecture");
1618            };
1619
1620            let platform_key = format!("{}-{}", os, arch);
1621            let target_config = targets.get(&platform_key).with_context(|| {
1622                format!(
1623                    "no target specified for platform '{}'. Available platforms: {}",
1624                    platform_key,
1625                    targets
1626                        .keys()
1627                        .map(|k| k.as_str())
1628                        .collect::<Vec<_>>()
1629                        .join(", ")
1630                )
1631            })?;
1632
1633            let archive_url = &target_config.archive;
1634
1635            // Use URL as version identifier for caching
1636            // Hash the URL to get a stable directory name
1637            use std::collections::hash_map::DefaultHasher;
1638            use std::hash::{Hash, Hasher};
1639            let mut hasher = DefaultHasher::new();
1640            archive_url.hash(&mut hasher);
1641            let url_hash = hasher.finish();
1642            let version_dir = dir.join(format!("v_{:x}", url_hash));
1643
1644            if !fs.is_dir(&version_dir).await {
1645                // Determine SHA256 for verification
1646                let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1647                    // Use provided SHA256
1648                    Some(provided_sha.clone())
1649                } else if archive_url.starts_with("https://github.com/") {
1650                    // Try to fetch SHA256 from GitHub API
1651                    // Parse URL to extract repo and tag/file info
1652                    // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1653                    if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1654                        let parts: Vec<&str> = caps.split('/').collect();
1655                        if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1656                            let repo = format!("{}/{}", parts[0], parts[1]);
1657                            let tag = parts[4];
1658                            let filename = parts[5..].join("/");
1659
1660                            // Try to get release info from GitHub
1661                            if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1662                                &repo,
1663                                tag,
1664                                http_client.clone(),
1665                            )
1666                            .await
1667                            {
1668                                // Find matching asset
1669                                if let Some(asset) =
1670                                    release.assets.iter().find(|a| a.name == filename)
1671                                {
1672                                    // Strip "sha256:" prefix if present
1673                                    asset.digest.as_ref().and_then(|d| {
1674                                        d.strip_prefix("sha256:")
1675                                            .map(|s| s.to_string())
1676                                            .or_else(|| Some(d.clone()))
1677                                    })
1678                                } else {
1679                                    None
1680                                }
1681                            } else {
1682                                None
1683                            }
1684                        } else {
1685                            None
1686                        }
1687                    } else {
1688                        None
1689                    }
1690                } else {
1691                    None
1692                };
1693
1694                // Determine archive type from URL
1695                let asset_kind = if archive_url.ends_with(".zip") {
1696                    AssetKind::Zip
1697                } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1698                    AssetKind::TarGz
1699                } else {
1700                    anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1701                };
1702
1703                // Download and extract
1704                ::http_client::github_download::download_server_binary(
1705                    &*http_client,
1706                    archive_url,
1707                    sha256.as_deref(),
1708                    &version_dir,
1709                    asset_kind,
1710                )
1711                .await?;
1712            }
1713
1714            // Validate and resolve cmd path
1715            let cmd = &target_config.cmd;
1716
1717            let cmd_path = if cmd == "node" {
1718                // Use Zed's managed Node.js runtime
1719                node_runtime.binary_path().await?
1720            } else {
1721                if cmd.contains("..") {
1722                    anyhow::bail!("command path cannot contain '..': {}", cmd);
1723                }
1724
1725                if cmd.starts_with("./") || cmd.starts_with(".\\") {
1726                    // Relative to extraction directory
1727                    let cmd_path = version_dir.join(&cmd[2..]);
1728                    anyhow::ensure!(
1729                        fs.is_file(&cmd_path).await,
1730                        "Missing command {} after extraction",
1731                        cmd_path.to_string_lossy()
1732                    );
1733                    cmd_path
1734                } else {
1735                    // On PATH
1736                    anyhow::bail!("command must be relative (start with './'): {}", cmd);
1737                }
1738            };
1739
1740            let command = AgentServerCommand {
1741                path: cmd_path,
1742                args: target_config.args.clone(),
1743                env: Some(env),
1744            };
1745
1746            Ok((command, version_dir.to_string_lossy().into_owned(), None))
1747        })
1748    }
1749
1750    fn as_any_mut(&mut self) -> &mut dyn Any {
1751        self
1752    }
1753}
1754
1755impl ExternalAgentServer for LocalCustomAgent {
1756    fn get_command(
1757        &mut self,
1758        root_dir: Option<&str>,
1759        extra_env: HashMap<String, String>,
1760        _status_tx: Option<watch::Sender<SharedString>>,
1761        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1762        cx: &mut AsyncApp,
1763    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1764        let mut command = self.command.clone();
1765        let root_dir: Arc<Path> = root_dir
1766            .map(|root_dir| Path::new(root_dir))
1767            .unwrap_or(paths::home_dir())
1768            .into();
1769        let project_environment = self.project_environment.downgrade();
1770        cx.spawn(async move |cx| {
1771            let mut env = project_environment
1772                .update(cx, |project_environment, cx| {
1773                    project_environment.local_directory_environment(
1774                        &Shell::System,
1775                        root_dir.clone(),
1776                        cx,
1777                    )
1778                })?
1779                .await
1780                .unwrap_or_default();
1781            env.extend(command.env.unwrap_or_default());
1782            env.extend(extra_env);
1783            command.env = Some(env);
1784            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1785        })
1786    }
1787
1788    fn as_any_mut(&mut self) -> &mut dyn Any {
1789        self
1790    }
1791}
1792
1793pub const GEMINI_NAME: &'static str = "gemini";
1794pub const CLAUDE_CODE_NAME: &'static str = "claude";
1795pub const CODEX_NAME: &'static str = "codex";
1796
1797#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1798pub struct AllAgentServersSettings {
1799    pub gemini: Option<BuiltinAgentServerSettings>,
1800    pub claude: Option<BuiltinAgentServerSettings>,
1801    pub codex: Option<BuiltinAgentServerSettings>,
1802    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1803}
1804#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1805pub struct BuiltinAgentServerSettings {
1806    pub path: Option<PathBuf>,
1807    pub args: Option<Vec<String>>,
1808    pub env: Option<HashMap<String, String>>,
1809    pub ignore_system_version: Option<bool>,
1810    pub default_mode: Option<String>,
1811    pub default_model: Option<String>,
1812}
1813
1814impl BuiltinAgentServerSettings {
1815    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1816        self.path.map(|path| AgentServerCommand {
1817            path,
1818            args: self.args.unwrap_or_default(),
1819            env: self.env,
1820        })
1821    }
1822}
1823
1824impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1825    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1826        BuiltinAgentServerSettings {
1827            path: value
1828                .path
1829                .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
1830            args: value.args,
1831            env: value.env,
1832            ignore_system_version: value.ignore_system_version,
1833            default_mode: value.default_mode,
1834            default_model: value.default_model,
1835        }
1836    }
1837}
1838
1839impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1840    fn from(value: AgentServerCommand) -> Self {
1841        BuiltinAgentServerSettings {
1842            path: Some(value.path),
1843            args: Some(value.args),
1844            env: value.env,
1845            ..Default::default()
1846        }
1847    }
1848}
1849
1850#[derive(Clone, JsonSchema, Debug, PartialEq)]
1851pub enum CustomAgentServerSettings {
1852    Custom {
1853        command: AgentServerCommand,
1854        /// The default mode to use for this agent.
1855        ///
1856        /// Note: Not only all agents support modes.
1857        ///
1858        /// Default: None
1859        default_mode: Option<String>,
1860        /// The default model to use for this agent.
1861        ///
1862        /// This should be the model ID as reported by the agent.
1863        ///
1864        /// Default: None
1865        default_model: Option<String>,
1866    },
1867    Extension {
1868        /// The default mode to use for this agent.
1869        ///
1870        /// Note: Not only all agents support modes.
1871        ///
1872        /// Default: None
1873        default_mode: Option<String>,
1874        /// The default model to use for this agent.
1875        ///
1876        /// This should be the model ID as reported by the agent.
1877        ///
1878        /// Default: None
1879        default_model: Option<String>,
1880    },
1881}
1882
1883impl CustomAgentServerSettings {
1884    pub fn command(&self) -> Option<&AgentServerCommand> {
1885        match self {
1886            CustomAgentServerSettings::Custom { command, .. } => Some(command),
1887            CustomAgentServerSettings::Extension { .. } => None,
1888        }
1889    }
1890
1891    pub fn default_mode(&self) -> Option<&str> {
1892        match self {
1893            CustomAgentServerSettings::Custom { default_mode, .. }
1894            | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(),
1895        }
1896    }
1897
1898    pub fn default_model(&self) -> Option<&str> {
1899        match self {
1900            CustomAgentServerSettings::Custom { default_model, .. }
1901            | CustomAgentServerSettings::Extension { default_model, .. } => {
1902                default_model.as_deref()
1903            }
1904        }
1905    }
1906}
1907
1908impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1909    fn from(value: settings::CustomAgentServerSettings) -> Self {
1910        match value {
1911            settings::CustomAgentServerSettings::Custom {
1912                path,
1913                args,
1914                env,
1915                default_mode,
1916                default_model,
1917            } => CustomAgentServerSettings::Custom {
1918                command: AgentServerCommand {
1919                    path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
1920                    args,
1921                    env,
1922                },
1923                default_mode,
1924                default_model,
1925            },
1926            settings::CustomAgentServerSettings::Extension {
1927                default_mode,
1928                default_model,
1929            } => CustomAgentServerSettings::Extension {
1930                default_mode,
1931                default_model,
1932            },
1933        }
1934    }
1935}
1936
1937impl settings::Settings for AllAgentServersSettings {
1938    fn from_settings(content: &settings::SettingsContent) -> Self {
1939        let agent_settings = content.agent_servers.clone().unwrap();
1940        Self {
1941            gemini: agent_settings.gemini.map(Into::into),
1942            claude: agent_settings.claude.map(Into::into),
1943            codex: agent_settings.codex.map(Into::into),
1944            custom: agent_settings
1945                .custom
1946                .into_iter()
1947                .map(|(k, v)| (k, v.into()))
1948                .collect(),
1949        }
1950    }
1951}
1952
1953#[cfg(test)]
1954mod extension_agent_tests {
1955    use crate::worktree_store::WorktreeStore;
1956
1957    use super::*;
1958    use gpui::TestAppContext;
1959    use std::sync::Arc;
1960
1961    #[test]
1962    fn extension_agent_constructs_proper_display_names() {
1963        // Verify the display name format for extension-provided agents
1964        let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
1965        assert!(name1.0.contains(": "));
1966
1967        let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
1968        assert_eq!(name2.0, "MyExt: MyAgent");
1969
1970        // Non-extension agents shouldn't have the separator
1971        let custom = ExternalAgentServerName(SharedString::from("custom"));
1972        assert!(!custom.0.contains(": "));
1973    }
1974
1975    struct NoopExternalAgent;
1976
1977    impl ExternalAgentServer for NoopExternalAgent {
1978        fn get_command(
1979            &mut self,
1980            _root_dir: Option<&str>,
1981            _extra_env: HashMap<String, String>,
1982            _status_tx: Option<watch::Sender<SharedString>>,
1983            _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1984            _cx: &mut AsyncApp,
1985        ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1986            Task::ready(Ok((
1987                AgentServerCommand {
1988                    path: PathBuf::from("noop"),
1989                    args: Vec::new(),
1990                    env: None,
1991                },
1992                "".to_string(),
1993                None,
1994            )))
1995        }
1996
1997        fn as_any_mut(&mut self) -> &mut dyn Any {
1998            self
1999        }
2000    }
2001
2002    #[test]
2003    fn sync_removes_only_extension_provided_agents() {
2004        let mut store = AgentServerStore {
2005            state: AgentServerStoreState::Collab,
2006            external_agents: HashMap::default(),
2007            agent_icons: HashMap::default(),
2008            agent_display_names: HashMap::default(),
2009        };
2010
2011        // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
2012        store.external_agents.insert(
2013            ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
2014            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2015        );
2016        store.external_agents.insert(
2017            ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
2018            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2019        );
2020        store.external_agents.insert(
2021            ExternalAgentServerName(SharedString::from("custom-agent")),
2022            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2023        );
2024
2025        // Simulate removal phase
2026        let keys_to_remove: Vec<_> = store
2027            .external_agents
2028            .keys()
2029            .filter(|name| name.0.contains(": "))
2030            .cloned()
2031            .collect();
2032
2033        for key in keys_to_remove {
2034            store.external_agents.remove(&key);
2035        }
2036
2037        // Only custom-agent should remain
2038        assert_eq!(store.external_agents.len(), 1);
2039        assert!(
2040            store
2041                .external_agents
2042                .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
2043        );
2044    }
2045
2046    #[test]
2047    fn archive_launcher_constructs_with_all_fields() {
2048        use extension::AgentServerManifestEntry;
2049
2050        let mut env = HashMap::default();
2051        env.insert("GITHUB_TOKEN".into(), "secret".into());
2052
2053        let mut targets = HashMap::default();
2054        targets.insert(
2055            "darwin-aarch64".to_string(),
2056            extension::TargetConfig {
2057                archive:
2058                    "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
2059                        .into(),
2060                cmd: "./agent".into(),
2061                args: vec![],
2062                sha256: None,
2063                env: Default::default(),
2064            },
2065        );
2066
2067        let _entry = AgentServerManifestEntry {
2068            name: "GitHub Agent".into(),
2069            targets,
2070            env,
2071            icon: None,
2072        };
2073
2074        // Verify display name construction
2075        let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
2076        assert_eq!(expected_name.0, "GitHub Agent");
2077    }
2078
2079    #[gpui::test]
2080    async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
2081        let fs = fs::FakeFs::new(cx.background_executor.clone());
2082        let http_client = http_client::FakeHttpClient::with_404_response();
2083        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2084        let project_environment = cx.new(|cx| {
2085            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2086        });
2087
2088        let agent = LocalExtensionArchiveAgent {
2089            fs,
2090            http_client,
2091            node_runtime: node_runtime::NodeRuntime::unavailable(),
2092            project_environment,
2093            extension_id: Arc::from("my-extension"),
2094            agent_id: Arc::from("my-agent"),
2095            targets: {
2096                let mut map = HashMap::default();
2097                map.insert(
2098                    "darwin-aarch64".to_string(),
2099                    extension::TargetConfig {
2100                        archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
2101                        cmd: "./my-agent".into(),
2102                        args: vec!["--serve".into()],
2103                        sha256: None,
2104                        env: Default::default(),
2105                    },
2106                );
2107                map
2108            },
2109            env: {
2110                let mut map = HashMap::default();
2111                map.insert("PORT".into(), "8080".into());
2112                map
2113            },
2114        };
2115
2116        // Verify agent is properly constructed
2117        assert_eq!(agent.extension_id.as_ref(), "my-extension");
2118        assert_eq!(agent.agent_id.as_ref(), "my-agent");
2119        assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
2120        assert!(agent.targets.contains_key("darwin-aarch64"));
2121    }
2122
2123    #[test]
2124    fn sync_extension_agents_registers_archive_launcher() {
2125        use extension::AgentServerManifestEntry;
2126
2127        let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
2128        assert_eq!(expected_name.0, "Release Agent");
2129
2130        // Verify the manifest entry structure for archive-based installation
2131        let mut env = HashMap::default();
2132        env.insert("API_KEY".into(), "secret".into());
2133
2134        let mut targets = HashMap::default();
2135        targets.insert(
2136            "linux-x86_64".to_string(),
2137            extension::TargetConfig {
2138                archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
2139                cmd: "./release-agent".into(),
2140                args: vec!["serve".into()],
2141                sha256: None,
2142                env: Default::default(),
2143            },
2144        );
2145
2146        let manifest_entry = AgentServerManifestEntry {
2147            name: "Release Agent".into(),
2148            targets: targets.clone(),
2149            env,
2150            icon: None,
2151        };
2152
2153        // Verify target config is present
2154        assert!(manifest_entry.targets.contains_key("linux-x86_64"));
2155        let target = manifest_entry.targets.get("linux-x86_64").unwrap();
2156        assert_eq!(target.cmd, "./release-agent");
2157    }
2158
2159    #[gpui::test]
2160    async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
2161        let fs = fs::FakeFs::new(cx.background_executor.clone());
2162        let http_client = http_client::FakeHttpClient::with_404_response();
2163        let node_runtime = NodeRuntime::unavailable();
2164        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2165        let project_environment = cx.new(|cx| {
2166            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2167        });
2168
2169        let agent = LocalExtensionArchiveAgent {
2170            fs: fs.clone(),
2171            http_client,
2172            node_runtime,
2173            project_environment,
2174            extension_id: Arc::from("node-extension"),
2175            agent_id: Arc::from("node-agent"),
2176            targets: {
2177                let mut map = HashMap::default();
2178                map.insert(
2179                    "darwin-aarch64".to_string(),
2180                    extension::TargetConfig {
2181                        archive: "https://example.com/node-agent.zip".into(),
2182                        cmd: "node".into(),
2183                        args: vec!["index.js".into()],
2184                        sha256: None,
2185                        env: Default::default(),
2186                    },
2187                );
2188                map
2189            },
2190            env: HashMap::default(),
2191        };
2192
2193        // Verify that when cmd is "node", it attempts to use the node runtime
2194        assert_eq!(agent.extension_id.as_ref(), "node-extension");
2195        assert_eq!(agent.agent_id.as_ref(), "node-agent");
2196
2197        let target = agent.targets.get("darwin-aarch64").unwrap();
2198        assert_eq!(target.cmd, "node");
2199        assert_eq!(target.args, vec!["index.js"]);
2200    }
2201
2202    #[gpui::test]
2203    async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
2204        let fs = fs::FakeFs::new(cx.background_executor.clone());
2205        let http_client = http_client::FakeHttpClient::with_404_response();
2206        let node_runtime = NodeRuntime::unavailable();
2207        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2208        let project_environment = cx.new(|cx| {
2209            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2210        });
2211
2212        let agent = LocalExtensionArchiveAgent {
2213            fs: fs.clone(),
2214            http_client,
2215            node_runtime,
2216            project_environment,
2217            extension_id: Arc::from("test-ext"),
2218            agent_id: Arc::from("test-agent"),
2219            targets: {
2220                let mut map = HashMap::default();
2221                map.insert(
2222                    "darwin-aarch64".to_string(),
2223                    extension::TargetConfig {
2224                        archive: "https://example.com/test.zip".into(),
2225                        cmd: "node".into(),
2226                        args: vec![
2227                            "server.js".into(),
2228                            "--config".into(),
2229                            "./config.json".into(),
2230                        ],
2231                        sha256: None,
2232                        env: Default::default(),
2233                    },
2234                );
2235                map
2236            },
2237            env: HashMap::default(),
2238        };
2239
2240        // Verify the agent is configured with relative paths in args
2241        let target = agent.targets.get("darwin-aarch64").unwrap();
2242        assert_eq!(target.args[0], "server.js");
2243        assert_eq!(target.args[2], "./config.json");
2244        // These relative paths will resolve relative to the extraction directory
2245        // when the command is executed
2246    }
2247
2248    #[test]
2249    fn test_tilde_expansion_in_settings() {
2250        let settings = settings::BuiltinAgentServerSettings {
2251            path: Some(PathBuf::from("~/bin/agent")),
2252            args: Some(vec!["--flag".into()]),
2253            env: None,
2254            ignore_system_version: None,
2255            default_mode: None,
2256            default_model: None,
2257        };
2258
2259        let BuiltinAgentServerSettings { path, .. } = settings.into();
2260
2261        let path = path.unwrap();
2262        assert!(
2263            !path.to_string_lossy().starts_with("~"),
2264            "Tilde should be expanded for builtin agent path"
2265        );
2266
2267        let settings = settings::CustomAgentServerSettings::Custom {
2268            path: PathBuf::from("~/custom/agent"),
2269            args: vec!["serve".into()],
2270            env: None,
2271            default_mode: None,
2272            default_model: None,
2273        };
2274
2275        let converted: CustomAgentServerSettings = settings.into();
2276        let CustomAgentServerSettings::Custom {
2277            command: AgentServerCommand { path, .. },
2278            ..
2279        } = converted
2280        else {
2281            panic!("Expected Custom variant");
2282        };
2283
2284        assert!(
2285            !path.to_string_lossy().starts_with("~"),
2286            "Tilde should be expanded for custom agent path"
2287        );
2288    }
2289}