extension: Add capabilities for the process API (#26224)

This PR adds support for capabilities for the extension process API.

In order to use the process API, an extension must declare which
commands it wants to use, with arguments:

```toml
[[capabilities]]
kind = "process:exec"
command = "echo"
args = ["hello!"]
```

A `*` can be used to denote a single wildcard in the argument list:

```toml
[[capabilities]]
kind = "process:exec"
command = "echo"
args = ["*"]
```

And `**` can be used to denote a wildcard for the remaining arguments:

```toml
[[capabilities]]
kind = "process:exec"
command = "ls"
args = ["-a", "**"]
```

Release Notes:

- N/A

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Marshall Bowers 2025-03-06 11:55:00 -05:00 committed by GitHub
parent 99216acdec
commit 6fd9708eee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 170 additions and 1 deletions

View file

@ -1,4 +1,4 @@
use anyhow::{anyhow, Context as _, Result};
use anyhow::{anyhow, bail, Context as _, Result};
use collections::{BTreeMap, HashMap};
use fs::Fs;
use language::LanguageName;
@ -85,6 +85,61 @@ pub struct ExtensionManifest {
pub indexed_docs_providers: BTreeMap<Arc<str>, IndexedDocsProviderEntry>,
#[serde(default)]
pub snippets: Option<PathBuf>,
#[serde(default)]
pub capabilities: Vec<ExtensionCapability>,
}
impl ExtensionManifest {
pub fn allow_exec(
&self,
desired_command: &str,
desired_args: &[impl AsRef<str> + std::fmt::Debug],
) -> Result<()> {
let is_allowed = self.capabilities.iter().any(|capability| match capability {
ExtensionCapability::ProcessExec { command, args } if command == desired_command => {
for (ix, arg) in args.iter().enumerate() {
if arg == "**" {
return true;
}
if ix >= desired_args.len() {
return false;
}
if arg != "*" && arg != desired_args[ix].as_ref() {
return false;
}
}
if args.len() < desired_args.len() {
return false;
}
true
}
_ => false,
});
if !is_allowed {
bail!(
"capability for process:exec {desired_command} {desired_args:?} was not listed in the extension manifest",
);
}
Ok(())
}
}
/// A capability for an extension.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum ExtensionCapability {
#[serde(rename = "process:exec")]
ProcessExec {
/// The command to execute.
command: String,
/// The arguments to pass to the command. Use `*` for a single wildcard argument.
/// If the last element is `**`, then any trailing arguments are allowed.
args: Vec<String>,
},
}
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
@ -218,5 +273,104 @@ fn manifest_from_old_manifest(
slash_commands: BTreeMap::default(),
indexed_docs_providers: BTreeMap::default(),
snippets: None,
capabilities: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn extension_manifest() -> ExtensionManifest {
ExtensionManifest {
id: "test".into(),
name: "Test".to_string(),
version: "1.0.0".into(),
schema_version: SchemaVersion::ZERO,
description: None,
repository: None,
authors: vec![],
lib: Default::default(),
themes: vec![],
icon_themes: vec![],
languages: vec![],
grammars: BTreeMap::default(),
language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(),
indexed_docs_providers: BTreeMap::default(),
snippets: None,
capabilities: vec![],
}
}
#[test]
fn test_allow_exact_match() {
let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec {
command: "ls".to_string(),
args: vec!["-la".to_string()],
}],
..extension_manifest()
};
assert!(manifest.allow_exec("ls", &["-la"]).is_ok());
assert!(manifest.allow_exec("ls", &["-l"]).is_err());
assert!(manifest.allow_exec("pwd", &[] as &[&str]).is_err());
}
#[test]
fn test_allow_wildcard_arg() {
let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec {
command: "git".to_string(),
args: vec!["*".to_string()],
}],
..extension_manifest()
};
assert!(manifest.allow_exec("git", &["status"]).is_ok());
assert!(manifest.allow_exec("git", &["commit"]).is_ok());
assert!(manifest.allow_exec("git", &["status", "-s"]).is_err()); // too many args
assert!(manifest.allow_exec("npm", &["install"]).is_err()); // wrong command
}
#[test]
fn test_allow_double_wildcard() {
let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec {
command: "cargo".to_string(),
args: vec!["test".to_string(), "**".to_string()],
}],
..extension_manifest()
};
assert!(manifest.allow_exec("cargo", &["test"]).is_ok());
assert!(manifest.allow_exec("cargo", &["test", "--all"]).is_ok());
assert!(manifest
.allow_exec("cargo", &["test", "--all", "--no-fail-fast"])
.is_ok());
assert!(manifest.allow_exec("cargo", &["build"]).is_err()); // wrong first arg
}
#[test]
fn test_allow_mixed_wildcards() {
let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec {
command: "docker".to_string(),
args: vec!["run".to_string(), "*".to_string(), "**".to_string()],
}],
..extension_manifest()
};
assert!(manifest.allow_exec("docker", &["run", "nginx"]).is_ok());
assert!(manifest.allow_exec("docker", &["run"]).is_err());
assert!(manifest
.allow_exec("docker", &["run", "ubuntu", "bash"])
.is_ok());
assert!(manifest
.allow_exec("docker", &["run", "alpine", "sh", "-c", "echo hello"])
.is_ok());
assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg
}
}

View file

@ -163,6 +163,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
slash_commands: BTreeMap::default(),
indexed_docs_providers: BTreeMap::default(),
snippets: None,
capabilities: Vec::new(),
}),
dev: false,
},
@ -191,6 +192,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
slash_commands: BTreeMap::default(),
indexed_docs_providers: BTreeMap::default(),
snippets: None,
capabilities: Vec::new(),
}),
dev: false,
},
@ -356,6 +358,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
slash_commands: BTreeMap::default(),
indexed_docs_providers: BTreeMap::default(),
snippets: None,
capabilities: Vec::new(),
}),
dev: false,
},

View file

@ -592,6 +592,8 @@ impl process::Host for WasmState {
command: process::Command,
) -> wasmtime::Result<Result<process::Output, String>> {
maybe!(async {
self.manifest.allow_exec(&command.command, &command.args)?;
let output = util::command::new_smol_command(command.command.as_str())
.args(&command.args)
.envs(command.env)

View file

@ -13,3 +13,8 @@ language = "Gleam"
[grammars.gleam]
repository = "https://github.com/gleam-lang/tree-sitter-gleam"
commit = "8432ffe32ccd360534837256747beb5b1c82fca1"
[[capabilities]]
kind = "process:exec"
command = "echo"
args = ["hello!"]

View file

@ -1,6 +1,7 @@
use std::fs;
use zed::lsp::CompletionKind;
use zed::{CodeLabel, CodeLabelSpan, LanguageServerId};
use zed_extension_api::process::Command;
use zed_extension_api::{self as zed, Result};
struct TestExtension {
@ -13,6 +14,10 @@ impl TestExtension {
language_server_id: &LanguageServerId,
_worktree: &zed::Worktree,
) -> Result<String> {
let echo_output = Command::new("echo").arg("hello!").output()?;
println!("{}", String::from_utf8_lossy(&echo_output.stdout));
if let Some(path) = &self.cached_binary_path {
if fs::metadata(path).map_or(false, |stat| stat.is_file()) {
return Ok(path.clone());