返回 CodeWhale
skills.rs
根目录 / crates / tui / src / extension_host / skills.rs
1 //! Owned reviewed roots feed the existing SkillRegistry; no parser or store here.
2 use std::path::{Component, Path, PathBuf};
3 use std::sync::Arc;
4 use std::sync::atomic::Ordering;
5
6 use serde::{Deserialize, Serialize};
7
8 use super::ManagerShared;
9 use super::protocol::{OwnerRef, RegisterParams, RegisterResult};
10 use super::supervisor::HostRequestContext;
11 use super::tier::HostTier;
12 use crate::plugins::activation::PluginActivationCapability;
13 use crate::plugins::types::{PluginAuthority, PluginSkillSnapshot};
14
15 pub const MAX_ROOTS_PER_OWNER: usize = 8;
16 pub const MAX_ROOTS_PER_HOST: usize = 64;
17 pub const MAX_SKILLS_PER_OWNER: usize = 128;
18 pub const MAX_SKILLS_PER_HOST: usize = 1024;
19 pub const MAX_BYTES_PER_OWNER: usize = 4 * 1024 * 1024;
20 pub const MAX_BYTES_PER_HOST: usize = 32 * 1024 * 1024;
21
22 /// Public receipt reuses the existing process and host/owner lifetimes. The
23 /// host-only owner token never enters prompts or persisted queued messages.
24 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25 #[serde(deny_unknown_fields)]
26 pub struct NativeSkillRef {
27 pub boot_id: String,
28 pub host_generation: u64,
29 pub owner_generation: u64,
30 pub handle: u64,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub selection: Option<super::composition_scope::SelectionRevision>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub scope: Option<super::protocol::EntryRef>,
35 }
36
37 #[derive(Debug, Clone)]
38 pub struct SkillRootRegistration {
39 pub handle: u64,
40 pub owner: OwnerRef,
41 pub scope: Option<super::protocol::EntryRef>,
42 pub host_generation: u64,
43 pub content_hash: String,
44 pub path: String,
45 pub snapshots: Vec<PluginSkillSnapshot>,
46 pub bytes: usize,
47 }
48
49 pub(crate) fn root_path(path: &str) -> Result<PathBuf, String> {
50 if path.is_empty()
51 || path.len() > 512
52 || path
53 .chars()
54 .any(|c| c.is_control() || matches!(c, '\\' | ':'))
55 || path
56 .split('/')
57 .any(|part| part.is_empty() || matches!(part, "." | ".."))
58 || Path::new(path)
59 .components()
60 .any(|component| !matches!(component, Component::Normal(_)))
61 {
62 return Err("skill root must be a bounded bundle-relative path with normal slash-separated components".to_string());
63 }
64 Ok(PathBuf::from(path))
65 }
66
67 pub(crate) fn snapshot_bytes(skill: &PluginSkillSnapshot) -> usize {
68 skill.name.len()
69 + skill.description.len()
70 + skill.body.len()
71 + skill.source_hash.len()
72 + skill.legacy_activation_name.as_ref().map_or(0, String::len)
73 + skill.argument_hint.as_ref().map_or(0, String::len)
74 + skill.aliases.iter().map(String::len).sum::<usize>()
75 + skill
76 .localized_descriptions
77 .iter()
78 .map(|(key, value)| key.len() + value.len())
79 .sum::<usize>()
80 }
81
82 /// The existing asynchronous request seam performs disk work outside every
83 /// registry lock; final admission rechecks cancellation and exact lifetimes.
84 pub(super) async fn admit_root(
85 shared: &Arc<ManagerShared>,
86 tier: HostTier,
87 host_generation: u64,
88 params: RegisterParams,
89 cx: &HostRequestContext,
90 ) -> RegisterResult {
91 let result = async {
92 params.check_spec()?;
93 if !params.spec.description.is_empty() {
94 return Err("skill root has no description".to_string());
95 }
96 let relative = root_path(&params.spec.name)?;
97 if tier != HostTier::Plugin {
98 return Err("skill roots require a reviewed plugin bundle".to_string());
99 }
100 let authority = shared
101 .live_owner_authority(tier, |registry| {
102 registry
103 .authority_for(&params.owner)
104 .ok_or_else(|| "stale or unknown skill owner".to_string())?;
105 Ok(params.owner.clone())
106 })?
107 .ok_or_else(|| "skill owner has no reviewed authority".to_string())?;
108 let snapshots =
109 bounded_review_check(Arc::clone(&shared.skill_admission), &cx.cancel, move || {
110 // One permit covers every disk check, including the Native receipt
111 // phase; cancellation never leaves that phase outside the bound.
112 crate::plugins::registry::verify_plugin_component_authority(
113 &authority,
114 PluginActivationCapability::Native,
115 )?;
116 let snapshots = crate::plugins::discovery::load_staged_skill_root_snapshots(
117 &authority, &relative,
118 )?;
119 crate::plugins::registry::verify_plugin_component_authority(
120 &authority,
121 PluginActivationCapability::Native,
122 )?;
123 Ok(snapshots)
124 })
125 .await?;
126 shared
127 .ready_host(tier)
128 .map_err(|status| status.to_string())?;
129 let runtime = shared.tier_runtime(tier);
130 let _slot = runtime.host.lock().expect("host lock");
131 if runtime.host_generation.load(Ordering::SeqCst) != host_generation
132 || cx.cancel.is_cancelled()
133 {
134 return Err(
135 "skill root host generation changed or admission was cancelled".to_string(),
136 );
137 }
138 shared
139 .registry
140 .lock()
141 .expect("registry lock")
142 .register_skill_root(&params, snapshots, host_generation)
143 }
144 .await;
145 match result {
146 Ok(handle) => RegisterResult::Admitted { handle },
147 Err(refused) => {
148 shared.plugin_diagnostic(
149 &params.owner.plugin_id,
150 format!("skill root refused: {refused}"),
151 );
152 RegisterResult::Refused { refused }
153 }
154 }
155 }
156
157 /// Every Native root or MCP disk phase uses this job. Its permit belongs to the
158 /// blocking closure, so RPC cancellation or abandonment cannot release it
159 /// until receipt validation and parsing actually stop.
160 pub(super) async fn bounded_review_check<T: Send + 'static>(
161 admission: Arc<tokio::sync::Semaphore>,
162 cancel: &tokio_util::sync::CancellationToken,
163 check: impl FnOnce() -> Result<T, String> + Send + 'static,
164 ) -> Result<T, String> {
165 if cancel.is_cancelled() {
166 return Err("Native review admission cancelled".to_string());
167 }
168 let permit = tokio::select! {
169 _ = cancel.cancelled() => return Err("Native review admission cancelled".to_string()),
170 permit = admission.acquire_owned() => permit.map_err(|_| "Native review admission is unavailable".to_string())?,
171 };
172 if cancel.is_cancelled() {
173 return Err("Native review admission cancelled".to_string());
174 }
175 let policy = super::activation::extension_host_policy_enabled();
176 #[cfg(test)]
177 let env_scope = crate::test_support::env_scope_ticket();
178 let work = tokio::task::spawn_blocking(move || {
179 let _permit = permit;
180 #[cfg(test)]
181 let _env_scope = crate::test_support::join_env_scope(env_scope);
182 let _policy = crate::plugins::activation::PolicyScope::propagate(policy);
183 check()
184 });
185 tokio::select! {
186 _ = cancel.cancelled() => Err("Native review admission cancelled".to_string()),
187 result = work => result.map_err(|error| format!("Native review check failed: {error}"))?,
188 }
189 }
190
191 fn live(
192 manager: &super::ExtensionHostManager,
193 authority: &PluginAuthority,
194 reference: &NativeSkillRef,
195 ) -> Result<(), String> {
196 if reference.boot_id != crate::session_manager::current_session_boot_id() {
197 return Err(
198 "native skill belongs to an earlier Codewhale process; select the skill again"
199 .to_string(),
200 );
201 }
202 if !super::activation::extension_host_policy_enabled() {
203 return Err("extension host is disabled".to_string());
204 }
205 let host = manager
206 .shared
207 .ready_host(HostTier::Plugin)
208 .map_err(|status| status.to_string())?;
209 if host.generation != reference.host_generation {
210 return Err("native skill host restarted; select the skill again".to_string());
211 }
212 let registry = manager.shared.registry.lock().expect("registry lock");
213 if !registry.is_live_skill_root(
214 reference.handle,
215 authority.plugin_id.as_str(),
216 reference.owner_generation,
217 reference.host_generation,
218 &authority.content_hash,
219 authority.state_generation,
220 ) {
221 return Err(
222 "native skill registration was disposed, revoked or replaced; select the skill again"
223 .to_string(),
224 );
225 }
226 Ok(())
227 }
228
229 pub(crate) fn verify_native_skill(
230 authority: &PluginAuthority,
231 reference: &NativeSkillRef,
232 ) -> Result<(), String> {
233 let manager = super::manager();
234 if reference.scope.is_some()
235 && !reference.selection.is_some_and(|selected| {
236 manager.shared.selection_current(
237 selected,
238 authority.plugin_id.as_str(),
239 &authority.content_hash,
240 reference.scope.as_ref(),
241 )
242 })
243 {
244 return Err("Native skill is no longer selected".into());
245 }
246 live(&manager, authority, reference)?;
247 crate::plugins::registry::verify_plugin_component_authority(
248 authority,
249 PluginActivationCapability::Native,
250 )?;
251 // Full receipt validation does disk I/O; do not accept a registration
252 // removed while that check was in progress.
253 live(&manager, authority, reference)?;
254 if reference.scope.is_some()
255 && !reference.selection.is_some_and(|selected| {
256 manager.shared.selection_current(
257 selected,
258 authority.plugin_id.as_str(),
259 &authority.content_hash,
260 reference.scope.as_ref(),
261 )
262 })
263 {
264 return Err("Native skill selection changed during verification".into());
265 }
266 Ok(())
267 }
268
269 /// Caller-snapshot scope, rather than the first workspace that activated an
270 /// owner. All normal discovery consumers use this one existing catalog merge.
271 pub(crate) fn roots_for_plugins(
272 plugins: &crate::plugins::PluginRegistry,
273 ) -> Vec<(SkillRootRegistration, PluginAuthority, NativeSkillRef)> {
274 if !super::activation::extension_host_policy_enabled() {
275 return Vec::new();
276 }
277 let Some(state_path) = plugins.state_path() else {
278 return Vec::new();
279 };
280 let manager = super::manager();
281 let roots = manager
282 .shared
283 .registry
284 .lock()
285 .expect("registry lock")
286 .live_skill_roots();
287 let mut selected: Vec<_> = roots
288 .into_iter()
289 .filter_map(|root| {
290 let plugin = plugins.get(&root.owner.plugin_id)?;
291 if !plugin.component_active(PluginActivationCapability::Native)
292 || plugin.content_hash != root.content_hash
293 {
294 return None;
295 }
296 let authority =
297 plugin.authority(state_path.to_path_buf(), plugins.workspace().to_path_buf())?;
298 let selected = plugins.caller_selection();
299 if root.scope.is_some()
300 && !selected.is_some_and(|selection| {
301 manager.shared.selection_current(
302 selection,
303 &root.owner.plugin_id,
304 &root.content_hash,
305 root.scope.as_ref(),
306 )
307 })
308 {
309 return None;
310 }
311 let reference = NativeSkillRef {
312 boot_id: crate::session_manager::current_session_boot_id().to_string(),
313 host_generation: root.host_generation,
314 owner_generation: root.owner.generation,
315 handle: root.handle,
316 selection: selected,
317 scope: root.scope.clone(),
318 };
319 verify_native_skill(&authority, &reference).ok()?;
320 Some((root, authority, reference))
321 })
322 .collect();
323 let mut counts = std::collections::BTreeMap::new();
324 for (root, _, _) in &selected {
325 for skill in &root.snapshots {
326 *counts
327 .entry((root.owner.plugin_id.clone(), skill.name.clone()))
328 .or_insert(0usize) += 1;
329 }
330 }
331 for (root, _, _) in &mut selected {
332 root.snapshots
333 .retain(|skill| counts[&(root.owner.plugin_id.clone(), skill.name.clone())] == 1);
334 }
335 selected.retain(|(root, _, _)| !root.snapshots.is_empty());
336 selected
337 }
338
339 #[cfg(test)]
340 mod tests {
341 use super::*;
342 use crate::extension_host::protocol::{RegisterKind, RegisterSpecWire};
343 use crate::extension_host::registry::OwnerRegistry;
344 use crate::extension_host::tests::{FixturePlugins, fake_authority, node_for_tests};
345 use crate::plugins::activation::TestPolicyGuard;
346 use crate::skills::{SkillDiscoveryMode, SkillInvocation, SkillProvenance};
347 use crate::tools::spec::{ToolContext, ToolSpec};
348 use std::collections::HashMap;
349
350 fn params(owner: &OwnerRef, path: &str) -> RegisterParams {
351 RegisterParams {
352 scope: None,
353 owner: owner.clone(),
354 kind: RegisterKind::SkillRoot,
355 spec: RegisterSpecWire {
356 name: path.to_string(),
357 description: String::new(),
358 input_schema: None,
359 argument_hint: None,
360 },
361 }
362 }
363 fn owner(registry: &mut OwnerRegistry, id: &str) -> OwnerRef {
364 registry
365 .begin_owner(
366 HostTier::Plugin,
367 id,
368 id,
369 Some(fake_authority(id)),
370 &format!("hash-{id}"),
371 )
372 .unwrap()
373 }
374 fn snapshot(name: &str, body: String) -> PluginSkillSnapshot {
375 PluginSkillSnapshot {
376 name: name.to_string(),
377 legacy_activation_name: None,
378 description: "focused fixture".to_string(),
379 localized_descriptions: HashMap::new(),
380 invocation: SkillInvocation::ModelAndUser,
381 aliases: Vec::new(),
382 argument_hint: None,
383 body,
384 path: PathBuf::from(format!("/staged/skills/{name}/SKILL.md")),
385 source_hash: "0".repeat(64),
386 }
387 }
388
389 #[tokio::test(flavor = "current_thread")]
390 async fn cancelled_native_root_disk_job_keeps_admission_permit_until_completion() {
391 // Exercise the production helper for both cooperative cancellation and
392 // a channel abandoning its handler. A blocked first disk phase cannot
393 // release its permit or start another queued check in either case.
394 for abandon_handler in [false, true] {
395 let admission = Arc::new(tokio::sync::Semaphore::new(1));
396 let cancel = tokio_util::sync::CancellationToken::new();
397 let (entered, started) = tokio::sync::oneshot::channel();
398 let (release, wait) = std::sync::mpsc::channel();
399 let running_admission = Arc::clone(&admission);
400 let running_cancel = cancel.clone();
401 let running = tokio::spawn(async move {
402 bounded_review_check(running_admission, &running_cancel, move || {
403 let _ = entered.send(());
404 wait.recv().map_err(|error| error.to_string())?;
405 Ok(7)
406 })
407 .await
408 });
409 started.await.unwrap();
410 if abandon_handler {
411 running.abort();
412 assert!(running.await.unwrap_err().is_cancelled());
413 } else {
414 cancel.cancel();
415 assert!(running.await.unwrap().unwrap_err().contains("cancelled"));
416 }
417 assert_eq!(
418 admission.available_permits(),
419 0,
420 "cancelled handler must not release the live disk job's permit"
421 );
422 let queued_cancel = tokio_util::sync::CancellationToken::new();
423 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
424 let queued_ran = Arc::clone(&ran);
425 let queued_admission = Arc::clone(&admission);
426 let queued_token = queued_cancel.clone();
427 let queued = tokio::spawn(async move {
428 bounded_review_check(queued_admission, &queued_token, move || {
429 queued_ran.store(true, Ordering::SeqCst);
430 Ok(9)
431 })
432 .await
433 });
434 tokio::task::yield_now().await;
435 queued_cancel.cancel();
436 assert!(queued.await.unwrap().is_err());
437 assert!(
438 !ran.load(Ordering::SeqCst),
439 "a cancelled queued check never enters the blocking pool"
440 );
441 release.send(()).unwrap();
442 let resumed = tokio::time::timeout(
443 std::time::Duration::from_secs(2),
444 bounded_review_check(
445 Arc::clone(&admission),
446 &tokio_util::sync::CancellationToken::new(),
447 || Ok(11),
448 ),
449 )
450 .await
451 .unwrap()
452 .unwrap();
453 assert_eq!(
454 resumed, 11,
455 "completed disk work releases admission for the next live owner"
456 );
457 assert_eq!(admission.available_permits(), 1);
458 }
459 }
460
461 #[test]
462 fn skill_root_paths_are_bounded_and_bundle_relative() {
463 assert!(root_path("profiles/review-skills").is_ok());
464 for path in [
465 "",
466 "/absolute",
467 "../outside",
468 "./same",
469 "a/../b",
470 "a//b",
471 "a/",
472 "C:/disk",
473 "a\\b",
474 "a\u{1b}b",
475 ] {
476 assert!(root_path(path).is_err(), "{path:?}");
477 }
478 assert!(root_path(&"界".repeat(171)).is_err());
479 }
480
481 #[test]
482 fn skill_root_withdrawal_is_exact_and_owner_generation_is_not_reusable() {
483 let mut registry = OwnerRegistry::new();
484 let a = owner(&mut registry, "a");
485 let b = owner(&mut registry, "b");
486 let handle = registry
487 .register_skill_root(
488 &params(&a, "skills"),
489 vec![snapshot("one", "body".into())],
490 7,
491 )
492 .unwrap();
493 assert!(registry.live_skill_roots().is_empty());
494 registry.mark_active(&a);
495 assert!(registry.is_live_skill_root(handle, "a", a.generation, 7, "hash-a", 1));
496 registry.unregister(&b, handle);
497 assert_eq!(registry.live_skill_roots().len(), 1);
498 registry.unregister(&a, handle);
499 assert!(registry.live_skill_roots().is_empty());
500 let next = registry
501 .register_skill_root(
502 &params(&a, "skills"),
503 vec![snapshot("one", "body".into())],
504 7,
505 )
506 .unwrap();
507 assert_ne!(handle, next);
508 registry.revoke_owner("a");
509 let replacement = owner(&mut registry, "a");
510 registry.mark_active(&replacement);
511 assert!(!registry.is_live_skill_root(next, "a", a.generation, 7, "hash-a", 1));
512 assert!(
513 registry
514 .register_skill_root(
515 &params(&a, "skills"),
516 vec![snapshot("one", "body".into())],
517 7
518 )
519 .is_err()
520 );
521 }
522
523 #[test]
524 fn skill_root_caps_retire_on_plugin_exit_and_preserve_other_tier() {
525 let mut registry = OwnerRegistry::new();
526 for index in 0..8 {
527 let current = owner(&mut registry, &format!("p{index}"));
528 for root in 0..8 {
529 registry
530 .register_skill_root(
531 &params(&current, &format!("s{root}")),
532 vec![snapshot(&format!("skill{root}"), "body".into())],
533 7,
534 )
535 .unwrap();
536 }
537 registry.mark_active(&current);
538 }
539 let extra = owner(&mut registry, "extra");
540 assert!(
541 registry
542 .register_skill_root(
543 &params(&extra, "skills"),
544 vec![snapshot("one", "body".into())],
545 7
546 )
547 .is_err()
548 );
549 registry.host_exited(HostTier::Builtin, "builtin crash");
550 assert_eq!(registry.live_skill_roots().len(), MAX_ROOTS_PER_HOST);
551 registry.host_exited(HostTier::Plugin, "plugin crash");
552 assert!(registry.live_skill_roots().is_empty());
553 let replacement = owner(&mut registry, "replacement");
554 registry
555 .register_skill_root(
556 &params(&replacement, "skills"),
557 vec![snapshot("one", "body".into())],
558 8,
559 )
560 .unwrap();
561 registry.revoke_all(HostTier::Plugin, "shutdown");
562 let restarted = owner(&mut registry, "replacement");
563 registry
564 .register_skill_root(
565 &params(&restarted, "skills"),
566 vec![snapshot("one", "body".into())],
567 9,
568 )
569 .unwrap();
570 }
571
572 #[test]
573 fn skill_root_admission_enforces_owner_count_bytes_and_duplicate_names() {
574 let mut registry = OwnerRegistry::new();
575 let current = owner(&mut registry, "a");
576 assert!(
577 registry
578 .register_skill_root(
579 &params(&current, "oversized"),
580 vec![snapshot("big", "x".repeat(MAX_BYTES_PER_OWNER))],
581 7
582 )
583 .is_err()
584 );
585 assert!(
586 registry
587 .register_skill_root(
588 &params(&current, "too-many"),
589 (0..129)
590 .map(|i| snapshot(&format!("s{i}"), "x".into()))
591 .collect(),
592 7
593 )
594 .is_err()
595 );
596 registry
597 .register_skill_root(
598 &params(&current, "first"),
599 vec![snapshot("one", "x".into())],
600 7,
601 )
602 .unwrap();
603 assert!(
604 registry
605 .register_skill_root(
606 &params(&current, "second"),
607 vec![snapshot("one", "x".into())],
608 7
609 )
610 .is_err()
611 );
612 for i in 1..8 {
613 registry
614 .register_skill_root(
615 &params(&current, &format!("r{i}")),
616 vec![snapshot(&format!("s{i}"), "x".into())],
617 7,
618 )
619 .unwrap();
620 }
621 assert!(
622 registry
623 .register_skill_root(
624 &params(&current, "ninth"),
625 vec![snapshot("last", "x".into())],
626 7
627 )
628 .is_err()
629 );
630 }
631
632 #[test]
633 fn queued_skill_provenance_preserves_legacy_receipts_and_rejects_process_restart() {
634 let authority = fake_authority("a");
635 let legacy = serde_json::to_string(&authority).unwrap();
636 let parsed: SkillProvenance = serde_json::from_str(&legacy).unwrap();
637 assert_eq!(serde_json::to_string(&parsed).unwrap(), legacy);
638 let provenance = SkillProvenance::NativeRoot(crate::skills::NativeSkillProvenance {
639 authority: authority.clone(),
640 registration: NativeSkillRef {
641 selection: None,
642 scope: None,
643 boot_id: "earlier-process".into(),
644 host_generation: 1,
645 owner_generation: 1,
646 handle: 1,
647 },
648 });
649 let restored: SkillProvenance =
650 serde_json::from_str(&serde_json::to_string(&provenance).unwrap()).unwrap();
651 assert!(
652 restored
653 .verify(&authority.workspace)
654 .unwrap_err()
655 .contains("earlier Codewhale process")
656 );
657 assert!(
658 restored
659 .verify(Path::new("/other"))
660 .unwrap_err()
661 .contains("different workspace")
662 );
663 }
664
665 fn parser_bundle(skills: usize, bytes: usize) -> (tempfile::TempDir, PluginAuthority) {
666 let temp = tempfile::tempdir().unwrap();
667 std::fs::write(temp.path().join("plugin.json"), r#"{"$schema":"https://agent-plugins.org/schemas/plugin.json","name":"bounded-skills","version":"0.1.0","description":"bounded fixture","extensions":{"net.codewhale":{"native":{"path":"index.mjs"}}}}"#).unwrap();
668 std::fs::write(
669 temp.path().join("index.mjs"),
670 "export function apply() {}\n",
671 )
672 .unwrap();
673 for index in 0..skills {
674 let directory = temp.path().join("skills").join(format!("s{index}"));
675 std::fs::create_dir_all(&directory).unwrap();
676 std::fs::write(
677 directory.join("SKILL.md"),
678 format!(
679 "---\nname: s{index}\ndescription: bounded fixture\n---\n{}",
680 "x".repeat(bytes)
681 ),
682 )
683 .unwrap();
684 }
685 let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
686 &temp.path().join("plugin.json"),
687 )
688 .unwrap();
689 let mut authority = fake_authority("bounded-skills");
690 authority.staged_manifest = validated.canonical_root.join("plugin.json");
691 authority.content_hash = validated.content_hash;
692 authority.capability_hash = validated.capability_hash;
693 (temp, authority)
694 }
695
696 #[test]
697 fn reviewed_skill_root_parser_bounds_accumulation_and_parent_claims() {
698 let (_many, authority) = parser_bundle(129, 1);
699 assert!(
700 crate::plugins::discovery::load_staged_skill_root_snapshots(
701 &authority,
702 Path::new("skills")
703 )
704 .unwrap_err()
705 .contains("candidate count")
706 );
707 let (_large, authority) = parser_bundle(8, 600 * 1024);
708 assert!(
709 crate::plugins::discovery::load_staged_skill_root_snapshots(
710 &authority,
711 Path::new("skills")
712 )
713 .unwrap_err()
714 .contains("byte limit")
715 );
716 let (one, mut authority) = parser_bundle(1, 1);
717 let nested = one.path().join("skills/s0/examples/nested");
718 std::fs::create_dir_all(&nested).unwrap();
719 std::fs::write(
720 nested.join("SKILL.md"),
721 "---\nname: nested\ndescription: nested fixture\n---\nbody",
722 )
723 .unwrap();
724 let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
725 &authority.staged_manifest,
726 )
727 .unwrap();
728 authority.content_hash = validated.content_hash;
729 authority.capability_hash = validated.capability_hash;
730 let snapshots = crate::plugins::discovery::load_staged_skill_root_snapshots(
731 &authority,
732 Path::new("skills"),
733 )
734 .unwrap();
735 assert_eq!(
736 snapshots
737 .iter()
738 .map(|s| s.name.as_str())
739 .collect::<Vec<_>>(),
740 ["s0"]
741 );
742 }
743
744 #[test]
745 fn declared_and_native_reviewed_roots_share_nested_hidden_and_depth_semantics() {
746 let (temp, _) = parser_bundle(1, 1);
747 let skill_root = temp.path().join("skills");
748 for (relative, name) in [
749 ("vendor/organized", "organized"),
750 ("s0/examples/nested", "nested"),
751 (".hidden/hidden", "hidden"),
752 ("a/b/c/d/e/f/g/h/deep8", "deep8"),
753 ("a/b/c/d/e/f/g/h/i/deep9", "deep9"),
754 ] {
755 let directory = skill_root.join(relative);
756 std::fs::create_dir_all(&directory).unwrap();
757 std::fs::write(
758 directory.join("SKILL.md"),
759 format!("---\nname: {name}\ndescription: parity fixture\n---\n{name} body"),
760 )
761 .unwrap();
762 }
763 std::fs::remove_file(temp.path().join("plugin.json")).unwrap();
764 let manifest = temp.path().join("plugin.toml");
765 std::fs::write(&manifest, "schema_version = 1\n[plugin]\nname = \"bounded-skills\"\nversion = \"0.1.0\"\n[skills]\npath = \"skills\"\n[native]\npath = \"index.mjs\"\n").unwrap();
766 let validated =
767 crate::plugins::manifest::PluginManifest::validate_from_path(&manifest).unwrap();
768 let declared = crate::plugins::discovery::load_staged_skill_snapshots(
769 &validated.canonical_root,
770 &validated.content_hash,
771 &validated.capability_hash,
772 )
773 .unwrap();
774 let mut authority = fake_authority("bounded-skills");
775 authority.staged_manifest = validated.canonical_root.join("plugin.toml");
776 authority.content_hash = validated.content_hash;
777 authority.capability_hash = validated.capability_hash;
778 let native = crate::plugins::discovery::load_staged_skill_root_snapshots(
779 &authority,
780 Path::new("skills"),
781 )
782 .unwrap();
783 let baseline = crate::skills::SkillRegistry::discover(&skill_root);
784 let projection = |snapshots: &[PluginSkillSnapshot]| {
785 snapshots
786 .iter()
787 .map(|skill| (skill.name.clone(), skill.body.clone()))
788 .collect::<std::collections::BTreeMap<_, _>>()
789 };
790 let expected = baseline
791 .list()
792 .iter()
793 .map(|skill| (skill.name.clone(), skill.body.clone()))
794 .collect::<std::collections::BTreeMap<_, _>>();
795 assert_eq!(
796 expected.keys().map(String::as_str).collect::<Vec<_>>(),
797 ["deep8", "organized", "s0"]
798 );
799 assert_eq!(projection(&declared), expected);
800 assert_eq!(projection(&native), expected);
801 }
802
803 #[tokio::test(flavor = "current_thread")]
804 async fn native_only_root_reaches_shared_catalog_model_tool_and_queued_provenance() {
805 let Some(node) = node_for_tests(
806 "native_only_root_reaches_shared_catalog_model_tool_and_queued_provenance",
807 ) else {
808 return;
809 };
810 let _home = crate::test_support::SealedHome::new();
811 let _policy = TestPolicyGuard::extension_host(true);
812 let fixture = FixturePlugins::new(&["skills-root"]).await;
813 let plugins = fixture.registry();
814 let plugin = plugins.get("skills-root").unwrap();
815 assert_eq!(plugin.inventory.skills, 0);
816 assert!(
817 plugin.skill_snapshots.is_empty(),
818 "fixture has no declarative Skill adapter snapshots"
819 );
820 let manager = fixture.manager(node);
821 let _manager = super::super::TestManagerGuard::install(Arc::clone(&manager));
822 let engine = manager.attach(Arc::clone(&plugins));
823 engine.sync().await.unwrap();
824 let plugins = engine.plugin_view();
825 let skills_dir = fixture.workspace().join("empty-skills");
826 let catalog = crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins(
827 fixture.workspace(),
828 &skills_dir,
829 SkillDiscoveryMode::CodeWhaleOnly,
830 Some(&plugins),
831 );
832 let skill = catalog
833 .get("skills-root:quick-check")
834 .expect("Native-only root merged into existing catalog");
835 assert!(skill.invocation.user_invocable());
836 let provenance = skill.source.provenance().unwrap();
837 provenance
838 .verify_for(fixture.workspace(), Some(&plugins))
839 .unwrap();
840 let block = crate::skills::render_available_skills_context_for_workspace_and_dir_with_mode_and_plugins(fixture.workspace(), &skills_dir, SkillDiscoveryMode::CodeWhaleOnly, "en", Some(&plugins), 8192).unwrap();
841 assert!(block.contains("skills-root:quick-check"));
842 let context =
843 ToolContext::new(fixture.workspace()).with_plugin_registry(Arc::clone(&plugins));
844 let result = crate::tools::skill::LoadSkillTool
845 .execute(
846 serde_json::json!({"name":"skills-root:quick-check"}),
847 &context,
848 )
849 .await
850 .unwrap();
851 assert!(result.content.contains("Inspect the exact source"));
852 assert!(result.metadata.as_ref().unwrap()["skill_path"].is_null());
853 let restored: SkillProvenance =
854 serde_json::from_str(&serde_json::to_string(&provenance).unwrap()).unwrap();
855 let root = manager
856 .shared
857 .registry
858 .lock()
859 .unwrap()
860 .live_skill_roots()
861 .pop()
862 .unwrap();
863 manager
864 .shared
865 .registry
866 .lock()
867 .unwrap()
868 .unregister(&root.owner, root.handle);
869 assert!(
870 restored
871 .verify_for(fixture.workspace(), Some(&plugins))
872 .is_err()
873 );
874 let empty = crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins(
875 fixture.workspace(),
876 &skills_dir,
877 SkillDiscoveryMode::CodeWhaleOnly,
878 Some(&plugins),
879 );
880 assert!(
881 empty.get("skills-root:quick-check").is_none(),
882 "the cache must not retain disposed roots"
883 );
884 manager.shutdown().await;
885 }
886
887 #[tokio::test(flavor = "current_thread")]
888 async fn native_skill_receipt_refuses_disable_source_tamper_and_host_restart() {
889 let Some(node) =
890 node_for_tests("native_skill_receipt_refuses_disable_source_tamper_and_host_restart")
891 else {
892 return;
893 };
894 let _home = crate::test_support::SealedHome::new();
895 let _policy = TestPolicyGuard::extension_host(true);
896 let fixture = FixturePlugins::new(&["skills-root"]).await;
897 let plugins = fixture.registry();
898 let manager = fixture.manager(node);
899 let _manager = super::super::TestManagerGuard::install(Arc::clone(&manager));
900 let engine = manager.attach(Arc::clone(&plugins));
901 engine.sync().await.unwrap();
902 let plugins = engine.plugin_view();
903 let catalog = crate::skills::discover_in_workspace_with_mode_and_plugins(
904 fixture.workspace(),
905 SkillDiscoveryMode::CodeWhaleOnly,
906 Some(&plugins),
907 );
908 let provenance = catalog
909 .get("skills-root:quick-check")
910 .unwrap()
911 .source
912 .provenance()
913 .unwrap();
914 manager.shutdown().await;
915 assert!(
916 provenance
917 .verify_for(fixture.workspace(), Some(&plugins))
918 .is_err()
919 );
920 let id = plugins.get("skills-root").unwrap().id.as_str();
921 assert!(matches!(
922 manager.owner_state(id),
923 Some(super::super::registry::OwnerState::Failed(_))
924 ));
925 engine.sync().await.unwrap();
926 assert!(
927 manager
928 .shared
929 .registry
930 .lock()
931 .unwrap()
932 .live_skill_roots()
933 .is_empty(),
934 "unchanged failed activations require an explicit retry"
935 );
936 manager.retry();
937 engine.sync().await.unwrap();
938 assert_eq!(
939 manager.owner_state(id),
940 Some(super::super::registry::OwnerState::Active),
941 "owner: {:?}; diagnostics: {:?}",
942 manager.owner_state(id),
943 manager.diagnostics()
944 );
945 assert!(
946 provenance
947 .verify_for(fixture.workspace(), Some(&plugins))
948 .is_err(),
949 "restart must not revive old handles"
950 );
951 let current = crate::skills::discover_in_workspace_with_mode_and_plugins(
952 fixture.workspace(),
953 SkillDiscoveryMode::CodeWhaleOnly,
954 Some(&plugins),
955 );
956 let current = current
957 .get("skills-root:quick-check")
958 .unwrap_or_else(|| {
959 panic!(
960 "Native root missing after explicit retry; owner: {:?}; diagnostics: {:?}",
961 manager.owner_state(id),
962 manager.diagnostics()
963 )
964 })
965 .source
966 .provenance()
967 .unwrap();
968 let source_root = crate::plugins::agent_plugin::plugin_root_for_manifest(
969 &current.authority().source_manifest,
970 )
971 .unwrap();
972 let source_skill = source_root.join("profiles/review-skills/quick-check/SKILL.md");
973 let reviewed = std::fs::read(&source_skill).unwrap();
974 std::fs::write(&source_skill, "changed after review").unwrap();
975 assert!(
976 current
977 .verify_for(fixture.workspace(), Some(&plugins))
978 .is_err(),
979 "mutable source tamper fails before reconcile"
980 );
981 std::fs::write(&source_skill, reviewed).unwrap();
982 current
983 .verify_for(fixture.workspace(), Some(&plugins))
984 .unwrap();
985 fixture.disable("skills-root");
986 assert!(
987 current
988 .verify_for(fixture.workspace(), Some(&plugins))
989 .is_err(),
990 "persisted disable wins before reconcile"
991 );
992 manager.shutdown().await;
993 }
994 }
995
995 lines RUST