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 | "session.drive"
275 )
276 });
277 if self.hooks.is_empty() && !has_capability_permission {
278 return Err(format!("Extension '{}' must subscribe to at least one hook or request a registration permission", id));
279 }
280
281 for (token, value) in &self.theme_tokens {
282 if token.trim().is_empty()
283 || token.contains('.')
284 || token.contains('/')
285 || token.contains('\\')
286 || token.contains(char::is_whitespace)
287 {
288 return Err(format!(
289 "Extension '{}' theme token '{}' is invalid: token names must be non-empty and must not contain dots, slashes, or spaces",
290 id, token,
291 ));
292 }
293 if !is_hex_color(value) {
294 return Err(format!(
295 "Extension '{}' theme token '{}' has invalid color '{}': expected '#rgb' or '#rrggbb' hex",
296 id, token, value,
297 ));
298 }
299 }
300
301 let permissions = PermissionSet::try_from_strings(&self.permissions)?;
302 let mut subscriptions = Vec::with_capacity(self.hooks.len());
303 for sub in &self.hooks {
304 let kind = HookKind::from_str(&sub.hook).ok_or_else(|| {
305 format!("Unknown hook kind: '{}' in extension '{}'", sub.hook, id)
306 })?;
307 if !permissions.allows_hook(kind) {
308 return Err(format!(
309 "Extension '{}' lacks permission '{}' required for hook '{}'",
310 id,
311 kind.required_permission().as_str(),
312 kind.as_str(),
313 ));
314 }
315 if sub.tool.is_some() && !kind.allows_tool_filter() {
316 return Err(format!(
317 "Extension '{}' hook '{}' does not allow a tool filter",
318 id,
319 kind.as_str(),
320 ));
321 }
322 subscriptions.push((kind, sub.tool.clone(), sub.matcher.clone()));
323 }
324
325 Ok(ValidatedExtensionManifest {
326 permissions,
327 subscriptions,
328 })
329 }
330}
331
332fn is_hex_color(value: &str) -> bool {
337 let hex = match value.trim().strip_prefix('#') {
338 Some(h) => h,
339 None => return false,
340 };
341 matches!(hex.len(), 3 | 6) && hex.chars().all(|c| c.is_ascii_hexdigit())
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "lowercase")]
347pub enum ExtensionRuntime {
348 Process,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct HookSubscription {
354 pub hook: String,
356 #[serde(default)]
358 pub tool: Option<String>,
359 #[serde(default, rename = "match")]
361 pub matcher: Option<HookMatcher>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
365#[serde(deny_unknown_fields)]
366pub struct HookMatcher {
367 #[serde(default)]
368 pub input_contains: Option<String>,
369 #[serde(default)]
370 pub input_equals: Option<serde_json::Value>,
371}
372
373impl HookMatcher {
374 pub const SUPPORTED_KEYS: &'static [&'static str] = &["input_contains", "input_equals"];
375
376 pub fn matches(&self, event: &crate::extensions::hooks::events::HookEvent) -> bool {
377 let input = event
378 .tool_input
379 .as_ref()
380 .unwrap_or(&serde_json::Value::Null);
381 if let Some(expected) = &self.input_equals {
382 if input != expected {
383 return false;
384 }
385 }
386 if let Some(needle) = &self.input_contains {
387 let haystack = serde_json::to_string(input).unwrap_or_default();
388 if !haystack.contains(needle) {
389 return false;
390 }
391 }
392 true
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
403 fn deserialize_full_manifest() {
404 let json = r#"{
405 "protocol_version": 1,
406 "runtime": "process",
407 "command": "/usr/bin/my-ext",
408 "args": ["--port", "0"],
409 "permissions": ["tools.intercept", "session.lifecycle"],
410 "hooks": [
411 {"hook": "before_tool_call", "tool": "bash"},
412 {"hook": "on_session_start"}
413 ]
414 }"#;
415
416 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
417 assert_eq!(m.protocol_version, 1);
418 assert_eq!(m.runtime, ExtensionRuntime::Process);
419 assert_eq!(m.command, "/usr/bin/my-ext");
420 assert_eq!(m.args, vec!["--port", "0"]);
421 assert_eq!(m.permissions, vec!["tools.intercept", "session.lifecycle"]);
422 assert_eq!(m.hooks.len(), 2);
423 assert_eq!(m.hooks[0].hook, "before_tool_call");
424 assert_eq!(m.hooks[0].tool.as_deref(), Some("bash"));
425 assert_eq!(m.hooks[1].hook, "on_session_start");
426 assert_eq!(m.hooks[1].tool, None);
427 }
428
429 #[test]
432 fn missing_optional_fields_get_defaults() {
433 let json = r#"{
434 "runtime": "process",
435 "command": "my-ext"
436 }"#;
437
438 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
439 assert_eq!(m.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION);
440 assert_eq!(m.runtime, ExtensionRuntime::Process);
441 assert_eq!(m.command, "my-ext");
442 assert!(m.args.is_empty(), "args should default to []");
443 assert!(m.permissions.is_empty(), "permissions should default to []");
444 assert!(m.hooks.is_empty(), "hooks should default to []");
445 }
446
447 #[test]
448 fn extension_config_entry_deserializes_optional_type() {
449 let json = r#"{
450 "key": "backend",
451 "type": "string",
452 "description": "Backend selector",
453 "default": "auto"
454 }"#;
455
456 let entry: ExtensionConfigEntry = serde_json::from_str(json).unwrap();
457 assert_eq!(entry.key, "backend");
458 assert_eq!(entry.value_type, Some(ExtensionConfigValueKind::String));
459 assert_eq!(entry.description.as_deref(), Some("Backend selector"));
460 assert_eq!(
461 entry.default,
462 Some(serde_json::Value::String("auto".to_string()))
463 );
464 }
465
466 #[test]
467 fn extension_config_entry_omitted_type_is_none() {
468 let json = r#"{"key": "backend"}"#;
469
470 let entry: ExtensionConfigEntry = serde_json::from_str(json).unwrap();
471 assert_eq!(entry.key, "backend");
472 assert_eq!(entry.value_type, None);
473 }
474
475 #[test]
476 fn hook_subscription_tool_defaults_to_none() {
477 let json = r#"{
478 "runtime": "process",
479 "command": "ext",
480 "hooks": [{"hook": "on_session_start"}]
481 }"#;
482
483 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
484 assert_eq!(m.hooks[0].tool, None);
485 }
486
487 #[test]
490 fn missing_command_fails() {
491 let json = r#"{"runtime": "process"}"#;
492 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
493 assert!(result.is_err(), "command is required");
494 }
495
496 #[test]
497 fn missing_runtime_fails() {
498 let json = r#"{"command": "my-ext"}"#;
499 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
500 assert!(result.is_err(), "runtime is required");
501 }
502
503 #[test]
506 fn unknown_runtime_type_errors() {
507 let json = r#"{
508 "runtime": "wasm",
509 "command": "my-ext"
510 }"#;
511 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
512 assert!(result.is_err(), "unknown runtime 'wasm' should be rejected");
513 }
514
515 #[test]
516 fn runtime_is_case_sensitive() {
517 let json = r#"{"runtime": "Process", "command": "ext"}"#;
518 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
519 assert!(result.is_err(), "runtime matching is lowercase-only");
520 }
521
522 #[test]
523 fn validate_rejects_unsupported_protocol_version() {
524 let manifest = ExtensionManifest {
525 theme_tokens: Default::default(),
526 deferred: None,
527 protocol_version: 999,
528 runtime: ExtensionRuntime::Process,
529 command: "ext".to_string(),
530 setup: None,
531 prebuilt: ::std::collections::HashMap::new(),
532 args: vec![],
533 permissions: vec!["tools.intercept".to_string()],
534 hooks: vec![HookSubscription {
535 hook: "before_tool_call".to_string(),
536 tool: None,
537 matcher: None,
538 }],
539 config: vec![],
540 };
541
542 let err = manifest.validate("bad-version").unwrap_err();
543 assert!(err.contains("unsupported protocol_version 999"));
544 }
545
546 #[test]
547 fn validate_allows_hookless_provider_registration_extensions() {
548 let manifest = ExtensionManifest {
549 theme_tokens: Default::default(),
550 deferred: None,
551 protocol_version: 1,
552 runtime: ExtensionRuntime::Process,
553 command: "ext".to_string(),
554 setup: None,
555 prebuilt: ::std::collections::HashMap::new(),
556 args: vec![],
557 permissions: vec!["providers.register".to_string()],
558 hooks: vec![],
559 config: vec![],
560 };
561
562 manifest.validate("provider-only").unwrap();
563 }
564
565 #[test]
566 fn validate_rejects_tool_filter_on_non_tool_hook() {
567 let manifest = ExtensionManifest {
568 theme_tokens: Default::default(),
569 deferred: None,
570 protocol_version: 1,
571 runtime: ExtensionRuntime::Process,
572 command: "ext".to_string(),
573 setup: None,
574 prebuilt: ::std::collections::HashMap::new(),
575 args: vec![],
576 permissions: vec!["session.lifecycle".to_string()],
577 hooks: vec![HookSubscription {
578 hook: "on_session_start".to_string(),
579 tool: Some("bash".to_string()),
580 matcher: None,
581 }],
582 config: vec![],
583 };
584
585 let err = manifest.validate("bad-filter").unwrap_err();
586 assert!(err.contains("does not allow a tool filter"));
587 }
588
589 #[test]
592 fn serialize_roundtrip() {
593 let original = ExtensionManifest {
594 theme_tokens: Default::default(),
595 deferred: None,
596 protocol_version: 1,
597 runtime: ExtensionRuntime::Process,
598 command: "my-ext".to_string(),
599 setup: None,
600 prebuilt: ::std::collections::HashMap::new(),
601 args: vec!["--verbose".to_string()],
602 permissions: vec!["tools.intercept".to_string()],
603 hooks: vec![HookSubscription {
604 hook: "before_tool_call".to_string(),
605 tool: Some("bash".to_string()),
606 matcher: None,
607 }],
608 config: vec![],
609 };
610
611 let json = serde_json::to_string(&original).unwrap();
612 let restored: ExtensionManifest = serde_json::from_str(&json).unwrap();
613
614 assert_eq!(restored.protocol_version, original.protocol_version);
615 assert_eq!(restored.runtime, original.runtime);
616 assert_eq!(restored.command, original.command);
617 assert_eq!(restored.args, original.args);
618 assert_eq!(restored.permissions, original.permissions);
619 assert_eq!(restored.hooks[0].hook, original.hooks[0].hook);
620 assert_eq!(restored.hooks[0].tool, original.hooks[0].tool);
621 }
622
623 #[test]
626 fn matcher_input_equals_requires_exact_tool_input() {
627 let matcher = HookMatcher {
628 input_contains: None,
629 input_equals: Some(serde_json::json!({"command": "echo safe"})),
630 };
631
632 let matching = crate::extensions::hooks::events::HookEvent::before_tool_call(
633 "bash",
634 serde_json::json!({"command": "echo safe"}),
635 );
636 let different = crate::extensions::hooks::events::HookEvent::before_tool_call(
637 "bash",
638 serde_json::json!({"command": "echo safe", "extra": true}),
639 );
640
641 assert!(matcher.matches(&matching));
642 assert!(!matcher.matches(&different));
643 }
644
645 #[test]
646 fn matcher_conditions_are_combined_with_and() {
647 let matcher = HookMatcher {
648 input_contains: Some("safe".to_string()),
649 input_equals: Some(serde_json::json!({"command": "echo safe"})),
650 };
651
652 let matching = crate::extensions::hooks::events::HookEvent::before_tool_call(
653 "bash",
654 serde_json::json!({"command": "echo safe"}),
655 );
656 let equals_but_missing_contains =
657 crate::extensions::hooks::events::HookEvent::before_tool_call(
658 "bash",
659 serde_json::json!({"command": "echo ok"}),
660 );
661
662 assert!(matcher.matches(&matching));
663 assert!(!matcher.matches(&equals_but_missing_contains));
664 }
665
666 #[test]
667 fn runtime_serializes_as_lowercase() {
668 let rt = ExtensionRuntime::Process;
669 let json = serde_json::to_string(&rt).unwrap();
670 assert_eq!(json, r#""process""#);
671 }
672
673 #[test]
674 fn extension_manifest_defaults_prebuilt_to_empty_when_absent() {
675 let json = r#"{
677 "runtime": "process",
678 "command": "bin/ext"
679 }"#;
680 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
681 assert!(m.prebuilt.is_empty());
682 assert!(m.setup.is_none());
683 }
684
685 #[test]
686 fn extension_manifest_round_trips_prebuilt_assets() {
687 let json = r#"{
688 "runtime": "process",
689 "command": "bin/ext",
690 "prebuilt": {
691 "linux-x86_64": {
692 "url": "https://example.com/ext-linux-x86_64.tar.gz",
693 "sha256": "abc123"
694 },
695 "darwin-arm64": {
696 "url": "https://example.com/ext-darwin-arm64.tar.gz",
697 "sha256": "def456"
698 }
699 }
700 }"#;
701 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
702 assert_eq!(m.prebuilt.len(), 2);
703 let linux = m.prebuilt.get("linux-x86_64").expect("linux entry");
704 assert_eq!(linux.url, "https://example.com/ext-linux-x86_64.tar.gz");
705 assert_eq!(linux.sha256, "abc123");
706 let back = serde_json::to_value(&m).unwrap();
708 assert_eq!(
709 back["prebuilt"]["darwin-arm64"]["sha256"],
710 serde_json::Value::String("def456".to_string())
711 );
712 }
713
714 #[test]
715 fn prebuilt_asset_requires_both_url_and_sha256() {
716 let json = r#"{ "url": "https://example.com/x.tar.gz" }"#;
718 let res: Result<PrebuiltAsset, _> = serde_json::from_str(json);
719 assert!(
720 res.is_err(),
721 "PrebuiltAsset without sha256 must fail to parse"
722 );
723 }
724
725 #[test]
728 fn theme_tokens_absent_defaults_to_empty_and_validates() {
729 let json = r#"{
733 "runtime": "process",
734 "command": "my-ext",
735 "permissions": ["tools.register"]
736 }"#;
737 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
738 assert!(
739 m.theme_tokens.is_empty(),
740 "absent theme_tokens must default to empty"
741 );
742 assert!(
743 m.validate("legacy-ext").is_ok(),
744 "legacy manifests must validate unchanged"
745 );
746 }
747
748 #[test]
749 fn theme_tokens_deserialize_and_validate() {
750 let json = r##"{
751 "runtime": "process",
752 "command": "my-ext",
753 "permissions": ["tools.register"],
754 "theme_tokens": { "accent": "#22d3ee", "warn": "#fa0" }
755 }"##;
756 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
757 assert_eq!(
758 m.theme_tokens.get("accent").map(String::as_str),
759 Some("#22d3ee")
760 );
761 assert_eq!(m.theme_tokens.get("warn").map(String::as_str), Some("#fa0"));
762 assert!(m.validate("themed-ext").is_ok());
763 }
764
765 #[test]
766 fn theme_tokens_reject_bad_token_names() {
767 for bad in ["with.dot", "with/slash", "with space", ""] {
768 let json = format!(
769 r##"{{
770 "runtime": "process",
771 "command": "my-ext",
772 "permissions": ["tools.register"],
773 "theme_tokens": {{ "{bad}": "#ffffff" }}
774 }}"##
775 );
776 let m: ExtensionManifest = serde_json::from_str(&json).unwrap();
777 let err = m.validate("bad-ext").unwrap_err();
778 assert!(
779 err.contains("theme token"),
780 "'{bad}' must be rejected: {err}"
781 );
782 }
783 }
784
785 #[test]
786 fn theme_tokens_reject_non_hex_colors() {
787 for bad in ["red", "#12345", "#gggggg", "22d3ee", "#"] {
788 let json = format!(
789 r#"{{
790 "runtime": "process",
791 "command": "my-ext",
792 "permissions": ["tools.register"],
793 "theme_tokens": {{ "accent": "{bad}" }}
794 }}"#
795 );
796 let m: ExtensionManifest = serde_json::from_str(&json).unwrap();
797 let err = m.validate("bad-ext").unwrap_err();
798 assert!(
799 err.contains("invalid color"),
800 "'{bad}' must be rejected: {err}"
801 );
802 }
803 }
804
805 #[test]
806 fn is_hex_color_accepts_short_and_long_forms() {
807 assert!(is_hex_color("#22d3ee"));
808 assert!(is_hex_color("#fa0"));
809 assert!(is_hex_color(" #ffffff "));
810 assert!(!is_hex_color("#22d3e"));
811 assert!(!is_hex_color("blue"));
812 }
813}