agent_server_store.rs

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