agent_server_store.rs

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