agent_server_store.rs

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