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