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