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)]
18pub struct ExtensionManifest {
19 #[serde(default = "default_protocol_version")]
21 pub protocol_version: u32,
22 pub runtime: ExtensionRuntime,
24 pub command: String,
26 #[serde(default)]
34 pub setup: Option<String>,
35 #[serde(default)]
44 pub prebuilt: std::collections::HashMap<String, PrebuiltAsset>,
45 #[serde(default)]
47 pub args: Vec<String>,
48 #[serde(default)]
50 pub permissions: Vec<String>,
51 #[serde(default)]
53 pub hooks: Vec<HookSubscription>,
54 #[serde(default)]
56 pub config: Vec<ExtensionConfigEntry>,
57 #[serde(default)]
65 pub theme_tokens: std::collections::BTreeMap<String, String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct PrebuiltAsset {
78 pub url: String,
82 pub sha256: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "lowercase")]
91pub enum ExtensionConfigValueKind {
92 String,
93 Bool,
94 Number,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct ExtensionConfigEntry {
99 pub key: String,
100 #[serde(default, rename = "type")]
101 pub value_type: Option<ExtensionConfigValueKind>,
102 #[serde(default)]
103 pub description: Option<String>,
104 #[serde(default)]
105 pub required: bool,
106 #[serde(default)]
107 pub default: Option<Value>,
108 #[serde(default)]
109 pub secret_env: Option<String>,
110}
111
112#[derive(Debug, Clone)]
114pub struct ValidatedExtensionManifest {
115 pub permissions: PermissionSet,
116 pub subscriptions: Vec<(HookKind, Option<String>, Option<HookMatcher>)>,
117}
118
119impl ExtensionManifest {
120 pub fn validate(&self, id: &str) -> Result<ValidatedExtensionManifest, String> {
122 if self.protocol_version != CURRENT_EXTENSION_PROTOCOL_VERSION {
123 return Err(format!(
124 "Extension '{}' uses unsupported protocol_version {} (supported: {})",
125 id, self.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION,
126 ));
127 }
128
129 if self.command.trim().is_empty() {
130 return Err(format!("Extension '{}' has empty command", id));
131 }
132
133 let has_capability_permission = self.permissions.iter().any(|permission| {
134 matches!(
135 permission.as_str(),
136 "tools.register" | "providers.register" | "memory.read" | "memory.write"
137 | "config.write" | "config.subscribe" | "audio.input" | "audio.output"
138 )
139 });
140 if self.hooks.is_empty() && !has_capability_permission {
141 return Err(format!("Extension '{}' must subscribe to at least one hook or request a registration permission", id));
142 }
143
144 for (token, value) in &self.theme_tokens {
145 if token.trim().is_empty()
146 || token.contains('.')
147 || token.contains('/')
148 || token.contains('\\')
149 || token.contains(char::is_whitespace)
150 {
151 return Err(format!(
152 "Extension '{}' theme token '{}' is invalid: token names must be non-empty and must not contain dots, slashes, or spaces",
153 id, token,
154 ));
155 }
156 if !is_hex_color(value) {
157 return Err(format!(
158 "Extension '{}' theme token '{}' has invalid color '{}': expected '#rgb' or '#rrggbb' hex",
159 id, token, value,
160 ));
161 }
162 }
163
164 let permissions = PermissionSet::try_from_strings(&self.permissions)?;
165 let mut subscriptions = Vec::with_capacity(self.hooks.len());
166 for sub in &self.hooks {
167 let kind = HookKind::from_str(&sub.hook).ok_or_else(|| {
168 format!("Unknown hook kind: '{}' in extension '{}'", sub.hook, id)
169 })?;
170 if !permissions.allows_hook(kind) {
171 return Err(format!(
172 "Extension '{}' lacks permission '{}' required for hook '{}'",
173 id,
174 kind.required_permission().as_str(),
175 kind.as_str(),
176 ));
177 }
178 if sub.tool.is_some() && !kind.allows_tool_filter() {
179 return Err(format!(
180 "Extension '{}' hook '{}' does not allow a tool filter",
181 id,
182 kind.as_str(),
183 ));
184 }
185 subscriptions.push((kind, sub.tool.clone(), sub.matcher.clone()));
186 }
187
188 Ok(ValidatedExtensionManifest {
189 permissions,
190 subscriptions,
191 })
192 }
193}
194
195fn is_hex_color(value: &str) -> bool {
200 let hex = match value.trim().strip_prefix('#') {
201 Some(h) => h,
202 None => return false,
203 };
204 matches!(hex.len(), 3 | 6) && hex.chars().all(|c| c.is_ascii_hexdigit())
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "lowercase")]
210pub enum ExtensionRuntime {
211 Process,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct HookSubscription {
217 pub hook: String,
219 #[serde(default)]
221 pub tool: Option<String>,
222 #[serde(default, rename = "match")]
224 pub matcher: Option<HookMatcher>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
228#[serde(deny_unknown_fields)]
229pub struct HookMatcher {
230 #[serde(default)]
231 pub input_contains: Option<String>,
232 #[serde(default)]
233 pub input_equals: Option<serde_json::Value>,
234}
235
236impl HookMatcher {
237 pub const SUPPORTED_KEYS: &'static [&'static str] = &["input_contains", "input_equals"];
238
239 pub fn matches(&self, event: &crate::extensions::hooks::events::HookEvent) -> bool {
240 let input = event.tool_input.as_ref().unwrap_or(&serde_json::Value::Null);
241 if let Some(expected) = &self.input_equals {
242 if input != expected {
243 return false;
244 }
245 }
246 if let Some(needle) = &self.input_contains {
247 let haystack = serde_json::to_string(input).unwrap_or_default();
248 if !haystack.contains(needle) {
249 return false;
250 }
251 }
252 true
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
263 fn deserialize_full_manifest() {
264 let json = r#"{
265 "protocol_version": 1,
266 "runtime": "process",
267 "command": "/usr/bin/my-ext",
268 "args": ["--port", "0"],
269 "permissions": ["tools.intercept", "session.lifecycle"],
270 "hooks": [
271 {"hook": "before_tool_call", "tool": "bash"},
272 {"hook": "on_session_start"}
273 ]
274 }"#;
275
276 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
277 assert_eq!(m.protocol_version, 1);
278 assert_eq!(m.runtime, ExtensionRuntime::Process);
279 assert_eq!(m.command, "/usr/bin/my-ext");
280 assert_eq!(m.args, vec!["--port", "0"]);
281 assert_eq!(m.permissions, vec!["tools.intercept", "session.lifecycle"]);
282 assert_eq!(m.hooks.len(), 2);
283 assert_eq!(m.hooks[0].hook, "before_tool_call");
284 assert_eq!(m.hooks[0].tool.as_deref(), Some("bash"));
285 assert_eq!(m.hooks[1].hook, "on_session_start");
286 assert_eq!(m.hooks[1].tool, None);
287 }
288
289 #[test]
292 fn missing_optional_fields_get_defaults() {
293 let json = r#"{
294 "runtime": "process",
295 "command": "my-ext"
296 }"#;
297
298 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
299 assert_eq!(m.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION);
300 assert_eq!(m.runtime, ExtensionRuntime::Process);
301 assert_eq!(m.command, "my-ext");
302 assert!(m.args.is_empty(), "args should default to []");
303 assert!(m.permissions.is_empty(), "permissions should default to []");
304 assert!(m.hooks.is_empty(), "hooks should default to []");
305 }
306
307 #[test]
308 fn extension_config_entry_deserializes_optional_type() {
309 let json = r#"{
310 "key": "backend",
311 "type": "string",
312 "description": "Backend selector",
313 "default": "auto"
314 }"#;
315
316 let entry: ExtensionConfigEntry = serde_json::from_str(json).unwrap();
317 assert_eq!(entry.key, "backend");
318 assert_eq!(entry.value_type, Some(ExtensionConfigValueKind::String));
319 assert_eq!(entry.description.as_deref(), Some("Backend selector"));
320 assert_eq!(entry.default, Some(serde_json::Value::String("auto".to_string())));
321 }
322
323 #[test]
324 fn extension_config_entry_omitted_type_is_none() {
325 let json = r#"{"key": "backend"}"#;
326
327 let entry: ExtensionConfigEntry = serde_json::from_str(json).unwrap();
328 assert_eq!(entry.key, "backend");
329 assert_eq!(entry.value_type, None);
330 }
331
332 #[test]
333 fn hook_subscription_tool_defaults_to_none() {
334 let json = r#"{
335 "runtime": "process",
336 "command": "ext",
337 "hooks": [{"hook": "on_session_start"}]
338 }"#;
339
340 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
341 assert_eq!(m.hooks[0].tool, None);
342 }
343
344 #[test]
347 fn missing_command_fails() {
348 let json = r#"{"runtime": "process"}"#;
349 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
350 assert!(result.is_err(), "command is required");
351 }
352
353 #[test]
354 fn missing_runtime_fails() {
355 let json = r#"{"command": "my-ext"}"#;
356 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
357 assert!(result.is_err(), "runtime is required");
358 }
359
360 #[test]
363 fn unknown_runtime_type_errors() {
364 let json = r#"{
365 "runtime": "wasm",
366 "command": "my-ext"
367 }"#;
368 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
369 assert!(result.is_err(), "unknown runtime 'wasm' should be rejected");
370 }
371
372 #[test]
373 fn runtime_is_case_sensitive() {
374 let json = r#"{"runtime": "Process", "command": "ext"}"#;
375 let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
376 assert!(result.is_err(), "runtime matching is lowercase-only");
377 }
378
379 #[test]
380 fn validate_rejects_unsupported_protocol_version() {
381 let manifest = ExtensionManifest {
382 theme_tokens: Default::default(),
383 protocol_version: 999,
384 runtime: ExtensionRuntime::Process,
385 command: "ext".to_string(),
386 setup: None,
387 prebuilt: ::std::collections::HashMap::new(),
388 args: vec![],
389 permissions: vec!["tools.intercept".to_string()],
390 hooks: vec![HookSubscription {
391 hook: "before_tool_call".to_string(),
392 tool: None,
393 matcher: None,
394 }],
395 config: vec![],
396 };
397
398 let err = manifest.validate("bad-version").unwrap_err();
399 assert!(err.contains("unsupported protocol_version 999"));
400 }
401
402 #[test]
403 fn validate_allows_hookless_provider_registration_extensions() {
404 let manifest = ExtensionManifest {
405 theme_tokens: Default::default(),
406 protocol_version: 1,
407 runtime: ExtensionRuntime::Process,
408 command: "ext".to_string(),
409 setup: None,
410 prebuilt: ::std::collections::HashMap::new(),
411 args: vec![],
412 permissions: vec!["providers.register".to_string()],
413 hooks: vec![],
414 config: vec![],
415 };
416
417 manifest.validate("provider-only").unwrap();
418 }
419
420 #[test]
421 fn validate_rejects_tool_filter_on_non_tool_hook() {
422 let manifest = ExtensionManifest {
423 theme_tokens: Default::default(),
424 protocol_version: 1,
425 runtime: ExtensionRuntime::Process,
426 command: "ext".to_string(),
427 setup: None,
428 prebuilt: ::std::collections::HashMap::new(),
429 args: vec![],
430 permissions: vec!["session.lifecycle".to_string()],
431 hooks: vec![HookSubscription {
432 hook: "on_session_start".to_string(),
433 tool: Some("bash".to_string()),
434 matcher: None,
435 }],
436 config: vec![],
437 };
438
439 let err = manifest.validate("bad-filter").unwrap_err();
440 assert!(err.contains("does not allow a tool filter"));
441 }
442
443 #[test]
446 fn serialize_roundtrip() {
447 let original = ExtensionManifest {
448 theme_tokens: Default::default(),
449 protocol_version: 1,
450 runtime: ExtensionRuntime::Process,
451 command: "my-ext".to_string(),
452 setup: None,
453 prebuilt: ::std::collections::HashMap::new(),
454 args: vec!["--verbose".to_string()],
455 permissions: vec!["tools.intercept".to_string()],
456 hooks: vec![HookSubscription {
457 hook: "before_tool_call".to_string(),
458 tool: Some("bash".to_string()),
459 matcher: None,
460 }],
461 config: vec![],
462 };
463
464 let json = serde_json::to_string(&original).unwrap();
465 let restored: ExtensionManifest = serde_json::from_str(&json).unwrap();
466
467 assert_eq!(restored.protocol_version, original.protocol_version);
468 assert_eq!(restored.runtime, original.runtime);
469 assert_eq!(restored.command, original.command);
470 assert_eq!(restored.args, original.args);
471 assert_eq!(restored.permissions, original.permissions);
472 assert_eq!(restored.hooks[0].hook, original.hooks[0].hook);
473 assert_eq!(restored.hooks[0].tool, original.hooks[0].tool);
474 }
475
476 #[test]
479 fn matcher_input_equals_requires_exact_tool_input() {
480 let matcher = HookMatcher {
481 input_contains: None,
482 input_equals: Some(serde_json::json!({"command": "echo safe"})),
483 };
484
485 let matching = crate::extensions::hooks::events::HookEvent::before_tool_call(
486 "bash",
487 serde_json::json!({"command": "echo safe"}),
488 );
489 let different = crate::extensions::hooks::events::HookEvent::before_tool_call(
490 "bash",
491 serde_json::json!({"command": "echo safe", "extra": true}),
492 );
493
494 assert!(matcher.matches(&matching));
495 assert!(!matcher.matches(&different));
496 }
497
498 #[test]
499 fn matcher_conditions_are_combined_with_and() {
500 let matcher = HookMatcher {
501 input_contains: Some("safe".to_string()),
502 input_equals: Some(serde_json::json!({"command": "echo safe"})),
503 };
504
505 let matching = crate::extensions::hooks::events::HookEvent::before_tool_call(
506 "bash",
507 serde_json::json!({"command": "echo safe"}),
508 );
509 let equals_but_missing_contains = crate::extensions::hooks::events::HookEvent::before_tool_call(
510 "bash",
511 serde_json::json!({"command": "echo ok"}),
512 );
513
514 assert!(matcher.matches(&matching));
515 assert!(!matcher.matches(&equals_but_missing_contains));
516 }
517
518 #[test]
519 fn runtime_serializes_as_lowercase() {
520 let rt = ExtensionRuntime::Process;
521 let json = serde_json::to_string(&rt).unwrap();
522 assert_eq!(json, r#""process""#);
523 }
524
525 #[test]
526 fn extension_manifest_defaults_prebuilt_to_empty_when_absent() {
527 let json = r#"{
529 "runtime": "process",
530 "command": "bin/ext"
531 }"#;
532 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
533 assert!(m.prebuilt.is_empty());
534 assert!(m.setup.is_none());
535 }
536
537 #[test]
538 fn extension_manifest_round_trips_prebuilt_assets() {
539 let json = r#"{
540 "runtime": "process",
541 "command": "bin/ext",
542 "prebuilt": {
543 "linux-x86_64": {
544 "url": "https://example.com/ext-linux-x86_64.tar.gz",
545 "sha256": "abc123"
546 },
547 "darwin-arm64": {
548 "url": "https://example.com/ext-darwin-arm64.tar.gz",
549 "sha256": "def456"
550 }
551 }
552 }"#;
553 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
554 assert_eq!(m.prebuilt.len(), 2);
555 let linux = m.prebuilt.get("linux-x86_64").expect("linux entry");
556 assert_eq!(linux.url, "https://example.com/ext-linux-x86_64.tar.gz");
557 assert_eq!(linux.sha256, "abc123");
558 let back = serde_json::to_value(&m).unwrap();
560 assert_eq!(
561 back["prebuilt"]["darwin-arm64"]["sha256"],
562 serde_json::Value::String("def456".to_string())
563 );
564 }
565
566 #[test]
567 fn prebuilt_asset_requires_both_url_and_sha256() {
568 let json = r#"{ "url": "https://example.com/x.tar.gz" }"#;
570 let res: Result<PrebuiltAsset, _> = serde_json::from_str(json);
571 assert!(res.is_err(), "PrebuiltAsset without sha256 must fail to parse");
572 }
573
574 #[test]
577 fn theme_tokens_absent_defaults_to_empty_and_validates() {
578 let json = r#"{
582 "runtime": "process",
583 "command": "my-ext",
584 "permissions": ["tools.register"]
585 }"#;
586 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
587 assert!(m.theme_tokens.is_empty(), "absent theme_tokens must default to empty");
588 assert!(m.validate("legacy-ext").is_ok(), "legacy manifests must validate unchanged");
589 }
590
591 #[test]
592 fn theme_tokens_deserialize_and_validate() {
593 let json = r##"{
594 "runtime": "process",
595 "command": "my-ext",
596 "permissions": ["tools.register"],
597 "theme_tokens": { "accent": "#22d3ee", "warn": "#fa0" }
598 }"##;
599 let m: ExtensionManifest = serde_json::from_str(json).unwrap();
600 assert_eq!(m.theme_tokens.get("accent").map(String::as_str), Some("#22d3ee"));
601 assert_eq!(m.theme_tokens.get("warn").map(String::as_str), Some("#fa0"));
602 assert!(m.validate("themed-ext").is_ok());
603 }
604
605 #[test]
606 fn theme_tokens_reject_bad_token_names() {
607 for bad in ["with.dot", "with/slash", "with space", ""] {
608 let json = format!(
609 r##"{{
610 "runtime": "process",
611 "command": "my-ext",
612 "permissions": ["tools.register"],
613 "theme_tokens": {{ "{bad}": "#ffffff" }}
614 }}"##
615 );
616 let m: ExtensionManifest = serde_json::from_str(&json).unwrap();
617 let err = m.validate("bad-ext").unwrap_err();
618 assert!(err.contains("theme token"), "'{bad}' must be rejected: {err}");
619 }
620 }
621
622 #[test]
623 fn theme_tokens_reject_non_hex_colors() {
624 for bad in ["red", "#12345", "#gggggg", "22d3ee", "#"] {
625 let json = format!(
626 r#"{{
627 "runtime": "process",
628 "command": "my-ext",
629 "permissions": ["tools.register"],
630 "theme_tokens": {{ "accent": "{bad}" }}
631 }}"#
632 );
633 let m: ExtensionManifest = serde_json::from_str(&json).unwrap();
634 let err = m.validate("bad-ext").unwrap_err();
635 assert!(err.contains("invalid color"), "'{bad}' must be rejected: {err}");
636 }
637 }
638
639 #[test]
640 fn is_hex_color_accepts_short_and_long_forms() {
641 assert!(is_hex_color("#22d3ee"));
642 assert!(is_hex_color("#fa0"));
643 assert!(is_hex_color(" #ffffff "));
644 assert!(!is_hex_color("#22d3e"));
645 assert!(!is_hex_color("blue"));
646 }
647}