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. Same security model as
39    /// `provides.sidecar.setup` (path must stay inside the plugin dir;
40    /// see [`crate::skills::post_install`] for the runner).
41    #[serde(default)]
42    pub setup: Option<String>,
43    /// Optional per-host-triple prebuilt asset map. When the installer
44    /// can't find [`Self::command`] on disk after the source clone, it
45    /// looks up the current host's triple (e.g. `linux-x86_64`,
46    /// `darwin-arm64`, `windows-x86_64` — see
47    /// [`crate::skills::post_install::host_triple`]) in this map and,
48    /// if a matching [`PrebuiltAsset`] exists, downloads and extracts
49    /// it into the plugin dir as a fast path that skips
50    /// [`Self::setup`]. Empty by default.
51    #[serde(default)]
52    pub prebuilt: std::collections::HashMap<String, PrebuiltAsset>,
53    /// Arguments to pass to the command.
54    #[serde(default)]
55    pub args: Vec<String>,
56    /// Permissions requested by the extension.
57    #[serde(default)]
58    pub permissions: Vec<String>,
59    /// Hooks the extension wants to subscribe to.
60    #[serde(default)]
61    pub hooks: Vec<HookSubscription>,
62    /// Non-secret config declarations resolved by Synaps and passed to initialize.
63    #[serde(default)]
64    pub config: Vec<ExtensionConfigEntry>,
65    /// OPTIONAL (additive, protocol v1-compatible per docs/STABILITY.md §1):
66    /// theme tokens this extension declares, merged by the TUI into the
67    /// active theme under the `ext.<plugin-id>.<token>` namespace at load.
68    /// Keys are token names (no dots/slashes/spaces); values are hex colors
69    /// (`#rgb` or `#rrggbb`). A user theme-TOML key `ext.<plugin-id>.<token>`
70    /// always overrides the manifest value. Absent => empty map => behavior
71    /// identical to pre-P19.2 manifests.
72    #[serde(default)]
73    pub theme_tokens: std::collections::BTreeMap<String, String>,
74    /// OPTIONAL (additive, protocol v1-compatible): passive `deferred`
75    /// lifecycle declarations (Task 20, spec 7.5). Absent => legacy eager
76    /// lifecycle, byte-for-byte compatible. Present declarations are
77    /// trusted local manifest expectations validated BEFORE any spawn and
78    /// matched EXACTLY against runtime initialize declarations.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub deferred: Option<super::lifecycle::DeferredDeclarations>,
81}
82
83/// Wire-shape shadow of [`ExtensionManifest`] carrying the LEGACY manifest
84/// aliases (`tools` + `activation`) used by pre-native deferred plugins.
85/// [`TryFrom`] folds them into the native `deferred` block with a
86/// fail-closed policy:
87///   * `activation` values other than `"deferred"` are rejected (a typo or
88///     unknown mode must never silently fall back to the eager lifecycle);
89///   * declaring BOTH native `deferred` and a legacy alias is ambiguous
90///     and rejected;
91///   * `activation: "deferred"` without legacy `tools` declarations has no
92///     deferrable surface and is rejected.
93#[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    /// LEGACY ALIAS: passive tool declarations at the extension top level.
116    #[serde(default)]
117    tools: Vec<super::lifecycle::DeclaredExtensionTool>,
118    /// LEGACY ALIAS: `"deferred"` marks the legacy `tools` block deferred.
119    #[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/// Per-host-triple prebuilt distribution asset for an extension. Lives
173/// inside [`ExtensionManifest::prebuilt`]. When a matching entry exists
174/// for the current host, the installer fetches `url`, verifies its
175/// SHA-256 against `sha256`, and extracts it into the plugin install
176/// directory — letting users skip a (potentially slow) source build.
177///
178/// The archive is expected to lay out files relative to the plugin root
179/// such that [`ExtensionManifest::command`] resolves after extraction.
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
181pub struct PrebuiltAsset {
182    /// HTTPS URL of the archive (`.tar.gz` or `.zip`). The installer
183    /// refuses non-`https://` schemes and `file://` (except in tests
184    /// gated by `cfg(test)`).
185    pub url: String,
186    /// Hex-encoded SHA-256 of the archive bytes; **required**. The
187    /// installer aborts and surfaces an error if the downloaded bytes
188    /// don't match — same model as the existing marketplace
189    /// `checksum_value` for plugin sources.
190    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    /// RESERVED host-context source: when present the value is resolved
215    /// EXCLUSIVELY from trusted host state recorded by the
216    /// `ExtensionManager` (never from env overrides, user config, or model
217    /// input) and injected into the resolved config sent at initialize.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub host_context: Option<HostContextKey>,
220}
221
222/// Typed host-context sources for [`ExtensionConfigEntry::host_context`].
223/// Each variant is a host-owned trusted value; no secrets, no environment
224/// forwarding.
225#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
226#[serde(rename_all = "snake_case")]
227pub enum HostContextKey {
228    /// Canonical trusted project root, discovered once by the host from
229    /// its own working directory (see `ExtensionManager::host_project_root`).
230    ProjectRoot,
231}
232
233/// A validated extension manifest prepared for loading.
234#[derive(Debug, Clone)]
235pub struct ValidatedExtensionManifest {
236    pub permissions: PermissionSet,
237    pub subscriptions: Vec<(HookKind, Option<String>, Option<HookMatcher>)>,
238}
239
240impl ExtensionManifest {
241    /// Validate manifest fields and derive typed permissions/subscriptions.
242    pub fn validate(&self, id: &str) -> Result<ValidatedExtensionManifest, String> {
243        // Review fix A1/A2: full deferred policy — block bounds PLUS
244        // permission coupling (deferred tools => 'tools.register',
245        // deferred providers => 'providers.register') and hook-lifecycle
246        // coupling (lifecycle 'hook' => at least one hook subscription).
247        // Runs before any spawn or catalog registration.
248        super::lifecycle::validate_manifest_deferred(self).map_err(|reason| {
249            format!("Extension '{id}' deferred declarations invalid: {reason}")
250        })?;
251        if self.protocol_version != CURRENT_EXTENSION_PROTOCOL_VERSION {
252            return Err(format!(
253                "Extension '{}' uses unsupported protocol_version {} (supported: {})",
254                id, self.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION,
255            ));
256        }
257
258        if self.command.trim().is_empty() {
259            return Err(format!("Extension '{}' has empty command", id));
260        }
261
262        let has_capability_permission = self.permissions.iter().any(|permission| {
263            matches!(
264                permission.as_str(),
265                "tools.register"
266                    | "providers.register"
267                    | "context_providers.register"
268                    | "memory.read"
269                    | "memory.write"
270                    | "config.write"
271                    | "config.subscribe"
272                    | "audio.input"
273                    | "audio.output"
274            )
275        });
276        if self.hooks.is_empty() && !has_capability_permission {
277            return Err(format!("Extension '{}' must subscribe to at least one hook or request a registration permission", id));
278        }
279
280        for (token, value) in &self.theme_tokens {
281            if token.trim().is_empty()
282                || token.contains('.')
283                || token.contains('/')
284                || token.contains('\\')
285                || token.contains(char::is_whitespace)
286            {
287                return Err(format!(
288                    "Extension '{}' theme token '{}' is invalid: token names must be non-empty and must not contain dots, slashes, or spaces",
289                    id, token,
290                ));
291            }
292            if !is_hex_color(value) {
293                return Err(format!(
294                    "Extension '{}' theme token '{}' has invalid color '{}': expected '#rgb' or '#rrggbb' hex",
295                    id, token, value,
296                ));
297            }
298        }
299
300        let permissions = PermissionSet::try_from_strings(&self.permissions)?;
301        let mut subscriptions = Vec::with_capacity(self.hooks.len());
302        for sub in &self.hooks {
303            let kind = HookKind::from_str(&sub.hook).ok_or_else(|| {
304                format!("Unknown hook kind: '{}' in extension '{}'", sub.hook, id)
305            })?;
306            if !permissions.allows_hook(kind) {
307                return Err(format!(
308                    "Extension '{}' lacks permission '{}' required for hook '{}'",
309                    id,
310                    kind.required_permission().as_str(),
311                    kind.as_str(),
312                ));
313            }
314            if sub.tool.is_some() && !kind.allows_tool_filter() {
315                return Err(format!(
316                    "Extension '{}' hook '{}' does not allow a tool filter",
317                    id,
318                    kind.as_str(),
319                ));
320            }
321            subscriptions.push((kind, sub.tool.clone(), sub.matcher.clone()));
322        }
323
324        Ok(ValidatedExtensionManifest {
325            permissions,
326            subscriptions,
327        })
328    }
329}
330
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}