Skip to main content

agent_engine/extensions/
manifest.rs

1//! Extension manifest model and validation.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use super::hooks::events::HookKind;
7use super::permissions::PermissionSet;
8
9/// Current extension protocol version supported by SynapsCLI.
10pub const CURRENT_EXTENSION_PROTOCOL_VERSION: u32 = 1;
11
12fn default_protocol_version() -> u32 {
13    CURRENT_EXTENSION_PROTOCOL_VERSION
14}
15
16/// Extension declaration inside a plugin manifest.
17///
18/// Deserialization goes through [`RawExtensionManifest`] so legacy
19/// manifest aliases (`extension.tools` + `activation: "deferred"`, the
20/// pre-native shape shipped by early deferred plugins such as
21/// axel-memory-manager 0.1) are folded into the native `deferred` block
22/// BEFORE any classification or validation can observe them. Unknown
23/// `activation` values fail closed instead of silently loading eager.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(try_from = "RawExtensionManifest")]
26pub struct ExtensionManifest {
27    /// Extension protocol version. Defaults to v1 for pre-versioned manifests.
28    #[serde(default = "default_protocol_version")]
29    pub protocol_version: u32,
30    /// Runtime type (only "process" in phase 1).
31    pub runtime: ExtensionRuntime,
32    /// Command to start the extension process.
33    pub command: String,
34    /// Optional path to a post-install setup script (relative to plugin
35    /// root). When present, the marketplace install flow runs this
36    /// script after the plugin source is in place — used by source-shipped
37    /// extensions (e.g. Rust binaries) that need to compile a binary
38    /// before [`Self::command`] resolves. The path must stay inside the
39    /// plugin dir; see [`crate::skills::post_install`] for the runner.
40    #[serde(default)]
41    pub setup: Option<String>,
42    /// Optional per-host-triple prebuilt asset map. When the installer
43    /// can't find [`Self::command`] on disk after the source clone, it
44    /// looks up the current host's triple (e.g. `linux-x86_64`,
45    /// `darwin-arm64`, `windows-x86_64` — see
46    /// [`crate::skills::post_install::host_triple`]) in this map and,
47    /// if a matching [`PrebuiltAsset`] exists, downloads and extracts
48    /// it into the plugin dir as a fast path that skips
49    /// [`Self::setup`]. Empty by default.
50    #[serde(default)]
51    pub prebuilt: std::collections::HashMap<String, PrebuiltAsset>,
52    /// Arguments to pass to the command.
53    #[serde(default)]
54    pub args: Vec<String>,
55    /// Permissions requested by the extension.
56    #[serde(default)]
57    pub permissions: Vec<String>,
58    /// Hooks the extension wants to subscribe to.
59    #[serde(default)]
60    pub hooks: Vec<HookSubscription>,
61    /// Non-secret config declarations resolved by Synaps and passed to initialize.
62    #[serde(default)]
63    pub config: Vec<ExtensionConfigEntry>,
64    /// OPTIONAL (additive, protocol v1-compatible per docs/STABILITY.md §1):
65    /// theme tokens this extension declares, merged by the TUI into the
66    /// active theme under the `ext.<plugin-id>.<token>` namespace at load.
67    /// Keys are token names (no dots/slashes/spaces); values are hex colors
68    /// (`#rgb` or `#rrggbb`). A user theme-TOML key `ext.<plugin-id>.<token>`
69    /// always overrides the manifest value. Absent => empty map => behavior
70    /// identical to pre-P19.2 manifests.
71    #[serde(default)]
72    pub theme_tokens: std::collections::BTreeMap<String, String>,
73    /// OPTIONAL (additive, protocol v1-compatible): passive `deferred`
74    /// lifecycle declarations (Task 20, spec 7.5). Absent => legacy eager
75    /// lifecycle, byte-for-byte compatible. Present declarations are
76    /// trusted local manifest expectations validated BEFORE any spawn and
77    /// matched EXACTLY against runtime initialize declarations.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub deferred: Option<super::lifecycle::DeferredDeclarations>,
80}
81
82/// Wire-shape shadow of [`ExtensionManifest`] carrying the LEGACY manifest
83/// aliases (`tools` + `activation`) used by pre-native deferred plugins.
84/// [`TryFrom`] folds them into the native `deferred` block with a
85/// fail-closed policy:
86///   * `activation` values other than `"deferred"` are rejected (a typo or
87///     unknown mode must never silently fall back to the eager lifecycle);
88///   * declaring BOTH native `deferred` and a legacy alias is ambiguous
89///     and rejected;
90///   * `activation: "deferred"` without legacy `tools` declarations has no
91///     deferrable surface and is rejected.
92#[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    /// LEGACY ALIAS: passive tool declarations at the extension top level.
115    #[serde(default)]
116    tools: Vec<super::lifecycle::DeclaredExtensionTool>,
117    /// LEGACY ALIAS: `"deferred"` marks the legacy `tools` block deferred.
118    #[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/// Per-host-triple prebuilt distribution asset for an extension. Lives
172/// inside [`ExtensionManifest::prebuilt`]. When a matching entry exists
173/// for the current host, the installer fetches `url`, verifies its
174/// SHA-256 against `sha256`, and extracts it into the plugin install
175/// directory — letting users skip a (potentially slow) source build.
176///
177/// The archive is expected to lay out files relative to the plugin root
178/// such that [`ExtensionManifest::command`] resolves after extraction.
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180pub struct PrebuiltAsset {
181    /// HTTPS URL of the archive (`.tar.gz` or `.zip`). The installer
182    /// refuses non-`https://` schemes and `file://` (except in tests
183    /// gated by `cfg(test)`).
184    pub url: String,
185    /// Hex-encoded SHA-256 of the archive bytes; **required**. The
186    /// installer aborts and surfaces an error if the downloaded bytes
187    /// don't match — same model as the existing marketplace
188    /// `checksum_value` for plugin sources.
189    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    /// RESERVED host-context source: when present the value is resolved
214    /// EXCLUSIVELY from trusted host state recorded by the
215    /// `ExtensionManager` (never from env overrides, user config, or model
216    /// input) and injected into the resolved config sent at initialize.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub host_context: Option<HostContextKey>,
219}
220
221/// Typed host-context sources for [`ExtensionConfigEntry::host_context`].
222/// Each variant is a host-owned trusted value; no secrets, no environment
223/// forwarding.
224#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
225#[serde(rename_all = "snake_case")]
226pub enum HostContextKey {
227    /// Canonical trusted project root, discovered once by the host from
228    /// its own working directory (see `ExtensionManager::host_project_root`).
229    ProjectRoot,
230}
231
232/// A validated extension manifest prepared for loading.
233#[derive(Debug, Clone)]
234pub struct ValidatedExtensionManifest {
235    pub permissions: PermissionSet,
236    pub subscriptions: Vec<(HookKind, Option<String>, Option<HookMatcher>)>,
237}
238
239impl ExtensionManifest {
240    /// Validate manifest fields and derive typed permissions/subscriptions.
241    pub fn validate(&self, id: &str) -> Result<ValidatedExtensionManifest, String> {
242        // Review fix A1/A2: full deferred policy — block bounds PLUS
243        // permission coupling (deferred tools => 'tools.register',
244        // deferred providers => 'providers.register') and hook-lifecycle
245        // coupling (lifecycle 'hook' => at least one hook subscription).
246        // Runs before any spawn or catalog registration.
247        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
331/// True when `value` is a `#rgb` or `#rrggbb` hex color literal. Used to
332/// fail-closed on malformed `theme_tokens` values at manifest validation
333/// (before the extension process is spawned) instead of silently rendering
334/// nothing.
335fn 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/// Supported extension runtime types.
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(rename_all = "lowercase")]
346pub enum ExtensionRuntime {
347    Process,
348}
349
350/// A hook subscription declared in the manifest.
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct HookSubscription {
353    /// Hook name (e.g. "before_tool_call", "on_session_start")
354    pub hook: String,
355    /// Optional tool filter (e.g. "bash" for tool-specific hooks)
356    #[serde(default)]
357    pub tool: Option<String>,
358    /// Optional simple matcher conditions.
359    #[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    // ── Happy-path deserialisation ──────────────────────────────────────────
400
401    #[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    // ── Optional fields default correctly ──────────────────────────────────
429
430    #[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    // ── Required fields ─────────────────────────────────────────────────────
487
488    #[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    // ── Unknown / invalid runtime type ─────────────────────────────────────
503
504    #[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    // ── Round-trip ─────────────────────────────────────────────────────────
589
590    #[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    // ── Runtime serialises as lowercase string ──────────────────────────────
623
624    #[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        // Older manifests without `prebuilt` must still parse cleanly.
675        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        // Round-trip
706        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        // Missing sha256 must error — no silent acceptance of unverified assets.
716        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    // ── Theme tokens (P19.2, additive-optional per STABILITY.md §1) ─────────
725
726    #[test]
727    fn theme_tokens_absent_defaults_to_empty_and_validates() {
728        // The stability guarantee in one test: a pre-P19.2 manifest (no
729        // theme_tokens key) parses to an empty map and passes validation
730        // exactly as before — additive optional, no protocol bump.
731        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}