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