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