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