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            create_parents: false,
1093        },
1094    )
1095    .await?;
1096
1097    anyhow::Ok(version)
1098}
1099
1100struct RemoteExternalAgentServer {
1101    project_id: u64,
1102    upstream_client: Entity<RemoteClient>,
1103    name: ExternalAgentServerName,
1104    status_tx: Option<watch::Sender<SharedString>>,
1105    new_version_available_tx: Option<watch::Sender<Option<String>>>,
1106}
1107
1108impl ExternalAgentServer for RemoteExternalAgentServer {
1109    fn get_command(
1110        &mut self,
1111        root_dir: Option<&str>,
1112        extra_env: HashMap<String, String>,
1113        status_tx: Option<watch::Sender<SharedString>>,
1114        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1115        cx: &mut AsyncApp,
1116    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1117        let project_id = self.project_id;
1118        let name = self.name.to_string();
1119        let upstream_client = self.upstream_client.downgrade();
1120        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
1121        self.status_tx = status_tx;
1122        self.new_version_available_tx = new_version_available_tx;
1123        cx.spawn(async move |cx| {
1124            let mut response = upstream_client
1125                .update(cx, |upstream_client, _| {
1126                    upstream_client
1127                        .proto_client()
1128                        .request(proto::GetAgentServerCommand {
1129                            project_id,
1130                            name,
1131                            root_dir: root_dir.clone(),
1132                        })
1133                })?
1134                .await?;
1135            let root_dir = response.root_dir;
1136            response.env.extend(extra_env);
1137            let command = upstream_client.update(cx, |client, _| {
1138                client.build_command(
1139                    Some(response.path),
1140                    &response.args,
1141                    &response.env.into_iter().collect(),
1142                    Some(root_dir.clone()),
1143                    None,
1144                )
1145            })??;
1146            Ok((
1147                AgentServerCommand {
1148                    path: command.program.into(),
1149                    args: command.args,
1150                    env: Some(command.env),
1151                },
1152                root_dir,
1153                response.login.map(SpawnInTerminal::from_proto),
1154            ))
1155        })
1156    }
1157
1158    fn as_any_mut(&mut self) -> &mut dyn Any {
1159        self
1160    }
1161}
1162
1163struct LocalGemini {
1164    fs: Arc<dyn Fs>,
1165    node_runtime: NodeRuntime,
1166    project_environment: Entity<ProjectEnvironment>,
1167    custom_command: Option<AgentServerCommand>,
1168    ignore_system_version: bool,
1169}
1170
1171impl ExternalAgentServer for LocalGemini {
1172    fn get_command(
1173        &mut self,
1174        root_dir: Option<&str>,
1175        extra_env: HashMap<String, String>,
1176        status_tx: Option<watch::Sender<SharedString>>,
1177        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1178        cx: &mut AsyncApp,
1179    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1180        let fs = self.fs.clone();
1181        let node_runtime = self.node_runtime.clone();
1182        let project_environment = self.project_environment.downgrade();
1183        let custom_command = self.custom_command.clone();
1184        let ignore_system_version = self.ignore_system_version;
1185        let root_dir: Arc<Path> = root_dir
1186            .map(|root_dir| Path::new(root_dir))
1187            .unwrap_or(paths::home_dir())
1188            .into();
1189
1190        cx.spawn(async move |cx| {
1191            let mut env = project_environment
1192                .update(cx, |project_environment, cx| {
1193                    project_environment.local_directory_environment(
1194                        &Shell::System,
1195                        root_dir.clone(),
1196                        cx,
1197                    )
1198                })?
1199                .await
1200                .unwrap_or_default();
1201
1202            let mut command = if let Some(mut custom_command) = custom_command {
1203                env.extend(custom_command.env.unwrap_or_default());
1204                custom_command.env = Some(env);
1205                custom_command
1206            } else if !ignore_system_version
1207                && let Some(bin) =
1208                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1209            {
1210                AgentServerCommand {
1211                    path: bin,
1212                    args: Vec::new(),
1213                    env: Some(env),
1214                }
1215            } else {
1216                let mut command = get_or_npm_install_builtin_agent(
1217                    GEMINI_NAME.into(),
1218                    "@google/gemini-cli".into(),
1219                    "node_modules/@google/gemini-cli/dist/index.js".into(),
1220                    if cfg!(windows) {
1221                        // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1222                        Some("0.9.0".parse().unwrap())
1223                    } else {
1224                        Some("0.2.1".parse().unwrap())
1225                    },
1226                    status_tx,
1227                    new_version_available_tx,
1228                    fs,
1229                    node_runtime,
1230                    cx,
1231                )
1232                .await?;
1233                command.env = Some(env);
1234                command
1235            };
1236
1237            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1238            let login = task::SpawnInTerminal {
1239                command: Some(command.path.to_string_lossy().into_owned()),
1240                args: command.args.clone(),
1241                env: command.env.clone().unwrap_or_default(),
1242                label: "gemini /auth".into(),
1243                ..Default::default()
1244            };
1245
1246            command.env.get_or_insert_default().extend(extra_env);
1247            command.args.push("--experimental-acp".into());
1248            Ok((
1249                command,
1250                root_dir.to_string_lossy().into_owned(),
1251                Some(login),
1252            ))
1253        })
1254    }
1255
1256    fn as_any_mut(&mut self) -> &mut dyn Any {
1257        self
1258    }
1259}
1260
1261struct LocalClaudeCode {
1262    fs: Arc<dyn Fs>,
1263    node_runtime: NodeRuntime,
1264    project_environment: Entity<ProjectEnvironment>,
1265    custom_command: Option<AgentServerCommand>,
1266}
1267
1268impl ExternalAgentServer for LocalClaudeCode {
1269    fn get_command(
1270        &mut self,
1271        root_dir: Option<&str>,
1272        extra_env: HashMap<String, String>,
1273        status_tx: Option<watch::Sender<SharedString>>,
1274        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1275        cx: &mut AsyncApp,
1276    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1277        let fs = self.fs.clone();
1278        let node_runtime = self.node_runtime.clone();
1279        let project_environment = self.project_environment.downgrade();
1280        let custom_command = self.custom_command.clone();
1281        let root_dir: Arc<Path> = root_dir
1282            .map(|root_dir| Path::new(root_dir))
1283            .unwrap_or(paths::home_dir())
1284            .into();
1285
1286        cx.spawn(async move |cx| {
1287            let mut env = project_environment
1288                .update(cx, |project_environment, cx| {
1289                    project_environment.local_directory_environment(
1290                        &Shell::System,
1291                        root_dir.clone(),
1292                        cx,
1293                    )
1294                })?
1295                .await
1296                .unwrap_or_default();
1297            env.insert("ANTHROPIC_API_KEY".into(), "".into());
1298
1299            let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1300                env.extend(custom_command.env.unwrap_or_default());
1301                custom_command.env = Some(env);
1302                (custom_command, None)
1303            } else {
1304                let mut command = get_or_npm_install_builtin_agent(
1305                    "claude-code-acp".into(),
1306                    "@zed-industries/claude-code-acp".into(),
1307                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1308                    Some("0.5.2".parse().unwrap()),
1309                    status_tx,
1310                    new_version_available_tx,
1311                    fs,
1312                    node_runtime,
1313                    cx,
1314                )
1315                .await?;
1316                command.env = Some(env);
1317                let login = command
1318                    .args
1319                    .first()
1320                    .and_then(|path| {
1321                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1322                    })
1323                    .map(|path_prefix| task::SpawnInTerminal {
1324                        command: Some(command.path.to_string_lossy().into_owned()),
1325                        args: vec![
1326                            Path::new(path_prefix)
1327                                .join("@anthropic-ai/claude-agent-sdk/cli.js")
1328                                .to_string_lossy()
1329                                .to_string(),
1330                            "/login".into(),
1331                        ],
1332                        env: command.env.clone().unwrap_or_default(),
1333                        label: "claude /login".into(),
1334                        ..Default::default()
1335                    });
1336                (command, login)
1337            };
1338
1339            command.env.get_or_insert_default().extend(extra_env);
1340            Ok((
1341                command,
1342                root_dir.to_string_lossy().into_owned(),
1343                login_command,
1344            ))
1345        })
1346    }
1347
1348    fn as_any_mut(&mut self) -> &mut dyn Any {
1349        self
1350    }
1351}
1352
1353struct LocalCodex {
1354    fs: Arc<dyn Fs>,
1355    project_environment: Entity<ProjectEnvironment>,
1356    http_client: Arc<dyn HttpClient>,
1357    custom_command: Option<AgentServerCommand>,
1358    is_remote: bool,
1359}
1360
1361impl ExternalAgentServer for LocalCodex {
1362    fn get_command(
1363        &mut self,
1364        root_dir: Option<&str>,
1365        extra_env: HashMap<String, String>,
1366        status_tx: Option<watch::Sender<SharedString>>,
1367        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1368        cx: &mut AsyncApp,
1369    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1370        let fs = self.fs.clone();
1371        let project_environment = self.project_environment.downgrade();
1372        let http = self.http_client.clone();
1373        let custom_command = self.custom_command.clone();
1374        let root_dir: Arc<Path> = root_dir
1375            .map(|root_dir| Path::new(root_dir))
1376            .unwrap_or(paths::home_dir())
1377            .into();
1378        let is_remote = self.is_remote;
1379
1380        cx.spawn(async move |cx| {
1381            let mut env = project_environment
1382                .update(cx, |project_environment, cx| {
1383                    project_environment.local_directory_environment(
1384                        &Shell::System,
1385                        root_dir.clone(),
1386                        cx,
1387                    )
1388                })?
1389                .await
1390                .unwrap_or_default();
1391            if is_remote {
1392                env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1393            }
1394
1395            let mut command = if let Some(mut custom_command) = custom_command {
1396                env.extend(custom_command.env.unwrap_or_default());
1397                custom_command.env = Some(env);
1398                custom_command
1399            } else {
1400                let dir = paths::external_agents_dir().join(CODEX_NAME);
1401                fs.create_dir(&dir).await?;
1402
1403                // Find or install the latest Codex release (no update checks for now).
1404                let release = ::http_client::github::latest_github_release(
1405                    CODEX_ACP_REPO,
1406                    true,
1407                    false,
1408                    http.clone(),
1409                )
1410                .await
1411                .context("fetching Codex latest release")?;
1412
1413                let version_dir = dir.join(&release.tag_name);
1414                if !fs.is_dir(&version_dir).await {
1415                    if let Some(mut status_tx) = status_tx {
1416                        status_tx.send("Installing…".into()).ok();
1417                    }
1418
1419                    let tag = release.tag_name.clone();
1420                    let version_number = tag.trim_start_matches('v');
1421                    let asset_name = asset_name(version_number)
1422                        .context("codex acp is not supported for this architecture")?;
1423                    let asset = release
1424                        .assets
1425                        .into_iter()
1426                        .find(|asset| asset.name == asset_name)
1427                        .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1428                    // Strip "sha256:" prefix from digest if present (GitHub API format)
1429                    let digest = asset
1430                        .digest
1431                        .as_deref()
1432                        .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1433                    ::http_client::github_download::download_server_binary(
1434                        &*http,
1435                        &asset.browser_download_url,
1436                        digest,
1437                        &version_dir,
1438                        if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1439                            AssetKind::Zip
1440                        } else {
1441                            AssetKind::TarGz
1442                        },
1443                    )
1444                    .await?;
1445
1446                    // remove older versions
1447                    util::fs::remove_matching(&dir, |entry| entry != version_dir).await;
1448                }
1449
1450                let bin_name = if cfg!(windows) {
1451                    "codex-acp.exe"
1452                } else {
1453                    "codex-acp"
1454                };
1455                let bin_path = version_dir.join(bin_name);
1456                anyhow::ensure!(
1457                    fs.is_file(&bin_path).await,
1458                    "Missing Codex binary at {} after installation",
1459                    bin_path.to_string_lossy()
1460                );
1461
1462                let mut cmd = AgentServerCommand {
1463                    path: bin_path,
1464                    args: Vec::new(),
1465                    env: None,
1466                };
1467                cmd.env = Some(env);
1468                cmd
1469            };
1470
1471            command.env.get_or_insert_default().extend(extra_env);
1472            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1473        })
1474    }
1475
1476    fn as_any_mut(&mut self) -> &mut dyn Any {
1477        self
1478    }
1479}
1480
1481pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1482
1483fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1484    let arch = if cfg!(target_arch = "x86_64") {
1485        "x86_64"
1486    } else if cfg!(target_arch = "aarch64") {
1487        "aarch64"
1488    } else {
1489        return None;
1490    };
1491
1492    let platform = if cfg!(target_os = "macos") {
1493        "apple-darwin"
1494    } else if cfg!(target_os = "windows") {
1495        "pc-windows-msvc"
1496    } else if cfg!(target_os = "linux") {
1497        "unknown-linux-gnu"
1498    } else {
1499        return None;
1500    };
1501
1502    // Only Windows x86_64 uses .zip in release assets
1503    let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1504        "zip"
1505    } else {
1506        "tar.gz"
1507    };
1508
1509    Some((arch, platform, ext))
1510}
1511
1512fn asset_name(version: &str) -> Option<String> {
1513    let (arch, platform, ext) = get_platform_info()?;
1514    Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1515}
1516
1517struct LocalExtensionArchiveAgent {
1518    fs: Arc<dyn Fs>,
1519    http_client: Arc<dyn HttpClient>,
1520    node_runtime: NodeRuntime,
1521    project_environment: Entity<ProjectEnvironment>,
1522    extension_id: Arc<str>,
1523    agent_id: Arc<str>,
1524    targets: HashMap<String, extension::TargetConfig>,
1525    env: HashMap<String, String>,
1526}
1527
1528struct LocalCustomAgent {
1529    project_environment: Entity<ProjectEnvironment>,
1530    command: AgentServerCommand,
1531}
1532
1533impl ExternalAgentServer for LocalExtensionArchiveAgent {
1534    fn get_command(
1535        &mut self,
1536        root_dir: Option<&str>,
1537        extra_env: HashMap<String, String>,
1538        _status_tx: Option<watch::Sender<SharedString>>,
1539        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1540        cx: &mut AsyncApp,
1541    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1542        let fs = self.fs.clone();
1543        let http_client = self.http_client.clone();
1544        let node_runtime = self.node_runtime.clone();
1545        let project_environment = self.project_environment.downgrade();
1546        let extension_id = self.extension_id.clone();
1547        let agent_id = self.agent_id.clone();
1548        let targets = self.targets.clone();
1549        let base_env = self.env.clone();
1550
1551        let root_dir: Arc<Path> = root_dir
1552            .map(|root_dir| Path::new(root_dir))
1553            .unwrap_or(paths::home_dir())
1554            .into();
1555
1556        cx.spawn(async move |cx| {
1557            // Get project environment
1558            let mut env = project_environment
1559                .update(cx, |project_environment, cx| {
1560                    project_environment.local_directory_environment(
1561                        &Shell::System,
1562                        root_dir.clone(),
1563                        cx,
1564                    )
1565                })?
1566                .await
1567                .unwrap_or_default();
1568
1569            // Merge manifest env and extra env
1570            env.extend(base_env);
1571            env.extend(extra_env);
1572
1573            let cache_key = format!("{}/{}", extension_id, agent_id);
1574            let dir = paths::external_agents_dir().join(&cache_key);
1575            fs.create_dir(&dir).await?;
1576
1577            // Determine platform key
1578            let os = if cfg!(target_os = "macos") {
1579                "darwin"
1580            } else if cfg!(target_os = "linux") {
1581                "linux"
1582            } else if cfg!(target_os = "windows") {
1583                "windows"
1584            } else {
1585                anyhow::bail!("unsupported OS");
1586            };
1587
1588            let arch = if cfg!(target_arch = "aarch64") {
1589                "aarch64"
1590            } else if cfg!(target_arch = "x86_64") {
1591                "x86_64"
1592            } else {
1593                anyhow::bail!("unsupported architecture");
1594            };
1595
1596            let platform_key = format!("{}-{}", os, arch);
1597            let target_config = targets.get(&platform_key).with_context(|| {
1598                format!(
1599                    "no target specified for platform '{}'. Available platforms: {}",
1600                    platform_key,
1601                    targets
1602                        .keys()
1603                        .map(|k| k.as_str())
1604                        .collect::<Vec<_>>()
1605                        .join(", ")
1606                )
1607            })?;
1608
1609            let archive_url = &target_config.archive;
1610
1611            // Use URL as version identifier for caching
1612            // Hash the URL to get a stable directory name
1613            use std::collections::hash_map::DefaultHasher;
1614            use std::hash::{Hash, Hasher};
1615            let mut hasher = DefaultHasher::new();
1616            archive_url.hash(&mut hasher);
1617            let url_hash = hasher.finish();
1618            let version_dir = dir.join(format!("v_{:x}", url_hash));
1619
1620            if !fs.is_dir(&version_dir).await {
1621                // Determine SHA256 for verification
1622                let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1623                    // Use provided SHA256
1624                    Some(provided_sha.clone())
1625                } else if archive_url.starts_with("https://github.com/") {
1626                    // Try to fetch SHA256 from GitHub API
1627                    // Parse URL to extract repo and tag/file info
1628                    // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1629                    if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1630                        let parts: Vec<&str> = caps.split('/').collect();
1631                        if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1632                            let repo = format!("{}/{}", parts[0], parts[1]);
1633                            let tag = parts[4];
1634                            let filename = parts[5..].join("/");
1635
1636                            // Try to get release info from GitHub
1637                            if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1638                                &repo,
1639                                tag,
1640                                http_client.clone(),
1641                            )
1642                            .await
1643                            {
1644                                // Find matching asset
1645                                if let Some(asset) =
1646                                    release.assets.iter().find(|a| a.name == filename)
1647                                {
1648                                    // Strip "sha256:" prefix if present
1649                                    asset.digest.as_ref().and_then(|d| {
1650                                        d.strip_prefix("sha256:")
1651                                            .map(|s| s.to_string())
1652                                            .or_else(|| Some(d.clone()))
1653                                    })
1654                                } else {
1655                                    None
1656                                }
1657                            } else {
1658                                None
1659                            }
1660                        } else {
1661                            None
1662                        }
1663                    } else {
1664                        None
1665                    }
1666                } else {
1667                    None
1668                };
1669
1670                // Determine archive type from URL
1671                let asset_kind = if archive_url.ends_with(".zip") {
1672                    AssetKind::Zip
1673                } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1674                    AssetKind::TarGz
1675                } else {
1676                    anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1677                };
1678
1679                // Download and extract
1680                ::http_client::github_download::download_server_binary(
1681                    &*http_client,
1682                    archive_url,
1683                    sha256.as_deref(),
1684                    &version_dir,
1685                    asset_kind,
1686                )
1687                .await?;
1688            }
1689
1690            // Validate and resolve cmd path
1691            let cmd = &target_config.cmd;
1692
1693            let cmd_path = if cmd == "node" {
1694                // Use Zed's managed Node.js runtime
1695                node_runtime.binary_path().await?
1696            } else {
1697                if cmd.contains("..") {
1698                    anyhow::bail!("command path cannot contain '..': {}", cmd);
1699                }
1700
1701                if cmd.starts_with("./") || cmd.starts_with(".\\") {
1702                    // Relative to extraction directory
1703                    let cmd_path = version_dir.join(&cmd[2..]);
1704                    anyhow::ensure!(
1705                        fs.is_file(&cmd_path).await,
1706                        "Missing command {} after extraction",
1707                        cmd_path.to_string_lossy()
1708                    );
1709                    cmd_path
1710                } else {
1711                    // On PATH
1712                    anyhow::bail!("command must be relative (start with './'): {}", cmd);
1713                }
1714            };
1715
1716            let command = AgentServerCommand {
1717                path: cmd_path,
1718                args: target_config.args.clone(),
1719                env: Some(env),
1720            };
1721
1722            Ok((command, version_dir.to_string_lossy().into_owned(), None))
1723        })
1724    }
1725
1726    fn as_any_mut(&mut self) -> &mut dyn Any {
1727        self
1728    }
1729}
1730
1731impl ExternalAgentServer for LocalCustomAgent {
1732    fn get_command(
1733        &mut self,
1734        root_dir: Option<&str>,
1735        extra_env: HashMap<String, String>,
1736        _status_tx: Option<watch::Sender<SharedString>>,
1737        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1738        cx: &mut AsyncApp,
1739    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1740        let mut command = self.command.clone();
1741        let root_dir: Arc<Path> = root_dir
1742            .map(|root_dir| Path::new(root_dir))
1743            .unwrap_or(paths::home_dir())
1744            .into();
1745        let project_environment = self.project_environment.downgrade();
1746        cx.spawn(async move |cx| {
1747            let mut env = project_environment
1748                .update(cx, |project_environment, cx| {
1749                    project_environment.local_directory_environment(
1750                        &Shell::System,
1751                        root_dir.clone(),
1752                        cx,
1753                    )
1754                })?
1755                .await
1756                .unwrap_or_default();
1757            env.extend(command.env.unwrap_or_default());
1758            env.extend(extra_env);
1759            command.env = Some(env);
1760            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1761        })
1762    }
1763
1764    fn as_any_mut(&mut self) -> &mut dyn Any {
1765        self
1766    }
1767}
1768
1769pub const GEMINI_NAME: &'static str = "gemini";
1770pub const CLAUDE_CODE_NAME: &'static str = "claude";
1771pub const CODEX_NAME: &'static str = "codex";
1772
1773#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1774pub struct AllAgentServersSettings {
1775    pub gemini: Option<BuiltinAgentServerSettings>,
1776    pub claude: Option<BuiltinAgentServerSettings>,
1777    pub codex: Option<BuiltinAgentServerSettings>,
1778    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1779}
1780#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1781pub struct BuiltinAgentServerSettings {
1782    pub path: Option<PathBuf>,
1783    pub args: Option<Vec<String>>,
1784    pub env: Option<HashMap<String, String>>,
1785    pub ignore_system_version: Option<bool>,
1786    pub default_mode: Option<String>,
1787    pub default_model: Option<String>,
1788}
1789
1790impl BuiltinAgentServerSettings {
1791    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1792        self.path.map(|path| AgentServerCommand {
1793            path,
1794            args: self.args.unwrap_or_default(),
1795            env: self.env,
1796        })
1797    }
1798}
1799
1800impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1801    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1802        BuiltinAgentServerSettings {
1803            path: value
1804                .path
1805                .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
1806            args: value.args,
1807            env: value.env,
1808            ignore_system_version: value.ignore_system_version,
1809            default_mode: value.default_mode,
1810            default_model: value.default_model,
1811        }
1812    }
1813}
1814
1815impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1816    fn from(value: AgentServerCommand) -> Self {
1817        BuiltinAgentServerSettings {
1818            path: Some(value.path),
1819            args: Some(value.args),
1820            env: value.env,
1821            ..Default::default()
1822        }
1823    }
1824}
1825
1826#[derive(Clone, JsonSchema, Debug, PartialEq)]
1827pub enum CustomAgentServerSettings {
1828    Custom {
1829        command: AgentServerCommand,
1830        /// The default mode to use for this agent.
1831        ///
1832        /// Note: Not only all agents support modes.
1833        ///
1834        /// Default: None
1835        default_mode: Option<String>,
1836        /// The default model to use for this agent.
1837        ///
1838        /// This should be the model ID as reported by the agent.
1839        ///
1840        /// Default: None
1841        default_model: Option<String>,
1842    },
1843    Extension {
1844        /// The default mode to use for this agent.
1845        ///
1846        /// Note: Not only all agents support modes.
1847        ///
1848        /// Default: None
1849        default_mode: Option<String>,
1850        /// The default model to use for this agent.
1851        ///
1852        /// This should be the model ID as reported by the agent.
1853        ///
1854        /// Default: None
1855        default_model: Option<String>,
1856    },
1857}
1858
1859impl CustomAgentServerSettings {
1860    pub fn command(&self) -> Option<&AgentServerCommand> {
1861        match self {
1862            CustomAgentServerSettings::Custom { command, .. } => Some(command),
1863            CustomAgentServerSettings::Extension { .. } => None,
1864        }
1865    }
1866
1867    pub fn default_mode(&self) -> Option<&str> {
1868        match self {
1869            CustomAgentServerSettings::Custom { default_mode, .. }
1870            | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(),
1871        }
1872    }
1873
1874    pub fn default_model(&self) -> Option<&str> {
1875        match self {
1876            CustomAgentServerSettings::Custom { default_model, .. }
1877            | CustomAgentServerSettings::Extension { default_model, .. } => {
1878                default_model.as_deref()
1879            }
1880        }
1881    }
1882}
1883
1884impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1885    fn from(value: settings::CustomAgentServerSettings) -> Self {
1886        match value {
1887            settings::CustomAgentServerSettings::Custom {
1888                path,
1889                args,
1890                env,
1891                default_mode,
1892                default_model,
1893            } => CustomAgentServerSettings::Custom {
1894                command: AgentServerCommand {
1895                    path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
1896                    args,
1897                    env,
1898                },
1899                default_mode,
1900                default_model,
1901            },
1902            settings::CustomAgentServerSettings::Extension {
1903                default_mode,
1904                default_model,
1905            } => CustomAgentServerSettings::Extension {
1906                default_mode,
1907                default_model,
1908            },
1909        }
1910    }
1911}
1912
1913impl settings::Settings for AllAgentServersSettings {
1914    fn from_settings(content: &settings::SettingsContent) -> Self {
1915        let agent_settings = content.agent_servers.clone().unwrap();
1916        Self {
1917            gemini: agent_settings.gemini.map(Into::into),
1918            claude: agent_settings.claude.map(Into::into),
1919            codex: agent_settings.codex.map(Into::into),
1920            custom: agent_settings
1921                .custom
1922                .into_iter()
1923                .map(|(k, v)| (k, v.into()))
1924                .collect(),
1925        }
1926    }
1927}
1928
1929#[cfg(test)]
1930mod extension_agent_tests {
1931    use crate::worktree_store::WorktreeStore;
1932
1933    use super::*;
1934    use gpui::TestAppContext;
1935    use std::sync::Arc;
1936
1937    #[test]
1938    fn extension_agent_constructs_proper_display_names() {
1939        // Verify the display name format for extension-provided agents
1940        let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
1941        assert!(name1.0.contains(": "));
1942
1943        let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
1944        assert_eq!(name2.0, "MyExt: MyAgent");
1945
1946        // Non-extension agents shouldn't have the separator
1947        let custom = ExternalAgentServerName(SharedString::from("custom"));
1948        assert!(!custom.0.contains(": "));
1949    }
1950
1951    struct NoopExternalAgent;
1952
1953    impl ExternalAgentServer for NoopExternalAgent {
1954        fn get_command(
1955            &mut self,
1956            _root_dir: Option<&str>,
1957            _extra_env: HashMap<String, String>,
1958            _status_tx: Option<watch::Sender<SharedString>>,
1959            _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1960            _cx: &mut AsyncApp,
1961        ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1962            Task::ready(Ok((
1963                AgentServerCommand {
1964                    path: PathBuf::from("noop"),
1965                    args: Vec::new(),
1966                    env: None,
1967                },
1968                "".to_string(),
1969                None,
1970            )))
1971        }
1972
1973        fn as_any_mut(&mut self) -> &mut dyn Any {
1974            self
1975        }
1976    }
1977
1978    #[test]
1979    fn sync_removes_only_extension_provided_agents() {
1980        let mut store = AgentServerStore {
1981            state: AgentServerStoreState::Collab,
1982            external_agents: HashMap::default(),
1983            agent_icons: HashMap::default(),
1984        };
1985
1986        // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
1987        store.external_agents.insert(
1988            ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
1989            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1990        );
1991        store.external_agents.insert(
1992            ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
1993            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1994        );
1995        store.external_agents.insert(
1996            ExternalAgentServerName(SharedString::from("custom-agent")),
1997            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1998        );
1999
2000        // Simulate removal phase
2001        let keys_to_remove: Vec<_> = store
2002            .external_agents
2003            .keys()
2004            .filter(|name| name.0.contains(": "))
2005            .cloned()
2006            .collect();
2007
2008        for key in keys_to_remove {
2009            store.external_agents.remove(&key);
2010        }
2011
2012        // Only custom-agent should remain
2013        assert_eq!(store.external_agents.len(), 1);
2014        assert!(
2015            store
2016                .external_agents
2017                .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
2018        );
2019    }
2020
2021    #[test]
2022    fn archive_launcher_constructs_with_all_fields() {
2023        use extension::AgentServerManifestEntry;
2024
2025        let mut env = HashMap::default();
2026        env.insert("GITHUB_TOKEN".into(), "secret".into());
2027
2028        let mut targets = HashMap::default();
2029        targets.insert(
2030            "darwin-aarch64".to_string(),
2031            extension::TargetConfig {
2032                archive:
2033                    "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
2034                        .into(),
2035                cmd: "./agent".into(),
2036                args: vec![],
2037                sha256: None,
2038                env: Default::default(),
2039            },
2040        );
2041
2042        let _entry = AgentServerManifestEntry {
2043            name: "GitHub Agent".into(),
2044            targets,
2045            env,
2046            icon: None,
2047        };
2048
2049        // Verify display name construction
2050        let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
2051        assert_eq!(expected_name.0, "GitHub Agent");
2052    }
2053
2054    #[gpui::test]
2055    async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
2056        let fs = fs::FakeFs::new(cx.background_executor.clone());
2057        let http_client = http_client::FakeHttpClient::with_404_response();
2058        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2059        let project_environment = cx.new(|cx| {
2060            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2061        });
2062
2063        let agent = LocalExtensionArchiveAgent {
2064            fs,
2065            http_client,
2066            node_runtime: node_runtime::NodeRuntime::unavailable(),
2067            project_environment,
2068            extension_id: Arc::from("my-extension"),
2069            agent_id: Arc::from("my-agent"),
2070            targets: {
2071                let mut map = HashMap::default();
2072                map.insert(
2073                    "darwin-aarch64".to_string(),
2074                    extension::TargetConfig {
2075                        archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
2076                        cmd: "./my-agent".into(),
2077                        args: vec!["--serve".into()],
2078                        sha256: None,
2079                        env: Default::default(),
2080                    },
2081                );
2082                map
2083            },
2084            env: {
2085                let mut map = HashMap::default();
2086                map.insert("PORT".into(), "8080".into());
2087                map
2088            },
2089        };
2090
2091        // Verify agent is properly constructed
2092        assert_eq!(agent.extension_id.as_ref(), "my-extension");
2093        assert_eq!(agent.agent_id.as_ref(), "my-agent");
2094        assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
2095        assert!(agent.targets.contains_key("darwin-aarch64"));
2096    }
2097
2098    #[test]
2099    fn sync_extension_agents_registers_archive_launcher() {
2100        use extension::AgentServerManifestEntry;
2101
2102        let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
2103        assert_eq!(expected_name.0, "Release Agent");
2104
2105        // Verify the manifest entry structure for archive-based installation
2106        let mut env = HashMap::default();
2107        env.insert("API_KEY".into(), "secret".into());
2108
2109        let mut targets = HashMap::default();
2110        targets.insert(
2111            "linux-x86_64".to_string(),
2112            extension::TargetConfig {
2113                archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
2114                cmd: "./release-agent".into(),
2115                args: vec!["serve".into()],
2116                sha256: None,
2117                env: Default::default(),
2118            },
2119        );
2120
2121        let manifest_entry = AgentServerManifestEntry {
2122            name: "Release Agent".into(),
2123            targets: targets.clone(),
2124            env,
2125            icon: None,
2126        };
2127
2128        // Verify target config is present
2129        assert!(manifest_entry.targets.contains_key("linux-x86_64"));
2130        let target = manifest_entry.targets.get("linux-x86_64").unwrap();
2131        assert_eq!(target.cmd, "./release-agent");
2132    }
2133
2134    #[gpui::test]
2135    async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
2136        let fs = fs::FakeFs::new(cx.background_executor.clone());
2137        let http_client = http_client::FakeHttpClient::with_404_response();
2138        let node_runtime = NodeRuntime::unavailable();
2139        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2140        let project_environment = cx.new(|cx| {
2141            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2142        });
2143
2144        let agent = LocalExtensionArchiveAgent {
2145            fs: fs.clone(),
2146            http_client,
2147            node_runtime,
2148            project_environment,
2149            extension_id: Arc::from("node-extension"),
2150            agent_id: Arc::from("node-agent"),
2151            targets: {
2152                let mut map = HashMap::default();
2153                map.insert(
2154                    "darwin-aarch64".to_string(),
2155                    extension::TargetConfig {
2156                        archive: "https://example.com/node-agent.zip".into(),
2157                        cmd: "node".into(),
2158                        args: vec!["index.js".into()],
2159                        sha256: None,
2160                        env: Default::default(),
2161                    },
2162                );
2163                map
2164            },
2165            env: HashMap::default(),
2166        };
2167
2168        // Verify that when cmd is "node", it attempts to use the node runtime
2169        assert_eq!(agent.extension_id.as_ref(), "node-extension");
2170        assert_eq!(agent.agent_id.as_ref(), "node-agent");
2171
2172        let target = agent.targets.get("darwin-aarch64").unwrap();
2173        assert_eq!(target.cmd, "node");
2174        assert_eq!(target.args, vec!["index.js"]);
2175    }
2176
2177    #[gpui::test]
2178    async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
2179        let fs = fs::FakeFs::new(cx.background_executor.clone());
2180        let http_client = http_client::FakeHttpClient::with_404_response();
2181        let node_runtime = NodeRuntime::unavailable();
2182        let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2183        let project_environment = cx.new(|cx| {
2184            crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2185        });
2186
2187        let agent = LocalExtensionArchiveAgent {
2188            fs: fs.clone(),
2189            http_client,
2190            node_runtime,
2191            project_environment,
2192            extension_id: Arc::from("test-ext"),
2193            agent_id: Arc::from("test-agent"),
2194            targets: {
2195                let mut map = HashMap::default();
2196                map.insert(
2197                    "darwin-aarch64".to_string(),
2198                    extension::TargetConfig {
2199                        archive: "https://example.com/test.zip".into(),
2200                        cmd: "node".into(),
2201                        args: vec![
2202                            "server.js".into(),
2203                            "--config".into(),
2204                            "./config.json".into(),
2205                        ],
2206                        sha256: None,
2207                        env: Default::default(),
2208                    },
2209                );
2210                map
2211            },
2212            env: HashMap::default(),
2213        };
2214
2215        // Verify the agent is configured with relative paths in args
2216        let target = agent.targets.get("darwin-aarch64").unwrap();
2217        assert_eq!(target.args[0], "server.js");
2218        assert_eq!(target.args[2], "./config.json");
2219        // These relative paths will resolve relative to the extraction directory
2220        // when the command is executed
2221    }
2222
2223    #[test]
2224    fn test_tilde_expansion_in_settings() {
2225        let settings = settings::BuiltinAgentServerSettings {
2226            path: Some(PathBuf::from("~/bin/agent")),
2227            args: Some(vec!["--flag".into()]),
2228            env: None,
2229            ignore_system_version: None,
2230            default_mode: None,
2231            default_model: None,
2232        };
2233
2234        let BuiltinAgentServerSettings { path, .. } = settings.into();
2235
2236        let path = path.unwrap();
2237        assert!(
2238            !path.to_string_lossy().starts_with("~"),
2239            "Tilde should be expanded for builtin agent path"
2240        );
2241
2242        let settings = settings::CustomAgentServerSettings::Custom {
2243            path: PathBuf::from("~/custom/agent"),
2244            args: vec!["serve".into()],
2245            env: None,
2246            default_mode: None,
2247            default_model: None,
2248        };
2249
2250        let converted: CustomAgentServerSettings = settings.into();
2251        let CustomAgentServerSettings::Custom {
2252            command: AgentServerCommand { path, .. },
2253            ..
2254        } = converted
2255        else {
2256            panic!("Expected Custom variant");
2257        };
2258
2259        assert!(
2260            !path.to_string_lossy().starts_with("~"),
2261            "Tilde should be expanded for custom agent path"
2262        );
2263    }
2264}