1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use super::hooks::events::HookKind;
7use super::permissions::PermissionSet;
8
9pub const CURRENT_EXTENSION_PROTOCOL_VERSION: u32 = 1;
11
12fn default_protocol_version() -> u32 {
13 CURRENT_EXTENSION_PROTOCOL_VERSION
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(try_from = "RawExtensionManifest")]
26pub struct ExtensionManifest {
27 #[serde(default = "default_protocol_version")]
29 pub protocol_version: u32,
30 pub runtime: ExtensionRuntime,
32 pub command: String,
34 #[serde(default)]
42 pub setup: Option<String>,
43 #[serde(default)]
52 pub prebuilt: std::collections::HashMap<String, PrebuiltAsset>,
53 #[serde(default)]
55 pub args: Vec<String>,
56 #[serde(default)]
58 pub permissions: Vec<String>,
59 #[serde(default)]
61 pub hooks: Vec<HookSubscription>,
62 #[serde(default)]
64 pub config: Vec<ExtensionConfigEntry>,
65 #[serde(default)]
73 pub theme_tokens: std::collections::BTreeMap<String, String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub deferred: Option<super::lifecycle::DeferredDeclarations>,
81}
82
83#[derive(Deserialize)]
94struct RawExtensionManifest {
95 #[serde(default = "default_protocol_version")]
96 protocol_version: u32,
97 runtime: ExtensionRuntime,
98 command: String,
99 #[serde(default)]
100 setup: Option<String>,
101 #[serde(default)]
102 prebuilt: std::collections::HashMap<String, PrebuiltAsset>,
103 #[serde(default)]
104 args: Vec<String>,
105 #[serde(default)]
106 permissions: Vec<String>,
107 #[serde(default)]
108 hooks: Vec<HookSubscription>,
109 #[serde(default)]
110 config: Vec<ExtensionConfigEntry>,
111 #[serde(default)]
112 theme_tokens: std::collections::BTreeMap<String, String>,
113 #[serde(default)]
114 deferred: Option<super::lifecycle::DeferredDeclarations>,
115 #[serde(default)]
117 tools: Vec<super::lifecycle::DeclaredExtensionTool>,
118 #[serde(default)]
120 activation: Option<String>,
121}
122
123impl TryFrom<RawExtensionManifest> for ExtensionManifest {
124 type Error = String;
125
126 fn try_from(raw: RawExtensionManifest) -> Result<Self, Self::Error> {
127 if let Some(activation) = raw.activation.as_deref() {
128 if activation != "deferred" {
129 return Err(format!(
130 "unsupported extension activation '{}' (supported: \"deferred\"); refusing to fall back to the eager lifecycle",
131 activation,
132 ));
133 }
134 }
135 let legacy_alias_used = raw.activation.is_some() || !raw.tools.is_empty();
136 if raw.deferred.is_some() && legacy_alias_used {
137 return Err(
138 "extension declares both a native 'deferred' block and legacy 'tools'/'activation' aliases; use only the native 'deferred' block".to_string(),
139 );
140 }
141 let deferred = if legacy_alias_used {
142 if raw.tools.is_empty() {
143 return Err(
144 "extension declares activation \"deferred\" without legacy 'tools' declarations; declare tools or use the native 'deferred' block".to_string(),
145 );
146 }
147 Some(super::lifecycle::DeferredDeclarations {
148 tools: raw.tools,
149 providers: Vec::new(),
150 lifecycle: None,
151 context_providers: Vec::new(),
152 })
153 } else {
154 raw.deferred
155 };
156 Ok(Self {
157 protocol_version: raw.protocol_version,
158 runtime: raw.runtime,
159 command: raw.command,
160 setup: raw.setup,
161 prebuilt: raw.prebuilt,
162 args: raw.args,
163 permissions: raw.permissions,
164 hooks: raw.hooks,
165 config: raw.config,
166 theme_tokens: raw.theme_tokens,
167 deferred,
168 })
169 }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
181pub struct PrebuiltAsset {
182 pub url: String,
186 pub sha256: String,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
194#[serde(rename_all = "lowercase")]
195pub enum ExtensionConfigValueKind {
196 String,
197 Bool,
198 Number,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202pub struct ExtensionConfigEntry {
203 pub key: String,
204 #[serde(default, rename = "type")]
205 pub value_type: Option<ExtensionConfigValueKind>,
206 #[serde(default)]
207 pub description: Option<String>,
208 #[serde(default)]
209 pub required: bool,
210 #[serde(default)]
211 pub default: Option<Value>,
212 #[serde(default)]
213 pub secret_env: Option<String>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub host_context: Option<HostContextKey>,
220}
221
222#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
226#[serde(rename_all = "snake_case")]
227pub enum HostContextKey {
228 ProjectRoot,
231}
232
233#[derive(Debug, Clone)]
235pub struct ValidatedExtensionManifest {
236 pub permissions: PermissionSet,
237 pub subscriptions: Vec<(HookKind, Option<String>, Option<HookMatcher>)>,
238}
239
240impl ExtensionManifest {
241 pub fn validate(&self, id: &str) -> Result<ValidatedExtensionManifest, String> {
243 super::lifecycle::validate_manifest_deferred(self).map_err(|reason| {
249 format!("Extension '{id}' deferred declarations invalid: {reason}")
250 })?;
251 if self.protocol_version != CURRENT_EXTENSION_PROTOCOL_VERSION {
252 return Err(format!(
253 "Extension '{}' uses unsupported protocol_version {} (supported: {})",
254 id, self.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION,
255 ));
256 }
257
258 if self.command.trim().is_empty() {
259 return Err(format!("Extension '{}' has empty command", id));
260 }
261
262 let has_capability_permission = self.permissions.iter().any(|permission| {
263 matches!(
264 permission.as_str(),
265 "tools.register"
266 | "providers.register"
267 | "context_providers.register"
268 | "memory.read"
269 | "memory.write"
270 | "config.write"
271 | "config.subscribe"
272 | "audio.input"
273 | "audio.output"
274 )
275 });
276 if self.hooks.is_empty() && !has_capability_permission {
277 return Err(format!("Extension '{}' must subscribe to at least one hook or request a registration permission", id));
278 }
279
280 for (token, value) in &self.theme_tokens {
281 if token.trim().is_empty()
282 || token.contains('.')
283 || token.contains('/')
284 || token.contains('\\')
285 || token.contains(char::is_whitespace)
286 {
287 return Err(format!(
288 "Extension '{}' theme token '{}' is invalid: token names must be non-empty and must not contain dots, slashes, or spaces",
289 id, token,
290 ));
291 }
292 if !is_hex_color(value) {
293 return Err(format!(
294 "Extension '{}' theme token '{}' has invalid color '{}': expected '#rgb' or '#rrggbb' hex",
295 id, token, value,
296 ));
297 }
298 }
299
300 let permissions = PermissionSet::try_from_strings(&self.permissions)?;
301 let mut subscriptions = Vec::with_capacity(self.hooks.len());
302 for sub in &self.hooks {
303 let kind = HookKind::from_str(&sub.hook).ok_or_else(|| {
304 format!("Unknown hook kind: '{}' in extension '{}'", sub.hook, id)
305 })?;
306 if !permissions.allows_hook(kind) {
307 return Err(format!(
308 "Extension '{}' lacks permission '{}' required for hook '{}'",
309 id,
310 kind.required_permission().as_str(),
311 kind.as_str(),
312 ));
313 }
314 if sub.tool.is_some() && !kind.allows_tool_filter() {
315 return Err(format!(
316 "Extension '{}' hook '{}' does not allow a tool filter",
317 id,
318 kind.as_str(),
319 ));
320 }
321 subscriptions.push((kind, sub.tool.clone(), sub.matcher.clone()));
322 }
323
324 Ok(ValidatedExtensionManifest {
325 permissions,
326 subscriptions,
327 })
328 }
329}
330
331fn is_hex_color(value: &str) -> bool {
336 let hex = match value.trim().strip_prefix('#') {
337 Some(h) => h,
338 None => return false,
339 };
340 matches!(hex.len(), 3 | 6) && hex.chars().all(|c| c.is_ascii_hexdigit())
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(rename_all = "lowercase")]
346pub enum ExtensionRuntime {
347 Process,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct HookSubscription {
353 pub hook: String,
355 #[serde(default)]
357 pub tool: Option<String>,
358 #[serde(default, rename = "match")]
360 pub matcher: Option<HookMatcher>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
364#[serde(deny_unknown_fields)]
365pub struct HookMatcher {
366 #[serde(default)]
367 pub input_contains: Option<String>,
368 #[serde(default)]
369 pub input_equals: Option<serde_json::Value>,
370}
371
372impl HookMatcher {
373 pub const SUPPORTED_KEYS: &'static [&'static str] = &["input_contains", "input_equals"];
374
375 pub fn matches(&self, event: &crate::extensions::hooks::events::HookEvent) -> bool {
376 let input = event
377 .tool_input
378 .as_ref()
379 .unwrap_or(&serde_json::Value::Null);
380 if let Some(expected) = &self.input_equals {
381 if input != expected {
382 return false;
383 }
384 }
385 if let Some(needle) = &self.input_contains {
386 let haystack = serde_json::to_string(input).unwrap_or_default();
387 if !haystack.contains(needle) {
388 return false;
389 }
390 }
391 true
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
402 fn deserialize_full_manifest() {
403 let json = r#"{
404 "protocol_version": 1,
405 "runtime": "process",
406 "command": "/usr/bin/my-ext",
407 "args": ["--port", "0"],
408 "permissions": ["tools.intercept", "session.lifecycle"],
409 "hooks": [
410 {"hook": "before_tool_call", "tool": "bash"},
411 {"hook": "on_session_start"}
412 ]
413 }"#;
414
415 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
416 assert_eq!(m.protocol_version, 1);
417 assert_eq!(m.runtime, ExtensionRuntime::Process);
418 assert_eq!(m.command, "/usr/bin/my-ext");
419 assert_eq!(m.args, vec!["--port", "0"]);
420 assert_eq!(m.permissions, vec!["tools.intercept", "session.lifecycle"]);
421 assert_eq!(m.hooks.len(), 2);
422 assert_eq!(m.hooks[0].hook, "before_tool_call");
423 assert_eq!(m.hooks[0].tool.as_deref(), Some("bash"));
424 assert_eq!(m.hooks[1].hook, "on_session_start");
425 assert_eq!(m.hooks[1].tool, None);
426 }
427
428 #[test]
431 fn missing_optional_fields_get_defaults() {
432 let json = r#"{
433 "runtime": "process",
434 "command": "my-ext"
435 }"#;
436
437 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
438 assert_eq!(m.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION);
439 assert_eq!(m.runtime, ExtensionRuntime::Process);
440 assert_eq!(m.command, "my-ext");
441 assert!(m.args.is_empty(), "args should default to []");
442 assert!(m.permissions.is_empty(), "permissions should default to []");
443 assert!(m.hooks.is_empty(), "hooks should default to []");
444 }
445
446 #[test]
447 fn extension_config_entry_deserializes_optional_type() {
448 let json = r#"{
449 "key": "backend",
450 "type": "string",
451 "description": "Backend selector",
452 "default": "auto"
453 }"#;
454
455 let entry: ExtensionConfigEntry = serde_json::from_str(json).unwrap();
456 assert_eq!(entry.key, "backend");
457 assert_eq!(entry.value_type, Some(ExtensionConfigValueKind::String));
458 assert_eq!(entry.description.as_deref(), Some("Backend selector"));
459 assert_eq!(
460 entry.default,
461 Some(serde_json::Value::String("auto".to_string()))
462 );
463 }
464
465 #[test]
466 fn extension_config_entry_omitted_type_is_none() {
467 let json = r#"{"key": "backend"}"#;
468
469 let entry: ExtensionConfigEntry = serde_json::from_str(json).unwrap();
470 assert_eq!(entry.key, "backend");
471 assert_eq!(entry.value_type, None);
472 }
473
474 #[test]
475 fn hook_subscription_tool_defaults_to_none() {
476 let json = r#"{
477 "runtime": "process",
478 "command": "ext",
479 "hooks": [{"hook": "on_session_start"}]
480 }"#;
481
482 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
483 assert_eq!(m.hooks[0].tool, None);
484 }
485
486 #[test]
489 fn missing_command_fails() {
490 let json = r#"{"runtime": "process"}"#;
491 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
492 assert!(result.is_err(), "command is required");
493 }
494
495 #[test]
496 fn missing_runtime_fails() {
497 let json = r#"{"command": "my-ext"}"#;
498 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
499 assert!(result.is_err(), "runtime is required");
500 }
501
502 #[test]
505 fn unknown_runtime_type_errors() {
506 let json = r#"{
507 "runtime": "wasm",
508 "command": "my-ext"
509 }"#;
510 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
511 assert!(result.is_err(), "unknown runtime 'wasm' should be rejected");
512 }
513
514 #[test]
515 fn runtime_is_case_sensitive() {
516 let json = r#"{"runtime": "Process", "command": "ext"}"#;
517 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
518 assert!(result.is_err(), "runtime matching is lowercase-only");
519 }
520
521 #[test]
522 fn validate_rejects_unsupported_protocol_version() {
523 let manifest = ExtensionManifest {
524 theme_tokens: Default::default(),
525 deferred: None,
526 protocol_version: 999,
527 runtime: ExtensionRuntime::Process,
528 command: "ext".to_string(),
529 setup: None,
530 prebuilt: ::std::collections::HashMap::new(),
531 args: vec![],
532 permissions: vec!["tools.intercept".to_string()],
533 hooks: vec![HookSubscription {
534 hook: "before_tool_call".to_string(),
535 tool: None,
536 matcher: None,
537 }],
538 config: vec![],
539 };
540
541 let err = manifest.validate("bad-version").unwrap_err();
542 assert!(err.contains("unsupported protocol_version 999"));
543 }
544
545 #[test]
546 fn validate_allows_hookless_provider_registration_extensions() {
547 let manifest = ExtensionManifest {
548 theme_tokens: Default::default(),
549 deferred: None,
550 protocol_version: 1,
551 runtime: ExtensionRuntime::Process,
552 command: "ext".to_string(),
553 setup: None,
554 prebuilt: ::std::collections::HashMap::new(),
555 args: vec![],
556 permissions: vec!["providers.register".to_string()],
557 hooks: vec![],
558 config: vec![],
559 };
560
561 manifest.validate("provider-only").unwrap();
562 }
563
564 #[test]
565 fn validate_rejects_tool_filter_on_non_tool_hook() {
566 let manifest = ExtensionManifest {
567 theme_tokens: Default::default(),
568 deferred: None,
569 protocol_version: 1,
570 runtime: ExtensionRuntime::Process,
571 command: "ext".to_string(),
572 setup: None,
573 prebuilt: ::std::collections::HashMap::new(),
574 args: vec![],
575 permissions: vec!["session.lifecycle".to_string()],
576 hooks: vec![HookSubscription {
577 hook: "on_session_start".to_string(),
578 tool: Some("bash".to_string()),
579 matcher: None,
580 }],
581 config: vec![],
582 };
583
584 let err = manifest.validate("bad-filter").unwrap_err();
585 assert!(err.contains("does not allow a tool filter"));
586 }
587
588 #[test]
591 fn serialize_roundtrip() {
592 let original = ExtensionManifest {
593 theme_tokens: Default::default(),
594 deferred: None,
595 protocol_version: 1,
596 runtime: ExtensionRuntime::Process,
597 command: "my-ext".to_string(),
598 setup: None,
599 prebuilt: ::std::collections::HashMap::new(),
600 args: vec!["--verbose".to_string()],
601 permissions: vec!["tools.intercept".to_string()],
602 hooks: vec![HookSubscription {
603 hook: "before_tool_call".to_string(),
604 tool: Some("bash".to_string()),
605 matcher: None,
606 }],
607 config: vec![],
608 };
609
610 let json = serde_json::to_string(&original).unwrap();
611 let restored: ExtensionManifest = serde_json::from_str(&json).unwrap();
612
613 assert_eq!(restored.protocol_version, original.protocol_version);
614 assert_eq!(restored.runtime, original.runtime);
615 assert_eq!(restored.command, original.command);
616 assert_eq!(restored.args, original.args);
617 assert_eq!(restored.permissions, original.permissions);
618 assert_eq!(restored.hooks[0].hook, original.hooks[0].hook);
619 assert_eq!(restored.hooks[0].tool, original.hooks[0].tool);
620 }
621
622 #[test]
625 fn matcher_input_equals_requires_exact_tool_input() {
626 let matcher = HookMatcher {
627 input_contains: None,
628 input_equals: Some(serde_json::json!({"command": "echo safe"})),
629 };
630
631 let matching = crate::extensions::hooks::events::HookEvent::before_tool_call(
632 "bash",
633 serde_json::json!({"command": "echo safe"}),
634 );
635 let different = crate::extensions::hooks::events::HookEvent::before_tool_call(
636 "bash",
637 serde_json::json!({"command": "echo safe", "extra": true}),
638 );
639
640 assert!(matcher.matches(&matching));
641 assert!(!matcher.matches(&different));
642 }
643
644 #[test]
645 fn matcher_conditions_are_combined_with_and() {
646 let matcher = HookMatcher {
647 input_contains: Some("safe".to_string()),
648 input_equals: Some(serde_json::json!({"command": "echo safe"})),
649 };
650
651 let matching = crate::extensions::hooks::events::HookEvent::before_tool_call(
652 "bash",
653 serde_json::json!({"command": "echo safe"}),
654 );
655 let equals_but_missing_contains =
656 crate::extensions::hooks::events::HookEvent::before_tool_call(
657 "bash",
658 serde_json::json!({"command": "echo ok"}),
659 );
660
661 assert!(matcher.matches(&matching));
662 assert!(!matcher.matches(&equals_but_missing_contains));
663 }
664
665 #[test]
666 fn runtime_serializes_as_lowercase() {
667 let rt = ExtensionRuntime::Process;
668 let json = serde_json::to_string(&rt).unwrap();
669 assert_eq!(json, r#""process""#);
670 }
671
672 #[test]
673 fn extension_manifest_defaults_prebuilt_to_empty_when_absent() {
674 let json = r#"{
676 "runtime": "process",
677 "command": "bin/ext"
678 }"#;
679 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
680 assert!(m.prebuilt.is_empty());
681 assert!(m.setup.is_none());
682 }
683
684 #[test]
685 fn extension_manifest_round_trips_prebuilt_assets() {
686 let json = r#"{
687 "runtime": "process",
688 "command": "bin/ext",
689 "prebuilt": {
690 "linux-x86_64": {
691 "url": "https://example.com/ext-linux-x86_64.tar.gz",
692 "sha256": "abc123"
693 },
694 "darwin-arm64": {
695 "url": "https://example.com/ext-darwin-arm64.tar.gz",
696 "sha256": "def456"
697 }
698 }
699 }"#;
700 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
701 assert_eq!(m.prebuilt.len(), 2);
702 let linux = m.prebuilt.get("linux-x86_64").expect("linux entry");
703 assert_eq!(linux.url, "https://example.com/ext-linux-x86_64.tar.gz");
704 assert_eq!(linux.sha256, "abc123");
705 let back = serde_json::to_value(&m).unwrap();
707 assert_eq!(
708 back["prebuilt"]["darwin-arm64"]["sha256"],
709 serde_json::Value::String("def456".to_string())
710 );
711 }
712
713 #[test]
714 fn prebuilt_asset_requires_both_url_and_sha256() {
715 let json = r#"{ "url": "https://example.com/x.tar.gz" }"#;
717 let res: Result<PrebuiltAsset, _> = serde_json::from_str(json);
718 assert!(
719 res.is_err(),
720 "PrebuiltAsset without sha256 must fail to parse"
721 );
722 }
723
724 #[test]
727 fn theme_tokens_absent_defaults_to_empty_and_validates() {
728 let json = r#"{
732 "runtime": "process",
733 "command": "my-ext",
734 "permissions": ["tools.register"]
735 }"#;
736 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
737 assert!(
738 m.theme_tokens.is_empty(),
739 "absent theme_tokens must default to empty"
740 );
741 assert!(
742 m.validate("legacy-ext").is_ok(),
743 "legacy manifests must validate unchanged"
744 );
745 }
746
747 #[test]
748 fn theme_tokens_deserialize_and_validate() {
749 let json = r##"{
750 "runtime": "process",
751 "command": "my-ext",
752 "permissions": ["tools.register"],
753 "theme_tokens": { "accent": "#22d3ee", "warn": "#fa0" }
754 }"##;
755 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
756 assert_eq!(
757 m.theme_tokens.get("accent").map(String::as_str),
758 Some("#22d3ee")
759 );
760 assert_eq!(m.theme_tokens.get("warn").map(String::as_str), Some("#fa0"));
761 assert!(m.validate("themed-ext").is_ok());
762 }
763
764 #[test]
765 fn theme_tokens_reject_bad_token_names() {
766 for bad in ["with.dot", "with/slash", "with space", ""] {
767 let json = format!(
768 r##"{{
769 "runtime": "process",
770 "command": "my-ext",
771 "permissions": ["tools.register"],
772 "theme_tokens": {{ "{bad}": "#ffffff" }}
773 }}"##
774 );
775 let m: ExtensionManifest = serde_json::from_str(&json).unwrap();
776 let err = m.validate("bad-ext").unwrap_err();
777 assert!(
778 err.contains("theme token"),
779 "'{bad}' must be rejected: {err}"
780 );
781 }
782 }
783
784 #[test]
785 fn theme_tokens_reject_non_hex_colors() {
786 for bad in ["red", "#12345", "#gggggg", "22d3ee", "#"] {
787 let json = format!(
788 r#"{{
789 "runtime": "process",
790 "command": "my-ext",
791 "permissions": ["tools.register"],
792 "theme_tokens": {{ "accent": "{bad}" }}
793 }}"#
794 );
795 let m: ExtensionManifest = serde_json::from_str(&json).unwrap();
796 let err = m.validate("bad-ext").unwrap_err();
797 assert!(
798 err.contains("invalid color"),
799 "'{bad}' must be rejected: {err}"
800 );
801 }
802 }
803
804 #[test]
805 fn is_hex_color_accepts_short_and_long_forms() {
806 assert!(is_hex_color("#22d3ee"));
807 assert!(is_hex_color("#fa0"));
808 assert!(is_hex_color(" #ffffff "));
809 assert!(!is_hex_color("#22d3e"));
810 assert!(!is_hex_color("blue"));
811 }
812}