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                is_remote: downstream_client.is_some(),
 457            }),
 458        );
 459        self.external_agents.insert(
 460            CLAUDE_CODE_NAME.into(),
 461            Box::new(LocalClaudeCode {
 462                fs: fs.clone(),
 463                node_runtime: node_runtime.clone(),
 464                project_environment: project_environment.clone(),
 465                custom_command: new_settings
 466                    .claude
 467                    .clone()
 468                    .and_then(|settings| settings.custom_command()),
 469            }),
 470        );
 471        self.external_agents
 472            .extend(
 473                new_settings
 474                    .custom
 475                    .iter()
 476                    .filter_map(|(name, settings)| match settings {
 477                        CustomAgentServerSettings::Custom { command, .. } => Some((
 478                            ExternalAgentServerName(name.clone()),
 479                            Box::new(LocalCustomAgent {
 480                                command: command.clone(),
 481                                project_environment: project_environment.clone(),
 482                            }) as Box<dyn ExternalAgentServer>,
 483                        )),
 484                        CustomAgentServerSettings::Extension { .. } => None,
 485                    }),
 486            );
 487        self.external_agents.extend(extension_agents.iter().map(
 488            |(agent_name, ext_id, targets, env, icon_path)| {
 489                let name = ExternalAgentServerName(agent_name.clone().into());
 490
 491                // Restore icon if present
 492                if let Some(icon) = icon_path {
 493                    self.agent_icons
 494                        .insert(name.clone(), SharedString::from(icon.clone()));
 495                }
 496
 497                (
 498                    name,
 499                    Box::new(LocalExtensionArchiveAgent {
 500                        fs: fs.clone(),
 501                        http_client: http_client.clone(),
 502                        node_runtime: node_runtime.clone(),
 503                        project_environment: project_environment.clone(),
 504                        extension_id: Arc::from(&**ext_id),
 505                        targets: targets.clone(),
 506                        env: env.clone(),
 507                        agent_id: agent_name.clone(),
 508                    }) as Box<dyn ExternalAgentServer>,
 509                )
 510            },
 511        ));
 512
 513        *old_settings = Some(new_settings.clone());
 514
 515        if let Some((project_id, downstream_client)) = downstream_client {
 516            downstream_client
 517                .send(proto::ExternalAgentsUpdated {
 518                    project_id: *project_id,
 519                    names: self
 520                        .external_agents
 521                        .keys()
 522                        .map(|name| name.to_string())
 523                        .collect(),
 524                })
 525                .log_err();
 526        }
 527        cx.emit(AgentServersUpdated);
 528    }
 529
 530    pub fn node_runtime(&self) -> Option<NodeRuntime> {
 531        match &self.state {
 532            AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
 533            _ => None,
 534        }
 535    }
 536
 537    pub fn local(
 538        node_runtime: NodeRuntime,
 539        fs: Arc<dyn Fs>,
 540        project_environment: Entity<ProjectEnvironment>,
 541        http_client: Arc<dyn HttpClient>,
 542        cx: &mut Context<Self>,
 543    ) -> Self {
 544        let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
 545            this.agent_servers_settings_changed(cx);
 546        });
 547        let mut this = Self {
 548            state: AgentServerStoreState::Local {
 549                node_runtime,
 550                fs,
 551                project_environment,
 552                http_client,
 553                downstream_client: None,
 554                settings: None,
 555                extension_agents: vec![],
 556                _subscriptions: [subscription],
 557            },
 558            external_agents: Default::default(),
 559            agent_icons: Default::default(),
 560        };
 561        if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
 562        this.agent_servers_settings_changed(cx);
 563        this
 564    }
 565
 566    pub(crate) fn remote(project_id: u64, upstream_client: Entity<RemoteClient>) -> Self {
 567        // Set up the builtin agents here so they're immediately available in
 568        // remote projects--we know that the HeadlessProject on the other end
 569        // will have them.
 570        let external_agents: [(ExternalAgentServerName, Box<dyn ExternalAgentServer>); 3] = [
 571            (
 572                CLAUDE_CODE_NAME.into(),
 573                Box::new(RemoteExternalAgentServer {
 574                    project_id,
 575                    upstream_client: upstream_client.clone(),
 576                    name: CLAUDE_CODE_NAME.into(),
 577                    status_tx: None,
 578                    new_version_available_tx: None,
 579                }) as Box<dyn ExternalAgentServer>,
 580            ),
 581            (
 582                CODEX_NAME.into(),
 583                Box::new(RemoteExternalAgentServer {
 584                    project_id,
 585                    upstream_client: upstream_client.clone(),
 586                    name: CODEX_NAME.into(),
 587                    status_tx: None,
 588                    new_version_available_tx: None,
 589                }) as Box<dyn ExternalAgentServer>,
 590            ),
 591            (
 592                GEMINI_NAME.into(),
 593                Box::new(RemoteExternalAgentServer {
 594                    project_id,
 595                    upstream_client: upstream_client.clone(),
 596                    name: GEMINI_NAME.into(),
 597                    status_tx: None,
 598                    new_version_available_tx: None,
 599                }) as Box<dyn ExternalAgentServer>,
 600            ),
 601        ];
 602
 603        Self {
 604            state: AgentServerStoreState::Remote {
 605                project_id,
 606                upstream_client,
 607            },
 608            external_agents: external_agents.into_iter().collect(),
 609            agent_icons: HashMap::default(),
 610        }
 611    }
 612
 613    pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
 614        Self {
 615            state: AgentServerStoreState::Collab,
 616            external_agents: Default::default(),
 617            agent_icons: Default::default(),
 618        }
 619    }
 620
 621    pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
 622        match &mut self.state {
 623            AgentServerStoreState::Local {
 624                downstream_client, ..
 625            } => {
 626                *downstream_client = Some((project_id, client.clone()));
 627                // Send the current list of external agents downstream, but only after a delay,
 628                // to avoid having the message arrive before the downstream project's agent server store
 629                // sets up its handlers.
 630                cx.spawn(async move |this, cx| {
 631                    cx.background_executor().timer(Duration::from_secs(1)).await;
 632                    let names = this.update(cx, |this, _| {
 633                        this.external_agents
 634                            .keys()
 635                            .map(|name| name.to_string())
 636                            .collect()
 637                    })?;
 638                    client
 639                        .send(proto::ExternalAgentsUpdated { project_id, names })
 640                        .log_err();
 641                    anyhow::Ok(())
 642                })
 643                .detach();
 644            }
 645            AgentServerStoreState::Remote { .. } => {
 646                debug_panic!(
 647                    "external agents over collab not implemented, remote project should not be shared"
 648                );
 649            }
 650            AgentServerStoreState::Collab => {
 651                debug_panic!("external agents over collab not implemented, should not be shared");
 652            }
 653        }
 654    }
 655
 656    pub fn get_external_agent(
 657        &mut self,
 658        name: &ExternalAgentServerName,
 659    ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
 660        self.external_agents
 661            .get_mut(name)
 662            .map(|agent| agent.as_mut())
 663    }
 664
 665    pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
 666        self.external_agents.keys()
 667    }
 668
 669    async fn handle_get_agent_server_command(
 670        this: Entity<Self>,
 671        envelope: TypedEnvelope<proto::GetAgentServerCommand>,
 672        mut cx: AsyncApp,
 673    ) -> Result<proto::AgentServerCommand> {
 674        let (command, root_dir, login_command) = this
 675            .update(&mut cx, |this, cx| {
 676                let AgentServerStoreState::Local {
 677                    downstream_client, ..
 678                } = &this.state
 679                else {
 680                    debug_panic!("should not receive GetAgentServerCommand in a non-local project");
 681                    bail!("unexpected GetAgentServerCommand request in a non-local project");
 682                };
 683                let agent = this
 684                    .external_agents
 685                    .get_mut(&*envelope.payload.name)
 686                    .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
 687                let (status_tx, new_version_available_tx) = downstream_client
 688                    .clone()
 689                    .map(|(project_id, downstream_client)| {
 690                        let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
 691                        let (new_version_available_tx, mut new_version_available_rx) =
 692                            watch::channel(None);
 693                        cx.spawn({
 694                            let downstream_client = downstream_client.clone();
 695                            let name = envelope.payload.name.clone();
 696                            async move |_, _| {
 697                                while let Some(status) = status_rx.recv().await.ok() {
 698                                    downstream_client.send(
 699                                        proto::ExternalAgentLoadingStatusUpdated {
 700                                            project_id,
 701                                            name: name.clone(),
 702                                            status: status.to_string(),
 703                                        },
 704                                    )?;
 705                                }
 706                                anyhow::Ok(())
 707                            }
 708                        })
 709                        .detach_and_log_err(cx);
 710                        cx.spawn({
 711                            let name = envelope.payload.name.clone();
 712                            async move |_, _| {
 713                                if let Some(version) =
 714                                    new_version_available_rx.recv().await.ok().flatten()
 715                                {
 716                                    downstream_client.send(
 717                                        proto::NewExternalAgentVersionAvailable {
 718                                            project_id,
 719                                            name: name.clone(),
 720                                            version,
 721                                        },
 722                                    )?;
 723                                }
 724                                anyhow::Ok(())
 725                            }
 726                        })
 727                        .detach_and_log_err(cx);
 728                        (status_tx, new_version_available_tx)
 729                    })
 730                    .unzip();
 731                anyhow::Ok(agent.get_command(
 732                    envelope.payload.root_dir.as_deref(),
 733                    HashMap::default(),
 734                    status_tx,
 735                    new_version_available_tx,
 736                    &mut cx.to_async(),
 737                ))
 738            })??
 739            .await?;
 740        Ok(proto::AgentServerCommand {
 741            path: command.path.to_string_lossy().into_owned(),
 742            args: command.args,
 743            env: command
 744                .env
 745                .map(|env| env.into_iter().collect())
 746                .unwrap_or_default(),
 747            root_dir: root_dir,
 748            login: login_command.map(|cmd| cmd.to_proto()),
 749        })
 750    }
 751
 752    async fn handle_external_agents_updated(
 753        this: Entity<Self>,
 754        envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
 755        mut cx: AsyncApp,
 756    ) -> Result<()> {
 757        this.update(&mut cx, |this, cx| {
 758            let AgentServerStoreState::Remote {
 759                project_id,
 760                upstream_client,
 761            } = &this.state
 762            else {
 763                debug_panic!(
 764                    "handle_external_agents_updated should not be called for a non-remote project"
 765                );
 766                bail!("unexpected ExternalAgentsUpdated message")
 767            };
 768
 769            let mut status_txs = this
 770                .external_agents
 771                .iter_mut()
 772                .filter_map(|(name, agent)| {
 773                    Some((
 774                        name.clone(),
 775                        agent
 776                            .downcast_mut::<RemoteExternalAgentServer>()?
 777                            .status_tx
 778                            .take(),
 779                    ))
 780                })
 781                .collect::<HashMap<_, _>>();
 782            let mut new_version_available_txs = this
 783                .external_agents
 784                .iter_mut()
 785                .filter_map(|(name, agent)| {
 786                    Some((
 787                        name.clone(),
 788                        agent
 789                            .downcast_mut::<RemoteExternalAgentServer>()?
 790                            .new_version_available_tx
 791                            .take(),
 792                    ))
 793                })
 794                .collect::<HashMap<_, _>>();
 795
 796            this.external_agents = envelope
 797                .payload
 798                .names
 799                .into_iter()
 800                .map(|name| {
 801                    let agent = RemoteExternalAgentServer {
 802                        project_id: *project_id,
 803                        upstream_client: upstream_client.clone(),
 804                        name: ExternalAgentServerName(name.clone().into()),
 805                        status_tx: status_txs.remove(&*name).flatten(),
 806                        new_version_available_tx: new_version_available_txs
 807                            .remove(&*name)
 808                            .flatten(),
 809                    };
 810                    (
 811                        ExternalAgentServerName(name.into()),
 812                        Box::new(agent) as Box<dyn ExternalAgentServer>,
 813                    )
 814                })
 815                .collect();
 816            cx.emit(AgentServersUpdated);
 817            Ok(())
 818        })?
 819    }
 820
 821    async fn handle_external_extension_agents_updated(
 822        this: Entity<Self>,
 823        envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
 824        mut cx: AsyncApp,
 825    ) -> Result<()> {
 826        this.update(&mut cx, |this, cx| {
 827            let AgentServerStoreState::Local {
 828                extension_agents, ..
 829            } = &mut this.state
 830            else {
 831                panic!(
 832                    "handle_external_extension_agents_updated \
 833                    should not be called for a non-remote project"
 834                );
 835            };
 836
 837            for ExternalExtensionAgent {
 838                name,
 839                icon_path,
 840                extension_id,
 841                targets,
 842                env,
 843            } in envelope.payload.agents
 844            {
 845                let icon_path_string = icon_path.clone();
 846                if let Some(icon_path) = icon_path {
 847                    this.agent_icons.insert(
 848                        ExternalAgentServerName(name.clone().into()),
 849                        icon_path.into(),
 850                    );
 851                }
 852                extension_agents.push((
 853                    Arc::from(&*name),
 854                    extension_id,
 855                    targets
 856                        .into_iter()
 857                        .map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
 858                        .collect(),
 859                    env.into_iter().collect(),
 860                    icon_path_string,
 861                ));
 862            }
 863
 864            this.reregister_agents(cx);
 865            cx.emit(AgentServersUpdated);
 866            Ok(())
 867        })?
 868    }
 869
 870    async fn handle_loading_status_updated(
 871        this: Entity<Self>,
 872        envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
 873        mut cx: AsyncApp,
 874    ) -> Result<()> {
 875        this.update(&mut cx, |this, _| {
 876            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 877                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 878                && let Some(status_tx) = &mut agent.status_tx
 879            {
 880                status_tx.send(envelope.payload.status.into()).ok();
 881            }
 882        })
 883    }
 884
 885    async fn handle_new_version_available(
 886        this: Entity<Self>,
 887        envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
 888        mut cx: AsyncApp,
 889    ) -> Result<()> {
 890        this.update(&mut cx, |this, _| {
 891            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 892                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 893                && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
 894            {
 895                new_version_available_tx
 896                    .send(Some(envelope.payload.version))
 897                    .ok();
 898            }
 899        })
 900    }
 901
 902    pub fn get_extension_id_for_agent(
 903        &mut self,
 904        name: &ExternalAgentServerName,
 905    ) -> Option<Arc<str>> {
 906        self.external_agents.get_mut(name).and_then(|agent| {
 907            agent
 908                .as_any_mut()
 909                .downcast_ref::<LocalExtensionArchiveAgent>()
 910                .map(|ext_agent| ext_agent.extension_id.clone())
 911        })
 912    }
 913}
 914
 915fn get_or_npm_install_builtin_agent(
 916    binary_name: SharedString,
 917    package_name: SharedString,
 918    entrypoint_path: PathBuf,
 919    minimum_version: Option<semver::Version>,
 920    status_tx: Option<watch::Sender<SharedString>>,
 921    new_version_available: Option<watch::Sender<Option<String>>>,
 922    fs: Arc<dyn Fs>,
 923    node_runtime: NodeRuntime,
 924    cx: &mut AsyncApp,
 925) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
 926    cx.spawn(async move |cx| {
 927        let node_path = node_runtime.binary_path().await?;
 928        let dir = paths::external_agents_dir().join(binary_name.as_str());
 929        fs.create_dir(&dir).await?;
 930
 931        let mut stream = fs.read_dir(&dir).await?;
 932        let mut versions = Vec::new();
 933        let mut to_delete = Vec::new();
 934        while let Some(entry) = stream.next().await {
 935            let Ok(entry) = entry else { continue };
 936            let Some(file_name) = entry.file_name() else {
 937                continue;
 938            };
 939
 940            if let Some(name) = file_name.to_str()
 941                && let Some(version) = semver::Version::from_str(name).ok()
 942                && fs
 943                    .is_file(&dir.join(file_name).join(&entrypoint_path))
 944                    .await
 945            {
 946                versions.push((version, file_name.to_owned()));
 947            } else {
 948                to_delete.push(file_name.to_owned())
 949            }
 950        }
 951
 952        versions.sort();
 953        let newest_version = if let Some((version, file_name)) = versions.last().cloned()
 954            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
 955        {
 956            versions.pop();
 957            Some(file_name)
 958        } else {
 959            None
 960        };
 961        log::debug!("existing version of {package_name}: {newest_version:?}");
 962        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
 963
 964        cx.background_spawn({
 965            let fs = fs.clone();
 966            let dir = dir.clone();
 967            async move {
 968                for file_name in to_delete {
 969                    fs.remove_dir(
 970                        &dir.join(file_name),
 971                        RemoveOptions {
 972                            recursive: true,
 973                            ignore_if_not_exists: false,
 974                        },
 975                    )
 976                    .await
 977                    .ok();
 978                }
 979            }
 980        })
 981        .detach();
 982
 983        let version = if let Some(file_name) = newest_version {
 984            cx.background_spawn({
 985                let file_name = file_name.clone();
 986                let dir = dir.clone();
 987                let fs = fs.clone();
 988                async move {
 989                    let latest_version = node_runtime
 990                        .npm_package_latest_version(&package_name)
 991                        .await
 992                        .ok();
 993                    if let Some(latest_version) = latest_version
 994                        && &latest_version != &file_name.to_string_lossy()
 995                    {
 996                        let download_result = download_latest_version(
 997                            fs,
 998                            dir.clone(),
 999                            node_runtime,
1000                            package_name.clone(),
1001                        )
1002                        .await
1003                        .log_err();
1004                        if let Some(mut new_version_available) = new_version_available
1005                            && download_result.is_some()
1006                        {
1007                            new_version_available.send(Some(latest_version)).ok();
1008                        }
1009                    }
1010                }
1011            })
1012            .detach();
1013            file_name
1014        } else {
1015            if let Some(mut status_tx) = status_tx {
1016                status_tx.send("Installing…".into()).ok();
1017            }
1018            let dir = dir.clone();
1019            cx.background_spawn(download_latest_version(
1020                fs.clone(),
1021                dir.clone(),
1022                node_runtime,
1023                package_name.clone(),
1024            ))
1025            .await?
1026            .into()
1027        };
1028
1029        let agent_server_path = dir.join(version).join(entrypoint_path);
1030        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
1031        anyhow::ensure!(
1032            agent_server_path_exists,
1033            "Missing entrypoint path {} after installation",
1034            agent_server_path.to_string_lossy()
1035        );
1036
1037        anyhow::Ok(AgentServerCommand {
1038            path: node_path,
1039            args: vec![agent_server_path.to_string_lossy().into_owned()],
1040            env: None,
1041        })
1042    })
1043}
1044
1045fn find_bin_in_path(
1046    bin_name: SharedString,
1047    root_dir: PathBuf,
1048    env: HashMap<String, String>,
1049    cx: &mut AsyncApp,
1050) -> Task<Option<PathBuf>> {
1051    cx.background_executor().spawn(async move {
1052        let which_result = if cfg!(windows) {
1053            which::which(bin_name.as_str())
1054        } else {
1055            let shell_path = env.get("PATH").cloned();
1056            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
1057        };
1058
1059        if let Err(which::Error::CannotFindBinaryPath) = which_result {
1060            return None;
1061        }
1062
1063        which_result.log_err()
1064    })
1065}
1066
1067async fn download_latest_version(
1068    fs: Arc<dyn Fs>,
1069    dir: PathBuf,
1070    node_runtime: NodeRuntime,
1071    package_name: SharedString,
1072) -> Result<String> {
1073    log::debug!("downloading latest version of {package_name}");
1074
1075    let tmp_dir = tempfile::tempdir_in(&dir)?;
1076
1077    node_runtime
1078        .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
1079        .await?;
1080
1081    let version = node_runtime
1082        .npm_package_installed_version(tmp_dir.path(), &package_name)
1083        .await?
1084        .context("expected package to be installed")?;
1085
1086    fs.rename(
1087        &tmp_dir.keep(),
1088        &dir.join(&version),
1089        RenameOptions {
1090            ignore_if_exists: true,
1091            overwrite: true,
1092        },
1093    )
1094    .await?;
1095
1096    anyhow::Ok(version)
1097}
1098
1099struct RemoteExternalAgentServer {
1100    project_id: u64,
1101    upstream_client: Entity<RemoteClient>,
1102    name: ExternalAgentServerName,
1103    status_tx: Option<watch::Sender<SharedString>>,
1104    new_version_available_tx: Option<watch::Sender<Option<String>>>,
1105}
1106
1107impl ExternalAgentServer for RemoteExternalAgentServer {
1108    fn get_command(
1109        &mut self,
1110        root_dir: Option<&str>,
1111        extra_env: HashMap<String, String>,
1112        status_tx: Option<watch::Sender<SharedString>>,
1113        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1114        cx: &mut AsyncApp,
1115    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1116        let project_id = self.project_id;
1117        let name = self.name.to_string();
1118        let upstream_client = self.upstream_client.downgrade();
1119        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
1120        self.status_tx = status_tx;
1121        self.new_version_available_tx = new_version_available_tx;
1122        cx.spawn(async move |cx| {
1123            let mut response = upstream_client
1124                .update(cx, |upstream_client, _| {
1125                    upstream_client
1126                        .proto_client()
1127                        .request(proto::GetAgentServerCommand {
1128                            project_id,
1129                            name,
1130                            root_dir: root_dir.clone(),
1131                        })
1132                })?
1133                .await?;
1134            let root_dir = response.root_dir;
1135            response.env.extend(extra_env);
1136            let command = upstream_client.update(cx, |client, _| {
1137                client.build_command(
1138                    Some(response.path),
1139                    &response.args,
1140                    &response.env.into_iter().collect(),
1141                    Some(root_dir.clone()),
1142                    None,
1143                )
1144            })??;
1145            Ok((
1146                AgentServerCommand {
1147                    path: command.program.into(),
1148                    args: command.args,
1149                    env: Some(command.env),
1150                },
1151                root_dir,
1152                response.login.map(SpawnInTerminal::from_proto),
1153            ))
1154        })
1155    }
1156
1157    fn as_any_mut(&mut self) -> &mut dyn Any {
1158        self
1159    }
1160}
1161
1162struct LocalGemini {
1163    fs: Arc<dyn Fs>,
1164    node_runtime: NodeRuntime,
1165    project_environment: Entity<ProjectEnvironment>,
1166    custom_command: Option<AgentServerCommand>,
1167    ignore_system_version: bool,
1168}
1169
1170impl ExternalAgentServer for LocalGemini {
1171    fn get_command(
1172        &mut self,
1173        root_dir: Option<&str>,
1174        extra_env: HashMap<String, String>,
1175        status_tx: Option<watch::Sender<SharedString>>,
1176        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1177        cx: &mut AsyncApp,
1178    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1179        let fs = self.fs.clone();
1180        let node_runtime = self.node_runtime.clone();
1181        let project_environment = self.project_environment.downgrade();
1182        let custom_command = self.custom_command.clone();
1183        let ignore_system_version = self.ignore_system_version;
1184        let root_dir: Arc<Path> = root_dir
1185            .map(|root_dir| Path::new(root_dir))
1186            .unwrap_or(paths::home_dir())
1187            .into();
1188
1189        cx.spawn(async move |cx| {
1190            let mut env = project_environment
1191                .update(cx, |project_environment, cx| {
1192                    project_environment.local_directory_environment(
1193                        &Shell::System,
1194                        root_dir.clone(),
1195                        cx,
1196                    )
1197                })?
1198                .await
1199                .unwrap_or_default();
1200
1201            let mut command = if let Some(mut custom_command) = custom_command {
1202                env.extend(custom_command.env.unwrap_or_default());
1203                custom_command.env = Some(env);
1204                custom_command
1205            } else if !ignore_system_version
1206                && let Some(bin) =
1207                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1208            {
1209                AgentServerCommand {
1210                    path: bin,
1211                    args: Vec::new(),
1212                    env: Some(env),
1213                }
1214            } else {
1215                let mut command = get_or_npm_install_builtin_agent(
1216                    GEMINI_NAME.into(),
1217                    "@google/gemini-cli".into(),
1218                    "node_modules/@google/gemini-cli/dist/index.js".into(),
1219                    if cfg!(windows) {
1220                        // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1221                        Some("0.9.0".parse().unwrap())
1222                    } else {
1223                        Some("0.2.1".parse().unwrap())
1224                    },
1225                    status_tx,
1226                    new_version_available_tx,
1227                    fs,
1228                    node_runtime,
1229                    cx,
1230                )
1231                .await?;
1232                command.env = Some(env);
1233                command
1234            };
1235
1236            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1237            let login = task::SpawnInTerminal {
1238                command: Some(command.path.to_string_lossy().into_owned()),
1239                args: command.args.clone(),
1240                env: command.env.clone().unwrap_or_default(),
1241                label: "gemini /auth".into(),
1242                ..Default::default()
1243            };
1244
1245            command.env.get_or_insert_default().extend(extra_env);
1246            command.args.push("--experimental-acp".into());
1247            Ok((
1248                command,
1249                root_dir.to_string_lossy().into_owned(),
1250                Some(login),
1251            ))
1252        })
1253    }
1254
1255    fn as_any_mut(&mut self) -> &mut dyn Any {
1256        self
1257    }
1258}
1259
1260struct LocalClaudeCode {
1261    fs: Arc<dyn Fs>,
1262    node_runtime: NodeRuntime,
1263    project_environment: Entity<ProjectEnvironment>,
1264    custom_command: Option<AgentServerCommand>,
1265}
1266
1267impl ExternalAgentServer for LocalClaudeCode {
1268    fn get_command(
1269        &mut self,
1270        root_dir: Option<&str>,
1271        extra_env: HashMap<String, String>,
1272        status_tx: Option<watch::Sender<SharedString>>,
1273        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1274        cx: &mut AsyncApp,
1275    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1276        let fs = self.fs.clone();
1277        let node_runtime = self.node_runtime.clone();
1278        let project_environment = self.project_environment.downgrade();
1279        let custom_command = self.custom_command.clone();
1280        let root_dir: Arc<Path> = root_dir
1281            .map(|root_dir| Path::new(root_dir))
1282            .unwrap_or(paths::home_dir())
1283            .into();
1284
1285        cx.spawn(async move |cx| {
1286            let mut env = project_environment
1287                .update(cx, |project_environment, cx| {
1288                    project_environment.local_directory_environment(
1289                        &Shell::System,
1290                        root_dir.clone(),
1291                        cx,
1292                    )
1293                })?
1294                .await
1295                .unwrap_or_default();
1296            env.insert("ANTHROPIC_API_KEY".into(), "".into());
1297
1298            let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1299                env.extend(custom_command.env.unwrap_or_default());
1300                custom_command.env = Some(env);
1301                (custom_command, None)
1302            } else {
1303                let mut command = get_or_npm_install_builtin_agent(
1304                    "claude-code-acp".into(),
1305                    "@zed-industries/claude-code-acp".into(),
1306                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1307                    Some("0.5.2".parse().unwrap()),
1308                    status_tx,
1309                    new_version_available_tx,
1310                    fs,
1311                    node_runtime,
1312                    cx,
1313                )
1314                .await?;
1315                command.env = Some(env);
1316                let login = command
1317                    .args
1318                    .first()
1319                    .and_then(|path| {
1320                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1321                    })
1322                    .map(|path_prefix| task::SpawnInTerminal {
1323                        command: Some(command.path.to_string_lossy().into_owned()),
1324                        args: vec![
1325                            Path::new(path_prefix)
1326                                .join("@anthropic-ai/claude-agent-sdk/cli.js")
1327                                .to_string_lossy()
1328                                .to_string(),
1329                            "/login".into(),
1330                        ],
1331                        env: command.env.clone().unwrap_or_default(),
1332                        label: "claude /login".into(),
1333                        ..Default::default()
1334                    });
1335                (command, login)
1336            };
1337
1338            command.env.get_or_insert_default().extend(extra_env);
1339            Ok((
1340                command,
1341                root_dir.to_string_lossy().into_owned(),
1342                login_command,
1343            ))
1344        })
1345    }
1346
1347    fn as_any_mut(&mut self) -> &mut dyn Any {
1348        self
1349    }
1350}
1351
1352struct LocalCodex {
1353    fs: Arc<dyn Fs>,
1354    project_environment: Entity<ProjectEnvironment>,
1355    http_client: Arc<dyn HttpClient>,
1356    custom_command: Option<AgentServerCommand>,
1357    is_remote: bool,
1358}
1359
1360impl ExternalAgentServer for LocalCodex {
1361    fn get_command(
1362        &mut self,
1363        root_dir: Option<&str>,
1364        extra_env: HashMap<String, String>,
1365        status_tx: Option<watch::Sender<SharedString>>,
1366        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1367        cx: &mut AsyncApp,
1368    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1369        let fs = self.fs.clone();
1370        let project_environment = self.project_environment.downgrade();
1371        let http = self.http_client.clone();
1372        let custom_command = self.custom_command.clone();
1373        let root_dir: Arc<Path> = root_dir
1374            .map(|root_dir| Path::new(root_dir))
1375            .unwrap_or(paths::home_dir())
1376            .into();
1377        let is_remote = self.is_remote;
1378
1379        cx.spawn(async move |cx| {
1380            let mut env = project_environment
1381                .update(cx, |project_environment, cx| {
1382                    project_environment.local_directory_environment(
1383                        &Shell::System,
1384                        root_dir.clone(),
1385                        cx,
1386                    )
1387                })?
1388                .await
1389                .unwrap_or_default();
1390            if is_remote {
1391                env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1392            }
1393
1394            let mut command = if let Some(mut custom_command) = custom_command {
1395                env.extend(custom_command.env.unwrap_or_default());
1396                custom_command.env = Some(env);
1397                custom_command
1398            } else {
1399                let dir = paths::external_agents_dir().join(CODEX_NAME);
1400                fs.create_dir(&dir).await?;
1401
1402                // Find or install the latest Codex release (no update checks for now).
1403                let release = ::http_client::github::latest_github_release(
1404                    CODEX_ACP_REPO,
1405                    true,
1406                    false,
1407                    http.clone(),
1408                )
1409                .await
1410                .context("fetching Codex latest release")?;
1411
1412                let version_dir = dir.join(&release.tag_name);
1413                if !fs.is_dir(&version_dir).await {
1414                    if let Some(mut status_tx) = status_tx {
1415                        status_tx.send("Installing…".into()).ok();
1416                    }
1417
1418                    let tag = release.tag_name.clone();
1419                    let version_number = tag.trim_start_matches('v');
1420                    let asset_name = asset_name(version_number)
1421                        .context("codex acp is not supported for this architecture")?;
1422                    let asset = release
1423                        .assets
1424                        .into_iter()
1425                        .find(|asset| asset.name == asset_name)
1426                        .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1427                    // Strip "sha256:" prefix from digest if present (GitHub API format)
1428                    let digest = asset
1429                        .digest
1430                        .as_deref()
1431                        .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1432                    ::http_client::github_download::download_server_binary(
1433                        &*http,
1434                        &asset.browser_download_url,
1435                        digest,
1436                        &version_dir,
1437                        if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1438                            AssetKind::Zip
1439                        } else {
1440                            AssetKind::TarGz
1441                        },
1442                    )
1443                    .await?;
1444
1445                    // remove older versions
1446                    util::fs::remove_matching(&dir, |entry| entry != version_dir).await;
1447                }
1448
1449                let bin_name = if cfg!(windows) {
1450                    "codex-acp.exe"
1451                } else {
1452                    "codex-acp"
1453                };
1454                let bin_path = version_dir.join(bin_name);
1455                anyhow::ensure!(
1456                    fs.is_file(&bin_path).await,
1457                    "Missing Codex binary at {} after installation",
1458                    bin_path.to_string_lossy()
1459                );
1460
1461                let mut cmd = AgentServerCommand {
1462                    path: bin_path,
1463                    args: Vec::new(),
1464                    env: None,
1465                };
1466                cmd.env = Some(env);
1467                cmd
1468            };
1469
1470            command.env.get_or_insert_default().extend(extra_env);
1471            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1472        })
1473    }
1474
1475    fn as_any_mut(&mut self) -> &mut dyn Any {
1476        self
1477    }
1478}
1479
1480pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1481
1482fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1483    let arch = if cfg!(target_arch = "x86_64") {
1484        "x86_64"
1485    } else if cfg!(target_arch = "aarch64") {
1486        "aarch64"
1487    } else {
1488        return None;
1489    };
1490
1491    let platform = if cfg!(target_os = "macos") {
1492        "apple-darwin"
1493    } else if cfg!(target_os = "windows") {
1494        "pc-windows-msvc"
1495    } else if cfg!(target_os = "linux") {
1496        "unknown-linux-gnu"
1497    } else {
1498        return None;
1499    };
1500
1501    // Only Windows x86_64 uses .zip in release assets
1502    let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1503        "zip"
1504    } else {
1505        "tar.gz"
1506    };
1507
1508    Some((arch, platform, ext))
1509}
1510
1511fn asset_name(version: &str) -> Option<String> {
1512    let (arch, platform, ext) = get_platform_info()?;
1513    Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1514}
1515
1516struct LocalExtensionArchiveAgent {
1517    fs: Arc<dyn Fs>,
1518    http_client: Arc<dyn HttpClient>,
1519    node_runtime: NodeRuntime,
1520    project_environment: Entity<ProjectEnvironment>,
1521    extension_id: Arc<str>,
1522    agent_id: Arc<str>,
1523    targets: HashMap<String, extension::TargetConfig>,
1524    env: HashMap<String, String>,
1525}
1526
1527struct LocalCustomAgent {
1528    project_environment: Entity<ProjectEnvironment>,
1529    command: AgentServerCommand,
1530}
1531
1532impl ExternalAgentServer for LocalExtensionArchiveAgent {
1533    fn get_command(
1534        &mut self,
1535        root_dir: Option<&str>,
1536        extra_env: HashMap<String, String>,
1537        _status_tx: Option<watch::Sender<SharedString>>,
1538        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1539        cx: &mut AsyncApp,
1540    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1541        let fs = self.fs.clone();
1542        let http_client = self.http_client.clone();
1543        let node_runtime = self.node_runtime.clone();
1544        let project_environment = self.project_environment.downgrade();
1545        let extension_id = self.extension_id.clone();
1546        let agent_id = self.agent_id.clone();
1547        let targets = self.targets.clone();
1548        let base_env = self.env.clone();
1549
1550        let root_dir: Arc<Path> = root_dir
1551            .map(|root_dir| Path::new(root_dir))
1552            .unwrap_or(paths::home_dir())
1553            .into();
1554
1555        cx.spawn(async move |cx| {
1556            // Get project environment
1557            let mut env = project_environment
1558                .update(cx, |project_environment, cx| {
1559                    project_environment.local_directory_environment(
1560                        &Shell::System,
1561                        root_dir.clone(),
1562                        cx,
1563                    )
1564                })?
1565                .await
1566                .unwrap_or_default();
1567
1568            // Merge manifest env and extra env
1569            env.extend(base_env);
1570            env.extend(extra_env);
1571
1572            let cache_key = format!("{}/{}", extension_id, agent_id);
1573            let dir = paths::external_agents_dir().join(&cache_key);
1574            fs.create_dir(&dir).await?;
1575
1576            // Determine platform key
1577            let os = if cfg!(target_os = "macos") {
1578                "darwin"
1579            } else if cfg!(target_os = "linux") {
1580                "linux"
1581            } else if cfg!(target_os = "windows") {
1582                "windows"
1583            } else {
1584                anyhow::bail!("unsupported OS");
1585            };
1586
1587            let arch = if cfg!(target_arch = "aarch64") {
1588                "aarch64"
1589            } else if cfg!(target_arch = "x86_64") {
1590                "x86_64"
1591            } else {
1592                anyhow::bail!("unsupported architecture");
1593            };
1594
1595            let platform_key = format!("{}-{}", os, arch);
1596            let target_config = targets.get(&platform_key).with_context(|| {
1597                format!(
1598                    "no target specified for platform '{}'. Available platforms: {}",
1599                    platform_key,
1600                    targets
1601                        .keys()
1602                        .map(|k| k.as_str())
1603                        .collect::<Vec<_>>()
1604                        .join(", ")
1605                )
1606            })?;
1607
1608            let archive_url = &target_config.archive;
1609
1610            // Use URL as version identifier for caching
1611            // Hash the URL to get a stable directory name
1612            use std::collections::hash_map::DefaultHasher;
1613            use std::hash::{Hash, Hasher};
1614            let mut hasher = DefaultHasher::new();
1615            archive_url.hash(&mut hasher);
1616            let url_hash = hasher.finish();
1617            let version_dir = dir.join(format!("v_{:x}", url_hash));
1618
1619            if !fs.is_dir(&version_dir).await {
1620                // Determine SHA256 for verification
1621                let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1622                    // Use provided SHA256
1623                    Some(provided_sha.clone())
1624                } else if archive_url.starts_with("https://github.com/") {
1625                    // Try to fetch SHA256 from GitHub API
1626                    // Parse URL to extract repo and tag/file info
1627                    // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1628                    if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1629                        let parts: Vec<&str> = caps.split('/').collect();
1630                        if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1631                            let repo = format!("{}/{}", parts[0], parts[1]);
1632                            let tag = parts[4];
1633                            let filename = parts[5..].join("/");
1634
1635                            // Try to get release info from GitHub
1636                            if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1637                                &repo,
1638                                tag,
1639                                http_client.clone(),
1640                            )
1641                            .await
1642                            {
1643                                // Find matching asset
1644                                if let Some(asset) =
1645                                    release.assets.iter().find(|a| a.name == filename)
1646                                {
1647                                    // Strip "sha256:" prefix if present
1648                                    asset.digest.as_ref().and_then(|d| {
1649                                        d.strip_prefix("sha256:")
1650                                            .map(|s| s.to_string())
1651                                            .or_else(|| Some(d.clone()))
1652                                    })
1653                                } else {
1654                                    None
1655                                }
1656                            } else {
1657                                None
1658                            }
1659                        } else {
1660                            None
1661                        }
1662                    } else {
1663                        None
1664                    }
1665                } else {
1666                    None
1667                };
1668
1669                // Determine archive type from URL
1670                let asset_kind = if archive_url.ends_with(".zip") {
1671                    AssetKind::Zip
1672                } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1673                    AssetKind::TarGz
1674                } else {
1675                    anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1676                };
1677
1678                // Download and extract
1679                ::http_client::github_download::download_server_binary(
1680                    &*http_client,
1681                    archive_url,
1682                    sha256.as_deref(),
1683                    &version_dir,
1684                    asset_kind,
1685                )
1686                .await?;
1687            }
1688
1689            // Validate and resolve cmd path
1690            let cmd = &target_config.cmd;
1691
1692            let cmd_path = if cmd == "node" {
1693                // Use Zed's managed Node.js runtime
1694                node_runtime.binary_path().await?
1695            } else {
1696                if cmd.contains("..") {
1697                    anyhow::bail!("command path cannot contain '..': {}", cmd);
1698                }
1699
1700                if cmd.starts_with("./") || cmd.starts_with(".\\") {
1701                    // Relative to extraction directory
1702                    let cmd_path = version_dir.join(&cmd[2..]);
1703                    anyhow::ensure!(
1704                        fs.is_file(&cmd_path).await,
1705                        "Missing command {} after extraction",
1706                        cmd_path.to_string_lossy()
1707                    );
1708                    cmd_path
1709                } else {
1710                    // On PATH
1711                    anyhow::bail!("command must be relative (start with './'): {}", cmd);
1712                }
1713            };
1714
1715            let command = AgentServerCommand {
1716                path: cmd_path,
1717                args: target_config.args.clone(),
1718                env: Some(env),
1719            };
1720
1721            Ok((command, version_dir.to_string_lossy().into_owned(), None))
1722        })
1723    }
1724
1725    fn as_any_mut(&mut self) -> &mut dyn Any {
1726        self
1727    }
1728}
1729
1730impl ExternalAgentServer for LocalCustomAgent {
1731    fn get_command(
1732        &mut self,
1733        root_dir: Option<&str>,
1734        extra_env: HashMap<String, String>,
1735        _status_tx: Option<watch::Sender<SharedString>>,
1736        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1737        cx: &mut AsyncApp,
1738    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1739        let mut command = self.command.clone();
1740        let root_dir: Arc<Path> = root_dir
1741            .map(|root_dir| Path::new(root_dir))
1742            .unwrap_or(paths::home_dir())
1743            .into();
1744        let project_environment = self.project_environment.downgrade();
1745        cx.spawn(async move |cx| {
1746            let mut env = project_environment
1747                .update(cx, |project_environment, cx| {
1748                    project_environment.local_directory_environment(
1749                        &Shell::System,
1750                        root_dir.clone(),
1751                        cx,
1752                    )
1753                })?
1754                .await
1755                .unwrap_or_default();
1756            env.extend(command.env.unwrap_or_default());
1757            env.extend(extra_env);
1758            command.env = Some(env);
1759            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1760        })
1761    }
1762
1763    fn as_any_mut(&mut self) -> &mut dyn Any {
1764        self
1765    }
1766}
1767
1768pub const GEMINI_NAME: &'static str = "gemini";
1769pub const CLAUDE_CODE_NAME: &'static str = "claude";
1770pub const CODEX_NAME: &'static str = "codex";
1771
1772#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1773pub struct AllAgentServersSettings {
1774    pub gemini: Option<BuiltinAgentServerSettings>,
1775    pub claude: Option<BuiltinAgentServerSettings>,
1776    pub codex: Option<BuiltinAgentServerSettings>,
1777    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1778}
1779#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1780pub struct BuiltinAgentServerSettings {
1781    pub path: Option<PathBuf>,
1782    pub args: Option<Vec<String>>,
1783    pub env: Option<HashMap<String, String>>,
1784    pub ignore_system_version: Option<bool>,
1785    pub default_mode: Option<String>,
1786    pub default_model: Option<String>,
1787}
1788
1789impl BuiltinAgentServerSettings {
1790    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1791        self.path.map(|path| AgentServerCommand {
1792            path,
1793            args: self.args.unwrap_or_default(),
1794            env: self.env,
1795        })
1796    }
1797}
1798
1799impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1800    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1801        BuiltinAgentServerSettings {
1802            path: value
1803                .path
1804                .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
1805            args: value.args,
1806            env: value.env,
1807            ignore_system_version: value.ignore_system_version,
1808            default_mode: value.default_mode,
1809            default_model: value.default_model,
1810        }
1811    }
1812}
1813
1814impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1815    fn from(value: AgentServerCommand) -> Self {
1816        BuiltinAgentServerSettings {
1817            path: Some(value.path),
1818            args: Some(value.args),
1819            env: value.env,
1820            ..Default::default()
1821        }
1822    }
1823}
1824
1825#[derive(Clone, JsonSchema, Debug, PartialEq)]
1826pub enum CustomAgentServerSettings {
1827    Custom {
1828        command: AgentServerCommand,
1829        /// The default mode to use for this agent.
1830        ///
1831        /// Note: Not only all agents support modes.
1832        ///
1833        /// Default: None
1834        default_mode: Option<String>,
1835        /// The default model to use for this agent.
1836        ///
1837        /// This should be the model ID as reported by the agent.
1838        ///
1839        /// Default: None
1840        default_model: Option<String>,
1841    },
1842    Extension {
1843        /// The default mode to use for this agent.
1844        ///
1845        /// Note: Not only all agents support modes.
1846        ///
1847        /// Default: None
1848        default_mode: Option<String>,
1849        /// The default model to use for this agent.
1850        ///
1851        /// This should be the model ID as reported by the agent.
1852        ///
1853        /// Default: None
1854        default_model: Option<String>,
1855    },
1856}
1857
1858impl CustomAgentServerSettings {
1859    pub fn command(&self) -> Option<&AgentServerCommand> {
1860        match self {
1861            CustomAgentServerSettings::Custom { command, .. } => Some(command),
1862            CustomAgentServerSettings::Extension { .. } => None,
1863        }
1864    }
1865
1866    pub fn default_mode(&self) -> Option<&str> {
1867        match self {
1868            CustomAgentServerSettings::Custom { default_mode, .. }
1869            | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(),
1870        }
1871    }
1872
1873    pub fn default_model(&self) -> Option<&str> {
1874        match self {
1875            CustomAgentServerSettings::Custom { default_model, .. }
1876            | CustomAgentServerSettings::Extension { default_model, .. } => {
1877                default_model.as_deref()
1878            }
1879        }
1880    }
1881}
1882
1883impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1884    fn from(value: settings::CustomAgentServerSettings) -> Self {
1885        match value {
1886            settings::CustomAgentServerSettings::Custom {
1887                path,
1888                args,
1889                env,
1890                default_mode,
1891                default_model,
1892            } => CustomAgentServerSettings::Custom {
1893                command: AgentServerCommand {
1894                    path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
1895                    args,
1896                    env,
1897                },
1898                default_mode,
1899                default_model,
1900            },
1901            settings::CustomAgentServerSettings::Extension {
1902                default_mode,
1903                default_model,
1904            } => CustomAgentServerSettings::Extension {
1905                default_mode,
1906                default_model,
1907            },
1908        }
1909    }
1910}
1911
1912impl settings::Settings for AllAgentServersSettings {
1913    fn from_settings(content: &settings::SettingsContent) -> Self {
1914        let agent_settings = content.agent_servers.clone().unwrap();
1915        Self {
1916            gemini: agent_settings.gemini.map(Into::into),
1917            claude: agent_settings.claude.map(Into::into),
1918            codex: agent_settings.codex.map(Into::into),
1919            custom: agent_settings
1920                .custom
1921                .into_iter()
1922                .map(|(k, v)| (k, v.into()))
1923                .collect(),
1924        }
1925    }
1926}
1927
1928#[cfg(test)]
1929mod extension_agent_tests {
1930    use crate::worktree_store::WorktreeStore;
1931
1932    use super::*;
1933    use gpui::TestAppContext;
1934    use std::sync::Arc;
1935
1936    #[test]
1937    fn extension_agent_constructs_proper_display_names() {
1938        // Verify the display name format for extension-provided agents
1939        let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
1940        assert!(name1.0.contains(": "));
1941
1942        let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
1943        assert_eq!(name2.0, "MyExt: MyAgent");
1944
1945        // Non-extension agents shouldn't have the separator
1946        let custom = ExternalAgentServerName(SharedString::from("custom"));
1947        assert!(!custom.0.contains(": "));
1948    }
1949
1950    struct NoopExternalAgent;
1951
1952    impl ExternalAgentServer for NoopExternalAgent {
1953        fn get_command(
1954            &mut self,
1955            _root_dir: Option<&str>,
1956            _extra_env: HashMap<String, String>,
1957            _status_tx: Option<watch::Sender<SharedString>>,
1958            _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1959            _cx: &mut AsyncApp,
1960        ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1961            Task::ready(Ok((
1962                AgentServerCommand {
1963                    path: PathBuf::from("noop"),
1964                    args: Vec::new(),
1965                    env: None,
1966                },
1967                "".to_string(),
1968                None,
1969            )))
1970        }
1971
1972        fn as_any_mut(&mut self) -> &mut dyn Any {
1973            self
1974        }
1975    }
1976
1977    #[test]
1978    fn sync_removes_only_extension_provided_agents() {
1979        let mut store = AgentServerStore {
1980            state: AgentServerStoreState::Collab,
1981            external_agents: HashMap::default(),
1982            agent_icons: HashMap::default(),
1983        };
1984
1985        // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
1986        store.external_agents.insert(
1987            ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
1988            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1989        );
1990        store.external_agents.insert(
1991            ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
1992            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1993        );
1994        store.external_agents.insert(
1995            ExternalAgentServerName(SharedString::from("custom-agent")),
1996            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1997        );
1998
1999        // Simulate removal phase
2000        let keys_to_remove: Vec<_> = store
2001            .external_agents
2002            .keys()
2003            .filter(|name| name.0.contains(": "))
2004            .cloned()
2005            .collect();
2006
2007        for key in keys_to_remove {
2008            store.external_agents.remove(&key);
2009        }
2010
2011        // Only custom-agent should remain
2012        assert_eq!(store.external_agents.len(), 1);
2013        assert!(
2014            store
2015                .external_agents
2016                .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
2017        );
2018    }
2019
2020    #[test]
2021    fn archive_launcher_constructs_with_all_fields() {
2022        use extension::AgentServerManifestEntry;
2023
2024        let mut env = HashMap::default();
2025        env.insert("GITHUB_TOKEN".into(), "secret".into());
2026
2027        let mut targets = HashMap::default();
2028        targets.insert(
2029            "darwin-aarch64".to_string(),
2030            extension::TargetConfig {
2031                archive:
2032                    "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
2033                        .into(),
2034                cmd: "./agent".into(),
2035                args: vec![],
2036                sha256: None,
2037                env: Default::default(),
2038            },
2039        );
2040
2041        let _entry = AgentServerManifestEntry {
2042            name: "GitHub Agent".into(),
2043            targets,
2044            env,
2045            icon: None,
2046        };
2047
2048        // Verify display name construction
2049        let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
2050        assert_eq!(expected_name.0, "GitHub Agent");
2051    }
2052
2053    #[gpui::test]
2054    async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
2055        let fs = fs::FakeFs::new(cx.background_executor.clone());
2056        let http_client = http_client::FakeHttpClient::with_404_response();
2057        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2058        let project_environment = cx.new(|cx| {
2059            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2060        });
2061
2062        let agent = LocalExtensionArchiveAgent {
2063            fs,
2064            http_client,
2065            node_runtime: node_runtime::NodeRuntime::unavailable(),
2066            project_environment,
2067            extension_id: Arc::from("my-extension"),
2068            agent_id: Arc::from("my-agent"),
2069            targets: {
2070                let mut map = HashMap::default();
2071                map.insert(
2072                    "darwin-aarch64".to_string(),
2073                    extension::TargetConfig {
2074                        archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
2075                        cmd: "./my-agent".into(),
2076                        args: vec!["--serve".into()],
2077                        sha256: None,
2078                        env: Default::default(),
2079                    },
2080                );
2081                map
2082            },
2083            env: {
2084                let mut map = HashMap::default();
2085                map.insert("PORT".into(), "8080".into());
2086                map
2087            },
2088        };
2089
2090        // Verify agent is properly constructed
2091        assert_eq!(agent.extension_id.as_ref(), "my-extension");
2092        assert_eq!(agent.agent_id.as_ref(), "my-agent");
2093        assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
2094        assert!(agent.targets.contains_key("darwin-aarch64"));
2095    }
2096
2097    #[test]
2098    fn sync_extension_agents_registers_archive_launcher() {
2099        use extension::AgentServerManifestEntry;
2100
2101        let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
2102        assert_eq!(expected_name.0, "Release Agent");
2103
2104        // Verify the manifest entry structure for archive-based installation
2105        let mut env = HashMap::default();
2106        env.insert("API_KEY".into(), "secret".into());
2107
2108        let mut targets = HashMap::default();
2109        targets.insert(
2110            "linux-x86_64".to_string(),
2111            extension::TargetConfig {
2112                archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
2113                cmd: "./release-agent".into(),
2114                args: vec!["serve".into()],
2115                sha256: None,
2116                env: Default::default(),
2117            },
2118        );
2119
2120        let manifest_entry = AgentServerManifestEntry {
2121            name: "Release Agent".into(),
2122            targets: targets.clone(),
2123            env,
2124            icon: None,
2125        };
2126
2127        // Verify target config is present
2128        assert!(manifest_entry.targets.contains_key("linux-x86_64"));
2129        let target = manifest_entry.targets.get("linux-x86_64").unwrap();
2130        assert_eq!(target.cmd, "./release-agent");
2131    }
2132
2133    #[gpui::test]
2134    async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
2135        let fs = fs::FakeFs::new(cx.background_executor.clone());
2136        let http_client = http_client::FakeHttpClient::with_404_response();
2137        let node_runtime = NodeRuntime::unavailable();
2138        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2139        let project_environment = cx.new(|cx| {
2140            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2141        });
2142
2143        let agent = LocalExtensionArchiveAgent {
2144            fs: fs.clone(),
2145            http_client,
2146            node_runtime,
2147            project_environment,
2148            extension_id: Arc::from("node-extension"),
2149            agent_id: Arc::from("node-agent"),
2150            targets: {
2151                let mut map = HashMap::default();
2152                map.insert(
2153                    "darwin-aarch64".to_string(),
2154                    extension::TargetConfig {
2155                        archive: "https://example.com/node-agent.zip".into(),
2156                        cmd: "node".into(),
2157                        args: vec!["index.js".into()],
2158                        sha256: None,
2159                        env: Default::default(),
2160                    },
2161                );
2162                map
2163            },
2164            env: HashMap::default(),
2165        };
2166
2167        // Verify that when cmd is "node", it attempts to use the node runtime
2168        assert_eq!(agent.extension_id.as_ref(), "node-extension");
2169        assert_eq!(agent.agent_id.as_ref(), "node-agent");
2170
2171        let target = agent.targets.get("darwin-aarch64").unwrap();
2172        assert_eq!(target.cmd, "node");
2173        assert_eq!(target.args, vec!["index.js"]);
2174    }
2175
2176    #[gpui::test]
2177    async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
2178        let fs = fs::FakeFs::new(cx.background_executor.clone());
2179        let http_client = http_client::FakeHttpClient::with_404_response();
2180        let node_runtime = NodeRuntime::unavailable();
2181        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2182        let project_environment = cx.new(|cx| {
2183            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2184        });
2185
2186        let agent = LocalExtensionArchiveAgent {
2187            fs: fs.clone(),
2188            http_client,
2189            node_runtime,
2190            project_environment,
2191            extension_id: Arc::from("test-ext"),
2192            agent_id: Arc::from("test-agent"),
2193            targets: {
2194                let mut map = HashMap::default();
2195                map.insert(
2196                    "darwin-aarch64".to_string(),
2197                    extension::TargetConfig {
2198                        archive: "https://example.com/test.zip".into(),
2199                        cmd: "node".into(),
2200                        args: vec![
2201                            "server.js".into(),
2202                            "--config".into(),
2203                            "./config.json".into(),
2204                        ],
2205                        sha256: None,
2206                        env: Default::default(),
2207                    },
2208                );
2209                map
2210            },
2211            env: HashMap::default(),
2212        };
2213
2214        // Verify the agent is configured with relative paths in args
2215        let target = agent.targets.get("darwin-aarch64").unwrap();
2216        assert_eq!(target.args[0], "server.js");
2217        assert_eq!(target.args[2], "./config.json");
2218        // These relative paths will resolve relative to the extraction directory
2219        // when the command is executed
2220    }
2221
2222    #[test]
2223    fn test_tilde_expansion_in_settings() {
2224        let settings = settings::BuiltinAgentServerSettings {
2225            path: Some(PathBuf::from("~/bin/agent")),
2226            args: Some(vec!["--flag".into()]),
2227            env: None,
2228            ignore_system_version: None,
2229            default_mode: None,
2230            default_model: None,
2231        };
2232
2233        let BuiltinAgentServerSettings { path, .. } = settings.into();
2234
2235        let path = path.unwrap();
2236        assert!(
2237            !path.to_string_lossy().starts_with("~"),
2238            "Tilde should be expanded for builtin agent path"
2239        );
2240
2241        let settings = settings::CustomAgentServerSettings::Custom {
2242            path: PathBuf::from("~/custom/agent"),
2243            args: vec!["serve".into()],
2244            env: None,
2245            default_mode: None,
2246            default_model: None,
2247        };
2248
2249        let converted: CustomAgentServerSettings = settings.into();
2250        let CustomAgentServerSettings::Custom {
2251            command: AgentServerCommand { path, .. },
2252            ..
2253        } = converted
2254        else {
2255            panic!("Expected Custom variant");
2256        };
2257
2258        assert!(
2259            !path.to_string_lossy().starts_with("~"),
2260            "Tilde should be expanded for custom agent path"
2261        );
2262    }
2263}