Skip to main content

everruns_core/capabilities/
declarative.rs

1use super::{
2    CapabilityStatus, MountAccess, MountPoint, RiskLevel, SKILLS_DISCOVERY_PATH, SkillContribution,
3};
4use crate::capability_types::MountSource;
5use crate::{CapabilityInfo, ScopedMcpServers, validate_skill_name};
6use everruns_capability::{CapabilityId, plugin_capability_id};
7use serde::{Deserialize, Serialize};
8
9pub const DECLARATIVE_CAPABILITY_PREFIX: &str = "declarative:";
10// Capability refs are persisted in existing VARCHAR(50) capability columns.
11// `declarative:` is 12 bytes, leaving 38 bytes for the unique name.
12const MAX_NAME_BYTES: usize = 38;
13const MAX_DISPLAY_NAME_BYTES: usize = 80;
14const MAX_PROMPT_BYTES: usize = 64 * 1024;
15const MAX_FILES: usize = 32;
16const MAX_FILE_BYTES: usize = 64 * 1024;
17const MAX_SKILLS: usize = 16;
18const MAX_SKILL_BYTES: usize = 64 * 1024;
19const MAX_MCP_SERVERS: usize = 16;
20
21pub fn declarative_capability_id(name: &str) -> String {
22    format!("{DECLARATIVE_CAPABILITY_PREFIX}{name}")
23}
24
25pub fn is_declarative_capability(capability_id: &str) -> bool {
26    capability_id.starts_with(DECLARATIVE_CAPABILITY_PREFIX)
27}
28
29pub fn parse_declarative_capability_id(capability_id: &str) -> Option<&str> {
30    capability_id.strip_prefix(DECLARATIVE_CAPABILITY_PREFIX)
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct DeclarativeCapabilityDefinition {
35    pub name: String,
36    #[serde(default)]
37    pub display_name: Option<String>,
38    pub description: String,
39    #[serde(default = "default_status")]
40    pub status: CapabilityStatus,
41    #[serde(default)]
42    pub icon: Option<String>,
43    #[serde(default)]
44    pub category: Option<String>,
45    #[serde(default)]
46    pub system_prompt: Option<String>,
47    #[serde(default)]
48    pub mcp_servers: Option<ScopedMcpServers>,
49    #[serde(default)]
50    pub skills: Vec<DeclarativeCapabilitySkill>,
51    #[serde(default)]
52    pub files: Vec<DeclarativeCapabilityFile>,
53    #[serde(default)]
54    pub dependencies: Vec<String>,
55    #[serde(default)]
56    pub features: Vec<String>,
57    #[serde(default = "default_risk_level")]
58    pub risk_level: RiskLevel,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct DeclarativeCapabilityFile {
63    pub path: String,
64    pub content: String,
65    #[serde(default)]
66    pub access: MountAccess,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DeclarativeCapabilitySkill {
71    pub name: String,
72    pub description: String,
73    pub instructions: String,
74    #[serde(default)]
75    pub files: Vec<DeclarativeCapabilitySkillFile>,
76    #[serde(default = "default_true")]
77    pub user_invocable: bool,
78    #[serde(default)]
79    pub disable_model_invocation: bool,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct DeclarativeCapabilitySkillFile {
84    pub path: String,
85    pub content: String,
86}
87
88fn default_true() -> bool {
89    true
90}
91
92fn default_status() -> CapabilityStatus {
93    CapabilityStatus::Available
94}
95
96fn default_risk_level() -> RiskLevel {
97    RiskLevel::Low
98}
99
100impl Default for DeclarativeCapabilityDefinition {
101    fn default() -> Self {
102        Self {
103            name: String::new(),
104            display_name: None,
105            description: String::new(),
106            status: CapabilityStatus::Available,
107            icon: Some("puzzle".to_string()),
108            category: Some("Declarative".to_string()),
109            system_prompt: None,
110            mcp_servers: None,
111            skills: Vec::new(),
112            files: Vec::new(),
113            dependencies: Vec::new(),
114            features: Vec::new(),
115            risk_level: RiskLevel::Low,
116        }
117    }
118}
119
120impl DeclarativeCapabilityDefinition {
121    pub fn mounts(&self, capability_id: &str) -> Vec<MountPoint> {
122        self.files
123            .iter()
124            .map(|file| {
125                let source = MountSource::text_file(file.content.clone());
126                match file.access {
127                    MountAccess::ReadOnly => {
128                        MountPoint::readonly(file.path.clone(), source, capability_id)
129                    }
130                    MountAccess::ReadWrite => {
131                        MountPoint::readwrite(file.path.clone(), source, capability_id)
132                    }
133                }
134            })
135            .collect()
136    }
137
138    pub fn skill_contributions(&self) -> Vec<SkillContribution> {
139        self.skills
140            .iter()
141            .map(|skill| {
142                SkillContribution::new(
143                    skill.name.clone(),
144                    skill.description.clone(),
145                    skill.instructions.clone(),
146                )
147                .with_files(
148                    skill
149                        .files
150                        .iter()
151                        .map(|file| (file.path.clone(), file.content.clone()))
152                        .collect(),
153                )
154                .with_user_invocable(skill.user_invocable)
155                .with_disable_model_invocation(skill.disable_model_invocation)
156            })
157            .collect()
158    }
159}
160
161pub fn hydrate_declarative_capability_config(
162    _config: serde_json::Value,
163    definition: &DeclarativeCapabilityDefinition,
164) -> serde_json::Value {
165    serde_json::to_value(definition).unwrap_or_default()
166}
167
168pub fn declarative_capability_info(
169    name: &str,
170    definition: DeclarativeCapabilityDefinition,
171) -> CapabilityInfo {
172    CapabilityInfo {
173        id: CapabilityId::new(declarative_capability_id(name)),
174        name: definition.display_name.unwrap_or(definition.name),
175        description: definition.description,
176        status: definition.status,
177        icon: definition.icon.or_else(|| Some("puzzle".to_string())),
178        category: definition
179            .category
180            .or_else(|| Some("Declarative".to_string())),
181        system_prompt: definition.system_prompt,
182        tool_definitions: Vec::new(),
183        is_mcp: false,
184        is_skill: false,
185        is_guardrail: false,
186        dependencies: definition.dependencies,
187        features: definition.features,
188        config_schema: None,
189        config_ui_schema: None,
190        risk_level: definition.risk_level,
191        agent_count: 0,
192        harness_count: 0,
193        docs_slug: None,
194        localizations: Default::default(),
195    }
196}
197
198/// Hydrate a `plugin:` capability config: same logic as the declarative counterpart.
199///
200/// The per-agent config for a `plugin:` capability ref is the serialized
201/// `DeclarativeCapabilityDefinition` produced by the compiler. Hydration simply
202/// re-serializes the definition so callers get a canonical JSON value.
203pub fn hydrate_plugin_capability_config(
204    config: serde_json::Value,
205    definition: &DeclarativeCapabilityDefinition,
206) -> serde_json::Value {
207    // Identical to the declarative path: discard the incoming config and
208    // return the canonical definition. The `plugin:` namespace keeps refs
209    // from colliding with `declarative:` refs.
210    hydrate_declarative_capability_config(config, definition)
211}
212
213/// Build a `CapabilityInfo` DTO for a plugin capability identity.
214///
215/// Server installs pass their public ID; standalone runtime plugins pass their
216/// manifest name. Both remain distinct from `declarative:{name}`.
217pub fn plugin_capability_info(
218    identity: &str,
219    definition: DeclarativeCapabilityDefinition,
220) -> CapabilityInfo {
221    CapabilityInfo {
222        id: CapabilityId::new(plugin_capability_id(identity)),
223        name: definition
224            .display_name
225            .clone()
226            .unwrap_or_else(|| definition.name.clone()),
227        description: definition.description.clone(),
228        status: definition.status,
229        icon: definition
230            .icon
231            .clone()
232            .or_else(|| Some("puzzle".to_string())),
233        category: definition
234            .category
235            .clone()
236            .or_else(|| Some("Plugin".to_string())),
237        system_prompt: definition.system_prompt.clone(),
238        tool_definitions: Vec::new(),
239        is_mcp: false,
240        is_skill: false,
241        is_guardrail: false,
242        dependencies: definition.dependencies.clone(),
243        features: definition.features.clone(),
244        config_schema: None,
245        config_ui_schema: None,
246        risk_level: definition.risk_level,
247        agent_count: 0,
248        harness_count: 0,
249        docs_slug: None,
250        localizations: Default::default(),
251    }
252}
253
254pub fn validate_declarative_capability_definition(
255    definition: &DeclarativeCapabilityDefinition,
256) -> Result<(), String> {
257    validate_name(&definition.name)?;
258    if let Some(display_name) = &definition.display_name {
259        validate_non_empty("display_name", display_name, MAX_DISPLAY_NAME_BYTES)?;
260    }
261    validate_non_empty("description", &definition.description, 512)?;
262
263    if let Some(prompt) = &definition.system_prompt {
264        validate_size("system_prompt", prompt, MAX_PROMPT_BYTES)?;
265    }
266    if let Some(servers) = &definition.mcp_servers
267        && servers.len() > MAX_MCP_SERVERS
268    {
269        return Err(format!(
270            "mcp_servers cannot contain more than {MAX_MCP_SERVERS} entries"
271        ));
272    }
273    if definition.files.len() > MAX_FILES {
274        return Err(format!(
275            "files cannot contain more than {MAX_FILES} entries"
276        ));
277    }
278    if definition.skills.len() > MAX_SKILLS {
279        return Err(format!(
280            "skills cannot contain more than {MAX_SKILLS} entries"
281        ));
282    }
283
284    for dependency in &definition.dependencies {
285        if is_declarative_capability(dependency) {
286            return Err("declarative capability dependencies cannot reference other declarative capabilities".to_string());
287        }
288    }
289
290    for file in &definition.files {
291        validate_mount_path(&file.path)?;
292        validate_size(
293            &format!("file {}", file.path),
294            &file.content,
295            MAX_FILE_BYTES,
296        )?;
297        if file.path == SKILLS_DISCOVERY_PATH
298            || file
299                .path
300                .strip_prefix(SKILLS_DISCOVERY_PATH)
301                .is_some_and(|rest| rest.starts_with('/'))
302        {
303            return Err(format!(
304                "file path {} is reserved; use skills[] for skill contributions",
305                file.path
306            ));
307        }
308    }
309
310    for skill in &definition.skills {
311        validate_skill_name(&skill.name).map_err(|errors| {
312            format!("invalid skill name '{}': {}", skill.name, errors.join("; "))
313        })?;
314        validate_non_empty("skill.description", &skill.description, 512)?;
315        validate_size(
316            &format!("skill {} instructions", skill.name),
317            &skill.instructions,
318            MAX_SKILL_BYTES,
319        )?;
320        for file in &skill.files {
321            validate_relative_path(&file.path)?;
322            validate_size(
323                &format!("skill {} file {}", skill.name, file.path),
324                &file.content,
325                MAX_FILE_BYTES,
326            )?;
327        }
328    }
329
330    Ok(())
331}
332
333fn validate_non_empty(field: &str, value: &str, max: usize) -> Result<(), String> {
334    if value.trim().is_empty() {
335        return Err(format!("{field} is required"));
336    }
337    validate_size(field, value, max)
338}
339
340fn validate_name(name: &str) -> Result<(), String> {
341    validate_non_empty("name", name, MAX_NAME_BYTES)?;
342    let mut chars = name.chars();
343    let Some(first) = chars.next() else {
344        return Err("name is required".to_string());
345    };
346    if !first.is_ascii_lowercase() {
347        return Err("name must start with a lowercase letter".to_string());
348    }
349    if !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') {
350        return Err("name may contain only lowercase letters, digits, '_' and '-'".to_string());
351    }
352    if name.ends_with('_') || name.ends_with('-') {
353        return Err("name cannot end with '_' or '-'".to_string());
354    }
355    Ok(())
356}
357
358fn validate_size(field: &str, value: &str, max: usize) -> Result<(), String> {
359    if value.len() > max {
360        return Err(format!("{field} cannot exceed {max} bytes"));
361    }
362    Ok(())
363}
364
365fn validate_mount_path(path: &str) -> Result<(), String> {
366    if !path.starts_with('/') || path.contains("..") || path.contains("//") {
367        return Err(format!("invalid mount path: {path}"));
368    }
369    Ok(())
370}
371
372fn validate_relative_path(path: &str) -> Result<(), String> {
373    if path.starts_with('/') || path.contains("..") || path.contains("//") || path.trim().is_empty()
374    {
375        return Err(format!("invalid relative file path: {path}"));
376    }
377    Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    fn valid_definition() -> DeclarativeCapabilityDefinition {
385        DeclarativeCapabilityDefinition {
386            name: "research_pack".to_string(),
387            display_name: Some("Research Pack".to_string()),
388            description: "Curated research behavior".to_string(),
389            ..Default::default()
390        }
391    }
392
393    #[test]
394    fn declarative_capability_ref_uses_unique_name() {
395        assert_eq!(
396            declarative_capability_id("research_pack"),
397            "declarative:research_pack"
398        );
399        assert_eq!(
400            parse_declarative_capability_id("declarative:research_pack"),
401            Some("research_pack")
402        );
403        assert!(is_declarative_capability("declarative:research_pack"));
404        for other in ["plugin:research_pack", "research_pack", ""] {
405            assert!(!is_declarative_capability(other));
406            assert_eq!(parse_declarative_capability_id(other), None);
407        }
408    }
409
410    #[test]
411    fn names_and_required_text_use_literal_byte_boundaries() {
412        for name in ["a", "a0_b-c", &"a".repeat(38)] {
413            let mut d = valid_definition();
414            d.name = name.into();
415            validate_declarative_capability_definition(&d).unwrap();
416        }
417        for (name, error) in [
418            ("".to_string(), "name is required"),
419            (" ".to_string(), "name is required"),
420            ("a".repeat(39), "name cannot exceed 38 bytes"),
421            ("Aname".into(), "name must start with a lowercase letter"),
422            ("0name".into(), "name must start with a lowercase letter"),
423            (
424                "a.b".into(),
425                "name may contain only lowercase letters, digits, '_' and '-'",
426            ),
427            (
428                "aα".into(),
429                "name may contain only lowercase letters, digits, '_' and '-'",
430            ),
431            ("a_".into(), "name cannot end with '_' or '-'"),
432            ("a-".into(), "name cannot end with '_' or '-'"),
433        ] {
434            let mut d = valid_definition();
435            d.name = name;
436            assert_eq!(
437                validate_declarative_capability_definition(&d),
438                Err(error.into())
439            );
440        }
441        for (field, max) in [
442            ("display_name", 80),
443            ("description", 512),
444            ("skill.description", 512),
445        ] {
446            for (text, expected) in [
447                ("α".repeat(max / 2), Ok(())),
448                (
449                    "α".repeat(max / 2) + "x",
450                    Err(format!("{field} cannot exceed {max} bytes")),
451                ),
452                (" \t".into(), Err(format!("{field} is required"))),
453            ] {
454                let mut d = valid_definition();
455                match field {
456                    "display_name" => d.display_name = Some(text),
457                    "description" => d.description = text,
458                    _ => {
459                        d.skills = vec![DeclarativeCapabilitySkill {
460                            name: "ops".into(),
461                            description: text,
462                            instructions: "Body".into(),
463                            files: vec![],
464                            user_invocable: true,
465                            disable_model_invocation: false,
466                        }]
467                    }
468                }
469                assert_eq!(
470                    validate_declarative_capability_definition(&d),
471                    expected,
472                    "{field}"
473                );
474            }
475        }
476    }
477
478    fn skill() -> DeclarativeCapabilitySkill {
479        DeclarativeCapabilitySkill {
480            name: "ops".into(),
481            description: "Operations".into(),
482            instructions: "Body".into(),
483            files: vec![],
484            user_invocable: true,
485            disable_model_invocation: false,
486        }
487    }
488
489    #[test]
490    fn contribution_collections_accept_limits_and_reject_one_more() {
491        for (field, max) in [("files", 32), ("skills", 16), ("mcp_servers", 16)] {
492            for count in [max, max + 1] {
493                let mut d = valid_definition();
494                match field {
495                    "files" => {
496                        d.files = (0..count)
497                            .map(|i| DeclarativeCapabilityFile {
498                                path: format!("/file-{i}"),
499                                content: "x".into(),
500                                access: MountAccess::ReadOnly,
501                            })
502                            .collect()
503                    }
504                    "skills" => {
505                        d.skills = (0..count)
506                            .map(|i| DeclarativeCapabilitySkill {
507                                name: format!("skill-{i}"),
508                                ..skill()
509                            })
510                            .collect()
511                    }
512                    _ => {
513                        d.mcp_servers = Some(
514                            serde_json::from_value(serde_json::Value::Object(
515                                (0..count)
516                                    .map(|i| {
517                                        (
518                                            format!("server-{i}"),
519                                            serde_json::json!({"url":"https://example.com/mcp"}),
520                                        )
521                                    })
522                                    .collect(),
523                            ))
524                            .unwrap(),
525                        )
526                    }
527                }
528                let expected = if count == max {
529                    Ok(())
530                } else {
531                    Err(format!("{field} cannot contain more than {max} entries"))
532                };
533                assert_eq!(validate_declarative_capability_definition(&d), expected);
534            }
535        }
536    }
537
538    #[test]
539    fn content_limits_count_utf8_bytes_for_every_contribution_surface() {
540        for field in [
541            "system_prompt",
542            "file /notes",
543            "skill ops instructions",
544            "skill ops file ref.txt",
545        ] {
546            for extra in [false, true] {
547                let mut d = valid_definition();
548                let content = "α".repeat(32768) + if extra { "x" } else { "" };
549                match field {
550                    "system_prompt" => d.system_prompt = Some(content),
551                    "file /notes" => {
552                        d.files = vec![DeclarativeCapabilityFile {
553                            path: "/notes".into(),
554                            content,
555                            access: MountAccess::ReadOnly,
556                        }]
557                    }
558                    "skill ops instructions" => {
559                        d.skills = vec![DeclarativeCapabilitySkill {
560                            instructions: content,
561                            ..skill()
562                        }]
563                    }
564                    _ => {
565                        d.skills = vec![DeclarativeCapabilitySkill {
566                            files: vec![DeclarativeCapabilitySkillFile {
567                                path: "ref.txt".into(),
568                                content,
569                            }],
570                            ..skill()
571                        }]
572                    }
573                }
574                let expected = if extra {
575                    Err(format!("{field} cannot exceed 65536 bytes"))
576                } else {
577                    Ok(())
578                };
579                assert_eq!(validate_declarative_capability_definition(&d), expected);
580            }
581        }
582    }
583
584    #[test]
585    fn path_validation_rejects_traversal_and_reserves_only_the_skill_directory() {
586        for path in [
587            "/notes.txt",
588            "/.agents/skills-extra/readme",
589            "/.agents/skills-backup",
590        ] {
591            let mut d = valid_definition();
592            d.files = vec![DeclarativeCapabilityFile {
593                path: path.into(),
594                content: "x".into(),
595                access: MountAccess::ReadOnly,
596            }];
597            assert_eq!(
598                validate_declarative_capability_definition(&d),
599                Ok(()),
600                "{path}"
601            );
602        }
603        for path in [
604            "relative",
605            "/../secret",
606            "/a//b",
607            "/.agents/skills",
608            "/.agents/skills/ops/SKILL.md",
609        ] {
610            let mut d = valid_definition();
611            d.files = vec![DeclarativeCapabilityFile {
612                path: path.into(),
613                content: "x".into(),
614                access: MountAccess::ReadOnly,
615            }];
616            let error = if path.starts_with("/.agents/skills") {
617                format!("file path {path} is reserved; use skills[] for skill contributions")
618            } else {
619                format!("invalid mount path: {path}")
620            };
621            assert_eq!(validate_declarative_capability_definition(&d), Err(error));
622        }
623        for path in ["", " ", "/absolute", "../secret", "a//b"] {
624            let mut d = valid_definition();
625            d.skills = vec![DeclarativeCapabilitySkill {
626                files: vec![DeclarativeCapabilitySkillFile {
627                    path: path.into(),
628                    content: "x".into(),
629                }],
630                ..skill()
631            }];
632            assert_eq!(
633                validate_declarative_capability_definition(&d),
634                Err(format!("invalid relative file path: {path}"))
635            );
636        }
637        let mut d = valid_definition();
638        d.skills = vec![DeclarativeCapabilitySkill {
639            files: vec![DeclarativeCapabilitySkillFile {
640                path: "scripts/run.sh".into(),
641                content: "x".into(),
642            }],
643            ..skill()
644        }];
645        assert_eq!(validate_declarative_capability_definition(&d), Ok(()));
646    }
647
648    #[test]
649    fn declarative_dependencies_are_rejected_but_other_namespaces_remain_valid() {
650        let mut d = valid_definition();
651        d.dependencies = vec!["session_file_system".into(), "plugin:tools".into()];
652        assert_eq!(validate_declarative_capability_definition(&d), Ok(()));
653        d.dependencies.push("declarative:other".into());
654        assert_eq!(validate_declarative_capability_definition(&d),Err("declarative capability dependencies cannot reference other declarative capabilities".into()));
655        d.dependencies.clear();
656        d.skills = vec![DeclarativeCapabilitySkill {
657            name: "../invalid".into(),
658            ..skill()
659        }];
660        assert!(
661            validate_declarative_capability_definition(&d)
662                .unwrap_err()
663                .starts_with("invalid skill name '../invalid':")
664        );
665    }
666
667    #[test]
668    fn catalog_projection_and_hydration_preserve_definition_not_incoming_overrides() {
669        let mut d = valid_definition();
670        d.system_prompt = Some("Exact prompt".into());
671        d.dependencies = vec!["filesystem".into()];
672        d.features = vec!["files".into()];
673        d.risk_level = RiskLevel::High;
674        d.status = CapabilityStatus::ComingSoon;
675        d.icon = None;
676        d.category = None;
677        for (info, id, category) in [
678            (
679                declarative_capability_info("external-id", d.clone()),
680                "declarative:external-id",
681                "Declarative",
682            ),
683            (
684                plugin_capability_info("install-42", d.clone()),
685                "plugin:install-42",
686                "Plugin",
687            ),
688        ] {
689            assert_eq!(info.id.as_str(), id);
690            assert_eq!(info.name, "Research Pack");
691            assert_eq!(info.description, "Curated research behavior");
692            assert_eq!(info.system_prompt.as_deref(), Some("Exact prompt"));
693            assert_eq!(info.dependencies, ["filesystem"]);
694            assert_eq!(info.features, ["files"]);
695            assert_eq!(info.risk_level, RiskLevel::High);
696            assert_eq!(info.status, CapabilityStatus::ComingSoon);
697            assert_eq!(info.icon.as_deref(), Some("puzzle"));
698            assert_eq!(info.category.as_deref(), Some(category));
699            assert!(info.tool_definitions.is_empty());
700        }
701        for hydrate in [
702            hydrate_declarative_capability_config,
703            hydrate_plugin_capability_config,
704        ] {
705            let value = hydrate(
706                serde_json::json!({"name":"attacker","system_prompt":"override","unknown":42}),
707                &d,
708            );
709            assert_eq!(value["name"], "research_pack");
710            assert_eq!(value["system_prompt"], "Exact prompt");
711            assert!(value.get("unknown").is_none());
712            assert_eq!(value, serde_json::to_value(&d).unwrap());
713        }
714        d.display_name = None;
715        d.icon = Some("custom".into());
716        d.category = Some("Custom".into());
717        for info in [
718            declarative_capability_info("id", d.clone()),
719            plugin_capability_info("id", d),
720        ] {
721            assert_eq!(info.name, "research_pack");
722            assert_eq!(info.icon.as_deref(), Some("custom"));
723            assert_eq!(info.category.as_deref(), Some("Custom"));
724        }
725    }
726
727    #[test]
728    fn mount_and_skill_projection_preserve_contents_owners_and_access() {
729        let mut d = valid_definition();
730        d.files = vec![
731            DeclarativeCapabilityFile {
732                path: "/readonly".into(),
733                content: "Read α".into(),
734                access: MountAccess::ReadOnly,
735            },
736            DeclarativeCapabilityFile {
737                path: "/writable".into(),
738                content: "Write β".into(),
739                access: MountAccess::ReadWrite,
740            },
741        ];
742        assert_eq!(
743            d.mounts("owner"),
744            vec![
745                MountPoint::readonly("/readonly", MountSource::text_file("Read α"), "owner"),
746                MountPoint::readwrite("/writable", MountSource::text_file("Write β"), "owner")
747            ]
748        );
749        d.skills = vec![DeclarativeCapabilitySkill {
750            user_invocable: false,
751            disable_model_invocation: true,
752            files: vec![DeclarativeCapabilitySkillFile {
753                path: "reference.txt".into(),
754                content: "Reference".into(),
755            }],
756            ..skill()
757        }];
758        let skills = d.skill_contributions();
759        assert_eq!(skills.len(), 1);
760        let s = &skills[0];
761        assert_eq!(s.name, "ops");
762        assert_eq!(s.description, "Operations");
763        assert_eq!(s.instructions, "Body");
764        assert_eq!(s.files, [("reference.txt".into(), "Reference".into())]);
765        assert!(!s.user_invocable);
766        assert!(s.disable_model_invocation);
767    }
768}