agent_server_store.rs

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