Deprecate and migrate ACP extensions (#57133)

Self-Review Checklist:

- [X] I've reviewed my own diff for quality, security, and reliability
- [N/A] Unsafe blocks (if any) have justifying comments
- [X] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [X] Performance impact has been considered and is acceptable

Release Notes:

- Removed support for ACP extensions. Installed ACP extensions will be
migrated to use the ACP servers as provided by the ACP registry instead.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Marshall Bowers <git@maxdeviant.com>
This commit is contained in:
Finn Evers 2026-05-21 10:32:23 +02:00 committed by GitHub
parent 4558d14cf8
commit c84c22dab5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 134 additions and 1459 deletions

1
Cargo.lock generated
View file

@ -6166,7 +6166,6 @@ name = "extensions_ui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"client",
"cloud_api_types", "cloud_api_types",
"collections", "collections",
"db", "db",

View file

@ -88,22 +88,18 @@ impl AgentServer for CustomAgentServer {
let config_id = config_id.to_string(); let config_id = config_id.to_string();
let value_id = value_id.to_string(); let value_id = value_id.to_string();
update_settings_file(fs, cx, move |settings, cx| { update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings let settings = settings
.agent_servers .agent_servers
.get_or_insert_default() .get_or_insert_default()
.entry(agent_id.0.to_string()) .entry(agent_id.0.to_string())
.or_insert_with(|| default_settings_for_agent(agent_id, cx)); .or_insert_with(default_settings_for_agent);
match settings { match settings {
settings::CustomAgentServerSettings::Custom { settings::CustomAgentServerSettings::Custom {
favorite_config_option_values, favorite_config_option_values,
.. ..
} }
| settings::CustomAgentServerSettings::Extension {
favorite_config_option_values,
..
}
| settings::CustomAgentServerSettings::Registry { | settings::CustomAgentServerSettings::Registry {
favorite_config_option_values, favorite_config_option_values,
.. ..
@ -129,16 +125,15 @@ impl AgentServer for CustomAgentServer {
fn set_default_mode(&self, mode_id: Option<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) { fn set_default_mode(&self, mode_id: Option<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
let agent_id = self.agent_id(); let agent_id = self.agent_id();
update_settings_file(fs, cx, move |settings, cx| { update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings let settings = settings
.agent_servers .agent_servers
.get_or_insert_default() .get_or_insert_default()
.entry(agent_id.0.to_string()) .entry(agent_id.0.to_string())
.or_insert_with(|| default_settings_for_agent(agent_id, cx)); .or_insert_with(default_settings_for_agent);
match settings { match settings {
settings::CustomAgentServerSettings::Custom { default_mode, .. } settings::CustomAgentServerSettings::Custom { default_mode, .. }
| settings::CustomAgentServerSettings::Extension { default_mode, .. }
| settings::CustomAgentServerSettings::Registry { default_mode, .. } => { | settings::CustomAgentServerSettings::Registry { default_mode, .. } => {
*default_mode = mode_id.map(|m| m.to_string()); *default_mode = mode_id.map(|m| m.to_string());
} }
@ -161,16 +156,15 @@ impl AgentServer for CustomAgentServer {
fn set_default_model(&self, model_id: Option<acp::ModelId>, fs: Arc<dyn Fs>, cx: &mut App) { fn set_default_model(&self, model_id: Option<acp::ModelId>, fs: Arc<dyn Fs>, cx: &mut App) {
let agent_id = self.agent_id(); let agent_id = self.agent_id();
update_settings_file(fs, cx, move |settings, cx| { update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings let settings = settings
.agent_servers .agent_servers
.get_or_insert_default() .get_or_insert_default()
.entry(agent_id.0.to_string()) .entry(agent_id.0.to_string())
.or_insert_with(|| default_settings_for_agent(agent_id, cx)); .or_insert_with(default_settings_for_agent);
match settings { match settings {
settings::CustomAgentServerSettings::Custom { default_model, .. } settings::CustomAgentServerSettings::Custom { default_model, .. }
| settings::CustomAgentServerSettings::Extension { default_model, .. }
| settings::CustomAgentServerSettings::Registry { default_model, .. } => { | settings::CustomAgentServerSettings::Registry { default_model, .. } => {
*default_model = model_id.map(|m| m.to_string()); *default_model = model_id.map(|m| m.to_string());
} }
@ -205,20 +199,17 @@ impl AgentServer for CustomAgentServer {
cx: &App, cx: &App,
) { ) {
let agent_id = self.agent_id(); let agent_id = self.agent_id();
update_settings_file(fs, cx, move |settings, cx| { update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings let settings = settings
.agent_servers .agent_servers
.get_or_insert_default() .get_or_insert_default()
.entry(agent_id.0.to_string()) .entry(agent_id.0.to_string())
.or_insert_with(|| default_settings_for_agent(agent_id, cx)); .or_insert_with(default_settings_for_agent);
let favorite_models = match settings { let favorite_models = match settings {
settings::CustomAgentServerSettings::Custom { settings::CustomAgentServerSettings::Custom {
favorite_models, .. favorite_models, ..
} }
| settings::CustomAgentServerSettings::Extension {
favorite_models, ..
}
| settings::CustomAgentServerSettings::Registry { | settings::CustomAgentServerSettings::Registry {
favorite_models, .. favorite_models, ..
} => favorite_models, } => favorite_models,
@ -258,22 +249,18 @@ impl AgentServer for CustomAgentServer {
let agent_id = self.agent_id(); let agent_id = self.agent_id();
let config_id = config_id.to_string(); let config_id = config_id.to_string();
let value_id = value_id.map(|s| s.to_string()); let value_id = value_id.map(|s| s.to_string());
update_settings_file(fs, cx, move |settings, cx| { update_settings_file(fs, cx, move |settings, _cx| {
let settings = settings let settings = settings
.agent_servers .agent_servers
.get_or_insert_default() .get_or_insert_default()
.entry(agent_id.0.to_string()) .entry(agent_id.0.to_string())
.or_insert_with(|| default_settings_for_agent(agent_id, cx)); .or_insert_with(default_settings_for_agent);
match settings { match settings {
settings::CustomAgentServerSettings::Custom { settings::CustomAgentServerSettings::Custom {
default_config_options, default_config_options,
.. ..
} }
| settings::CustomAgentServerSettings::Extension {
default_config_options,
..
}
| settings::CustomAgentServerSettings::Registry { | settings::CustomAgentServerSettings::Registry {
default_config_options, default_config_options,
.. ..
@ -307,10 +294,6 @@ impl AgentServer for CustomAgentServer {
default_config_options, default_config_options,
.. ..
} }
| project::agent_server_store::CustomAgentServerSettings::Extension {
default_config_options,
..
}
| project::agent_server_store::CustomAgentServerSettings::Registry { | project::agent_server_store::CustomAgentServerSettings::Registry {
default_config_options, default_config_options,
.. ..
@ -422,28 +405,14 @@ fn is_registry_agent(agent_id: impl Into<AgentId>, cx: &App) -> bool {
is_in_registry || is_settings_registry is_in_registry || is_settings_registry
} }
fn default_settings_for_agent( fn default_settings_for_agent() -> settings::CustomAgentServerSettings {
agent_id: impl Into<AgentId>, settings::CustomAgentServerSettings::Registry {
cx: &App, default_model: None,
) -> settings::CustomAgentServerSettings { default_mode: None,
if is_registry_agent(agent_id, cx) { env: Default::default(),
settings::CustomAgentServerSettings::Registry { favorite_models: Vec::new(),
default_model: None, default_config_options: Default::default(),
default_mode: None, favorite_config_option_values: Default::default(),
env: Default::default(),
favorite_models: Vec::new(),
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
}
} else {
settings::CustomAgentServerSettings::Extension {
default_model: None,
default_mode: None,
env: Default::default(),
favorite_models: Vec::new(),
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
}
} }
} }
@ -547,53 +516,4 @@ mod tests {
assert!(is_registry_agent("agent-from-settings", cx)); assert!(is_registry_agent("agent-from-settings", cx));
}); });
} }
#[gpui::test]
fn test_agent_with_extension_settings_type_is_not_registry(cx: &mut TestAppContext) {
init_test(cx);
set_agent_server_settings(
cx,
vec![(
"my-extension-agent",
settings::CustomAgentServerSettings::Extension {
env: HashMap::default(),
default_mode: None,
default_model: None,
favorite_models: Vec::new(),
default_config_options: HashMap::default(),
favorite_config_option_values: HashMap::default(),
},
)],
);
cx.update(|cx| {
assert!(!is_registry_agent("my-extension-agent", cx));
});
}
#[gpui::test]
fn test_default_settings_for_extension_agent(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
assert!(matches!(
default_settings_for_agent("some-extension-agent", cx),
settings::CustomAgentServerSettings::Extension { .. }
));
});
}
#[gpui::test]
fn test_default_settings_for_agent_in_registry(cx: &mut TestAppContext) {
init_test(cx);
init_registry_with_agents(cx, &["new-registry-agent"]);
cx.update(|cx| {
assert!(matches!(
default_settings_for_agent("new-registry-agent", cx),
settings::CustomAgentServerSettings::Registry { .. }
));
assert!(matches!(
default_settings_for_agent("not-in-registry", cx),
settings::CustomAgentServerSettings::Extension { .. }
));
});
}
} }

View file

@ -1174,7 +1174,6 @@ impl AgentConfiguration {
}; };
let source_kind = match source { let source_kind = match source {
ExternalAgentSource::Extension => AiSettingItemSource::Extension,
ExternalAgentSource::Registry => AiSettingItemSource::Registry, ExternalAgentSource::Registry => AiSettingItemSource::Registry,
ExternalAgentSource::Custom => AiSettingItemSource::Custom, ExternalAgentSource::Custom => AiSettingItemSource::Custom,
}; };
@ -1218,26 +1217,6 @@ impl AgentConfiguration {
}); });
let uninstall_button = match source { let uninstall_button = match source {
ExternalAgentSource::Extension => Some(
IconButton::new(
SharedString::from(format!("uninstall-{}", id)),
IconName::Trash,
)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Uninstall Agent Extension"))
.on_click(cx.listener(move |this, _, _window, cx| {
let agent_name = agent_server_name.clone();
if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| {
store.get_extension_id_for_agent(&agent_name)
}) {
ExtensionStore::global(cx)
.update(cx, |store, cx| store.uninstall_extension(ext_id, cx))
.detach_and_log_err(cx);
}
})),
),
ExternalAgentSource::Registry => { ExternalAgentSource::Registry => {
let fs = self.fs.clone(); let fs = self.fs.clone();
Some( Some(

View file

@ -59,7 +59,6 @@ use client::UserStore;
use cloud_api_types::Plan; use cloud_api_types::Plan;
use collections::HashMap; use collections::HashMap;
use editor::{Editor, MultiBuffer}; use editor::{Editor, MultiBuffer};
use extension::ExtensionEvents;
use extension_host::ExtensionStore; use extension_host::ExtensionStore;
use fs::Fs; use fs::Fs;
@ -1234,21 +1233,14 @@ impl AgentPanel {
}); });
// Subscribe to extension events to sync agent servers when extensions change // Subscribe to extension events to sync agent servers when extensions change
let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx) let extension_subscription = ExtensionStore::try_global(cx).map(|store| {
{ cx.subscribe(&store, |this, _source, event, cx| match event {
Some( extension_host::Event::ExtensionUninstalled(id) => {
cx.subscribe(&extension_events, |this, _source, event, cx| match event { this.migrate_agent_server_from_extensions(id.clone(), cx);
extension::Event::ExtensionInstalled(_) }
| extension::Event::ExtensionUninstalled(_) _ => {}
| extension::Event::ExtensionsInstalledChanged => { })
this.sync_agent_servers_from_extensions(cx); });
}
_ => {}
}),
)
} else {
None
};
let connection_store = cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)); let connection_store = cx.new(|cx| AgentConnectionStore::new(project.clone(), cx));
let _project_subscription = let _project_subscription =
@ -1280,7 +1272,7 @@ impl AgentPanel {
}) })
.detach(); .detach();
let mut panel = Self { let panel = Self {
workspace_id, workspace_id,
base_view, base_view,
last_created_entry_kind: AgentPanelEntryKind::Thread, last_created_entry_kind: AgentPanelEntryKind::Thread,
@ -1321,8 +1313,6 @@ impl AgentPanel {
is_active: false, is_active: false,
}; };
// Initial sync of agent servers from extensions
panel.sync_agent_servers_from_extensions(cx);
panel.ensure_native_agent_connection(cx); panel.ensure_native_agent_connection(cx);
panel panel
} }
@ -3901,29 +3891,12 @@ impl AgentPanel {
}) })
} }
fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) { fn migrate_agent_server_from_extensions(&mut self, id: Arc<str>, cx: &mut Context<Self>) {
if let Some(extension_store) = ExtensionStore::try_global(cx) { self.project.update(cx, |project, cx| {
let (manifests, extensions_dir) = { project.agent_server_store().update(cx, |store, cx| {
let store = extension_store.read(cx); store.migrate_agent_server_from_extensions(id, project.fs().clone(), cx);
let installed = store.installed_extensions();
let manifests: Vec<_> = installed
.iter()
.map(|(id, entry)| (id.clone(), entry.manifest.clone()))
.collect();
let extensions_dir = paths::extensions_dir().join("installed");
(manifests, extensions_dir)
};
self.project.update(cx, |project, cx| {
project.agent_server_store().update(cx, |store, cx| {
let manifest_refs: Vec<_> = manifests
.iter()
.map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
.collect();
store.sync_extension_agents(manifest_refs, extensions_dir, cx);
});
}); });
} });
} }
pub fn new_agent_thread_with_external_source_prompt( pub fn new_agent_thread_with_external_source_prompt(

View file

@ -34,7 +34,6 @@ enum RegistryInstallStatus {
NotInstalled, NotInstalled,
InstalledRegistry, InstalledRegistry,
InstalledCustom, InstalledCustom,
InstalledExtension,
} }
#[derive(IntoElement)] #[derive(IntoElement)]
@ -155,9 +154,6 @@ impl AgentRegistryPage {
RegistryInstallStatus::InstalledRegistry RegistryInstallStatus::InstalledRegistry
} }
CustomAgentServerSettings::Custom { .. } => RegistryInstallStatus::InstalledCustom, CustomAgentServerSettings::Custom { .. } => RegistryInstallStatus::InstalledCustom,
CustomAgentServerSettings::Extension { .. } => {
RegistryInstallStatus::InstalledExtension
}
}; };
self.installed_statuses.insert(id.clone(), status); self.installed_statuses.insert(id.clone(), status);
} }
@ -560,9 +556,6 @@ impl AgentRegistryPage {
RegistryInstallStatus::InstalledCustom => Button::new(button_id, "Installed") RegistryInstallStatus::InstalledCustom => Button::new(button_id, "Installed")
.style(ButtonStyle::OutlinedGhost) .style(ButtonStyle::OutlinedGhost)
.disabled(true), .disabled(true),
RegistryInstallStatus::InstalledExtension => Button::new(button_id, "Installed")
.style(ButtonStyle::OutlinedGhost)
.disabled(true),
} }
} }
} }

View file

@ -42,8 +42,11 @@ pub enum ExtensionProvides {
Grammars, Grammars,
LanguageServers, LanguageServers,
ContextServers, ContextServers,
/// Deprecated
AgentServers, AgentServers,
/// Deprecated
SlashCommands, SlashCommands,
/// Deprecated
IndexedDocsProviders, IndexedDocsProviders,
Snippets, Snippets,
DebugAdapters, DebugAdapters,

View file

@ -107,8 +107,6 @@ pub struct ExtensionManifest {
#[serde(default)] #[serde(default)]
pub context_servers: BTreeMap<Arc<str>, ContextServerManifestEntry>, pub context_servers: BTreeMap<Arc<str>, ContextServerManifestEntry>,
#[serde(default)] #[serde(default)]
pub agent_servers: BTreeMap<Arc<str>, AgentServerManifestEntry>,
#[serde(default)]
pub slash_commands: BTreeMap<Arc<str>, SlashCommandManifestEntry>, pub slash_commands: BTreeMap<Arc<str>, SlashCommandManifestEntry>,
#[serde(default)] #[serde(default)]
pub snippets: Option<ExtensionSnippets>, pub snippets: Option<ExtensionSnippets>,
@ -150,10 +148,6 @@ impl ExtensionManifest {
provides.insert(ExtensionProvides::ContextServers); provides.insert(ExtensionProvides::ContextServers);
} }
if !self.agent_servers.is_empty() {
provides.insert(ExtensionProvides::AgentServers);
}
if self.snippets.is_some() { if self.snippets.is_some() {
provides.insert(ExtensionProvides::Snippets); provides.insert(ExtensionProvides::Snippets);
} }
@ -433,7 +427,6 @@ fn manifest_from_old_manifest(
.collect(), .collect(),
language_servers: Default::default(), language_servers: Default::default(),
context_servers: BTreeMap::default(), context_servers: BTreeMap::default(),
agent_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(), slash_commands: BTreeMap::default(),
snippets: None, snippets: None,
capabilities: Vec::new(), capabilities: Vec::new(),
@ -445,7 +438,6 @@ fn manifest_from_old_manifest(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use indoc::indoc;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use util::rel_path::rel_path_buf; use util::rel_path::rel_path_buf;
@ -469,7 +461,6 @@ mod tests {
grammars: BTreeMap::default(), grammars: BTreeMap::default(),
language_servers: BTreeMap::default(), language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(), context_servers: BTreeMap::default(),
agent_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(), slash_commands: BTreeMap::default(),
snippets: None, snippets: None,
capabilities: vec![], capabilities: vec![],
@ -578,6 +569,8 @@ mod tests {
#[test] #[test]
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn test_deserialize_manifest_with_windows_separators() { fn test_deserialize_manifest_with_windows_separators() {
use indoc::indoc;
let content = indoc! {r#" let content = indoc! {r#"
id = "test-manifest" id = "test-manifest"
name = "Test Manifest" name = "Test Manifest"
@ -588,32 +581,4 @@ mod tests {
let manifest: ExtensionManifest = toml::from_str(&content).expect("manifest should parse"); let manifest: ExtensionManifest = toml::from_str(&content).expect("manifest should parse");
assert_eq!(manifest.languages, vec![rel_path_buf("foo/bar")]); assert_eq!(manifest.languages, vec![rel_path_buf("foo/bar")]);
} }
#[test]
fn parse_manifest_with_agent_server_archive_launcher() {
let toml_src = indoc! {r#"
id = "example.agent-server-ext"
name = "Agent Server Example"
version = "1.0.0"
schema_version = 0
[agent_servers.foo]
name = "Foo Agent"
[agent_servers.foo.targets.linux-x86_64]
archive = "https://example.com/agent-linux-x64.tar.gz"
cmd = "./agent"
args = ["--serve"]
"#};
let manifest: ExtensionManifest = toml::from_str(toml_src).expect("manifest should parse");
assert_eq!(manifest.id.as_ref(), "example.agent-server-ext");
assert!(manifest.agent_servers.contains_key("foo"));
let entry = manifest.agent_servers.get("foo").unwrap();
assert!(entry.targets.contains_key("linux-x86_64"));
let target = entry.targets.get("linux-x86_64").unwrap();
assert_eq!(target.archive, "https://example.com/agent-linux-x64.tar.gz");
assert_eq!(target.cmd, "./agent");
assert_eq!(target.args, vec!["--serve"]);
}
} }

View file

@ -207,21 +207,6 @@ async fn copy_extension_resources(
.context("failed to copy icons")?; .context("failed to copy icons")?;
} }
for (_, agent_entry) in &manifest.agent_servers {
if let Some(icon_path) = &agent_entry.icon {
let source_icon = extension_path.join(icon_path);
let dest_icon = output_dir.join(icon_path);
// Create parent directory if needed
if let Some(parent) = dest_icon.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&source_icon, &dest_icon)
.with_context(|| format!("failed to copy agent server icon '{}'", icon_path))?;
}
}
if !manifest.languages.is_empty() { if !manifest.languages.is_empty() {
let output_languages_dir = output_dir.join("languages"); let output_languages_dir = output_dir.join("languages");
fs::create_dir_all(&output_languages_dir)?; fs::create_dir_all(&output_languages_dir)?;

View file

@ -137,7 +137,6 @@ fn manifest() -> ExtensionManifest {
.into_iter() .into_iter()
.collect(), .collect(),
context_servers: BTreeMap::default(), context_servers: BTreeMap::default(),
agent_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(), slash_commands: BTreeMap::default(),
snippets: None, snippets: None,
capabilities: vec![ExtensionCapability::ProcessExec( capabilities: vec![ExtensionCapability::ProcessExec(

View file

@ -107,7 +107,6 @@ mod tests {
grammars: BTreeMap::default(), grammars: BTreeMap::default(),
language_servers: BTreeMap::default(), language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(), context_servers: BTreeMap::default(),
agent_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(), slash_commands: BTreeMap::default(),
snippets: None, snippets: None,
capabilities: vec![], capabilities: vec![],

View file

@ -11,7 +11,7 @@ use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive; use async_tar::Archive;
use client::{Client, proto, telemetry::Telemetry}; use client::{Client, proto, telemetry::Telemetry};
use cloud_api_types::{ExtensionMetadata, ExtensionProvides, GetExtensionsResponse}; use cloud_api_types::{ExtensionMetadata, ExtensionProvides, GetExtensionsResponse};
use collections::{BTreeMap, BTreeSet, HashSet, btree_map}; use collections::{BTreeMap, BTreeSet, FxHashSet, HashSet, btree_map};
pub use extension::ExtensionManifest; pub use extension::ExtensionManifest;
use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder}; use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
use extension::{ use extension::{
@ -48,6 +48,7 @@ use serde::{Deserialize, Serialize};
use settings::{SemanticTokenRules, Settings, SettingsStore}; use settings::{SemanticTokenRules, Settings, SettingsStore};
use std::ops::RangeInclusive; use std::ops::RangeInclusive;
use std::str::FromStr; use std::str::FromStr;
use std::sync::LazyLock;
use std::{ use std::{
cmp::Ordering, cmp::Ordering,
path::{self, Path, PathBuf}, path::{self, Path, PathBuf},
@ -77,7 +78,25 @@ const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(1);
/// ///
/// These snippets should no longer be downloaded or loaded, because their /// These snippets should no longer be downloaded or loaded, because their
/// functionality has been integrated into the core editor. /// functionality has been integrated into the core editor.
const SUPPRESSED_EXTENSIONS: &[&str] = &["snippets", "ruff", "ty", "basedpyright", "basher"]; static SUPPRESSED_EXTENSIONS: LazyLock<FxHashSet<&str>> = LazyLock::new(|| {
FxHashSet::from_iter([
"snippets",
"ruff",
"ty",
"basedpyright",
"basher",
// ACP
"opencode",
"mistral-vibe",
"auggie",
"stakpak",
"codebuddy",
"autohand-acp",
"corust-agent",
"factory-droid",
"qqcode",
])
});
/// Returns the [`SchemaVersion`] range that is compatible with this version of Zed. /// Returns the [`SchemaVersion`] range that is compatible with this version of Zed.
pub fn schema_version_range() -> RangeInclusive<SchemaVersion> { pub fn schema_version_range() -> RangeInclusive<SchemaVersion> {
@ -604,7 +623,7 @@ impl ExtensionStore {
.extension_index .extension_index
.extensions .extensions
.contains_key(extension_id.as_ref()); .contains_key(extension_id.as_ref());
!is_already_installed && !SUPPRESSED_EXTENSIONS.contains(&extension_id.as_ref()) !is_already_installed && !SUPPRESSED_EXTENSIONS.contains(extension_id.as_ref())
}) })
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -687,7 +706,7 @@ impl ExtensionStore {
response response
.data .data
.retain(|extension| !SUPPRESSED_EXTENSIONS.contains(&extension.id.as_ref())); .retain(|extension| !SUPPRESSED_EXTENSIONS.contains(extension.id.as_ref()));
Ok(response.data) Ok(response.data)
}) })
@ -1112,9 +1131,12 @@ impl ExtensionStore {
) -> Task<()> { ) -> Task<()> {
let old_index = &self.extension_index; let old_index = &self.extension_index;
new_index let suppressed_extensions_to_remove = new_index
.extensions .extensions
.retain(|extension_id, _| !SUPPRESSED_EXTENSIONS.contains(&extension_id.as_ref())); .extract_if(.., |extension_id, _| {
SUPPRESSED_EXTENSIONS.contains(extension_id.as_ref())
})
.collect::<Vec<_>>();
// Determine which extensions need to be loaded and unloaded, based // Determine which extensions need to be loaded and unloaded, based
// on the changes to the manifest and the extensions that we know have been // on the changes to the manifest and the extensions that we know have been
@ -1155,8 +1177,16 @@ impl ExtensionStore {
self.modified_extensions.clear(); self.modified_extensions.clear();
} }
let trigger_suppressed_extension_removal =
move |this: &mut ExtensionStore, cx: &mut Context<ExtensionStore>| {
for (id, _) in suppressed_extensions_to_remove {
this.uninstall_extension(id, cx).detach_and_log_err(cx);
}
};
if extensions_to_load.is_empty() && extensions_to_unload.is_empty() { if extensions_to_load.is_empty() && extensions_to_unload.is_empty() {
self.reload_complete_senders.clear(); self.reload_complete_senders.clear();
trigger_suppressed_extension_removal(self, cx);
return Task::ready(()); return Task::ready(());
} }
@ -1496,6 +1526,7 @@ impl ExtensionStore {
this.proxy.set_extensions_loaded(); this.proxy.set_extensions_loaded();
this.proxy.reload_current_theme(cx); this.proxy.reload_current_theme(cx);
this.proxy.reload_current_icon_theme(cx); this.proxy.reload_current_icon_theme(cx);
trigger_suppressed_extension_removal(this, cx);
if let Some(events) = ExtensionEvents::try_global(cx) { if let Some(events) = ExtensionEvents::try_global(cx) {
events.update(cx, |this, cx| { events.update(cx, |this, cx| {
@ -1566,10 +1597,6 @@ impl ExtensionStore {
let mut extension_manifest = ExtensionManifest::load(fs.clone(), &extension_dir).await?; let mut extension_manifest = ExtensionManifest::load(fs.clone(), &extension_dir).await?;
let extension_id = extension_manifest.id.clone(); let extension_id = extension_manifest.id.clone();
if SUPPRESSED_EXTENSIONS.contains(&extension_id.as_ref()) {
return Ok(());
}
// TODO: distinguish dev extensions more explicitly, by the absence // TODO: distinguish dev extensions more explicitly, by the absence
// of a checksum file that we'll create when downloading normal extensions. // of a checksum file that we'll create when downloading normal extensions.
let is_dev = fs let is_dev = fs

View file

@ -162,7 +162,6 @@ async fn test_extension_store(cx: &mut TestAppContext) {
.collect(), .collect(),
language_servers: BTreeMap::default(), language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(), context_servers: BTreeMap::default(),
agent_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(), slash_commands: BTreeMap::default(),
snippets: None, snippets: None,
capabilities: Vec::new(), capabilities: Vec::new(),
@ -194,7 +193,6 @@ async fn test_extension_store(cx: &mut TestAppContext) {
grammars: BTreeMap::default(), grammars: BTreeMap::default(),
language_servers: BTreeMap::default(), language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(), context_servers: BTreeMap::default(),
agent_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(), slash_commands: BTreeMap::default(),
snippets: None, snippets: None,
capabilities: Vec::new(), capabilities: Vec::new(),
@ -377,7 +375,6 @@ async fn test_extension_store(cx: &mut TestAppContext) {
grammars: BTreeMap::default(), grammars: BTreeMap::default(),
language_servers: BTreeMap::default(), language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(), context_servers: BTreeMap::default(),
agent_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(), slash_commands: BTreeMap::default(),
snippets: None, snippets: None,
capabilities: Vec::new(), capabilities: Vec::new(),

View file

@ -13,7 +13,6 @@ path = "src/extensions_ui.rs"
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
client.workspace = true
cloud_api_types.workspace = true cloud_api_types.workspace = true
collections.workspace = true collections.workspace = true
db.workspace = true db.workspace = true

View file

@ -7,7 +7,6 @@ use std::time::Duration;
use std::{ops::Range, sync::Arc}; use std::{ops::Range, sync::Arc};
use anyhow::Context as _; use anyhow::Context as _;
use client::zed_urls;
use cloud_api_types::{ExtensionMetadata, ExtensionProvides}; use cloud_api_types::{ExtensionMetadata, ExtensionProvides};
use collections::{BTreeMap, BTreeSet}; use collections::{BTreeMap, BTreeSet};
use editor::{Editor, EditorElement, EditorStyle}; use editor::{Editor, EditorElement, EditorStyle};
@ -68,7 +67,6 @@ pub fn init(cx: &mut App) {
ExtensionCategoryFilter::ContextServers => { ExtensionCategoryFilter::ContextServers => {
ExtensionProvides::ContextServers ExtensionProvides::ContextServers
} }
ExtensionCategoryFilter::AgentServers => ExtensionProvides::AgentServers,
ExtensionCategoryFilter::Snippets => ExtensionProvides::Snippets, ExtensionCategoryFilter::Snippets => ExtensionProvides::Snippets,
ExtensionCategoryFilter::DebugAdapters => ExtensionProvides::DebugAdapters, ExtensionCategoryFilter::DebugAdapters => ExtensionProvides::DebugAdapters,
}); });
@ -287,19 +285,6 @@ fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
}) })
} }
fn acp_registry_upsell_keywords() -> &'static [&'static str] {
&[
"opencode",
"mistral",
"auggie",
"stakpak",
"codebuddy",
"autohand",
"factory droid",
"corust",
]
}
fn extension_button_id(extension_id: &Arc<str>, operation: ExtensionOperation) -> ElementId { fn extension_button_id(extension_id: &Arc<str>, operation: ExtensionOperation) -> ElementId {
(SharedString::from(extension_id.clone()), operation as usize).into() (SharedString::from(extension_id.clone()), operation as usize).into()
} }
@ -326,7 +311,6 @@ pub struct ExtensionsPage {
_subscriptions: [gpui::Subscription; 2], _subscriptions: [gpui::Subscription; 2],
extension_fetch_task: Option<Task<()>>, extension_fetch_task: Option<Task<()>>,
upsells: BTreeSet<Feature>, upsells: BTreeSet<Feature>,
show_acp_registry_upsell: bool,
} }
impl ExtensionsPage { impl ExtensionsPage {
@ -389,7 +373,6 @@ impl ExtensionsPage {
_subscriptions: subscriptions, _subscriptions: subscriptions,
query_editor, query_editor,
upsells: BTreeSet::default(), upsells: BTreeSet::default(),
show_acp_registry_upsell: false,
}; };
this.fetch_extensions( this.fetch_extensions(
this.search_query(cx), this.search_query(cx),
@ -824,7 +807,8 @@ impl ExtensionsPage {
.iter() .iter()
.filter_map(|provides| { .filter_map(|provides| {
match provides { match provides {
ExtensionProvides::SlashCommands ExtensionProvides::AgentServers
| ExtensionProvides::SlashCommands
| ExtensionProvides::IndexedDocsProviders => { | ExtensionProvides::IndexedDocsProviders => {
return None; return None;
} }
@ -1416,13 +1400,11 @@ impl ExtensionsPage {
fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) { fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
let Some(search) = self.search_query(cx) else { let Some(search) = self.search_query(cx) else {
self.upsells.clear(); self.upsells.clear();
self.show_acp_registry_upsell = false;
return; return;
}; };
if let Some(id) = search.strip_prefix("id:") { if let Some(id) = search.strip_prefix("id:") {
self.upsells.clear(); self.upsells.clear();
self.show_acp_registry_upsell = false;
let upsell = match id.to_lowercase().as_str() { let upsell = match id.to_lowercase().as_str() {
"ruff" => Some(Feature::ExtensionRuff), "ruff" => Some(Feature::ExtensionRuff),
@ -1454,61 +1436,6 @@ impl ExtensionsPage {
self.upsells.remove(feature); self.upsells.remove(feature);
} }
} }
self.show_acp_registry_upsell = acp_registry_upsell_keywords()
.iter()
.any(|keyword| search_terms.iter().any(|term| keyword.contains(term)));
}
fn render_acp_registry_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
let registry_url = zed_urls::acp_registry_blog(cx);
let view_registry = Button::new("view_registry", "View Registry")
.style(ButtonStyle::Tinted(ui::TintColor::Warning))
.on_click({
let registry_url = registry_url.clone();
move |_, window, cx| {
telemetry::event!(
"ACP Registry Opened from Extensions",
source = "ACP Registry Upsell",
url = registry_url,
);
window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
}
});
let open_registry_button = Button::new("open_registry", "Learn More")
.end_icon(
Icon::new(IconName::ArrowUpRight)
.size(IconSize::Small)
.color(Color::Muted),
)
.on_click({
move |_event, _window, cx| {
telemetry::event!(
"ACP Registry Viewed",
source = "ACP Registry Upsell",
url = registry_url,
);
cx.open_url(&registry_url)
}
});
div().pt_4().px_4().child(
Banner::new()
.severity(Severity::Warning)
.child(
Label::new(
"Agent Server extensions will be deprecated in favor of the ACP registry.",
)
.mt_0p5(),
)
.action_slot(
h_flex()
.gap_1()
.child(open_registry_button)
.child(view_registry),
),
)
} }
fn render_feature_upsell_banner( fn render_feature_upsell_banner(
@ -1832,11 +1759,6 @@ impl Render for ExtensionsPage {
) )
})), })),
) )
.when(
self.provides_filter == Some(ExtensionProvides::AgentServers)
|| self.show_acp_registry_upsell,
|this| this.child(self.render_acp_registry_upsell(cx)),
)
.child(self.render_feature_upsells(cx)) .child(self.render_feature_upsells(cx))
.child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| { .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
let mut count = self.filtered_remote_extension_indices.len(); let mut count = self.filtered_remote_extension_indices.len();

View file

@ -1,7 +1,7 @@
use std::{ use std::{
any::Any, any::Any,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::{Arc, LazyLock},
time::Duration, time::Duration,
}; };
@ -17,14 +17,11 @@ use http_client::{HttpClient, github::AssetKind};
use node_runtime::NodeRuntime; use node_runtime::NodeRuntime;
use percent_encoding::percent_decode_str; use percent_encoding::percent_decode_str;
use remote::RemoteClient; use remote::RemoteClient;
use rpc::{ use rpc::{AnyProtoClient, TypedEnvelope, proto};
AnyProtoClient, TypedEnvelope,
proto::{self, ExternalExtensionAgent},
};
use schemars::JsonSchema; use schemars::JsonSchema;
use semver::Version; use semver::Version;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{RegisterSetting, SettingsStore}; use settings::{RegisterSetting, SettingsStore, update_settings_file};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use url::Url; use url::Url;
use util::{ResultExt as _, debug_panic}; use util::{ResultExt as _, debug_panic};
@ -114,7 +111,6 @@ impl std::borrow::Borrow<str> for AgentId {
pub enum ExternalAgentSource { pub enum ExternalAgentSource {
#[default] #[default]
Custom, Custom,
Extension,
Registry, Registry,
} }
@ -140,16 +136,6 @@ pub trait ExternalAgentServer {
fn as_any_mut(&mut self) -> &mut dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any;
} }
struct ExtensionAgentEntry {
agent_name: Arc<str>,
extension_id: String,
targets: HashMap<String, extension::TargetConfig>,
env: HashMap<String, String>,
icon_path: Option<String>,
display_name: Option<SharedString>,
version: Option<SharedString>,
}
enum AgentServerStoreState { enum AgentServerStoreState {
Local { Local {
node_runtime: NodeRuntime, node_runtime: NodeRuntime,
@ -158,7 +144,6 @@ enum AgentServerStoreState {
downstream_client: Option<(u64, AnyProtoClient)>, downstream_client: Option<(u64, AnyProtoClient)>,
settings: Option<AllAgentServersSettings>, settings: Option<AllAgentServersSettings>,
http_client: Arc<dyn HttpClient>, http_client: Arc<dyn HttpClient>,
extension_agents: Vec<ExtensionAgentEntry>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
}, },
Remote { Remote {
@ -201,123 +186,54 @@ pub struct AgentServersUpdated;
impl EventEmitter<AgentServersUpdated> for AgentServerStore {} impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
static EXTENSION_TO_REGISTRY_IDS: LazyLock<HashMap<&'static str, &'static str>> =
LazyLock::new(|| {
HashMap::from_iter([
("opencode", "opencode"),
("mistral-vibe", "mistral-vibe"),
("auggie", "auggie"),
("stakpak", "stakpak"),
("codebuddy", "codebuddy-code"),
("autohand-acp", "autohand"),
("corust-agent", "corust-agent"),
("factory-droid", "factory-droid"),
// Unmaintained
// ("qqcode", ""),
])
});
impl AgentServerStore { impl AgentServerStore {
/// Synchronizes extension-provided agent servers with the store. pub fn migrate_agent_server_from_extensions(
pub fn sync_extension_agents<'a, I>(
&mut self, &mut self,
manifests: I, id: Arc<str>,
extensions_dir: PathBuf, fs: Arc<dyn Fs>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) where ) {
I: IntoIterator<Item = (&'a str, &'a extension::ExtensionManifest)>, let Some(registry_id) = EXTENSION_TO_REGISTRY_IDS.get(id.as_ref()) else {
{ return;
// Collect manifests first so we can iterate twice };
let manifests: Vec<_> = manifests.into_iter().collect();
// Remove all extension-provided agents update_settings_file(fs, cx, move |settings, _| {
// (They will be re-added below if they're in the currently installed extensions) let agent_servers = settings.agent_servers.get_or_insert_default();
self.external_agents // Take the old settings
.retain(|_, entry| entry.source != ExternalAgentSource::Extension); let settings = agent_servers.remove(id.as_ref());
// If they had both installed, just remove the extension settings, leave theirregistry settings alone
// Insert agent servers from extension manifests if agent_servers.contains_key(*registry_id) {
match &mut self.state { return;
AgentServerStoreState::Local {
extension_agents, ..
} => {
extension_agents.clear();
for (ext_id, manifest) in manifests {
for (agent_name, agent_entry) in &manifest.agent_servers {
let display_name = SharedString::from(agent_entry.name.clone());
let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
resolve_extension_icon_path(&extensions_dir, ext_id, icon)
});
extension_agents.push(ExtensionAgentEntry {
agent_name: agent_name.clone(),
extension_id: ext_id.to_owned(),
targets: agent_entry.targets.clone(),
env: agent_entry.env.clone(),
icon_path,
display_name: Some(display_name),
version: Some(SharedString::from(manifest.version.clone())),
});
}
}
self.reregister_agents(cx);
} }
AgentServerStoreState::Remote { // Insert the old settings, or write new ones so it is "installed" via the registry
project_id, agent_servers.insert(
upstream_client, registry_id.to_string(),
worktree_store, settings.unwrap_or_else(|| settings::CustomAgentServerSettings::Registry {
} => { default_mode: None,
let mut agents = vec![]; default_model: None,
for (ext_id, manifest) in manifests { env: Default::default(),
for (agent_name, agent_entry) in &manifest.agent_servers { favorite_models: Vec::new(),
let display_name = SharedString::from(agent_entry.name.clone()); default_config_options: HashMap::default(),
let icon_path = agent_entry.icon.as_ref().and_then(|icon| { favorite_config_option_values: HashMap::default(),
resolve_extension_icon_path(&extensions_dir, ext_id, icon) }),
}); );
let icon_shared = icon_path });
.as_ref()
.map(|path| SharedString::from(path.clone()));
let icon = icon_path;
let agent_server_name = AgentId(agent_name.clone().into());
self.external_agents
.entry(agent_server_name.clone())
.and_modify(|entry| {
entry.icon = icon_shared.clone();
entry.display_name = Some(display_name.clone());
entry.source = ExternalAgentSource::Extension;
})
.or_insert_with(|| {
ExternalAgentEntry::new(
Box::new(RemoteExternalAgentServer {
project_id: *project_id,
upstream_client: upstream_client.clone(),
worktree_store: worktree_store.clone(),
name: agent_server_name.clone(),
new_version_available_tx: None,
})
as Box<dyn ExternalAgentServer>,
ExternalAgentSource::Extension,
icon_shared.clone(),
Some(display_name.clone()),
)
});
agents.push(ExternalExtensionAgent {
name: agent_name.to_string(),
icon_path: icon,
extension_id: ext_id.to_string(),
targets: agent_entry
.targets
.iter()
.map(|(k, v)| (k.clone(), v.to_proto()))
.collect(),
env: agent_entry
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
version: Some(manifest.version.to_string()),
});
}
}
upstream_client
.read(cx)
.proto_client()
.send(proto::ExternalExtensionAgentsUpdated {
project_id: *project_id,
agents,
})
.log_err();
}
AgentServerStoreState::Collab => {
// Do nothing
}
}
cx.emit(AgentServersUpdated);
} }
pub fn agent_icon(&self, id: &AgentId) -> Option<SharedString> { pub fn agent_icon(&self, id: &AgentId) -> Option<SharedString> {
@ -331,46 +247,6 @@ impl AgentServerStore {
} }
} }
/// Safely resolves an extension icon path, ensuring it stays within the extension directory.
/// Returns `None` if the path would escape the extension directory (path traversal attack).
pub fn resolve_extension_icon_path(
extensions_dir: &Path,
extension_id: &str,
icon_relative_path: &str,
) -> Option<String> {
let extension_root = extensions_dir.join(extension_id);
let icon_path = extension_root.join(icon_relative_path);
// Canonicalize both paths to resolve symlinks and normalize the paths.
// For the extension root, we need to handle the case where it might be a symlink
// (common for dev extensions).
let canonical_extension_root = extension_root.canonicalize().unwrap_or(extension_root);
let canonical_icon_path = match icon_path.canonicalize() {
Ok(path) => path,
Err(err) => {
log::warn!(
"Failed to canonicalize icon path for extension '{}': {} (path: {})",
extension_id,
err,
icon_relative_path
);
return None;
}
};
// Verify the resolved icon path is within the extension directory
if canonical_icon_path.starts_with(&canonical_extension_root) {
Some(canonical_icon_path.to_string_lossy().to_string())
} else {
log::warn!(
"Icon path '{}' for extension '{}' escapes extension directory, ignoring for security",
icon_relative_path,
extension_id
);
None
}
}
impl AgentServerStore { impl AgentServerStore {
pub fn agent_display_name(&self, name: &AgentId) -> Option<SharedString> { pub fn agent_display_name(&self, name: &AgentId) -> Option<SharedString> {
self.external_agents self.external_agents
@ -384,7 +260,6 @@ impl AgentServerStore {
} }
pub fn init_headless(session: &AnyProtoClient) { pub fn init_headless(session: &AnyProtoClient) {
session.add_entity_message_handler(Self::handle_external_extension_agents_updated);
session.add_entity_request_handler(Self::handle_get_agent_server_command); session.add_entity_request_handler(Self::handle_get_agent_server_command);
} }
@ -419,7 +294,6 @@ impl AgentServerStore {
downstream_client, downstream_client,
settings: old_settings, settings: old_settings,
http_client, http_client,
extension_agents,
.. ..
} = &mut self.state } = &mut self.state
else { else {
@ -470,47 +344,6 @@ impl AgentServerStore {
} }
} }
// Insert extension agents before custom/registry so registry entries override extensions.
for entry in extension_agents.iter() {
let name = AgentId(entry.agent_name.clone().into());
let mut env = entry.env.clone();
if let Some(settings_env) =
new_settings
.get(entry.agent_name.as_ref())
.and_then(|settings| match settings {
CustomAgentServerSettings::Extension { env, .. } => Some(env.clone()),
_ => None,
})
{
env.extend(settings_env);
}
let icon = entry
.icon_path
.as_ref()
.map(|path| SharedString::from(path.clone()));
self.external_agents.insert(
name.clone(),
ExternalAgentEntry::new(
Box::new(LocalExtensionArchiveAgent {
fs: fs.clone(),
http_client: http_client.clone(),
node_runtime: node_runtime.clone(),
project_environment: project_environment.clone(),
extension_id: Arc::from(&*entry.extension_id),
targets: entry.targets.clone(),
env,
agent_id: entry.agent_name.clone(),
version: entry.version.clone(),
new_version_available_tx: None,
}) as Box<dyn ExternalAgentServer>,
ExternalAgentSource::Extension,
icon,
entry.display_name.clone(),
),
);
}
for (name, settings) in new_settings.iter() { for (name, settings) in new_settings.iter() {
match settings { match settings {
CustomAgentServerSettings::Custom { command, .. } => { CustomAgentServerSettings::Custom { command, .. } => {
@ -593,7 +426,6 @@ impl AgentServerStore {
} }
} }
} }
CustomAgentServerSettings::Extension { .. } => {}
} }
} }
@ -662,12 +494,10 @@ impl AgentServerStore {
http_client, http_client,
downstream_client: None, downstream_client: None,
settings: None, settings: None,
extension_agents: vec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
}, },
external_agents: HashMap::default(), external_agents: HashMap::default(),
}; };
if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
this.agent_servers_settings_changed(cx); this.agent_servers_settings_changed(cx);
this this
} }
@ -900,52 +730,6 @@ impl AgentServerStore {
}) })
} }
async fn handle_external_extension_agents_updated(
this: Entity<Self>,
envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
mut cx: AsyncApp,
) -> Result<()> {
this.update(&mut cx, |this, cx| {
let AgentServerStoreState::Local {
extension_agents, ..
} = &mut this.state
else {
panic!(
"handle_external_extension_agents_updated \
should not be called for a non-remote project"
);
};
extension_agents.clear();
for ExternalExtensionAgent {
name,
icon_path,
extension_id,
targets,
env,
version,
} in envelope.payload.agents
{
extension_agents.push(ExtensionAgentEntry {
agent_name: Arc::from(&*name),
extension_id,
targets: targets
.into_iter()
.map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
.collect(),
env: env.into_iter().collect(),
icon_path,
display_name: None,
version: version.map(SharedString::from),
});
}
this.reregister_agents(cx);
cx.emit(AgentServersUpdated);
Ok(())
})
}
async fn handle_new_version_available( async fn handle_new_version_available(
this: Entity<Self>, this: Entity<Self>,
envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>, envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
@ -961,16 +745,6 @@ impl AgentServerStore {
}); });
Ok(()) Ok(())
} }
pub fn get_extension_id_for_agent(&self, name: &AgentId) -> Option<Arc<str>> {
self.external_agents.get(name).and_then(|entry| {
entry
.server
.as_any()
.downcast_ref::<LocalExtensionArchiveAgent>()
.map(|ext_agent| ext_agent.extension_id.clone())
})
}
} }
struct RemoteExternalAgentServer { struct RemoteExternalAgentServer {
@ -1196,213 +970,6 @@ async fn remove_stale_versioned_archive_cache_dirs(
Ok(()) Ok(())
} }
pub struct LocalExtensionArchiveAgent {
pub fs: Arc<dyn Fs>,
pub http_client: Arc<dyn HttpClient>,
pub node_runtime: NodeRuntime,
pub project_environment: Entity<ProjectEnvironment>,
pub extension_id: Arc<str>,
pub agent_id: Arc<str>,
pub targets: HashMap<String, extension::TargetConfig>,
pub env: HashMap<String, String>,
pub version: Option<SharedString>,
pub new_version_available_tx: Option<watch::Sender<Option<String>>>,
}
impl ExternalAgentServer for LocalExtensionArchiveAgent {
fn version(&self) -> Option<&SharedString> {
self.version.as_ref()
}
fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
self.new_version_available_tx.take()
}
fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
self.new_version_available_tx = Some(tx);
}
fn get_command(
&self,
extra_args: Vec<String>,
extra_env: HashMap<String, String>,
cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>> {
let fs = self.fs.clone();
let http_client = self.http_client.clone();
let node_runtime = self.node_runtime.clone();
let project_environment = self.project_environment.downgrade();
let extension_id = self.extension_id.clone();
let agent_id = self.agent_id.clone();
let targets = self.targets.clone();
let base_env = self.env.clone();
let version = self.version.clone();
cx.spawn(async move |cx| {
// Get project environment
let mut env = project_environment
.update(cx, |project_environment, cx| {
project_environment.default_environment(cx)
})?
.await
.unwrap_or_default();
// Merge manifest env and extra env
env.extend(base_env);
env.extend(extra_env);
let cache_key = format!("{}/{}", extension_id, agent_id);
let dir = paths::external_agents_dir().join(&cache_key);
fs.create_dir(&dir).await?;
// Determine platform key
let os = if cfg!(target_os = "macos") {
"darwin"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else {
anyhow::bail!("unsupported OS");
};
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
anyhow::bail!("unsupported architecture");
};
let platform_key = format!("{}-{}", os, arch);
let target_config = targets.get(&platform_key).with_context(|| {
format!(
"no target specified for platform '{}'. Available platforms: {}",
platform_key,
targets
.keys()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
let archive_url = &target_config.archive;
let version_dir = versioned_archive_cache_dir(
&dir,
version.as_ref().map(|version| version.as_ref()),
archive_url,
);
if !fs.is_dir(&version_dir).await {
// Determine SHA256 for verification
let sha256 = if let Some(provided_sha) = &target_config.sha256 {
// Use provided SHA256
Some(provided_sha.clone())
} else if let Some(github_archive) = github_release_archive_from_url(archive_url) {
// Try to fetch SHA256 from GitHub API
if let Ok(release) = ::http_client::github::get_release_by_tag_name(
&github_archive.repo_name_with_owner,
&github_archive.tag,
http_client.clone(),
)
.await
{
// Find matching asset
if let Some(asset) = release
.assets
.iter()
.find(|a| a.name == github_archive.asset_name)
{
// Strip "sha256:" prefix if present
asset.digest.as_ref().map(|d| {
d.strip_prefix("sha256:")
.map(|s| s.to_string())
.unwrap_or_else(|| d.clone())
})
} else {
None
}
} else {
None
}
} else {
None
};
let asset_kind = asset_kind_for_archive_url(archive_url)?;
// Download and extract
::http_client::github_download::download_server_binary(
&*http_client,
archive_url,
sha256.as_deref(),
&version_dir,
asset_kind,
)
.await?;
}
// Validate and resolve cmd path
let cmd = &target_config.cmd;
let cmd_path = if cmd == "node" {
// Use Zed's managed Node.js runtime
node_runtime.binary_path().await?
} else {
if cmd.contains("..") {
anyhow::bail!("command path cannot contain '..': {}", cmd);
}
if cmd.starts_with("./") || cmd.starts_with(".\\") {
// Relative to extraction directory
let cmd_path = version_dir.join(&cmd[2..]);
anyhow::ensure!(
fs.is_file(&cmd_path).await,
"Missing command {} after extraction",
cmd_path.to_string_lossy()
);
cmd_path
} else {
// On PATH
anyhow::bail!("command must be relative (start with './'): {}", cmd);
}
};
cx.background_spawn({
let fs = fs.clone();
let dir = dir.clone();
let version_dir = version_dir.clone();
async move {
remove_stale_versioned_archive_cache_dirs(fs, &dir, &version_dir)
.await
.log_err();
}
})
.detach();
let mut args = target_config.args.clone();
args.extend(extra_args);
let command = AgentServerCommand {
path: cmd_path,
args,
env: Some(env),
};
Ok(command)
})
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
struct LocalRegistryArchiveAgent { struct LocalRegistryArchiveAgent {
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
http_client: Arc<dyn HttpClient>, http_client: Arc<dyn HttpClient>,
@ -1813,40 +1380,6 @@ pub enum CustomAgentServerSettings {
/// Default: {} /// Default: {}
favorite_config_option_values: HashMap<String, Vec<String>>, favorite_config_option_values: HashMap<String, Vec<String>>,
}, },
Extension {
/// Additional environment variables to pass to the agent.
///
/// Default: {}
env: HashMap<String, String>,
/// The default mode to use for this agent.
///
/// Note: Not only all agents support modes.
///
/// Default: None
default_mode: Option<String>,
/// The default model to use for this agent.
///
/// This should be the model ID as reported by the agent.
///
/// Default: None
default_model: Option<String>,
/// The favorite models for this agent.
///
/// Default: []
favorite_models: Vec<String>,
/// Default values for session config options.
///
/// This is a map from config option ID to value ID.
///
/// Default: {}
default_config_options: HashMap<String, String>,
/// Favorited values for session config options.
///
/// This is a map from config option ID to a list of favorited value IDs.
///
/// Default: {}
favorite_config_option_values: HashMap<String, Vec<String>>,
},
Registry { Registry {
/// Additional environment variables to pass to the agent. /// Additional environment variables to pass to the agent.
/// ///
@ -1887,15 +1420,13 @@ impl CustomAgentServerSettings {
pub fn command(&self) -> Option<&AgentServerCommand> { pub fn command(&self) -> Option<&AgentServerCommand> {
match self { match self {
CustomAgentServerSettings::Custom { command, .. } => Some(command), CustomAgentServerSettings::Custom { command, .. } => Some(command),
CustomAgentServerSettings::Extension { .. } CustomAgentServerSettings::Registry { .. } => None,
| CustomAgentServerSettings::Registry { .. } => None,
} }
} }
pub fn default_mode(&self) -> Option<&str> { pub fn default_mode(&self) -> Option<&str> {
match self { match self {
CustomAgentServerSettings::Custom { default_mode, .. } CustomAgentServerSettings::Custom { default_mode, .. }
| CustomAgentServerSettings::Extension { default_mode, .. }
| CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(), | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
} }
} }
@ -1903,7 +1434,6 @@ impl CustomAgentServerSettings {
pub fn default_model(&self) -> Option<&str> { pub fn default_model(&self) -> Option<&str> {
match self { match self {
CustomAgentServerSettings::Custom { default_model, .. } CustomAgentServerSettings::Custom { default_model, .. }
| CustomAgentServerSettings::Extension { default_model, .. }
| CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(), | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(),
} }
} }
@ -1913,9 +1443,6 @@ impl CustomAgentServerSettings {
CustomAgentServerSettings::Custom { CustomAgentServerSettings::Custom {
favorite_models, .. favorite_models, ..
} }
| CustomAgentServerSettings::Extension {
favorite_models, ..
}
| CustomAgentServerSettings::Registry { | CustomAgentServerSettings::Registry {
favorite_models, .. favorite_models, ..
} => favorite_models, } => favorite_models,
@ -1928,10 +1455,6 @@ impl CustomAgentServerSettings {
default_config_options, default_config_options,
.. ..
} }
| CustomAgentServerSettings::Extension {
default_config_options,
..
}
| CustomAgentServerSettings::Registry { | CustomAgentServerSettings::Registry {
default_config_options, default_config_options,
.. ..
@ -1945,10 +1468,6 @@ impl CustomAgentServerSettings {
favorite_config_option_values, favorite_config_option_values,
.. ..
} }
| CustomAgentServerSettings::Extension {
favorite_config_option_values,
..
}
| CustomAgentServerSettings::Registry { | CustomAgentServerSettings::Registry {
favorite_config_option_values, favorite_config_option_values,
.. ..
@ -1983,21 +1502,6 @@ impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
default_config_options, default_config_options,
favorite_config_option_values, favorite_config_option_values,
}, },
settings::CustomAgentServerSettings::Extension {
env,
default_mode,
default_model,
default_config_options,
favorite_models,
favorite_config_option_values,
} => CustomAgentServerSettings::Extension {
env,
default_mode,
default_model,
default_config_options,
favorite_models,
favorite_config_option_values,
},
settings::CustomAgentServerSettings::Registry { settings::CustomAgentServerSettings::Registry {
env, env,
default_mode, default_mode,
@ -2024,7 +1528,15 @@ impl settings::Settings for AllAgentServersSettings {
agent_settings agent_settings
.0 .0
.into_iter() .into_iter()
.map(|(k, v)| (k, v.into())) .map(|(k, v)| {
(
EXTENSION_TO_REGISTRY_IDS
.get(&k.as_str())
.map(|v| v.to_string())
.unwrap_or(k),
v.into(),
)
})
.collect(), .collect(),
) )
} }

View file

@ -1,224 +0,0 @@
use anyhow::Result;
use collections::HashMap;
use gpui::{AsyncApp, SharedString, Task};
use project::agent_server_store::*;
use std::{any::Any, collections::HashSet, fmt::Write as _, path::PathBuf};
// A simple fake that implements ExternalAgentServer without needing async plumbing.
struct NoopExternalAgent;
impl ExternalAgentServer for NoopExternalAgent {
fn get_command(
&self,
_extra_args: Vec<String>,
_extra_env: HashMap<String, String>,
_cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>> {
Task::ready(Ok(AgentServerCommand {
path: PathBuf::from("noop"),
args: Vec::new(),
env: None,
}))
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[test]
fn external_agent_server_name_display() {
let name = AgentId(SharedString::from("Ext: Tool"));
let mut s = String::new();
write!(&mut s, "{name}").unwrap();
assert_eq!(s, "Ext: Tool");
}
#[test]
fn sync_extension_agents_removes_previous_extension_entries() {
let mut store = AgentServerStore::collab();
// Seed with a couple of agents that will be replaced by extensions
store.external_agents.insert(
AgentId(SharedString::from("foo-agent")),
ExternalAgentEntry::new(
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
ExternalAgentSource::Custom,
None,
None,
),
);
store.external_agents.insert(
AgentId(SharedString::from("bar-agent")),
ExternalAgentEntry::new(
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
ExternalAgentSource::Custom,
None,
None,
),
);
store.external_agents.insert(
AgentId(SharedString::from("custom")),
ExternalAgentEntry::new(
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
ExternalAgentSource::Custom,
None,
None,
),
);
// Simulate the removal phase: if we're syncing extensions that provide
// "foo-agent" and "bar-agent", those should be removed first
let extension_agent_names: HashSet<String> = ["foo-agent".to_string(), "bar-agent".to_string()]
.into_iter()
.collect();
let keys_to_remove: Vec<_> = store
.external_agents
.keys()
.filter(|name| extension_agent_names.contains(name.0.as_ref()))
.cloned()
.collect();
for key in keys_to_remove {
store.external_agents.remove(&key);
}
// Only the custom entry should remain.
let remaining: Vec<_> = store
.external_agents
.keys()
.map(|k| k.0.to_string())
.collect();
assert_eq!(remaining, vec!["custom".to_string()]);
}
#[test]
fn resolve_extension_icon_path_allows_valid_paths() {
// Create a temporary directory structure for testing
let temp_dir = tempfile::tempdir().unwrap();
let extensions_dir = temp_dir.path();
let ext_dir = extensions_dir.join("my-extension");
std::fs::create_dir_all(&ext_dir).unwrap();
// Create a valid icon file
let icon_path = ext_dir.join("icon.svg");
std::fs::write(&icon_path, "<svg></svg>").unwrap();
// Test that a valid relative path works
let result = project::agent_server_store::resolve_extension_icon_path(
extensions_dir,
"my-extension",
"icon.svg",
);
assert!(result.is_some());
assert!(result.unwrap().ends_with("icon.svg"));
}
#[test]
fn resolve_extension_icon_path_allows_nested_paths() {
let temp_dir = tempfile::tempdir().unwrap();
let extensions_dir = temp_dir.path();
let ext_dir = extensions_dir.join("my-extension");
let icons_dir = ext_dir.join("assets").join("icons");
std::fs::create_dir_all(&icons_dir).unwrap();
let icon_path = icons_dir.join("logo.svg");
std::fs::write(&icon_path, "<svg></svg>").unwrap();
let result = project::agent_server_store::resolve_extension_icon_path(
extensions_dir,
"my-extension",
"assets/icons/logo.svg",
);
assert!(result.is_some());
assert!(result.unwrap().ends_with("logo.svg"));
}
#[test]
fn resolve_extension_icon_path_blocks_path_traversal() {
let temp_dir = tempfile::tempdir().unwrap();
let extensions_dir = temp_dir.path();
// Create two extension directories
let ext1_dir = extensions_dir.join("extension1");
let ext2_dir = extensions_dir.join("extension2");
std::fs::create_dir_all(&ext1_dir).unwrap();
std::fs::create_dir_all(&ext2_dir).unwrap();
// Create a file in extension2
let secret_file = ext2_dir.join("secret.svg");
std::fs::write(&secret_file, "<svg>secret</svg>").unwrap();
// Try to access extension2's file from extension1 using path traversal
let result = project::agent_server_store::resolve_extension_icon_path(
extensions_dir,
"extension1",
"../extension2/secret.svg",
);
assert!(
result.is_none(),
"Path traversal to sibling extension should be blocked"
);
}
#[test]
fn resolve_extension_icon_path_blocks_absolute_escape() {
let temp_dir = tempfile::tempdir().unwrap();
let extensions_dir = temp_dir.path();
let ext_dir = extensions_dir.join("my-extension");
std::fs::create_dir_all(&ext_dir).unwrap();
// Create a file outside the extensions directory
let outside_file = temp_dir.path().join("outside.svg");
std::fs::write(&outside_file, "<svg>outside</svg>").unwrap();
// Try to escape to parent directory
let result = project::agent_server_store::resolve_extension_icon_path(
extensions_dir,
"my-extension",
"../outside.svg",
);
assert!(
result.is_none(),
"Path traversal to parent directory should be blocked"
);
}
#[test]
fn resolve_extension_icon_path_blocks_deep_traversal() {
let temp_dir = tempfile::tempdir().unwrap();
let extensions_dir = temp_dir.path();
let ext_dir = extensions_dir.join("my-extension");
std::fs::create_dir_all(&ext_dir).unwrap();
// Try deep path traversal
let result = project::agent_server_store::resolve_extension_icon_path(
extensions_dir,
"my-extension",
"../../../../../../etc/passwd",
);
assert!(
result.is_none(),
"Deep path traversal should be blocked (file doesn't exist)"
);
}
#[test]
fn resolve_extension_icon_path_returns_none_for_nonexistent() {
let temp_dir = tempfile::tempdir().unwrap();
let extensions_dir = temp_dir.path();
let ext_dir = extensions_dir.join("my-extension");
std::fs::create_dir_all(&ext_dir).unwrap();
// Try to access a file that doesn't exist
let result = project::agent_server_store::resolve_extension_icon_path(
extensions_dir,
"my-extension",
"nonexistent.svg",
);
assert!(result.is_none(), "Nonexistent file should return None");
}

View file

@ -1,332 +0,0 @@
use anyhow::Result;
use collections::HashMap;
use gpui::{AppContext, AsyncApp, SharedString, Task, TestAppContext};
use node_runtime::NodeRuntime;
use project::worktree_store::WorktreeStore;
use project::{agent_server_store::*, worktree_store::WorktreeIdCounter};
use std::{any::Any, path::PathBuf, sync::Arc};
#[test]
fn extension_agent_constructs_proper_display_names() {
// Verify the display name format for extension-provided agents
let name1 = AgentId(SharedString::from("Extension: Agent"));
assert!(name1.0.contains(": "));
let name2 = AgentId(SharedString::from("MyExt: MyAgent"));
assert_eq!(name2.0, "MyExt: MyAgent");
// Non-extension agents shouldn't have the separator
let custom = AgentId(SharedString::from("custom"));
assert!(!custom.0.contains(": "));
}
struct NoopExternalAgent;
impl ExternalAgentServer for NoopExternalAgent {
fn get_command(
&self,
_extra_args: Vec<String>,
_extra_env: HashMap<String, String>,
_cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>> {
Task::ready(Ok(AgentServerCommand {
path: PathBuf::from("noop"),
args: Vec::new(),
env: None,
}))
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[test]
fn sync_removes_only_extension_provided_agents() {
let mut store = AgentServerStore::collab();
// Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
store.external_agents.insert(
AgentId(SharedString::from("Ext1: Agent1")),
ExternalAgentEntry::new(
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
ExternalAgentSource::Extension,
None,
None,
),
);
store.external_agents.insert(
AgentId(SharedString::from("Ext2: Agent2")),
ExternalAgentEntry::new(
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
ExternalAgentSource::Extension,
None,
None,
),
);
store.external_agents.insert(
AgentId(SharedString::from("custom-agent")),
ExternalAgentEntry::new(
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
ExternalAgentSource::Custom,
None,
None,
),
);
// Simulate removal phase
store
.external_agents
.retain(|_, entry| entry.source != ExternalAgentSource::Extension);
// Only custom-agent should remain
assert_eq!(store.external_agents.len(), 1);
assert!(
store
.external_agents
.contains_key(&AgentId(SharedString::from("custom-agent")))
);
}
#[test]
fn archive_launcher_constructs_with_all_fields() {
use extension::AgentServerManifestEntry;
let mut env = HashMap::default();
env.insert("GITHUB_TOKEN".into(), "secret".into());
let mut targets = HashMap::default();
targets.insert(
"darwin-aarch64".to_string(),
extension::TargetConfig {
archive:
"https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
.into(),
cmd: "./agent".into(),
args: vec![],
sha256: None,
env: Default::default(),
},
);
let _entry = AgentServerManifestEntry {
name: "GitHub Agent".into(),
targets,
env,
icon: None,
};
// Verify display name construction
let expected_name = AgentId(SharedString::from("GitHub Agent"));
assert_eq!(expected_name.0, "GitHub Agent");
}
#[gpui::test]
async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
let fs = fs::FakeFs::new(cx.background_executor.clone());
let http_client = http_client::FakeHttpClient::with_404_response();
let worktree_store =
cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
let project_environment = cx.new(|cx| {
crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
});
let agent = LocalExtensionArchiveAgent {
fs,
http_client,
node_runtime: node_runtime::NodeRuntime::unavailable(),
project_environment,
extension_id: Arc::from("my-extension"),
agent_id: Arc::from("my-agent"),
version: Some(SharedString::from("1.0.0")),
targets: {
let mut map = HashMap::default();
map.insert(
"darwin-aarch64".to_string(),
extension::TargetConfig {
archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
cmd: "./my-agent".into(),
args: vec!["--serve".into()],
sha256: None,
env: Default::default(),
},
);
map
},
env: {
let mut map = HashMap::default();
map.insert("PORT".into(), "8080".into());
map
},
new_version_available_tx: None,
};
// Verify agent is properly constructed
assert_eq!(agent.extension_id.as_ref(), "my-extension");
assert_eq!(agent.agent_id.as_ref(), "my-agent");
assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
assert!(agent.targets.contains_key("darwin-aarch64"));
}
#[test]
fn sync_extension_agents_registers_archive_launcher() {
use extension::AgentServerManifestEntry;
let expected_name = AgentId(SharedString::from("Release Agent"));
assert_eq!(expected_name.0, "Release Agent");
// Verify the manifest entry structure for archive-based installation
let mut env = HashMap::default();
env.insert("API_KEY".into(), "secret".into());
let mut targets = HashMap::default();
targets.insert(
"linux-x86_64".to_string(),
extension::TargetConfig {
archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
cmd: "./release-agent".into(),
args: vec!["serve".into()],
sha256: None,
env: Default::default(),
},
);
let manifest_entry = AgentServerManifestEntry {
name: "Release Agent".into(),
targets: targets.clone(),
env,
icon: None,
};
// Verify target config is present
assert!(manifest_entry.targets.contains_key("linux-x86_64"));
let target = manifest_entry.targets.get("linux-x86_64").unwrap();
assert_eq!(target.cmd, "./release-agent");
}
#[gpui::test]
async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
let fs = fs::FakeFs::new(cx.background_executor.clone());
let http_client = http_client::FakeHttpClient::with_404_response();
let node_runtime = NodeRuntime::unavailable();
let worktree_store =
cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
let project_environment = cx.new(|cx| {
crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
});
let agent = LocalExtensionArchiveAgent {
fs: fs.clone(),
http_client,
node_runtime,
project_environment,
extension_id: Arc::from("node-extension"),
agent_id: Arc::from("node-agent"),
version: Some(SharedString::from("1.0.0")),
targets: {
let mut map = HashMap::default();
map.insert(
"darwin-aarch64".to_string(),
extension::TargetConfig {
archive: "https://example.com/node-agent.zip".into(),
cmd: "node".into(),
args: vec!["index.js".into()],
sha256: None,
env: Default::default(),
},
);
map
},
env: HashMap::default(),
new_version_available_tx: None,
};
// Verify that when cmd is "node", it attempts to use the node runtime
assert_eq!(agent.extension_id.as_ref(), "node-extension");
assert_eq!(agent.agent_id.as_ref(), "node-agent");
let target = agent.targets.get("darwin-aarch64").unwrap();
assert_eq!(target.cmd, "node");
assert_eq!(target.args, vec!["index.js"]);
}
#[gpui::test]
async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
let fs = fs::FakeFs::new(cx.background_executor.clone());
let http_client = http_client::FakeHttpClient::with_404_response();
let node_runtime = NodeRuntime::unavailable();
let worktree_store =
cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
let project_environment = cx.new(|cx| {
crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
});
let agent = LocalExtensionArchiveAgent {
fs: fs.clone(),
http_client,
node_runtime,
project_environment,
extension_id: Arc::from("test-ext"),
agent_id: Arc::from("test-agent"),
version: Some(SharedString::from("1.0.0")),
targets: {
let mut map = HashMap::default();
map.insert(
"darwin-aarch64".to_string(),
extension::TargetConfig {
archive: "https://example.com/test.zip".into(),
cmd: "node".into(),
args: vec![
"server.js".into(),
"--config".into(),
"./config.json".into(),
],
sha256: None,
env: Default::default(),
},
);
map
},
env: Default::default(),
new_version_available_tx: None,
};
// Verify the agent is configured with relative paths in args
let target = agent.targets.get("darwin-aarch64").unwrap();
assert_eq!(target.args[0], "server.js");
assert_eq!(target.args[2], "./config.json");
// These relative paths will resolve relative to the extraction directory
// when the command is executed
}
#[test]
fn test_tilde_expansion_in_settings() {
let settings = settings::CustomAgentServerSettings::Custom {
path: PathBuf::from("~/custom/agent"),
args: vec!["serve".into()],
env: Default::default(),
default_mode: None,
default_model: None,
favorite_models: vec![],
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
};
let converted: CustomAgentServerSettings = settings.into();
let CustomAgentServerSettings::Custom {
command: AgentServerCommand { path, .. },
..
} = converted
else {
panic!("Expected Custom variant");
};
assert!(
!path.to_string_lossy().starts_with("~"),
"Tilde should be expanded for custom agent path"
);
}

View file

@ -5,8 +5,6 @@ mod bookmark_store;
mod color_extractor; mod color_extractor;
mod context_server_store; mod context_server_store;
mod debugger; mod debugger;
mod ext_agent_tests;
mod extension_agent_tests;
mod git_store; mod git_store;
mod image_store; mod image_store;
mod lsp_command; mod lsp_command;

View file

@ -523,46 +523,8 @@ pub enum CustomAgentServerSettings {
#[serde(default, skip_serializing_if = "HashMap::is_empty")] #[serde(default, skip_serializing_if = "HashMap::is_empty")]
favorite_config_option_values: HashMap<String, Vec<String>>, favorite_config_option_values: HashMap<String, Vec<String>>,
}, },
Extension { // Used for the ACP extension migration
/// Additional environment variables to pass to the agent. #[serde(alias = "extension")]
///
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
env: HashMap<String, String>,
/// The default mode to use for this agent.
///
/// Note: Not only all agents support modes.
///
/// Default: None
default_mode: Option<String>,
/// The default model to use for this agent.
///
/// This should be the model ID as reported by the agent.
///
/// Default: None
default_model: Option<String>,
/// The favorite models for this agent.
///
/// These are the model IDs as reported by the agent.
///
/// Default: []
#[serde(default, skip_serializing_if = "Vec::is_empty")]
favorite_models: Vec<String>,
/// Default values for session config options.
///
/// This is a map from config option ID to value ID.
///
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
default_config_options: HashMap<String, String>,
/// Favorited values for session config options.
///
/// This is a map from config option ID to a list of favorited value IDs.
///
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
favorite_config_option_values: HashMap<String, Vec<String>>,
},
Registry { Registry {
/// Additional environment variables to pass to the agent. /// Additional environment variables to pass to the agent.
/// ///

View file

@ -87,7 +87,6 @@ pub enum ExtensionCategoryFilter {
Grammars, Grammars,
LanguageServers, LanguageServers,
ContextServers, ContextServers,
AgentServers,
Snippets, Snippets,
DebugAdapters, DebugAdapters,
} }