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 Some(leases) = ctx.capabilities.extension_leases.clone() else {
630 return Err(crate::RuntimeError::Tool(format!(
631 "extension tool '{}' (plugin '{}') is activation-deferred and no extension \
632 runtime lease capability is available in this context; no process was started",
633 self.runtime_name, self.plugin_id
634 )));
635 };
636 match leases
637 .call_exact(
638 &self.plugin_id,
639 &self.tool_name,
640 &self.expected_digest,
641 params,
642 )
643 .await
644 {
645 Ok(value) => Ok(render_tool_result(&value)),
646 Err(err) => {
647 if err.revokes_exact_grant() {
655 if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
656 if activation.revoke_exact_extension_grant(
657 leases.session(),
658 &self.plugin_id,
659 &self.tool_name,
660 ) {
661 tracing::debug!(
662 "revoked exact extension activation after declaration invalidation"
663 );
664 }
665 }
666 }
667 Err(crate::RuntimeError::Tool(err.to_string()))
668 }
669 }
670 }
671}
672
673fn render_tool_result(value: &Value) -> String {
677 if let Some(text) = value.as_str() {
678 text.to_string()
679 } else if let Some(text) = value.get("content").and_then(Value::as_str) {
680 text.to_string()
681 } else {
682 value.to_string()
683 }
684}
685
686impl DeclaredExtensionTool {
687 pub fn schema_digest(&self) -> SchemaDigest {
691 SchemaDigest::of_schema(&self.input_schema)
692 }
693}
694
695pub fn dormant_extension_tools(
699 plugin_id: &str,
700 manifest: &ExtensionManifest,
701) -> Vec<Arc<dyn Tool>> {
702 if plugin_id.is_empty() || plugin_id.contains(':') || plugin_id.chars().any(char::is_control) {
704 return Vec::new();
705 }
706 let Some(deferred) = &manifest.deferred else {
707 return Vec::new();
708 };
709 if validate_manifest_deferred(manifest).is_err() {
713 return Vec::new();
714 }
715 deferred
716 .tools
717 .iter()
718 .map(|declared| Arc::new(DeferredExtensionTool::new(plugin_id, declared)) as Arc<dyn Tool>)
719 .collect()
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use serde_json::json;
726
727 fn base_manifest(permissions: &[&str]) -> ExtensionManifest {
728 serde_json::from_value(json!({
729 "runtime": "process",
730 "command": "/bin/false",
731 "permissions": permissions,
732 }))
733 .unwrap()
734 }
735
736 fn declared_tool(name: &str) -> DeclaredExtensionTool {
737 DeclaredExtensionTool {
738 name: name.to_string(),
739 description: format!("declared {name}"),
740 input_schema: json!({"type": "object"}),
741 }
742 }
743
744 fn declared_context_provider(id: &str) -> DeclaredExtensionContextProvider {
745 DeclaredExtensionContextProvider {
746 id: id.to_string(),
747 capability: "project-memory".to_string(),
748 description: "Project memory context contributions".to_string(),
749 schema_version: 1,
750 }
751 }
752
753 fn with_context_providers(
754 permissions: &[&str],
755 context_providers: Vec<DeclaredExtensionContextProvider>,
756 ) -> ExtensionManifest {
757 let mut m = base_manifest(permissions);
758 m.deferred = Some(DeferredDeclarations {
759 context_providers,
760 ..DeferredDeclarations::default()
761 });
762 m
763 }
764
765 #[test]
768 fn absent_or_empty_deferred_blocks_classify_exactly_as_before() {
769 let legacy = base_manifest(&["tools.register"]);
771 assert!(legacy.deferred.is_none());
772 assert_eq!(classify(&legacy), ExtensionClass::LegacyEager);
773 assert_eq!(earliest_trigger(&legacy), ActivationTrigger::Eager);
774 legacy.validate("legacy").unwrap();
775
776 let mut empty = base_manifest(&["tools.register"]);
778 empty.deferred = Some(DeferredDeclarations::default());
779 assert!(empty
780 .deferred
781 .as_ref()
782 .is_some_and(|d| d.context_providers.is_empty() && !has_context_provider(d)));
783 assert_eq!(classify(&empty), ExtensionClass::LegacyEager);
784 assert_eq!(earliest_trigger(&empty), ActivationTrigger::Eager);
785 empty.validate("empty").unwrap();
786
787 let d: DeferredDeclarations = serde_json::from_value(json!({
791 "tools": [], "providers": []
792 }))
793 .unwrap();
794 assert!(d.context_providers.is_empty());
795 let round = serde_json::to_value(&d).unwrap();
796 assert!(round.get("context_providers").is_none());
797
798 let mut tool_only = base_manifest(&["tools.register"]);
801 tool_only.deferred = Some(DeferredDeclarations {
802 tools: vec![declared_tool("t")],
803 ..DeferredDeclarations::default()
804 });
805 assert_eq!(classify(&tool_only), ExtensionClass::ToolOnly);
806 let mut sidecar = base_manifest(&["tools.register"]);
808 sidecar.deferred = Some(DeferredDeclarations {
809 lifecycle: Some(DeferredLifecycle::User),
810 ..DeferredDeclarations::default()
811 });
812 assert_eq!(classify(&sidecar), ExtensionClass::UiSidecar);
813 let mut hooky: ExtensionManifest = serde_json::from_value(json!({
815 "runtime": "process",
816 "command": "/bin/false",
817 "permissions": ["tools.intercept"],
818 "hooks": [{"hook": "before_tool_call"}]
819 }))
820 .unwrap();
821 hooky.deferred = Some(DeferredDeclarations::default());
822 assert_eq!(classify(&hooky), ExtensionClass::HookLifecycle);
823 }
824
825 #[test]
828 fn context_provider_only_manifest_classifies_distinctly_and_requires_permission() {
829 let m = with_context_providers(
830 &["context_providers.register"],
831 vec![declared_context_provider("project-memory")],
832 );
833 assert!(m.deferred.as_ref().is_some_and(has_context_provider));
834 assert_eq!(classify(&m), ExtensionClass::ContextProvider);
835 assert_eq!(earliest_trigger(&m), ActivationTrigger::UserAction);
838 m.validate("axel-memory-manager").unwrap();
839
840 let ungated = with_context_providers(
842 &["tools.register"],
843 vec![declared_context_provider("project-memory")],
844 );
845 let err = ungated.validate("axel-memory-manager").unwrap_err();
846 assert!(
847 err.contains(
848 "deferred_context_providers_require_context_providers_register_permission"
849 ),
850 "{err}"
851 );
852 let wrong_gate = with_context_providers(
854 &["providers.register"],
855 vec![declared_context_provider("project-memory")],
856 );
857 assert!(wrong_gate.validate("axel-memory-manager").is_err());
858 }
859
860 #[test]
861 fn mixed_combinations_with_context_providers_still_classify_mixed() {
862 let mut m = with_context_providers(
864 &["tools.register", "context_providers.register"],
865 vec![declared_context_provider("project-memory")],
866 );
867 m.deferred.as_mut().unwrap().tools = vec![declared_tool("t")];
868 assert_eq!(classify(&m), ExtensionClass::Mixed);
869 m.validate("plug").unwrap();
870 assert_eq!(earliest_trigger(&m), ActivationTrigger::ExactToolActivation);
873 }
874
875 #[test]
878 fn too_many_declared_context_providers_are_rejected() {
879 let at_cap: Vec<_> = (0..DECLARED_MAX_CONTEXT_PROVIDERS)
880 .map(|i| declared_context_provider(&format!("p{i}")))
881 .collect();
882 with_context_providers(&["context_providers.register"], at_cap)
883 .validate("plug")
884 .unwrap();
885
886 let over: Vec<_> = (0..=DECLARED_MAX_CONTEXT_PROVIDERS)
887 .map(|i| declared_context_provider(&format!("p{i}")))
888 .collect();
889 let m = with_context_providers(&["context_providers.register"], over);
890 assert_eq!(
891 validate_deferred(m.deferred.as_ref().unwrap()),
892 Err("too_many_declared_context_providers")
893 );
894 assert!(m.validate("plug").is_err());
895 }
896
897 #[test]
898 fn oversized_or_hostile_context_provider_fields_are_rejected() {
899 let check = |p: DeclaredExtensionContextProvider, reason: &'static str| {
900 let m = with_context_providers(&["context_providers.register"], vec![p]);
901 assert_eq!(validate_deferred(m.deferred.as_ref().unwrap()), Err(reason));
902 assert!(m.validate("plug").is_err());
903 };
904 check(
906 declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES + 1)),
907 "invalid_declared_context_provider_id",
908 );
909 check(
911 declared_context_provider(""),
912 "invalid_declared_context_provider_id",
913 );
914 check(
915 declared_context_provider("has:colon"),
916 "invalid_declared_context_provider_id",
917 );
918 let mut p = declared_context_provider("p");
920 p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES + 1);
921 check(p, "invalid_declared_context_provider_capability");
922 let mut p = declared_context_provider("p");
923 p.capability = String::new();
924 check(p, "invalid_declared_context_provider_capability");
925 let mut p = declared_context_provider("p");
926 p.capability = "ctrl\u{7}".to_string();
927 check(p, "invalid_declared_context_provider_capability");
928 let mut p = declared_context_provider("p");
930 p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES + 1);
931 check(p, "invalid_declared_context_provider_description");
932 let mut p = declared_context_provider("p");
933 p.description = String::new();
934 check(p, "invalid_declared_context_provider_description");
935 let mut p = declared_context_provider("p");
937 p.schema_version = 0;
938 check(p, "invalid_declared_context_provider_schema_version");
939 let m = with_context_providers(
941 &["context_providers.register"],
942 vec![
943 declared_context_provider("dup"),
944 declared_context_provider("dup"),
945 ],
946 );
947 assert_eq!(
948 validate_deferred(m.deferred.as_ref().unwrap()),
949 Err("duplicate_declared_context_provider_id")
950 );
951 let mut p = declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES));
953 p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES);
954 p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES);
955 with_context_providers(&["context_providers.register"], vec![p])
956 .validate("plug")
957 .unwrap();
958 }
959
960 #[test]
963 fn dormant_descriptors_are_minted_only_for_validated_gated_manifests() {
964 use super::super::context_provider::dormant_context_provider_descriptors;
965 let m = with_context_providers(
966 &["context_providers.register"],
967 vec![declared_context_provider("project-memory")],
968 );
969 let descriptors = dormant_context_provider_descriptors("axel-memory-manager", &m);
970 assert_eq!(descriptors.len(), 1);
971 assert_eq!(
972 descriptors[0].runtime_address(),
973 "extension:axel-memory-manager:project-memory"
974 );
975 assert_eq!(descriptors[0].schema_version(), 1);
976
977 let ungated = with_context_providers(
980 &["tools.register"],
981 vec![declared_context_provider("project-memory")],
982 );
983 assert!(dormant_context_provider_descriptors("axel-memory-manager", &ungated).is_empty());
984 assert!(dormant_context_provider_descriptors("bad:plugin", &m).is_empty());
986 assert!(
988 dormant_context_provider_descriptors("p", &base_manifest(&["tools.register"]))
989 .is_empty()
990 );
991 }
992
993 #[test]
1001 fn declared_capability_ids_agree_with_runtime_validation() {
1002 let cases = [
1003 "ok",
1004 "with-dash",
1005 "with_underscore",
1006 "MiXedCase123",
1007 "",
1008 "has space",
1009 "has\ttab",
1010 "colon:name",
1011 "trailing ",
1012 &"a".repeat(super::super::validation::MAX_ID_LENGTH),
1013 &"a".repeat(super::super::validation::MAX_ID_LENGTH + 1),
1014 &"a".repeat(DECLARED_NAME_MAX_BYTES),
1015 ];
1016 for name in cases {
1017 let manifest_ok = validate_tool_shape(name, "d", &json!({"type": "object"})).is_ok();
1018 let runtime_ok = validate_id_segment(name).is_ok();
1019 assert_eq!(
1020 manifest_ok, runtime_ok,
1021 "manifest/runtime disagree on declared tool name {name:?}: \
1022 manifest_ok={manifest_ok} runtime_ok={runtime_ok}"
1023 );
1024 }
1025 }
1026
1027 #[test]
1032 fn declared_tool_name_over_runtime_bound_fails_at_manifest() {
1033 let name = "a".repeat(super::super::validation::MAX_ID_LENGTH + 1);
1034 assert_eq!(
1035 validate_deferred(&DeferredDeclarations {
1036 tools: vec![declared_tool(&name)],
1037 ..Default::default()
1038 }),
1039 Err("invalid_declared_tool_name"),
1040 );
1041 assert!(name.len() <= DECLARED_NAME_MAX_BYTES);
1044 }
1045
1046 #[test]
1048 fn declared_capability_ids_reject_whitespace_at_manifest() {
1049 assert_eq!(
1050 validate_deferred(&DeferredDeclarations {
1051 tools: vec![declared_tool("my tool")],
1052 ..Default::default()
1053 }),
1054 Err("invalid_declared_tool_name"),
1055 );
1056 }
1057}