agent_server_store.rs

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