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)]
41 pub setup: Option<String>,
42 #[serde(default)]
51 pub prebuilt: std::collections::HashMap<String, PrebuiltAsset>,
52 #[serde(default)]
54 pub args: Vec<String>,
55 #[serde(default)]
57 pub permissions: Vec<String>,
58 #[serde(default)]
60 pub hooks: Vec<HookSubscription>,
61 #[serde(default)]
63 pub config: Vec<ExtensionConfigEntry>,
64 #[serde(default)]
72 pub theme_tokens: std::collections::BTreeMap<String, String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub deferred: Option<super::lifecycle::DeferredDeclarations>,
80}
81
82#[derive(Deserialize)]
93struct RawExtensionManifest {
94 #[serde(default = "default_protocol_version")]
95 protocol_version: u32,
96 runtime: ExtensionRuntime,
97 command: String,
98 #[serde(default)]
99 setup: Option<String>,
100 #[serde(default)]
101 prebuilt: std::collections::HashMap<String, PrebuiltAsset>,
102 #[serde(default)]
103 args: Vec<String>,
104 #[serde(default)]
105 permissions: Vec<String>,
106 #[serde(default)]
107 hooks: Vec<HookSubscription>,
108 #[serde(default)]
109 config: Vec<ExtensionConfigEntry>,
110 #[serde(default)]
111 theme_tokens: std::collections::BTreeMap<String, String>,
112 #[serde(default)]
113 deferred: Option<super::lifecycle::DeferredDeclarations>,
114 #[serde(default)]
116 tools: Vec<super::lifecycle::DeclaredExtensionTool>,
117 #[serde(default)]
119 activation: Option<String>,
120}
121
122impl TryFrom<RawExtensionManifest> for ExtensionManifest {
123 type Error = String;
124
125 fn try_from(raw: RawExtensionManifest) -> Result<Self, Self::Error> {
126 if let Some(activation) = raw.activation.as_deref() {
127 if activation != "deferred" {
128 return Err(format!(
129 "unsupported extension activation '{}' (supported: \"deferred\"); refusing to fall back to the eager lifecycle",
130 activation,
131 ));
132 }
133 }
134 let legacy_alias_used = raw.activation.is_some() || !raw.tools.is_empty();
135 if raw.deferred.is_some() && legacy_alias_used {
136 return Err(
137 "extension declares both a native 'deferred' block and legacy 'tools'/'activation' aliases; use only the native 'deferred' block".to_string(),
138 );
139 }
140 let deferred = if legacy_alias_used {
141 if raw.tools.is_empty() {
142 return Err(
143 "extension declares activation \"deferred\" without legacy 'tools' declarations; declare tools or use the native 'deferred' block".to_string(),
144 );
145 }
146 Some(super::lifecycle::DeferredDeclarations {
147 tools: raw.tools,
148 providers: Vec::new(),
149 lifecycle: None,
150 context_providers: Value::Null,
151 })
152 } else {
153 raw.deferred
154 };
155 Ok(Self {
156 protocol_version: raw.protocol_version,
157 runtime: raw.runtime,
158 command: raw.command,
159 setup: raw.setup,
160 prebuilt: raw.prebuilt,
161 args: raw.args,
162 permissions: raw.permissions,
163 hooks: raw.hooks,
164 config: raw.config,
165 theme_tokens: raw.theme_tokens,
166 deferred,
167 })
168 }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180pub struct PrebuiltAsset {
181 pub url: String,
185 pub sha256: String,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
193#[serde(rename_all = "lowercase")]
194pub enum ExtensionConfigValueKind {
195 String,
196 Bool,
197 Number,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
201pub struct ExtensionConfigEntry {
202 pub key: String,
203 #[serde(default, rename = "type")]
204 pub value_type: Option<ExtensionConfigValueKind>,
205 #[serde(default)]
206 pub description: Option<String>,
207 #[serde(default)]
208 pub required: bool,
209 #[serde(default)]
210 pub default: Option<Value>,
211 #[serde(default)]
212 pub secret_env: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub host_context: Option<HostContextKey>,
219}
220
221#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
225#[serde(rename_all = "snake_case")]
226pub enum HostContextKey {
227 ProjectRoot,
230}
231
232#[derive(Debug, Clone)]
234pub struct ValidatedExtensionManifest {
235 pub permissions: PermissionSet,
236 pub subscriptions: Vec<(HookKind, Option<String>, Option<HookMatcher>)>,
237}
238
239impl ExtensionManifest {
240 pub fn validate(&self, id: &str) -> Result<ValidatedExtensionManifest, String> {
242 super::lifecycle::validate_manifest_deferred(self).map_err(|reason| {
248 format!("Extension '{id}' deferred declarations invalid: {reason}")
249 })?;
250 if self.protocol_version != CURRENT_EXTENSION_PROTOCOL_VERSION {
251 return Err(format!(
252 "Extension '{}' uses unsupported protocol_version {} (supported: {})",
253 id, self.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION,
254 ));
255 }
256
257 if self.command.trim().is_empty() {
258 return Err(format!("Extension '{}' has empty command", id));
259 }
260
261 let has_capability_permission = self.permissions.iter().any(|permission| {
262 matches!(
263 permission.as_str(),
264 "tools.register"
265 | "providers.register"
266 | "context_providers.register"
267 | "memory.read"
268 | "memory.write"
269 | "config.write"
270 | "config.subscribe"
271 | "audio.input"
272 | "audio.output"
273 | "session.drive"
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}