extension_host: Fix Windows manifest paths when uploading extensions to WSL remote (#50653)

Thomas Jensen and John Tur created

Closes #42731 

Before you mark this PR as ready for review, make sure that you have:
- [X] Added a solid test coverage and/or screenshots from doing manual
testing
- [X] Done a self-review taking into account security and performance
aspects
- [X] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

<img width="1440" height="900" alt="Screenshot 2026-03-09 at 5 10 46 PM"
src="https://github.com/user-attachments/assets/bf124481-dc10-44e9-aaab-3e562d71e41e"
/>

Release Notes:

- Fixed Windows path handling in extension manifests to ensure
extensions upload correctly to remote environments like WSL.

---------

Co-authored-by: John Tur <john-tur@outlook.com>

Change summary

crates/extension/src/extension_builder.rs         | 17 ++-
crates/extension/src/extension_manifest.rs        | 83 ++++++++++------
crates/extension_cli/src/main.rs                  | 12 +-
crates/extension_host/src/extension_host.rs       | 29 +++--
crates/extension_host/src/extension_store_test.rs | 13 +-
crates/extension_host/src/headless_host.rs        |  2 
crates/util/src/rel_path.rs                       | 42 ++++++++
7 files changed, 132 insertions(+), 66 deletions(-)

Detailed changes

crates/extension/src/extension_builder.rs 🔗

@@ -15,7 +15,7 @@ use std::{
     str::FromStr,
     sync::Arc,
 };
-use util::command::Stdio;
+use util::{command::Stdio, rel_path::PathExt};
 use wasm_encoder::{ComponentSectionId, Encode as _, RawSection, Section as _};
 use wasmparser::Parser;
 
@@ -108,7 +108,7 @@ impl ExtensionBuilder {
 
         for (debug_adapter_name, meta) in &mut extension_manifest.debug_adapters {
             let debug_adapter_schema_path =
-                extension_dir.join(build_debug_adapter_schema_path(debug_adapter_name, meta));
+                extension_dir.join(build_debug_adapter_schema_path(debug_adapter_name, meta)?);
 
             let debug_adapter_schema = fs::read_to_string(&debug_adapter_schema_path)
                 .with_context(|| {
@@ -582,8 +582,9 @@ async fn populate_defaults(
             let language_dir = language_dir?;
             let config_path = language_dir.join(LanguageConfig::FILE_NAME);
             if fs.is_file(config_path.as_path()).await {
-                let relative_language_dir =
-                    language_dir.strip_prefix(extension_path)?.to_path_buf();
+                let relative_language_dir = language_dir
+                    .strip_prefix(extension_path)?
+                    .to_rel_path_buf()?;
                 if !manifest.languages.contains(&relative_language_dir) {
                     manifest.languages.push(relative_language_dir);
                 }
@@ -601,7 +602,8 @@ async fn populate_defaults(
         while let Some(theme_path) = theme_dir_entries.next().await {
             let theme_path = theme_path?;
             if theme_path.extension() == Some("json".as_ref()) {
-                let relative_theme_path = theme_path.strip_prefix(extension_path)?.to_path_buf();
+                let relative_theme_path =
+                    theme_path.strip_prefix(extension_path)?.to_rel_path_buf()?;
                 if !manifest.themes.contains(&relative_theme_path) {
                     manifest.themes.push(relative_theme_path);
                 }
@@ -619,8 +621,9 @@ async fn populate_defaults(
         while let Some(icon_theme_path) = icon_theme_dir_entries.next().await {
             let icon_theme_path = icon_theme_path?;
             if icon_theme_path.extension() == Some("json".as_ref()) {
-                let relative_icon_theme_path =
-                    icon_theme_path.strip_prefix(extension_path)?.to_path_buf();
+                let relative_icon_theme_path = icon_theme_path
+                    .strip_prefix(extension_path)?
+                    .to_rel_path_buf()?;
                 if !manifest.icon_themes.contains(&relative_icon_theme_path) {
                     manifest.icon_themes.push(relative_icon_theme_path);
                 }

crates/extension/src/extension_manifest.rs 🔗

@@ -11,6 +11,7 @@ use language::LanguageName;
 use lsp::LanguageServerName;
 use semver::Version;
 use serde::{Deserialize, Serialize};
+use util::rel_path::{PathExt, RelPathBuf};
 
 use crate::ExtensionCapability;
 
@@ -28,11 +29,11 @@ pub struct OldExtensionManifest {
     pub authors: Vec<String>,
 
     #[serde(default)]
-    pub themes: BTreeMap<Arc<str>, PathBuf>,
+    pub themes: BTreeMap<Arc<str>, RelPathBuf>,
     #[serde(default)]
-    pub languages: BTreeMap<Arc<str>, PathBuf>,
+    pub languages: BTreeMap<Arc<str>, RelPathBuf>,
     #[serde(default)]
-    pub grammars: BTreeMap<Arc<str>, PathBuf>,
+    pub grammars: BTreeMap<Arc<str>, RelPathBuf>,
 }
 
 /// The schema version of the [`ExtensionManifest`].
@@ -94,11 +95,11 @@ pub struct ExtensionManifest {
     pub lib: LibManifestEntry,
 
     #[serde(default)]
-    pub themes: Vec<PathBuf>,
+    pub themes: Vec<RelPathBuf>,
     #[serde(default)]
-    pub icon_themes: Vec<PathBuf>,
+    pub icon_themes: Vec<RelPathBuf>,
     #[serde(default)]
-    pub languages: Vec<PathBuf>,
+    pub languages: Vec<RelPathBuf>,
     #[serde(default)]
     pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
     #[serde(default)]
@@ -195,11 +196,13 @@ impl ExtensionManifest {
 pub fn build_debug_adapter_schema_path(
     adapter_name: &Arc<str>,
     meta: &DebugAdapterManifestEntry,
-) -> PathBuf {
-    meta.schema_path.clone().unwrap_or_else(|| {
-        Path::new("debug_adapter_schemas")
+) -> anyhow::Result<RelPathBuf> {
+    match &meta.schema_path {
+        Some(path) => Ok(path.clone()),
+        None => Path::new("debug_adapter_schemas")
             .join(Path::new(adapter_name.as_ref()).with_extension("json"))
-    })
+            .to_rel_path_buf(),
+    }
 }
 
 #[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
@@ -350,7 +353,7 @@ pub struct SlashCommandManifestEntry {
 
 #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
 pub struct DebugAdapterManifestEntry {
-    pub schema_path: Option<PathBuf>,
+    pub schema_path: Option<RelPathBuf>,
 }
 
 #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
@@ -442,7 +445,9 @@ fn manifest_from_old_manifest(
 
 #[cfg(test)]
 mod tests {
+    use indoc::indoc;
     use pretty_assertions::assert_eq;
+    use util::rel_path::rel_path_buf;
 
     use crate::ProcessExecCapability;
 
@@ -478,11 +483,11 @@ mod tests {
     fn test_build_adapter_schema_path_with_schema_path() {
         let adapter_name = Arc::from("my_adapter");
         let entry = DebugAdapterManifestEntry {
-            schema_path: Some(PathBuf::from("foo/bar")),
+            schema_path: Some(rel_path_buf("foo/bar")),
         };
 
-        let path = build_debug_adapter_schema_path(&adapter_name, &entry);
-        assert_eq!(path, PathBuf::from("foo/bar"));
+        let path = build_debug_adapter_schema_path(&adapter_name, &entry).unwrap();
+        assert_eq!(path, rel_path_buf("foo/bar"));
     }
 
     #[test]
@@ -490,11 +495,8 @@ mod tests {
         let adapter_name = Arc::from("my_adapter");
         let entry = DebugAdapterManifestEntry { schema_path: None };
 
-        let path = build_debug_adapter_schema_path(&adapter_name, &entry);
-        assert_eq!(
-            path,
-            PathBuf::from("debug_adapter_schemas").join("my_adapter.json")
-        );
+        let path = build_debug_adapter_schema_path(&adapter_name, &entry).unwrap();
+        assert_eq!(path, rel_path_buf("debug_adapter_schemas/my_adapter.json"));
     }
 
     #[test]
@@ -572,22 +574,37 @@ mod tests {
         );
         assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg
     }
+
+    #[test]
+    #[cfg(target_os = "windows")]
+    fn test_deserialize_manifest_with_windows_separators() {
+        let content = indoc! {r#"
+            id = "test-manifest"
+            name = "Test Manifest"
+            version = "0.0.1"
+            schema_version = 0
+            languages = ["foo\\bar"]
+        "#};
+        let manifest: ExtensionManifest = toml::from_str(&content).expect("manifest should parse");
+        assert_eq!(manifest.languages, vec![rel_path_buf("foo/bar")]);
+    }
+
     #[test]
     fn parse_manifest_with_agent_server_archive_launcher() {
-        let toml_src = 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 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");

crates/extension_cli/src/main.rs 🔗

@@ -165,6 +165,7 @@ async fn copy_extension_resources(
         let output_themes_dir = output_dir.join("themes");
         fs::create_dir_all(&output_themes_dir)?;
         for theme_path in &manifest.themes {
+            let theme_path = theme_path.as_std_path();
             fs::copy(
                 extension_path.join(theme_path),
                 output_themes_dir.join(theme_path.file_name().context("invalid theme path")?),
@@ -177,6 +178,7 @@ async fn copy_extension_resources(
         let output_icon_themes_dir = output_dir.join("icon_themes");
         fs::create_dir_all(&output_icon_themes_dir)?;
         for icon_theme_path in &manifest.icon_themes {
+            let icon_theme_path = icon_theme_path.as_std_path();
             fs::copy(
                 extension_path.join(icon_theme_path),
                 output_icon_themes_dir.join(
@@ -224,6 +226,7 @@ async fn copy_extension_resources(
         let output_languages_dir = output_dir.join("languages");
         fs::create_dir_all(&output_languages_dir)?;
         for language_path in &manifest.languages {
+            let language_path = language_path.as_std_path();
             copy_recursive(
                 fs.as_ref(),
                 &extension_path.join(language_path),
@@ -243,14 +246,11 @@ async fn copy_extension_resources(
 
     if !manifest.debug_adapters.is_empty() {
         for (debug_adapter, entry) in &manifest.debug_adapters {
-            let schema_path = entry.schema_path.clone().unwrap_or_else(|| {
-                PathBuf::from("debug_adapter_schemas".to_owned())
-                    .join(debug_adapter.as_ref())
-                    .with_extension("json")
-            });
+            let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, entry)?;
             let parent = schema_path
                 .parent()
                 .with_context(|| format!("invalid empty schema path for {debug_adapter}"))?;
+            let schema_path = schema_path.as_std_path();
             fs::create_dir_all(output_dir.join(parent))?;
             copy_recursive(
                 fs.as_ref(),
@@ -265,7 +265,7 @@ async fn copy_extension_resources(
             .with_context(|| {
                 format!(
                     "failed to copy debug adapter schema '{}'",
-                    schema_path.display()
+                    schema_path.display(),
                 )
             })?;
         }

crates/extension_host/src/extension_host.rs 🔗

@@ -56,7 +56,7 @@ use std::{
 };
 use task::TaskTemplates;
 use url::Url;
-use util::{ResultExt, paths::RemotePathBuf};
+use util::{ResultExt, paths::RemotePathBuf, rel_path::PathExt};
 use wasm_host::{
     WasmExtension, WasmHost,
     wit::{is_supported_wasm_api_version, wasm_api_version_range},
@@ -1244,13 +1244,16 @@ impl ExtensionStore {
             }));
             themes_to_add.extend(extension.manifest.themes.iter().map(|theme_path| {
                 let mut path = self.installed_dir.clone();
-                path.extend([Path::new(extension_id.as_ref()), theme_path.as_path()]);
+                path.extend([Path::new(extension_id.as_ref()), theme_path.as_std_path()]);
                 path
             }));
             icon_themes_to_add.extend(extension.manifest.icon_themes.iter().map(
                 |icon_theme_path| {
                     let mut path = self.installed_dir.clone();
-                    path.extend([Path::new(extension_id.as_ref()), icon_theme_path.as_path()]);
+                    path.extend([
+                        Path::new(extension_id.as_ref()),
+                        icon_theme_path.as_std_path(),
+                    ]);
 
                     let mut icons_root_path = self.installed_dir.clone();
                     icons_root_path.extend([Path::new(extension_id.as_ref())]);
@@ -1560,7 +1563,7 @@ impl ExtensionStore {
                 })?;
                 let config = ::toml::from_str::<LanguageConfig>(&config)?;
 
-                let relative_path = relative_path.to_path_buf();
+                let relative_path = relative_path.to_rel_path_buf()?;
                 if !extension_manifest.languages.contains(&relative_path) {
                     extension_manifest.languages.push(relative_path.clone());
                 }
@@ -1569,7 +1572,7 @@ impl ExtensionStore {
                     config.name.clone(),
                     ExtensionIndexLanguageEntry {
                         extension: extension_id.clone(),
-                        path: relative_path,
+                        path: relative_path.as_std_path().to_path_buf(),
                         matcher: config.matcher,
                         hidden: config.hidden,
                         grammar: config.grammar,
@@ -1593,7 +1596,7 @@ impl ExtensionStore {
                     continue;
                 };
 
-                let relative_path = relative_path.to_path_buf();
+                let relative_path = relative_path.to_rel_path_buf()?;
                 if !extension_manifest.themes.contains(&relative_path) {
                     extension_manifest.themes.push(relative_path.clone());
                 }
@@ -1603,7 +1606,7 @@ impl ExtensionStore {
                         theme_name.into(),
                         ExtensionIndexThemeEntry {
                             extension: extension_id.clone(),
-                            path: relative_path.clone(),
+                            path: relative_path.as_std_path().to_path_buf(),
                         },
                     );
                 }
@@ -1625,7 +1628,7 @@ impl ExtensionStore {
                     continue;
                 };
 
-                let relative_path = relative_path.to_path_buf();
+                let relative_path = relative_path.to_rel_path_buf()?;
                 if !extension_manifest.icon_themes.contains(&relative_path) {
                     extension_manifest.icon_themes.push(relative_path.clone());
                 }
@@ -1635,7 +1638,7 @@ impl ExtensionStore {
                         icon_theme_name.into(),
                         ExtensionIndexIconThemeEntry {
                             extension: extension_id.clone(),
-                            path: relative_path.clone(),
+                            path: relative_path.as_std_path().to_path_buf(),
                         },
                     );
                 }
@@ -1721,15 +1724,15 @@ impl ExtensionStore {
             }
 
             for (adapter_name, meta) in loaded_extension.manifest.debug_adapters.iter() {
-                let schema_path = &extension::build_debug_adapter_schema_path(adapter_name, meta);
+                let schema_path = extension::build_debug_adapter_schema_path(adapter_name, meta)?;
 
-                if fs.is_file(&src_dir.join(schema_path)).await {
+                if fs.is_file(&src_dir.join(&schema_path)).await {
                     if let Some(parent) = schema_path.parent() {
                         fs.create_dir(&tmp_dir.join(parent)).await?
                     }
                     fs.copy_file(
-                        &src_dir.join(schema_path),
-                        &tmp_dir.join(schema_path),
+                        &src_dir.join(&schema_path),
+                        &tmp_dir.join(&schema_path),
                         fs::CopyOptions::default(),
                     )
                     .await?

crates/extension_host/src/extension_store_test.rs 🔗

@@ -26,7 +26,7 @@ use std::{
     sync::Arc,
 };
 use theme::ThemeRegistry;
-use util::test::TempTree;
+use util::{rel_path::rel_path_buf, test::TempTree};
 
 #[cfg(test)]
 #[ctor::ctor]
@@ -150,7 +150,10 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         themes: Default::default(),
                         icon_themes: Vec::new(),
                         lib: Default::default(),
-                        languages: vec!["languages/erb".into(), "languages/ruby".into()],
+                        languages: vec![
+                            rel_path_buf("languages/erb"),
+                            rel_path_buf("languages/ruby"),
+                        ],
                         grammars: [
                             ("embedded_template".into(), GrammarManifestEntry::default()),
                             ("ruby".into(), GrammarManifestEntry::default()),
@@ -182,8 +185,8 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         authors: vec![],
                         repository: None,
                         themes: vec![
-                            "themes/monokai-pro.json".into(),
-                            "themes/monokai.json".into(),
+                            rel_path_buf("themes/monokai-pro.json"),
+                            rel_path_buf("themes/monokai.json"),
                         ],
                         icon_themes: Vec::new(),
                         lib: Default::default(),
@@ -367,7 +370,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                 description: None,
                 authors: vec![],
                 repository: None,
-                themes: vec!["themes/gruvbox.json".into()],
+                themes: vec![rel_path_buf("themes/gruvbox.json")],
                 icon_themes: Vec::new(),
                 lib: Default::default(),
                 languages: Default::default(),

crates/extension_host/src/headless_host.rs 🔗

@@ -194,7 +194,7 @@ impl HeadlessExtensionStore {
         }
 
         for (debug_adapter, meta) in &manifest.debug_adapters {
-            let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, meta);
+            let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, meta)?;
 
             this.update(cx, |this, _cx| {
                 this.proxy.register_debug_adapter(

crates/util/src/rel_path.rs 🔗

@@ -27,7 +27,7 @@ pub struct RelPath(str);
 /// relative and normalized.
 ///
 /// This type is to [`RelPath`] as [`std::path::PathBuf`] is to [`std::path::Path`]
-#[derive(PartialEq, Eq, Clone, Serialize, Deserialize)]
+#[derive(PartialEq, Eq, Clone, Ord, PartialOrd, Serialize)]
 pub struct RelPathBuf(String);
 
 impl RelPath {
@@ -333,12 +333,36 @@ impl RelPathBuf {
     }
 }
 
+impl<'de> Deserialize<'de> for RelPathBuf {
+    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let path = String::deserialize(deserializer)?;
+        let rel_path =
+            RelPath::new(Path::new(&path), PathStyle::local()).map_err(serde::de::Error::custom)?;
+        Ok(rel_path.into_owned())
+    }
+}
+
 impl Into<Arc<RelPath>> for RelPathBuf {
     fn into(self) -> Arc<RelPath> {
         Arc::from(self.as_rel_path())
     }
 }
 
+impl AsRef<Path> for RelPathBuf {
+    fn as_ref(&self) -> &Path {
+        self.as_std_path()
+    }
+}
+
+impl AsRef<Path> for RelPath {
+    fn as_ref(&self) -> &Path {
+        self.as_std_path()
+    }
+}
+
 impl AsRef<RelPath> for RelPathBuf {
     fn as_ref(&self) -> &RelPath {
         self.as_rel_path()
@@ -378,12 +402,28 @@ pub fn rel_path(path: &str) -> &RelPath {
     RelPath::unix(path).unwrap()
 }
 
+#[cfg(any(test, feature = "test-support"))]
+#[track_caller]
+pub fn rel_path_buf(path: &str) -> RelPathBuf {
+    RelPath::unix(path).unwrap().to_rel_path_buf()
+}
+
 impl PartialEq<str> for RelPath {
     fn eq(&self, other: &str) -> bool {
         self.0 == *other
     }
 }
 
+pub trait PathExt {
+    fn to_rel_path_buf(&self) -> Result<RelPathBuf>;
+}
+
+impl<T: AsRef<Path> + ?Sized> PathExt for T {
+    fn to_rel_path_buf(&self) -> Result<RelPathBuf> {
+        Ok(RelPath::new(self.as_ref(), PathStyle::local())?.into_owned())
+    }
+}
+
 #[derive(Default)]
 pub struct RelPathComponents<'a>(&'a str);