1use std::collections::HashSet;
15use std::sync::Arc;
16
17use serde_json::Value;
18
19use super::manifest::ExtensionManifest;
20use super::runtime::process::RegisteredExtensionToolSpec;
21use super::validation::validate_id_segment;
22use crate::tools::catalog::SchemaDigest;
23use crate::tools::{Tool, ToolContext, ToolOrigin};
24use agent_core::BoundedText;
25
26pub const DECLARED_MAX_TOOLS: usize = 256;
29pub const DECLARED_MAX_PROVIDERS: usize = 16;
30pub const DECLARED_NAME_MAX_BYTES: usize = 128;
31pub const DECLARED_DESCRIPTION_MAX_BYTES: usize = 1024;
32pub const DECLARED_SCHEMA_MAX_BYTES: usize = 64 * 1024;
33pub const DECLARED_MAX_CONTEXT_PROVIDERS: usize = 8;
37
38#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
40pub struct DeferredDeclarations {
41 #[serde(default)]
43 pub tools: Vec<DeclaredExtensionTool>,
44 #[serde(default)]
48 pub providers: Vec<DeclaredExtensionProvider>,
49 #[serde(default)]
51 pub lifecycle: Option<DeferredLifecycle>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub context_providers: Vec<DeclaredExtensionContextProvider>,
62}
63
64#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
68pub struct DeclaredExtensionProvider {
69 pub id: String,
70 pub display_name: String,
71 #[serde(default)]
72 pub description: String,
73 #[serde(default)]
74 pub models: Vec<DeclaredExtensionProviderModel>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub config_schema: Option<Value>,
77}
78
79#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
81pub struct DeclaredExtensionProviderModel {
82 pub id: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub display_name: Option<String>,
85 #[serde(default)]
86 pub capabilities: Value,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub context_window: Option<u64>,
89}
90
91pub const DECLARED_MAX_PROVIDER_MODELS: usize = 64;
93pub const DECLARED_DISPLAY_MAX_BYTES: usize = 256;
94
95#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
104pub struct DeclaredExtensionContextProvider {
105 pub id: String,
108 pub capability: String,
110 #[serde(default)]
112 pub description: String,
113 pub schema_version: u32,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
118#[serde(rename_all = "lowercase")]
119pub enum DeferredLifecycle {
120 Hook,
122 User,
124}
125
126#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
130pub struct DeclaredExtensionTool {
131 pub name: String,
132 #[serde(default)]
133 pub description: String,
134 pub input_schema: Value,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ExtensionClass {
140 LegacyEager,
142 ToolOnly,
143 Provider,
144 HookLifecycle,
145 UiSidecar,
146 ContextProvider,
150 Mixed,
151}
152
153pub fn has_context_provider(declared: &DeferredDeclarations) -> bool {
156 !declared.context_providers.is_empty()
157}
158
159pub fn classify(manifest: &ExtensionManifest) -> ExtensionClass {
161 let Some(deferred) = &manifest.deferred else {
162 return ExtensionClass::LegacyEager;
163 };
164 let tools = !deferred.tools.is_empty();
165 let providers = !deferred.providers.is_empty();
166 let context = has_context_provider(deferred);
167 let hooks = !manifest.hooks.is_empty() || deferred.lifecycle == Some(DeferredLifecycle::Hook);
168 let user = deferred.lifecycle == Some(DeferredLifecycle::User);
169 match (tools, providers, context, hooks, user) {
172 (false, false, false, false, false) => ExtensionClass::LegacyEager,
173 (true, false, false, false, false) => ExtensionClass::ToolOnly,
174 (false, true, false, false, false) => ExtensionClass::Provider,
175 (false, false, true, false, false) => ExtensionClass::ContextProvider,
176 (false, false, false, true, false) => ExtensionClass::HookLifecycle,
177 (false, false, false, false, true) => ExtensionClass::UiSidecar,
178 _ => ExtensionClass::Mixed,
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum ActivationTrigger {
189 Eager,
190 FirstAuthorizedHookEvent,
191 ProviderSelection,
192 ExactToolActivation,
193 UserAction,
194}
195
196pub fn earliest_trigger(manifest: &ExtensionManifest) -> ActivationTrigger {
197 match classify(manifest) {
198 ExtensionClass::LegacyEager => ActivationTrigger::Eager,
199 ExtensionClass::ToolOnly => ActivationTrigger::ExactToolActivation,
200 ExtensionClass::Provider => ActivationTrigger::ProviderSelection,
201 ExtensionClass::HookLifecycle => ActivationTrigger::FirstAuthorizedHookEvent,
202 ExtensionClass::UiSidecar => ActivationTrigger::UserAction,
203 ExtensionClass::ContextProvider => ActivationTrigger::UserAction,
208 ExtensionClass::Mixed => {
209 let deferred = manifest.deferred.as_ref();
210 let hooks = !manifest.hooks.is_empty()
211 || deferred.and_then(|d| d.lifecycle) == Some(DeferredLifecycle::Hook);
212 let providers = deferred.map(|d| !d.providers.is_empty()).unwrap_or(false);
213 if hooks {
214 ActivationTrigger::FirstAuthorizedHookEvent
215 } else if providers {
216 ActivationTrigger::ProviderSelection
217 } else {
218 ActivationTrigger::ExactToolActivation
219 }
220 }
221 }
222}
223
224fn valid_name(name: &str) -> bool {
225 !name.is_empty() && name.len() <= DECLARED_NAME_MAX_BYTES && !name.chars().any(char::is_control)
226}
227
228fn valid_capability_id(name: &str) -> bool {
243 validate_id_segment(name).is_ok()
244}
245
246pub fn validate_deferred(deferred: &DeferredDeclarations) -> Result<(), &'static str> {
249 if deferred.tools.len() > DECLARED_MAX_TOOLS {
250 return Err("too_many_declared_tools");
251 }
252 let mut seen: HashSet<&str> = HashSet::new();
253 for tool in &deferred.tools {
254 validate_tool_shape(&tool.name, &tool.description, &tool.input_schema)?;
255 if !seen.insert(tool.name.as_str()) {
256 return Err("duplicate_declared_tool_name");
257 }
258 }
259 if deferred.providers.len() > DECLARED_MAX_PROVIDERS {
260 return Err("too_many_declared_providers");
261 }
262 let mut provider_ids: HashSet<&str> = HashSet::new();
263 for provider in &deferred.providers {
264 if !valid_capability_id(&provider.id) {
265 return Err("invalid_declared_provider_id");
266 }
267 if !provider_ids.insert(provider.id.as_str()) {
268 return Err("duplicate_declared_provider_id");
269 }
270 if provider.display_name.is_empty()
271 || provider.display_name.len() > DECLARED_DISPLAY_MAX_BYTES
272 || provider.display_name.chars().any(char::is_control)
273 {
274 return Err("invalid_declared_provider_display_name");
275 }
276 if provider.description.is_empty()
277 || provider.description.len() > DECLARED_DESCRIPTION_MAX_BYTES
278 {
279 return Err("invalid_declared_provider_description");
280 }
281 if provider.models.is_empty() || provider.models.len() > DECLARED_MAX_PROVIDER_MODELS {
282 return Err("invalid_declared_provider_model_count");
283 }
284 let mut model_ids: HashSet<&str> = HashSet::new();
285 for model in &provider.models {
286 if !valid_capability_id(&model.id) {
287 return Err("invalid_declared_provider_model_id");
288 }
289 if !model_ids.insert(model.id.as_str()) {
290 return Err("duplicate_declared_provider_model_id");
291 }
292 if let Some(display) = &model.display_name {
293 if display.len() > DECLARED_DISPLAY_MAX_BYTES
294 || display.chars().any(char::is_control)
295 {
296 return Err("invalid_declared_provider_model_display_name");
297 }
298 }
299 match &model.capabilities {
301 Value::Null => {}
302 Value::Object(map) => {
303 for (key, value) in map {
304 if !valid_name(key) || !value.is_boolean() {
305 return Err("invalid_declared_provider_model_capabilities");
306 }
307 }
308 }
309 _ => return Err("invalid_declared_provider_model_capabilities"),
310 }
311 }
312 if let Some(config_schema) = &provider.config_schema {
313 let len = serde_json::to_vec(config_schema)
314 .map(|b| b.len())
315 .unwrap_or(usize::MAX);
316 if !config_schema.is_object() || len > DECLARED_SCHEMA_MAX_BYTES {
317 return Err("invalid_declared_provider_config_schema");
318 }
319 }
320 }
321 if deferred.context_providers.len() > DECLARED_MAX_CONTEXT_PROVIDERS {
325 return Err("too_many_declared_context_providers");
326 }
327 let mut context_provider_ids: HashSet<&str> = HashSet::new();
328 for provider in &deferred.context_providers {
329 if super::context_provider::ContextProviderId::parse(&provider.id).is_err() {
332 return Err("invalid_declared_context_provider_id");
333 }
334 if !context_provider_ids.insert(provider.id.as_str()) {
335 return Err("duplicate_declared_context_provider_id");
336 }
337 if !valid_name(&provider.capability) {
338 return Err("invalid_declared_context_provider_capability");
339 }
340 if provider.description.is_empty()
341 || provider.description.len() > DECLARED_DESCRIPTION_MAX_BYTES
342 {
343 return Err("invalid_declared_context_provider_description");
344 }
345 if provider.schema_version == 0 {
346 return Err("invalid_declared_context_provider_schema_version");
347 }
348 }
349 if deferred.lifecycle == Some(DeferredLifecycle::User)
352 && (!deferred.tools.is_empty() || !deferred.providers.is_empty())
353 {
354 return Err("user_lifecycle_conflicts_with_active_capabilities");
355 }
356 Ok(())
357}
358
359pub fn validate_manifest_deferred(manifest: &ExtensionManifest) -> Result<(), &'static str> {
372 let Some(deferred) = &manifest.deferred else {
373 return Ok(());
374 };
375 validate_deferred(deferred)?;
376 let has_permission = |wire: &str| manifest.permissions.iter().any(|p| p == wire);
377 if !deferred.tools.is_empty() && !has_permission("tools.register") {
378 return Err("deferred_tools_require_tools_register_permission");
379 }
380 if !deferred.providers.is_empty() && !has_permission("providers.register") {
381 return Err("deferred_providers_require_providers_register_permission");
382 }
383 if !deferred.context_providers.is_empty() && !has_permission("context_providers.register") {
388 return Err("deferred_context_providers_require_context_providers_register_permission");
389 }
390 if deferred.lifecycle == Some(DeferredLifecycle::Hook) && manifest.hooks.is_empty() {
391 return Err("hook_lifecycle_requires_hook_subscriptions");
392 }
393 Ok(())
394}
395
396fn validate_tool_shape(
400 name: &str,
401 description: &str,
402 input_schema: &Value,
403) -> Result<(), &'static str> {
404 if !valid_capability_id(name) {
405 return Err("invalid_declared_tool_name");
406 }
407 if description.trim().is_empty() || description.len() > DECLARED_DESCRIPTION_MAX_BYTES {
411 return Err("invalid_declared_tool_description");
412 }
413 if !input_schema.is_object() {
414 return Err("declared_tool_schema_not_object");
415 }
416 let schema_len = serde_json::to_vec(input_schema)
417 .map(|b| b.len())
418 .unwrap_or(usize::MAX);
419 if schema_len > DECLARED_SCHEMA_MAX_BYTES {
420 return Err("oversized_declared_tool_schema");
421 }
422 Ok(())
423}
424
425pub fn validate_runtime_tool_declarations(
431 declared: &[DeclaredExtensionTool],
432 registered: &[RegisteredExtensionToolSpec],
433) -> Result<(), &'static str> {
434 if declared.len() != registered.len() {
435 return Err("declared_and_registered_tool_counts_differ");
436 }
437 for spec in registered {
438 validate_tool_shape(&spec.name, &spec.description, &spec.input_schema)?;
441 }
442 for decl in declared {
443 let Some(live) = registered.iter().find(|spec| spec.name == decl.name) else {
444 return Err("declared_tool_not_registered");
445 };
446 if SchemaDigest::of_schema(&live.input_schema)
447 != SchemaDigest::of_schema(&decl.input_schema)
448 {
449 return Err("registered_tool_schema_digest_mismatch");
450 }
451 if live.description != decl.description {
454 return Err("registered_tool_description_mismatch");
455 }
456 }
457 let mut live_names: HashSet<&str> = HashSet::new();
461 for spec in registered {
462 if !live_names.insert(spec.name.as_str()) {
463 return Err("registered_tool_names_duplicate");
464 }
465 }
466 Ok(())
467}
468
469pub fn validate_runtime_provider_declarations(
478 declared: &[DeclaredExtensionProvider],
479 registered: &[crate::extensions::runtime::process::RegisteredProviderSpec],
480) -> Result<(), &'static str> {
481 if declared.len() != registered.len() {
482 return Err("declared_and_registered_provider_counts_differ");
483 }
484 let mut live_ids: HashSet<&str> = HashSet::new();
485 for spec in registered {
486 if !live_ids.insert(spec.id.as_str()) {
487 return Err("registered_provider_ids_duplicate");
488 }
489 }
490 for decl in declared {
491 let Some(live) = registered.iter().find(|spec| spec.id == decl.id) else {
492 return Err("declared_provider_not_registered");
493 };
494 if live.display_name != decl.display_name {
495 return Err("registered_provider_display_name_mismatch");
496 }
497 if live.description != decl.description {
498 return Err("registered_provider_description_mismatch");
499 }
500 if live.config_schema != decl.config_schema {
501 return Err("registered_provider_config_schema_mismatch");
502 }
503 if live.models.len() != decl.models.len() {
504 return Err("registered_provider_model_counts_differ");
505 }
506 let mut live_model_ids: HashSet<&str> = HashSet::new();
507 for model in &live.models {
508 if !live_model_ids.insert(model.id.as_str()) {
509 return Err("registered_provider_model_ids_duplicate");
510 }
511 }
512 for declared_model in &decl.models {
513 let Some(live_model) = live.models.iter().find(|m| m.id == declared_model.id) else {
514 return Err("declared_provider_model_not_registered");
515 };
516 if live_model.display_name != declared_model.display_name {
517 return Err("registered_provider_model_display_name_mismatch");
518 }
519 if live_model.context_window != declared_model.context_window {
520 return Err("registered_provider_model_context_window_mismatch");
521 }
522 if live_model.capabilities != declared_model.capabilities {
523 return Err("registered_provider_model_capabilities_mismatch");
524 }
525 }
526 }
527 Ok(())
528}
529
530impl DeclaredExtensionProvider {
531 pub fn to_registered_spec(
536 &self,
537 ) -> crate::extensions::runtime::process::RegisteredProviderSpec {
538 crate::extensions::runtime::process::RegisteredProviderSpec {
539 id: self.id.clone(),
540 display_name: self.display_name.clone(),
541 description: self.description.clone(),
542 models: self
543 .models
544 .iter()
545 .map(
546 |model| crate::extensions::runtime::process::RegisteredProviderModelSpec {
547 id: model.id.clone(),
548 display_name: model.display_name.clone(),
549 capabilities: model.capabilities.clone(),
550 context_window: model.context_window,
551 },
552 )
553 .collect(),
554 config_schema: self.config_schema.clone(),
555 }
556 }
557}
558
559pub struct DeferredExtensionTool {
568 plugin_id: String,
569 runtime_name: String,
570 tool_name: String,
571 description: String,
572 input_schema: Value,
573 expected_digest: SchemaDigest,
574}
575
576impl DeferredExtensionTool {
577 fn new(plugin_id: &str, declared: &DeclaredExtensionTool) -> Self {
578 Self {
579 plugin_id: plugin_id.to_string(),
580 runtime_name: format!("{}:{}", plugin_id, declared.name),
582 tool_name: declared.name.clone(),
583 description: BoundedText::new(&declared.description, DECLARED_DESCRIPTION_MAX_BYTES)
584 .text,
585 input_schema: declared.input_schema.clone(),
586 expected_digest: SchemaDigest::of_schema(&declared.input_schema),
587 }
588 }
589
590 pub fn plugin_id(&self) -> &str {
591 &self.plugin_id
592 }
593
594 pub fn tool_name(&self) -> &str {
595 &self.tool_name
596 }
597
598 pub fn expected_digest(&self) -> &SchemaDigest {
601 &self.expected_digest
602 }
603}
604
605#[async_trait::async_trait]
606impl Tool for DeferredExtensionTool {
607 fn origin(&self) -> ToolOrigin {
608 ToolOrigin::Extension {
609 extension_id: self.plugin_id.clone(),
610 }
611 }
612
613 fn name(&self) -> &str {
614 &self.runtime_name
615 }
616
617 fn description(&self) -> &str {
618 &self.description
619 }
620
621 fn parameters(&self) -> Value {
622 self.input_schema.clone()
623 }
624
625 async fn execute(&self, params: Value, ctx: ToolContext) -> crate::Result<String> {
626 let binding = ctx
628 .capabilities
629 .memory_backend
630 .clone()
631 .unwrap_or_else(crate::memory_backend::MemoryBinding::configured_current);
632 if binding.exclusive() && crate::memory_backend::competing_note_tool(&self.tool_name) {
633 return Err(crate::RuntimeError::Tool("independent extension memory is disabled by the selected host backend; use short memory tools".into()));
634 }
635 let Some(leases) = ctx.capabilities.extension_leases.clone() else {
638 return Err(crate::RuntimeError::Tool(format!(
639 "extension tool '{}' (plugin '{}') is activation-deferred and no extension \
640 runtime lease capability is available in this context; no process was started",
641 self.runtime_name, self.plugin_id
642 )));
643 };
644 match leases
645 .call_exact(
646 &self.plugin_id,
647 &self.tool_name,
648 &self.expected_digest,
649 params,
650 )
651 .await
652 {
653 Ok(value) => Ok(render_tool_result(&value)),
654 Err(err) => {
655 if err.revokes_exact_grant() {
663 if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
664 if activation.revoke_exact_extension_grant(
665 leases.session(),
666 &self.plugin_id,
667 &self.tool_name,
668 ) {
669 tracing::debug!(
670 "revoked exact extension activation after declaration invalidation"
671 );
672 }
673 }
674 }
675 Err(crate::RuntimeError::Tool(err.to_string()))
676 }
677 }
678 }
679}
680
681fn render_tool_result(value: &Value) -> String {
685 if let Some(text) = value.as_str() {
686 text.to_string()
687 } else if let Some(text) = value.get("content").and_then(Value::as_str) {
688 text.to_string()
689 } else {
690 value.to_string()
691 }
692}
693
694impl DeclaredExtensionTool {
695 pub fn schema_digest(&self) -> SchemaDigest {
699 SchemaDigest::of_schema(&self.input_schema)
700 }
701}
702
703pub fn dormant_extension_tools(
707 plugin_id: &str,
708 manifest: &ExtensionManifest,
709) -> Vec<Arc<dyn Tool>> {
710 if plugin_id.is_empty() || plugin_id.contains(':') || plugin_id.chars().any(char::is_control) {
712 return Vec::new();
713 }
714 let Some(deferred) = &manifest.deferred else {
715 return Vec::new();
716 };
717 if validate_manifest_deferred(manifest).is_err() {
721 return Vec::new();
722 }
723 deferred
724 .tools
725 .iter()
726 .map(|declared| Arc::new(DeferredExtensionTool::new(plugin_id, declared)) as Arc<dyn Tool>)
727 .collect()
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use serde_json::json;
734
735 fn base_manifest(permissions: &[&str]) -> ExtensionManifest {
736 serde_json::from_value(json!({
737 "runtime": "process",
738 "command": "/bin/false",
739 "permissions": permissions,
740 }))
741 .unwrap()
742 }
743
744 fn declared_tool(name: &str) -> DeclaredExtensionTool {
745 DeclaredExtensionTool {
746 name: name.to_string(),
747 description: format!("declared {name}"),
748 input_schema: json!({"type": "object"}),
749 }
750 }
751
752 fn declared_context_provider(id: &str) -> DeclaredExtensionContextProvider {
753 DeclaredExtensionContextProvider {
754 id: id.to_string(),
755 capability: "project-memory".to_string(),
756 description: "Project memory context contributions".to_string(),
757 schema_version: 1,
758 }
759 }
760
761 fn with_context_providers(
762 permissions: &[&str],
763 context_providers: Vec<DeclaredExtensionContextProvider>,
764 ) -> ExtensionManifest {
765 let mut m = base_manifest(permissions);
766 m.deferred = Some(DeferredDeclarations {
767 context_providers,
768 ..DeferredDeclarations::default()
769 });
770 m
771 }
772
773 #[test]
776 fn absent_or_empty_deferred_blocks_classify_exactly_as_before() {
777 let legacy = base_manifest(&["tools.register"]);
779 assert!(legacy.deferred.is_none());
780 assert_eq!(classify(&legacy), ExtensionClass::LegacyEager);
781 assert_eq!(earliest_trigger(&legacy), ActivationTrigger::Eager);
782 legacy.validate("legacy").unwrap();
783
784 let mut empty = base_manifest(&["tools.register"]);
786 empty.deferred = Some(DeferredDeclarations::default());
787 assert!(empty
788 .deferred
789 .as_ref()
790 .is_some_and(|d| d.context_providers.is_empty() && !has_context_provider(d)));
791 assert_eq!(classify(&empty), ExtensionClass::LegacyEager);
792 assert_eq!(earliest_trigger(&empty), ActivationTrigger::Eager);
793 empty.validate("empty").unwrap();
794
795 let d: DeferredDeclarations = serde_json::from_value(json!({
799 "tools": [], "providers": []
800 }))
801 .unwrap();
802 assert!(d.context_providers.is_empty());
803 let round = serde_json::to_value(&d).unwrap();
804 assert!(round.get("context_providers").is_none());
805
806 let mut tool_only = base_manifest(&["tools.register"]);
809 tool_only.deferred = Some(DeferredDeclarations {
810 tools: vec![declared_tool("t")],
811 ..DeferredDeclarations::default()
812 });
813 assert_eq!(classify(&tool_only), ExtensionClass::ToolOnly);
814 let mut sidecar = base_manifest(&["tools.register"]);
816 sidecar.deferred = Some(DeferredDeclarations {
817 lifecycle: Some(DeferredLifecycle::User),
818 ..DeferredDeclarations::default()
819 });
820 assert_eq!(classify(&sidecar), ExtensionClass::UiSidecar);
821 let mut hooky: ExtensionManifest = serde_json::from_value(json!({
823 "runtime": "process",
824 "command": "/bin/false",
825 "permissions": ["tools.intercept"],
826 "hooks": [{"hook": "before_tool_call"}]
827 }))
828 .unwrap();
829 hooky.deferred = Some(DeferredDeclarations::default());
830 assert_eq!(classify(&hooky), ExtensionClass::HookLifecycle);
831 }
832
833 #[test]
836 fn context_provider_only_manifest_classifies_distinctly_and_requires_permission() {
837 let m = with_context_providers(
838 &["context_providers.register"],
839 vec![declared_context_provider("project-memory")],
840 );
841 assert!(m.deferred.as_ref().is_some_and(has_context_provider));
842 assert_eq!(classify(&m), ExtensionClass::ContextProvider);
843 assert_eq!(earliest_trigger(&m), ActivationTrigger::UserAction);
846 m.validate("axel-memory-manager").unwrap();
847
848 let ungated = with_context_providers(
850 &["tools.register"],
851 vec![declared_context_provider("project-memory")],
852 );
853 let err = ungated.validate("axel-memory-manager").unwrap_err();
854 assert!(
855 err.contains(
856 "deferred_context_providers_require_context_providers_register_permission"
857 ),
858 "{err}"
859 );
860 let wrong_gate = with_context_providers(
862 &["providers.register"],
863 vec![declared_context_provider("project-memory")],
864 );
865 assert!(wrong_gate.validate("axel-memory-manager").is_err());
866 }
867
868 #[test]
869 fn mixed_combinations_with_context_providers_still_classify_mixed() {
870 let mut m = with_context_providers(
872 &["tools.register", "context_providers.register"],
873 vec![declared_context_provider("project-memory")],
874 );
875 m.deferred.as_mut().unwrap().tools = vec![declared_tool("t")];
876 assert_eq!(classify(&m), ExtensionClass::Mixed);
877 m.validate("plug").unwrap();
878 assert_eq!(earliest_trigger(&m), ActivationTrigger::ExactToolActivation);
881 }
882
883 #[test]
886 fn too_many_declared_context_providers_are_rejected() {
887 let at_cap: Vec<_> = (0..DECLARED_MAX_CONTEXT_PROVIDERS)
888 .map(|i| declared_context_provider(&format!("p{i}")))
889 .collect();
890 with_context_providers(&["context_providers.register"], at_cap)
891 .validate("plug")
892 .unwrap();
893
894 let over: Vec<_> = (0..=DECLARED_MAX_CONTEXT_PROVIDERS)
895 .map(|i| declared_context_provider(&format!("p{i}")))
896 .collect();
897 let m = with_context_providers(&["context_providers.register"], over);
898 assert_eq!(
899 validate_deferred(m.deferred.as_ref().unwrap()),
900 Err("too_many_declared_context_providers")
901 );
902 assert!(m.validate("plug").is_err());
903 }
904
905 #[test]
906 fn oversized_or_hostile_context_provider_fields_are_rejected() {
907 let check = |p: DeclaredExtensionContextProvider, reason: &'static str| {
908 let m = with_context_providers(&["context_providers.register"], vec![p]);
909 assert_eq!(validate_deferred(m.deferred.as_ref().unwrap()), Err(reason));
910 assert!(m.validate("plug").is_err());
911 };
912 check(
914 declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES + 1)),
915 "invalid_declared_context_provider_id",
916 );
917 check(
919 declared_context_provider(""),
920 "invalid_declared_context_provider_id",
921 );
922 check(
923 declared_context_provider("has:colon"),
924 "invalid_declared_context_provider_id",
925 );
926 let mut p = declared_context_provider("p");
928 p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES + 1);
929 check(p, "invalid_declared_context_provider_capability");
930 let mut p = declared_context_provider("p");
931 p.capability = String::new();
932 check(p, "invalid_declared_context_provider_capability");
933 let mut p = declared_context_provider("p");
934 p.capability = "ctrl\u{7}".to_string();
935 check(p, "invalid_declared_context_provider_capability");
936 let mut p = declared_context_provider("p");
938 p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES + 1);
939 check(p, "invalid_declared_context_provider_description");
940 let mut p = declared_context_provider("p");
941 p.description = String::new();
942 check(p, "invalid_declared_context_provider_description");
943 let mut p = declared_context_provider("p");
945 p.schema_version = 0;
946 check(p, "invalid_declared_context_provider_schema_version");
947 let m = with_context_providers(
949 &["context_providers.register"],
950 vec![
951 declared_context_provider("dup"),
952 declared_context_provider("dup"),
953 ],
954 );
955 assert_eq!(
956 validate_deferred(m.deferred.as_ref().unwrap()),
957 Err("duplicate_declared_context_provider_id")
958 );
959 let mut p = declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES));
961 p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES);
962 p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES);
963 with_context_providers(&["context_providers.register"], vec![p])
964 .validate("plug")
965 .unwrap();
966 }
967
968 #[test]
971 fn dormant_descriptors_are_minted_only_for_validated_gated_manifests() {
972 use super::super::context_provider::dormant_context_provider_descriptors;
973 let m = with_context_providers(
974 &["context_providers.register"],
975 vec![declared_context_provider("project-memory")],
976 );
977 let descriptors = dormant_context_provider_descriptors("axel-memory-manager", &m);
978 assert_eq!(descriptors.len(), 1);
979 assert_eq!(
980 descriptors[0].runtime_address(),
981 "extension:axel-memory-manager:project-memory"
982 );
983 assert_eq!(descriptors[0].schema_version(), 1);
984
985 let ungated = with_context_providers(
988 &["tools.register"],
989 vec![declared_context_provider("project-memory")],
990 );
991 assert!(dormant_context_provider_descriptors("axel-memory-manager", &ungated).is_empty());
992 assert!(dormant_context_provider_descriptors("bad:plugin", &m).is_empty());
994 assert!(
996 dormant_context_provider_descriptors("p", &base_manifest(&["tools.register"]))
997 .is_empty()
998 );
999 }
1000
1001 #[test]
1009 fn declared_capability_ids_agree_with_runtime_validation() {
1010 let cases = [
1011 "ok",
1012 "with-dash",
1013 "with_underscore",
1014 "MiXedCase123",
1015 "",
1016 "has space",
1017 "has\ttab",
1018 "colon:name",
1019 "trailing ",
1020 &"a".repeat(super::super::validation::MAX_ID_LENGTH),
1021 &"a".repeat(super::super::validation::MAX_ID_LENGTH + 1),
1022 &"a".repeat(DECLARED_NAME_MAX_BYTES),
1023 ];
1024 for name in cases {
1025 let manifest_ok = validate_tool_shape(name, "d", &json!({"type": "object"})).is_ok();
1026 let runtime_ok = validate_id_segment(name).is_ok();
1027 assert_eq!(
1028 manifest_ok, runtime_ok,
1029 "manifest/runtime disagree on declared tool name {name:?}: \
1030 manifest_ok={manifest_ok} runtime_ok={runtime_ok}"
1031 );
1032 }
1033 }
1034
1035 #[test]
1040 fn declared_tool_name_over_runtime_bound_fails_at_manifest() {
1041 let name = "a".repeat(super::super::validation::MAX_ID_LENGTH + 1);
1042 assert_eq!(
1043 validate_deferred(&DeferredDeclarations {
1044 tools: vec![declared_tool(&name)],
1045 ..Default::default()
1046 }),
1047 Err("invalid_declared_tool_name"),
1048 );
1049 assert!(name.len() <= DECLARED_NAME_MAX_BYTES);
1052 }
1053
1054 #[test]
1056 fn declared_capability_ids_reject_whitespace_at_manifest() {
1057 assert_eq!(
1058 validate_deferred(&DeferredDeclarations {
1059 tools: vec![declared_tool("my tool")],
1060 ..Default::default()
1061 }),
1062 Err("invalid_declared_tool_name"),
1063 );
1064 }
1065}