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::external_agents_dir().join(binary_name.as_str());
 777        fs.create_dir(&dir).await?;
 778
 779        let mut stream = fs.read_dir(&dir).await?;
 780        let mut versions = Vec::new();
 781        let mut to_delete = Vec::new();
 782        while let Some(entry) = stream.next().await {
 783            let Ok(entry) = entry else { continue };
 784            let Some(file_name) = entry.file_name() else {
 785                continue;
 786            };
 787
 788            if let Some(name) = file_name.to_str()
 789                && let Some(version) = semver::Version::from_str(name).ok()
 790                && fs
 791                    .is_file(&dir.join(file_name).join(&entrypoint_path))
 792                    .await
 793            {
 794                versions.push((version, file_name.to_owned()));
 795            } else {
 796                to_delete.push(file_name.to_owned())
 797            }
 798        }
 799
 800        versions.sort();
 801        let newest_version = if let Some((version, file_name)) = versions.last().cloned()
 802            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
 803        {
 804            versions.pop();
 805            Some(file_name)
 806        } else {
 807            None
 808        };
 809        log::debug!("existing version of {package_name}: {newest_version:?}");
 810        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
 811
 812        cx.background_spawn({
 813            let fs = fs.clone();
 814            let dir = dir.clone();
 815            async move {
 816                for file_name in to_delete {
 817                    fs.remove_dir(
 818                        &dir.join(file_name),
 819                        RemoveOptions {
 820                            recursive: true,
 821                            ignore_if_not_exists: false,
 822                        },
 823                    )
 824                    .await
 825                    .ok();
 826                }
 827            }
 828        })
 829        .detach();
 830
 831        let version = if let Some(file_name) = newest_version {
 832            cx.background_spawn({
 833                let file_name = file_name.clone();
 834                let dir = dir.clone();
 835                let fs = fs.clone();
 836                async move {
 837                    let latest_version = node_runtime
 838                        .npm_package_latest_version(&package_name)
 839                        .await
 840                        .ok();
 841                    if let Some(latest_version) = latest_version
 842                        && &latest_version != &file_name.to_string_lossy()
 843                    {
 844                        let download_result = download_latest_version(
 845                            fs,
 846                            dir.clone(),
 847                            node_runtime,
 848                            package_name.clone(),
 849                        )
 850                        .await
 851                        .log_err();
 852                        if let Some(mut new_version_available) = new_version_available
 853                            && download_result.is_some()
 854                        {
 855                            new_version_available.send(Some(latest_version)).ok();
 856                        }
 857                    }
 858                }
 859            })
 860            .detach();
 861            file_name
 862        } else {
 863            if let Some(mut status_tx) = status_tx {
 864                status_tx.send("Installing…".into()).ok();
 865            }
 866            let dir = dir.clone();
 867            cx.background_spawn(download_latest_version(
 868                fs.clone(),
 869                dir.clone(),
 870                node_runtime,
 871                package_name.clone(),
 872            ))
 873            .await?
 874            .into()
 875        };
 876
 877        let agent_server_path = dir.join(version).join(entrypoint_path);
 878        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
 879        anyhow::ensure!(
 880            agent_server_path_exists,
 881            "Missing entrypoint path {} after installation",
 882            agent_server_path.to_string_lossy()
 883        );
 884
 885        anyhow::Ok(AgentServerCommand {
 886            path: node_path,
 887            args: vec![agent_server_path.to_string_lossy().into_owned()],
 888            env: None,
 889        })
 890    })
 891}
 892
 893fn find_bin_in_path(
 894    bin_name: SharedString,
 895    root_dir: PathBuf,
 896    env: HashMap<String, String>,
 897    cx: &mut AsyncApp,
 898) -> Task<Option<PathBuf>> {
 899    cx.background_executor().spawn(async move {
 900        let which_result = if cfg!(windows) {
 901            which::which(bin_name.as_str())
 902        } else {
 903            let shell_path = env.get("PATH").cloned();
 904            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
 905        };
 906
 907        if let Err(which::Error::CannotFindBinaryPath) = which_result {
 908            return None;
 909        }
 910
 911        which_result.log_err()
 912    })
 913}
 914
 915async fn download_latest_version(
 916    fs: Arc<dyn Fs>,
 917    dir: PathBuf,
 918    node_runtime: NodeRuntime,
 919    package_name: SharedString,
 920) -> Result<String> {
 921    log::debug!("downloading latest version of {package_name}");
 922
 923    let tmp_dir = tempfile::tempdir_in(&dir)?;
 924
 925    node_runtime
 926        .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
 927        .await?;
 928
 929    let version = node_runtime
 930        .npm_package_installed_version(tmp_dir.path(), &package_name)
 931        .await?
 932        .context("expected package to be installed")?;
 933
 934    fs.rename(
 935        &tmp_dir.keep(),
 936        &dir.join(&version),
 937        RenameOptions {
 938            ignore_if_exists: true,
 939            overwrite: true,
 940        },
 941    )
 942    .await?;
 943
 944    anyhow::Ok(version)
 945}
 946
 947struct RemoteExternalAgentServer {
 948    project_id: u64,
 949    upstream_client: Entity<RemoteClient>,
 950    name: ExternalAgentServerName,
 951    status_tx: Option<watch::Sender<SharedString>>,
 952    new_version_available_tx: Option<watch::Sender<Option<String>>>,
 953}
 954
 955impl ExternalAgentServer for RemoteExternalAgentServer {
 956    fn get_command(
 957        &mut self,
 958        root_dir: Option<&str>,
 959        extra_env: HashMap<String, String>,
 960        status_tx: Option<watch::Sender<SharedString>>,
 961        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 962        cx: &mut AsyncApp,
 963    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 964        let project_id = self.project_id;
 965        let name = self.name.to_string();
 966        let upstream_client = self.upstream_client.downgrade();
 967        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
 968        self.status_tx = status_tx;
 969        self.new_version_available_tx = new_version_available_tx;
 970        cx.spawn(async move |cx| {
 971            let mut response = upstream_client
 972                .update(cx, |upstream_client, _| {
 973                    upstream_client
 974                        .proto_client()
 975                        .request(proto::GetAgentServerCommand {
 976                            project_id,
 977                            name,
 978                            root_dir: root_dir.clone(),
 979                        })
 980                })?
 981                .await?;
 982            let root_dir = response.root_dir;
 983            response.env.extend(extra_env);
 984            let command = upstream_client.update(cx, |client, _| {
 985                client.build_command(
 986                    Some(response.path),
 987                    &response.args,
 988                    &response.env.into_iter().collect(),
 989                    Some(root_dir.clone()),
 990                    None,
 991                )
 992            })??;
 993            Ok((
 994                AgentServerCommand {
 995                    path: command.program.into(),
 996                    args: command.args,
 997                    env: Some(command.env),
 998                },
 999                root_dir,
1000                None,
1001            ))
1002        })
1003    }
1004
1005    fn as_any_mut(&mut self) -> &mut dyn Any {
1006        self
1007    }
1008}
1009
1010struct LocalGemini {
1011    fs: Arc<dyn Fs>,
1012    node_runtime: NodeRuntime,
1013    project_environment: Entity<ProjectEnvironment>,
1014    custom_command: Option<AgentServerCommand>,
1015    ignore_system_version: bool,
1016}
1017
1018impl ExternalAgentServer for LocalGemini {
1019    fn get_command(
1020        &mut self,
1021        root_dir: Option<&str>,
1022        extra_env: HashMap<String, String>,
1023        status_tx: Option<watch::Sender<SharedString>>,
1024        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1025        cx: &mut AsyncApp,
1026    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1027        let fs = self.fs.clone();
1028        let node_runtime = self.node_runtime.clone();
1029        let project_environment = self.project_environment.downgrade();
1030        let custom_command = self.custom_command.clone();
1031        let ignore_system_version = self.ignore_system_version;
1032        let root_dir: Arc<Path> = root_dir
1033            .map(|root_dir| Path::new(root_dir))
1034            .unwrap_or(paths::home_dir())
1035            .into();
1036
1037        cx.spawn(async move |cx| {
1038            let mut env = project_environment
1039                .update(cx, |project_environment, cx| {
1040                    project_environment.get_local_directory_environment(
1041                        &Shell::System,
1042                        root_dir.clone(),
1043                        cx,
1044                    )
1045                })?
1046                .await
1047                .unwrap_or_default();
1048
1049            let mut command = if let Some(mut custom_command) = custom_command {
1050                env.extend(custom_command.env.unwrap_or_default());
1051                custom_command.env = Some(env);
1052                custom_command
1053            } else if !ignore_system_version
1054                && let Some(bin) =
1055                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1056            {
1057                AgentServerCommand {
1058                    path: bin,
1059                    args: Vec::new(),
1060                    env: Some(env),
1061                }
1062            } else {
1063                let mut command = get_or_npm_install_builtin_agent(
1064                    GEMINI_NAME.into(),
1065                    "@google/gemini-cli".into(),
1066                    "node_modules/@google/gemini-cli/dist/index.js".into(),
1067                    if cfg!(windows) {
1068                        // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1069                        Some("0.9.0".parse().unwrap())
1070                    } else {
1071                        Some("0.2.1".parse().unwrap())
1072                    },
1073                    status_tx,
1074                    new_version_available_tx,
1075                    fs,
1076                    node_runtime,
1077                    cx,
1078                )
1079                .await?;
1080                command.env = Some(env);
1081                command
1082            };
1083
1084            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1085            let login = task::SpawnInTerminal {
1086                command: Some(command.path.to_string_lossy().into_owned()),
1087                args: command.args.clone(),
1088                env: command.env.clone().unwrap_or_default(),
1089                label: "gemini /auth".into(),
1090                ..Default::default()
1091            };
1092
1093            command.env.get_or_insert_default().extend(extra_env);
1094            command.args.push("--experimental-acp".into());
1095            Ok((
1096                command,
1097                root_dir.to_string_lossy().into_owned(),
1098                Some(login),
1099            ))
1100        })
1101    }
1102
1103    fn as_any_mut(&mut self) -> &mut dyn Any {
1104        self
1105    }
1106}
1107
1108struct LocalClaudeCode {
1109    fs: Arc<dyn Fs>,
1110    node_runtime: NodeRuntime,
1111    project_environment: Entity<ProjectEnvironment>,
1112    custom_command: Option<AgentServerCommand>,
1113}
1114
1115impl ExternalAgentServer for LocalClaudeCode {
1116    fn get_command(
1117        &mut self,
1118        root_dir: Option<&str>,
1119        extra_env: HashMap<String, String>,
1120        status_tx: Option<watch::Sender<SharedString>>,
1121        new_version_available_tx: Option<watch::Sender<Option<String>>>,
1122        cx: &mut AsyncApp,
1123    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1124        let fs = self.fs.clone();
1125        let node_runtime = self.node_runtime.clone();
1126        let project_environment = self.project_environment.downgrade();
1127        let custom_command = self.custom_command.clone();
1128        let root_dir: Arc<Path> = root_dir
1129            .map(|root_dir| Path::new(root_dir))
1130            .unwrap_or(paths::home_dir())
1131            .into();
1132
1133        cx.spawn(async move |cx| {
1134            let mut env = project_environment
1135                .update(cx, |project_environment, cx| {
1136                    project_environment.get_local_directory_environment(
1137                        &Shell::System,
1138                        root_dir.clone(),
1139                        cx,
1140                    )
1141                })?
1142                .await
1143                .unwrap_or_default();
1144            env.insert("ANTHROPIC_API_KEY".into(), "".into());
1145
1146            let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1147                env.extend(custom_command.env.unwrap_or_default());
1148                custom_command.env = Some(env);
1149                (custom_command, None)
1150            } else {
1151                let mut command = get_or_npm_install_builtin_agent(
1152                    "claude-code-acp".into(),
1153                    "@zed-industries/claude-code-acp".into(),
1154                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1155                    Some("0.5.2".parse().unwrap()),
1156                    status_tx,
1157                    new_version_available_tx,
1158                    fs,
1159                    node_runtime,
1160                    cx,
1161                )
1162                .await?;
1163                command.env = Some(env);
1164                let login = command
1165                    .args
1166                    .first()
1167                    .and_then(|path| {
1168                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1169                    })
1170                    .map(|path_prefix| task::SpawnInTerminal {
1171                        command: Some(command.path.to_string_lossy().into_owned()),
1172                        args: vec![
1173                            Path::new(path_prefix)
1174                                .join("@anthropic-ai/claude-agent-sdk/cli.js")
1175                                .to_string_lossy()
1176                                .to_string(),
1177                            "/login".into(),
1178                        ],
1179                        env: command.env.clone().unwrap_or_default(),
1180                        label: "claude /login".into(),
1181                        ..Default::default()
1182                    });
1183                (command, login)
1184            };
1185
1186            command.env.get_or_insert_default().extend(extra_env);
1187            Ok((
1188                command,
1189                root_dir.to_string_lossy().into_owned(),
1190                login_command,
1191            ))
1192        })
1193    }
1194
1195    fn as_any_mut(&mut self) -> &mut dyn Any {
1196        self
1197    }
1198}
1199
1200struct LocalCodex {
1201    fs: Arc<dyn Fs>,
1202    project_environment: Entity<ProjectEnvironment>,
1203    http_client: Arc<dyn HttpClient>,
1204    custom_command: Option<AgentServerCommand>,
1205    is_remote: bool,
1206}
1207
1208impl ExternalAgentServer for LocalCodex {
1209    fn get_command(
1210        &mut self,
1211        root_dir: Option<&str>,
1212        extra_env: HashMap<String, String>,
1213        status_tx: Option<watch::Sender<SharedString>>,
1214        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1215        cx: &mut AsyncApp,
1216    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1217        let fs = self.fs.clone();
1218        let project_environment = self.project_environment.downgrade();
1219        let http = self.http_client.clone();
1220        let custom_command = self.custom_command.clone();
1221        let root_dir: Arc<Path> = root_dir
1222            .map(|root_dir| Path::new(root_dir))
1223            .unwrap_or(paths::home_dir())
1224            .into();
1225        let is_remote = self.is_remote;
1226
1227        cx.spawn(async move |cx| {
1228            let mut env = project_environment
1229                .update(cx, |project_environment, cx| {
1230                    project_environment.get_local_directory_environment(
1231                        &Shell::System,
1232                        root_dir.clone(),
1233                        cx,
1234                    )
1235                })?
1236                .await
1237                .unwrap_or_default();
1238            if is_remote {
1239                env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1240            }
1241
1242            let mut command = if let Some(mut custom_command) = custom_command {
1243                env.extend(custom_command.env.unwrap_or_default());
1244                custom_command.env = Some(env);
1245                custom_command
1246            } else {
1247                let dir = paths::external_agents_dir().join(CODEX_NAME);
1248                fs.create_dir(&dir).await?;
1249
1250                // Find or install the latest Codex release (no update checks for now).
1251                let release = ::http_client::github::latest_github_release(
1252                    CODEX_ACP_REPO,
1253                    true,
1254                    false,
1255                    http.clone(),
1256                )
1257                .await
1258                .context("fetching Codex latest release")?;
1259
1260                let version_dir = dir.join(&release.tag_name);
1261                if !fs.is_dir(&version_dir).await {
1262                    if let Some(mut status_tx) = status_tx {
1263                        status_tx.send("Installing…".into()).ok();
1264                    }
1265
1266                    let tag = release.tag_name.clone();
1267                    let version_number = tag.trim_start_matches('v');
1268                    let asset_name = asset_name(version_number)
1269                        .context("codex acp is not supported for this architecture")?;
1270                    let asset = release
1271                        .assets
1272                        .into_iter()
1273                        .find(|asset| asset.name == asset_name)
1274                        .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1275                    // Strip "sha256:" prefix from digest if present (GitHub API format)
1276                    let digest = asset
1277                        .digest
1278                        .as_deref()
1279                        .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1280                    ::http_client::github_download::download_server_binary(
1281                        &*http,
1282                        &asset.browser_download_url,
1283                        digest,
1284                        &version_dir,
1285                        if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1286                            AssetKind::Zip
1287                        } else {
1288                            AssetKind::TarGz
1289                        },
1290                    )
1291                    .await?;
1292
1293                    // remove older versions
1294                    util::fs::remove_matching(&dir, |entry| entry != version_dir).await;
1295                }
1296
1297                let bin_name = if cfg!(windows) {
1298                    "codex-acp.exe"
1299                } else {
1300                    "codex-acp"
1301                };
1302                let bin_path = version_dir.join(bin_name);
1303                anyhow::ensure!(
1304                    fs.is_file(&bin_path).await,
1305                    "Missing Codex binary at {} after installation",
1306                    bin_path.to_string_lossy()
1307                );
1308
1309                let mut cmd = AgentServerCommand {
1310                    path: bin_path,
1311                    args: Vec::new(),
1312                    env: None,
1313                };
1314                cmd.env = Some(env);
1315                cmd
1316            };
1317
1318            command.env.get_or_insert_default().extend(extra_env);
1319            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1320        })
1321    }
1322
1323    fn as_any_mut(&mut self) -> &mut dyn Any {
1324        self
1325    }
1326}
1327
1328pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1329
1330fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1331    let arch = if cfg!(target_arch = "x86_64") {
1332        "x86_64"
1333    } else if cfg!(target_arch = "aarch64") {
1334        "aarch64"
1335    } else {
1336        return None;
1337    };
1338
1339    let platform = if cfg!(target_os = "macos") {
1340        "apple-darwin"
1341    } else if cfg!(target_os = "windows") {
1342        "pc-windows-msvc"
1343    } else if cfg!(target_os = "linux") {
1344        "unknown-linux-gnu"
1345    } else {
1346        return None;
1347    };
1348
1349    // Only Windows x86_64 uses .zip in release assets
1350    let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1351        "zip"
1352    } else {
1353        "tar.gz"
1354    };
1355
1356    Some((arch, platform, ext))
1357}
1358
1359fn asset_name(version: &str) -> Option<String> {
1360    let (arch, platform, ext) = get_platform_info()?;
1361    Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1362}
1363
1364struct LocalExtensionArchiveAgent {
1365    fs: Arc<dyn Fs>,
1366    http_client: Arc<dyn HttpClient>,
1367    project_environment: Entity<ProjectEnvironment>,
1368    extension_id: Arc<str>,
1369    agent_id: Arc<str>,
1370    targets: HashMap<String, extension::TargetConfig>,
1371    env: HashMap<String, String>,
1372}
1373
1374struct LocalCustomAgent {
1375    project_environment: Entity<ProjectEnvironment>,
1376    command: AgentServerCommand,
1377}
1378
1379impl ExternalAgentServer for LocalExtensionArchiveAgent {
1380    fn get_command(
1381        &mut self,
1382        root_dir: Option<&str>,
1383        extra_env: HashMap<String, String>,
1384        _status_tx: Option<watch::Sender<SharedString>>,
1385        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1386        cx: &mut AsyncApp,
1387    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1388        let fs = self.fs.clone();
1389        let http_client = self.http_client.clone();
1390        let project_environment = self.project_environment.downgrade();
1391        let extension_id = self.extension_id.clone();
1392        let agent_id = self.agent_id.clone();
1393        let targets = self.targets.clone();
1394        let base_env = self.env.clone();
1395
1396        let root_dir: Arc<Path> = root_dir
1397            .map(|root_dir| Path::new(root_dir))
1398            .unwrap_or(paths::home_dir())
1399            .into();
1400
1401        cx.spawn(async move |cx| {
1402            // Get project environment
1403            let mut env = project_environment
1404                .update(cx, |project_environment, cx| {
1405                    project_environment.get_local_directory_environment(
1406                        &Shell::System,
1407                        root_dir.clone(),
1408                        cx,
1409                    )
1410                })?
1411                .await
1412                .unwrap_or_default();
1413
1414            // Merge manifest env and extra env
1415            env.extend(base_env);
1416            env.extend(extra_env);
1417
1418            let cache_key = format!("{}/{}", extension_id, agent_id);
1419            let dir = paths::external_agents_dir().join(&cache_key);
1420            fs.create_dir(&dir).await?;
1421
1422            // Determine platform key
1423            let os = if cfg!(target_os = "macos") {
1424                "darwin"
1425            } else if cfg!(target_os = "linux") {
1426                "linux"
1427            } else if cfg!(target_os = "windows") {
1428                "windows"
1429            } else {
1430                anyhow::bail!("unsupported OS");
1431            };
1432
1433            let arch = if cfg!(target_arch = "aarch64") {
1434                "aarch64"
1435            } else if cfg!(target_arch = "x86_64") {
1436                "x86_64"
1437            } else {
1438                anyhow::bail!("unsupported architecture");
1439            };
1440
1441            let platform_key = format!("{}-{}", os, arch);
1442            let target_config = targets.get(&platform_key).with_context(|| {
1443                format!(
1444                    "no target specified for platform '{}'. Available platforms: {}",
1445                    platform_key,
1446                    targets
1447                        .keys()
1448                        .map(|k| k.as_str())
1449                        .collect::<Vec<_>>()
1450                        .join(", ")
1451                )
1452            })?;
1453
1454            let archive_url = &target_config.archive;
1455
1456            // Use URL as version identifier for caching
1457            // Hash the URL to get a stable directory name
1458            use std::collections::hash_map::DefaultHasher;
1459            use std::hash::{Hash, Hasher};
1460            let mut hasher = DefaultHasher::new();
1461            archive_url.hash(&mut hasher);
1462            let url_hash = hasher.finish();
1463            let version_dir = dir.join(format!("v_{:x}", url_hash));
1464
1465            if !fs.is_dir(&version_dir).await {
1466                // Determine SHA256 for verification
1467                let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1468                    // Use provided SHA256
1469                    Some(provided_sha.clone())
1470                } else if archive_url.starts_with("https://github.com/") {
1471                    // Try to fetch SHA256 from GitHub API
1472                    // Parse URL to extract repo and tag/file info
1473                    // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1474                    if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1475                        let parts: Vec<&str> = caps.split('/').collect();
1476                        if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1477                            let repo = format!("{}/{}", parts[0], parts[1]);
1478                            let tag = parts[4];
1479                            let filename = parts[5..].join("/");
1480
1481                            // Try to get release info from GitHub
1482                            if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1483                                &repo,
1484                                tag,
1485                                http_client.clone(),
1486                            )
1487                            .await
1488                            {
1489                                // Find matching asset
1490                                if let Some(asset) =
1491                                    release.assets.iter().find(|a| a.name == filename)
1492                                {
1493                                    // Strip "sha256:" prefix if present
1494                                    asset.digest.as_ref().and_then(|d| {
1495                                        d.strip_prefix("sha256:")
1496                                            .map(|s| s.to_string())
1497                                            .or_else(|| Some(d.clone()))
1498                                    })
1499                                } else {
1500                                    None
1501                                }
1502                            } else {
1503                                None
1504                            }
1505                        } else {
1506                            None
1507                        }
1508                    } else {
1509                        None
1510                    }
1511                } else {
1512                    None
1513                };
1514
1515                // Determine archive type from URL
1516                let asset_kind = if archive_url.ends_with(".zip") {
1517                    AssetKind::Zip
1518                } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1519                    AssetKind::TarGz
1520                } else {
1521                    anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1522                };
1523
1524                // Download and extract
1525                ::http_client::github_download::download_server_binary(
1526                    &*http_client,
1527                    archive_url,
1528                    sha256.as_deref(),
1529                    &version_dir,
1530                    asset_kind,
1531                )
1532                .await?;
1533            }
1534
1535            // Validate and resolve cmd path
1536            let cmd = &target_config.cmd;
1537            if cmd.contains("..") {
1538                anyhow::bail!("command path cannot contain '..': {}", cmd);
1539            }
1540
1541            let cmd_path = if cmd.starts_with("./") || cmd.starts_with(".\\") {
1542                // Relative to extraction directory
1543                version_dir.join(&cmd[2..])
1544            } else {
1545                // On PATH
1546                anyhow::bail!("command must be relative (start with './'): {}", cmd);
1547            };
1548
1549            anyhow::ensure!(
1550                fs.is_file(&cmd_path).await,
1551                "Missing command {} after extraction",
1552                cmd_path.to_string_lossy()
1553            );
1554
1555            let command = AgentServerCommand {
1556                path: cmd_path,
1557                args: target_config.args.clone(),
1558                env: Some(env),
1559            };
1560
1561            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1562        })
1563    }
1564
1565    fn as_any_mut(&mut self) -> &mut dyn Any {
1566        self
1567    }
1568}
1569
1570impl ExternalAgentServer for LocalCustomAgent {
1571    fn get_command(
1572        &mut self,
1573        root_dir: Option<&str>,
1574        extra_env: HashMap<String, String>,
1575        _status_tx: Option<watch::Sender<SharedString>>,
1576        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1577        cx: &mut AsyncApp,
1578    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1579        let mut command = self.command.clone();
1580        let root_dir: Arc<Path> = root_dir
1581            .map(|root_dir| Path::new(root_dir))
1582            .unwrap_or(paths::home_dir())
1583            .into();
1584        let project_environment = self.project_environment.downgrade();
1585        cx.spawn(async move |cx| {
1586            let mut env = project_environment
1587                .update(cx, |project_environment, cx| {
1588                    project_environment.get_local_directory_environment(
1589                        &Shell::System,
1590                        root_dir.clone(),
1591                        cx,
1592                    )
1593                })?
1594                .await
1595                .unwrap_or_default();
1596            env.extend(command.env.unwrap_or_default());
1597            env.extend(extra_env);
1598            command.env = Some(env);
1599            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1600        })
1601    }
1602
1603    fn as_any_mut(&mut self) -> &mut dyn Any {
1604        self
1605    }
1606}
1607
1608pub const GEMINI_NAME: &'static str = "gemini";
1609pub const CLAUDE_CODE_NAME: &'static str = "claude";
1610pub const CODEX_NAME: &'static str = "codex";
1611
1612#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1613pub struct AllAgentServersSettings {
1614    pub gemini: Option<BuiltinAgentServerSettings>,
1615    pub claude: Option<BuiltinAgentServerSettings>,
1616    pub codex: Option<BuiltinAgentServerSettings>,
1617    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1618}
1619#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1620pub struct BuiltinAgentServerSettings {
1621    pub path: Option<PathBuf>,
1622    pub args: Option<Vec<String>>,
1623    pub env: Option<HashMap<String, String>>,
1624    pub ignore_system_version: Option<bool>,
1625    pub default_mode: Option<String>,
1626}
1627
1628impl BuiltinAgentServerSettings {
1629    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1630        self.path.map(|path| AgentServerCommand {
1631            path,
1632            args: self.args.unwrap_or_default(),
1633            env: self.env,
1634        })
1635    }
1636}
1637
1638impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1639    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1640        BuiltinAgentServerSettings {
1641            path: value
1642                .path
1643                .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
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: PathBuf::from(shellexpand::tilde(&value.path.to_string_lossy()).as_ref()),
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
1899    #[test]
1900    fn test_tilde_expansion_in_settings() {
1901        let settings = settings::BuiltinAgentServerSettings {
1902            path: Some(PathBuf::from("~/bin/agent")),
1903            args: Some(vec!["--flag".into()]),
1904            env: None,
1905            ignore_system_version: None,
1906            default_mode: None,
1907        };
1908
1909        let BuiltinAgentServerSettings { path, .. } = settings.into();
1910
1911        let path = path.unwrap();
1912        assert!(
1913            !path.to_string_lossy().starts_with("~"),
1914            "Tilde should be expanded for builtin agent path"
1915        );
1916
1917        let settings = settings::CustomAgentServerSettings {
1918            path: PathBuf::from("~/custom/agent"),
1919            args: vec!["serve".into()],
1920            env: None,
1921            default_mode: None,
1922        };
1923
1924        let CustomAgentServerSettings {
1925            command: AgentServerCommand { path, .. },
1926            ..
1927        } = settings.into();
1928
1929        assert!(
1930            !path.to_string_lossy().starts_with("~"),
1931            "Tilde should be expanded for custom agent path"
1932        );
1933    }
1934}