agent_server_store.rs

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