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                    | "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
332/// True when `value` is a `#rgb` or `#rrggbb` hex color literal. Used to
333/// fail-closed on malformed `theme_tokens` values at manifest validation
334/// (before the extension process is spawned) instead of silently rendering
335/// nothing.
336fn 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/// Supported extension runtime types.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "lowercase")]
347pub enum ExtensionRuntime {
348    Process,
349}
350
351/// A hook subscription declared in the manifest.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct HookSubscription {
354    /// Hook name (e.g. "before_tool_call", "on_session_start")
355    pub hook: String,
356    /// Optional tool filter (e.g. "bash" for tool-specific hooks)
357    #[serde(default)]
358    pub tool: Option<String>,
359    /// Optional simple matcher conditions.
360    #[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    // ── Happy-path deserialisation ──────────────────────────────────────────
401
402    #[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    // ── Optional fields default correctly ──────────────────────────────────
430
431    #[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    // ── Required fields ─────────────────────────────────────────────────────
488
489    #[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    // ── Unknown / invalid runtime type ─────────────────────────────────────
504
505    #[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    // ── Round-trip ─────────────────────────────────────────────────────────
590
591    #[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    // ── Runtime serialises as lowercase string ──────────────────────────────
624
625    #[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        // Older manifests without `prebuilt` must still parse cleanly.
676        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        // Round-trip
707        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        // Missing sha256 must error — no silent acceptance of unverified assets.
717        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    // ── Theme tokens (P19.2, additive-optional per STABILITY.md §1) ─────────
726
727    #[test]
728    fn theme_tokens_absent_defaults_to_empty_and_validates() {
729        // The stability guarantee in one test: a pre-P19.2 manifest (no
730        // theme_tokens key) parses to an empty map and passes validation
731        // exactly as before — additive optional, no protocol bump.
732        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}