Skip to repository content608 lines · 20.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:10:32.445Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
extension_manifest.rs
1use std::borrow::Cow;
2use std::ffi::OsStr;
3use std::fmt;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use anyhow::{Context as _, Result, anyhow, bail};
8use cloud_api_types::ExtensionProvides;
9use collections::{BTreeMap, BTreeSet, HashMap};
10use fs::Fs;
11use language::LanguageName;
12use lsp::LanguageServerName;
13use semver::Version;
14use serde::{Deserialize, Serialize};
15use util::paths::PathStyle;
16use util::rel_path::{RelPath, RelPathBuf};
17
18use crate::ExtensionCapability;
19
20/// This is the old version of the extension manifest, from when it was `extension.json`.
21#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
22pub struct OldExtensionManifest {
23 pub name: String,
24 pub version: Arc<str>,
25
26 #[serde(default)]
27 pub description: Option<String>,
28 #[serde(default)]
29 pub repository: Option<String>,
30 #[serde(default)]
31 pub authors: Vec<String>,
32
33 #[serde(default)]
34 pub themes: BTreeMap<Arc<str>, RelPathBuf>,
35 #[serde(default)]
36 pub languages: BTreeMap<Arc<str>, RelPathBuf>,
37 #[serde(default)]
38 pub grammars: BTreeMap<Arc<str>, RelPathBuf>,
39}
40
41/// The schema version of the [`ExtensionManifest`].
42#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)]
43pub struct SchemaVersion(pub i32);
44
45impl fmt::Display for SchemaVersion {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 write!(f, "{}", self.0)
48 }
49}
50
51impl SchemaVersion {
52 pub const ZERO: Self = Self(0);
53
54 pub fn is_v0(&self) -> bool {
55 self == &Self::ZERO
56 }
57}
58
59// TODO: We should change this to just always be a Vec<PathBuf> once we bump the
60// extension.toml schema version to 2
61#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
62#[serde(untagged)]
63pub enum ExtensionSnippets {
64 Single(PathBuf),
65 Multiple(Vec<PathBuf>),
66}
67
68impl ExtensionSnippets {
69 pub fn paths(&self) -> impl Iterator<Item = &PathBuf> {
70 match self {
71 ExtensionSnippets::Single(path) => std::slice::from_ref(path).iter(),
72 ExtensionSnippets::Multiple(paths) => paths.iter(),
73 }
74 }
75}
76
77impl From<&str> for ExtensionSnippets {
78 fn from(value: &str) -> Self {
79 ExtensionSnippets::Single(value.into())
80 }
81}
82
83#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
84pub struct ExtensionManifest {
85 pub id: Arc<str>,
86 pub name: String,
87 pub version: Arc<str>,
88 pub schema_version: SchemaVersion,
89
90 #[serde(default)]
91 pub description: Option<String>,
92 #[serde(default)]
93 pub repository: Option<String>,
94 #[serde(default)]
95 pub authors: Vec<String>,
96 #[serde(default)]
97 pub lib: LibManifestEntry,
98
99 #[serde(default)]
100 pub themes: Vec<RelPathBuf>,
101 #[serde(default)]
102 pub icon_themes: Vec<RelPathBuf>,
103 #[serde(default)]
104 pub languages: Vec<RelPathBuf>,
105 #[serde(default)]
106 pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
107 #[serde(default)]
108 pub language_servers: BTreeMap<LanguageServerName, LanguageServerManifestEntry>,
109 #[serde(default)]
110 pub context_servers: BTreeMap<Arc<str>, ContextServerManifestEntry>,
111 #[serde(default)]
112 pub slash_commands: BTreeMap<Arc<str>, SlashCommandManifestEntry>,
113 #[serde(default)]
114 pub snippets: Option<ExtensionSnippets>,
115 #[serde(default)]
116 pub capabilities: Vec<ExtensionCapability>,
117 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
118 pub debug_adapters: BTreeMap<Arc<str>, DebugAdapterManifestEntry>,
119 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
120 pub debug_locators: BTreeMap<Arc<str>, DebugLocatorManifestEntry>,
121 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
122 pub language_model_providers: BTreeMap<Arc<str>, LanguageModelProviderManifestEntry>,
123}
124
125impl ExtensionManifest {
126 /// Returns the set of features provided by the extension.
127 pub fn provides(&self) -> BTreeSet<ExtensionProvides> {
128 let mut provides = BTreeSet::default();
129 if !self.themes.is_empty() {
130 provides.insert(ExtensionProvides::Themes);
131 }
132
133 if !self.icon_themes.is_empty() {
134 provides.insert(ExtensionProvides::IconThemes);
135 }
136
137 if !self.languages.is_empty() {
138 provides.insert(ExtensionProvides::Languages);
139 }
140
141 if !self.grammars.is_empty() {
142 provides.insert(ExtensionProvides::Grammars);
143 }
144
145 if !self.language_servers.is_empty() {
146 provides.insert(ExtensionProvides::LanguageServers);
147 }
148
149 if !self.context_servers.is_empty() {
150 provides.insert(ExtensionProvides::ContextServers);
151 }
152
153 if self.snippets.is_some() {
154 provides.insert(ExtensionProvides::Snippets);
155 }
156
157 if !self.debug_adapters.is_empty() {
158 provides.insert(ExtensionProvides::DebugAdapters);
159 }
160
161 provides
162 }
163
164 pub fn allow_exec(
165 &self,
166 desired_command: &str,
167 desired_args: &[impl AsRef<str> + std::fmt::Debug],
168 ) -> Result<()> {
169 let is_allowed = self.capabilities.iter().any(|capability| match capability {
170 ExtensionCapability::ProcessExec(capability) => {
171 capability.allows(desired_command, desired_args)
172 }
173 _ => false,
174 });
175
176 if !is_allowed {
177 bail!(
178 "capability for process:exec {desired_command} {desired_args:?} was not listed in the extension manifest",
179 );
180 }
181
182 Ok(())
183 }
184
185 pub fn allow_remote_load(&self) -> bool {
186 self.remote_load().is_some()
187 }
188
189 pub fn remote_load(&self) -> Option<RemoteLoad<'_>> {
190 (!self.language_servers.is_empty()
191 || !self.debug_adapters.is_empty()
192 || !self.debug_locators.is_empty())
193 .then_some(RemoteLoad { manifest: self })
194 }
195}
196
197pub struct RemoteLoad<'a> {
198 manifest: &'a ExtensionManifest,
199}
200
201impl RemoteLoad<'_> {
202 pub fn language_dependencies(&self) -> impl Iterator<Item = LanguageName> + '_ {
203 self.manifest
204 .language_servers
205 .values()
206 .flat_map(|language_server_config| language_server_config.languages())
207 }
208}
209
210pub fn build_debug_adapter_schema_path(
211 adapter_name: &Arc<str>,
212 meta: &DebugAdapterManifestEntry,
213) -> anyhow::Result<RelPathBuf> {
214 match &meta.schema_path {
215 Some(path) => Ok(path.clone()),
216 None => RelPath::new(
217 &Path::new("debug_adapter_schemas")
218 .join(Path::new(adapter_name.as_ref()).with_extension("json")),
219 PathStyle::local(),
220 )
221 .map(Cow::into_owned),
222 }
223}
224
225#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
226pub struct LibManifestEntry {
227 pub kind: Option<ExtensionLibraryKind>,
228 pub version: Option<Version>,
229}
230
231#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
232pub struct AgentServerManifestEntry {
233 /// Display name for the agent (shown in menus).
234 pub name: String,
235 /// Environment variables to set when launching the agent server.
236 #[serde(default)]
237 pub env: HashMap<String, String>,
238 /// Optional icon path (relative to extension root, e.g., "ai.svg").
239 /// Should be a small SVG icon for display in menus.
240 #[serde(default)]
241 pub icon: Option<String>,
242 /// Per-target configuration for archive-based installation.
243 /// The key format is "{os}-{arch}" where:
244 /// - os: "darwin" (macOS), "linux", "windows"
245 /// - arch: "aarch64" (arm64), "x86_64"
246 ///
247 /// Example:
248 /// ```toml
249 /// [agent_servers.myagent.targets.darwin-aarch64]
250 /// archive = "https://example.com/myagent-darwin-arm64.zip"
251 /// cmd = "./myagent"
252 /// args = ["--serve"]
253 /// sha256 = "abc123..." # optional
254 /// ```
255 ///
256 /// For Node.js-based agents, you can use "node" as the cmd to automatically
257 /// use Zed's managed Node.js runtime instead of relying on the user's PATH:
258 /// ```toml
259 /// [agent_servers.nodeagent.targets.darwin-aarch64]
260 /// archive = "https://example.com/nodeagent.zip"
261 /// cmd = "node"
262 /// args = ["index.js", "--port", "3000"]
263 /// ```
264 ///
265 /// Note: All commands are executed with the archive extraction directory as the
266 /// working directory, so relative paths in args (like "index.js") will resolve
267 /// relative to the extracted archive contents.
268 pub targets: HashMap<String, TargetConfig>,
269}
270
271#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
272pub struct TargetConfig {
273 /// URL to download the archive from (e.g., "https://github.com/owner/repo/releases/download/v1.0.0/myagent-darwin-arm64.zip")
274 pub archive: String,
275 /// Command to run (e.g., "./myagent" or "./myagent.exe")
276 pub cmd: String,
277 /// Command-line arguments to pass to the agent server.
278 #[serde(default)]
279 pub args: Vec<String>,
280 /// Optional SHA-256 hash of the archive for verification.
281 /// If not provided and the URL is a GitHub release, we'll attempt to fetch it from GitHub.
282 #[serde(default)]
283 pub sha256: Option<String>,
284 /// Environment variables to set when launching the agent server.
285 /// These target-specific env vars will override any env vars set at the agent level.
286 #[serde(default)]
287 pub env: HashMap<String, String>,
288}
289
290impl TargetConfig {
291 pub fn from_proto(proto: proto::ExternalExtensionAgentTarget) -> Self {
292 Self {
293 archive: proto.archive,
294 cmd: proto.cmd,
295 args: proto.args,
296 sha256: proto.sha256,
297 env: proto.env.into_iter().collect(),
298 }
299 }
300
301 pub fn to_proto(&self) -> proto::ExternalExtensionAgentTarget {
302 proto::ExternalExtensionAgentTarget {
303 archive: self.archive.clone(),
304 cmd: self.cmd.clone(),
305 args: self.args.clone(),
306 sha256: self.sha256.clone(),
307 env: self
308 .env
309 .iter()
310 .map(|(k, v)| (k.clone(), v.clone()))
311 .collect(),
312 }
313 }
314}
315
316#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
317pub enum ExtensionLibraryKind {
318 Rust,
319}
320
321#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
322pub struct GrammarManifestEntry {
323 pub repository: String,
324 #[serde(alias = "commit")]
325 pub rev: String,
326 #[serde(default)]
327 pub path: Option<String>,
328}
329
330#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
331pub struct LanguageServerManifestEntry {
332 /// Deprecated in favor of `languages`.
333 #[serde(default)]
334 language: Option<LanguageName>,
335 /// The list of languages this language server should work with.
336 #[serde(default)]
337 languages: Vec<LanguageName>,
338 #[serde(default)]
339 pub language_ids: HashMap<LanguageName, String>,
340 #[serde(default)]
341 pub code_action_kinds: Option<Vec<lsp::CodeActionKind>>,
342}
343
344impl LanguageServerManifestEntry {
345 /// Returns the list of languages for the language server.
346 ///
347 /// Prefer this over accessing the `language` or `languages` fields directly,
348 /// as we currently support both.
349 ///
350 /// We can replace this with just field access for the `languages` field once
351 /// we have removed `language`.
352 pub fn languages(&self) -> impl IntoIterator<Item = LanguageName> + '_ {
353 let language = if self.languages.is_empty() {
354 self.language.clone()
355 } else {
356 None
357 };
358 self.languages.iter().cloned().chain(language)
359 }
360}
361
362#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
363pub struct ContextServerManifestEntry {}
364
365#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
366pub struct SlashCommandManifestEntry {
367 pub description: String,
368 pub requires_argument: bool,
369}
370
371#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
372pub struct DebugAdapterManifestEntry {
373 pub schema_path: Option<RelPathBuf>,
374}
375
376#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
377pub struct DebugLocatorManifestEntry {}
378
379/// Manifest entry for a language model provider.
380#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
381pub struct LanguageModelProviderManifestEntry {
382 /// Display name for the provider.
383 pub name: String,
384 /// Path to an SVG icon file relative to the extension root (e.g., "icons/provider.svg").
385 #[serde(default)]
386 pub icon: Option<String>,
387}
388
389impl ExtensionManifest {
390 pub async fn load(fs: Arc<dyn Fs>, extension_dir: &Path) -> Result<Self> {
391 let extension_name = extension_dir
392 .file_name()
393 .and_then(OsStr::to_str)
394 .context("invalid extension name")?;
395
396 let extension_manifest_path = extension_dir.join("extension.toml");
397 if fs.is_file(&extension_manifest_path).await {
398 let manifest_content = fs.load(&extension_manifest_path).await.with_context(|| {
399 format!("loading {extension_name} extension.toml, {extension_manifest_path:?}")
400 })?;
401 toml::from_str(&manifest_content).map_err(|err| {
402 anyhow!("Invalid extension.toml for extension {extension_name}:\n{err}")
403 })
404 } else if let extension_manifest_path = extension_manifest_path.with_extension("json")
405 && fs.is_file(&extension_manifest_path).await
406 {
407 let manifest_content = fs.load(&extension_manifest_path).await.with_context(|| {
408 format!("loading {extension_name} extension.json, {extension_manifest_path:?}")
409 })?;
410
411 serde_json::from_str::<OldExtensionManifest>(&manifest_content)
412 .with_context(|| format!("invalid extension.json for extension {extension_name}"))
413 .map(|manifest_json| manifest_from_old_manifest(manifest_json, extension_name))
414 } else {
415 anyhow::bail!("No extension manifest found for extension {extension_name}")
416 }
417 }
418}
419
420fn manifest_from_old_manifest(
421 manifest_json: OldExtensionManifest,
422 extension_id: &str,
423) -> ExtensionManifest {
424 ExtensionManifest {
425 id: extension_id.into(),
426 name: manifest_json.name,
427 version: manifest_json.version,
428 description: manifest_json.description,
429 repository: manifest_json.repository,
430 authors: manifest_json.authors,
431 schema_version: SchemaVersion::ZERO,
432 lib: Default::default(),
433 themes: {
434 let mut themes = manifest_json.themes.into_values().collect::<Vec<_>>();
435 themes.sort_unstable();
436 themes.dedup();
437 themes
438 },
439 icon_themes: Vec::new(),
440 languages: {
441 let mut languages = manifest_json.languages.into_values().collect::<Vec<_>>();
442 languages.sort_unstable();
443 languages.dedup();
444 languages
445 },
446 grammars: manifest_json
447 .grammars
448 .into_keys()
449 .map(|grammar_name| (grammar_name, Default::default()))
450 .collect(),
451 language_servers: Default::default(),
452 context_servers: BTreeMap::default(),
453 slash_commands: BTreeMap::default(),
454 snippets: None,
455 capabilities: Vec::new(),
456 debug_adapters: Default::default(),
457 debug_locators: Default::default(),
458 language_model_providers: Default::default(),
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use pretty_assertions::assert_eq;
465 use util::rel_path::rel_path_buf;
466
467 use crate::ProcessExecCapability;
468
469 use super::*;
470
471 fn extension_manifest() -> ExtensionManifest {
472 ExtensionManifest {
473 id: "test".into(),
474 name: "Test".to_string(),
475 version: "1.0.0".into(),
476 schema_version: SchemaVersion::ZERO,
477 description: None,
478 repository: None,
479 authors: vec![],
480 lib: Default::default(),
481 themes: vec![],
482 icon_themes: vec![],
483 languages: vec![],
484 grammars: BTreeMap::default(),
485 language_servers: BTreeMap::default(),
486 context_servers: BTreeMap::default(),
487 slash_commands: BTreeMap::default(),
488 snippets: None,
489 capabilities: vec![],
490 debug_adapters: Default::default(),
491 debug_locators: Default::default(),
492 language_model_providers: BTreeMap::default(),
493 }
494 }
495
496 #[test]
497 fn test_build_adapter_schema_path_with_schema_path() {
498 let adapter_name = Arc::from("my_adapter");
499 let entry = DebugAdapterManifestEntry {
500 schema_path: Some(rel_path_buf("foo/bar")),
501 };
502
503 let path = build_debug_adapter_schema_path(&adapter_name, &entry).unwrap();
504 assert_eq!(path, rel_path_buf("foo/bar"));
505 }
506
507 #[test]
508 fn test_build_adapter_schema_path_without_schema_path() {
509 let adapter_name = Arc::from("my_adapter");
510 let entry = DebugAdapterManifestEntry::default();
511
512 let path = build_debug_adapter_schema_path(&adapter_name, &entry).unwrap();
513 assert_eq!(path, rel_path_buf("debug_adapter_schemas/my_adapter.json"));
514 }
515
516 #[test]
517 fn test_allow_exec_exact_match() {
518 let manifest = ExtensionManifest {
519 capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
520 command: "ls".to_string(),
521 args: vec!["-la".to_string()],
522 })],
523 ..extension_manifest()
524 };
525
526 assert!(manifest.allow_exec("ls", &["-la"]).is_ok());
527 assert!(manifest.allow_exec("ls", &["-l"]).is_err());
528 assert!(manifest.allow_exec("pwd", &[] as &[&str]).is_err());
529 }
530
531 #[test]
532 fn test_allow_exec_wildcard_arg() {
533 let manifest = ExtensionManifest {
534 capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
535 command: "git".to_string(),
536 args: vec!["*".to_string()],
537 })],
538 ..extension_manifest()
539 };
540
541 assert!(manifest.allow_exec("git", &["status"]).is_ok());
542 assert!(manifest.allow_exec("git", &["commit"]).is_ok());
543 assert!(manifest.allow_exec("git", &["status", "-s"]).is_err()); // too many args
544 assert!(manifest.allow_exec("npm", &["install"]).is_err()); // wrong command
545 }
546
547 #[test]
548 fn test_allow_exec_double_wildcard() {
549 let manifest = ExtensionManifest {
550 capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
551 command: "cargo".to_string(),
552 args: vec!["test".to_string(), "**".to_string()],
553 })],
554 ..extension_manifest()
555 };
556
557 assert!(manifest.allow_exec("cargo", &["test"]).is_ok());
558 assert!(manifest.allow_exec("cargo", &["test", "--all"]).is_ok());
559 assert!(
560 manifest
561 .allow_exec("cargo", &["test", "--all", "--no-fail-fast"])
562 .is_ok()
563 );
564 assert!(manifest.allow_exec("cargo", &["build"]).is_err()); // wrong first arg
565 }
566
567 #[test]
568 fn test_allow_exec_mixed_wildcards() {
569 let manifest = ExtensionManifest {
570 capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
571 command: "docker".to_string(),
572 args: vec!["run".to_string(), "*".to_string(), "**".to_string()],
573 })],
574 ..extension_manifest()
575 };
576
577 assert!(manifest.allow_exec("docker", &["run", "nginx"]).is_ok());
578 assert!(manifest.allow_exec("docker", &["run"]).is_err());
579 assert!(
580 manifest
581 .allow_exec("docker", &["run", "ubuntu", "bash"])
582 .is_ok()
583 );
584 assert!(
585 manifest
586 .allow_exec("docker", &["run", "alpine", "sh", "-c", "echo hello"])
587 .is_ok()
588 );
589 assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg
590 }
591
592 #[test]
593 #[cfg(target_os = "windows")]
594 fn test_deserialize_manifest_with_windows_separators() {
595 use indoc::indoc;
596
597 let content = indoc! {r#"
598 id = "test-manifest"
599 name = "Test Manifest"
600 version = "0.0.1"
601 schema_version = 0
602 languages = ["foo\\bar"]
603 "#};
604 let manifest: ExtensionManifest = toml::from_str(&content).expect("manifest should parse");
605 assert_eq!(manifest.languages, vec![rel_path_buf("foo/bar")]);
606 }
607}
608