返回 CodeWhale
skill_cli.rs
根目录 / crates / tui / tests / skill_cli.rs
1 //! Integration tests for the community-skill CLI flows (#140).
2 //!
3 //! Keep this file name free of `install`: Windows can treat unmanifested test
4 //! binaries with installer-like names as elevation-required programs.
5 //!
6 //! These tests exercise the full validation pipeline against a tiny in-process
7 //! HTTP server, so the network gate, download cap, tarball validation, atomic
8 //! rename, and `.installed-from` marker all run end-to-end. The module is
9 //! pulled in via `#[path]` includes (matching `integration_mock_llm.rs`) so we
10 //! get access to private helpers without a separate library crate.
11
12 use std::io::Write;
13 use std::path::Path;
14
15 use flate2::Compression;
16 use flate2::write::GzEncoder;
17 use tempfile::TempDir;
18 use tiny_http::{Method, Response, Server};
19
20 // Pull the production source files into this test binary so the test can
21 // reach `install`'s public surface without a dedicated library crate.
22 //
23 // `install.rs` references `crate::network_policy` and `super::package_digest`
24 // (sibling under `skills::`), so both are pulled in alongside `install`.
25 #[path = "../src/network_policy.rs"]
26 mod network_policy;
27
28 // Both `network_policy` and `install` resolve the home directory through
29 // `crate::config::effective_home_dir()` (#4757). `config/home.rs` is a leaf
30 // over `std`/`codewhale-paths` with no `crate::` references, so including it
31 // here gives this binary the real implementation; naming it `config` matches
32 // how those two files address it in the lib.
33 #[path = "../src/config/home.rs"]
34 #[allow(dead_code)]
35 mod config;
36
37 #[path = "../src/skills/package_digest.rs"]
38 #[allow(dead_code)]
39 mod package_digest;
40
41 #[path = "../src/skills/install.rs"]
42 #[allow(dead_code)]
43 mod install;
44
45 use crate::install::{InstallOutcome, InstallSource, UpdateResult};
46 use crate::network_policy::{DecisionToml, NetworkPolicy};
47
48 /// Construct a gzipped tarball from `(path, body)` pairs. Permissions are set
49 /// to 0o644 so umask differences across platforms don't perturb the bytes.
50 fn make_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
51 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
52 {
53 let mut builder = tar::Builder::new(&mut gz);
54 for (path, body) in entries {
55 let mut header = tar::Header::new_gnu();
56 header.set_size(body.len() as u64);
57 header.set_mode(0o644);
58 header.set_cksum();
59 builder
60 .append_data(&mut header, path, *body)
61 .expect("append_data");
62 }
63 builder.finish().expect("finish tar");
64 }
65 gz.finish().expect("finish gz")
66 }
67
68 fn skill_md(name: &str, description: &str) -> Vec<u8> {
69 format!(
70 "---\nname: {name}\ndescription: {description}\n---\n# {name}\n\nThis is a test skill.\n"
71 )
72 .into_bytes()
73 }
74
75 fn allow_all_policy() -> NetworkPolicy {
76 NetworkPolicy {
77 default: DecisionToml::Allow,
78 allow: Vec::new(),
79 deny: Vec::new(),
80 proxy: Vec::new(),
81 proxy_fake_ip_cidrs: Vec::new(),
82 audit: false,
83 }
84 }
85
86 fn deny_all_policy() -> NetworkPolicy {
87 NetworkPolicy {
88 default: DecisionToml::Deny,
89 allow: Vec::new(),
90 deny: Vec::new(),
91 proxy: Vec::new(),
92 proxy_fake_ip_cidrs: Vec::new(),
93 audit: false,
94 }
95 }
96
97 fn prompt_all_policy() -> NetworkPolicy {
98 NetworkPolicy {
99 default: DecisionToml::Prompt,
100 allow: Vec::new(),
101 deny: Vec::new(),
102 proxy: Vec::new(),
103 proxy_fake_ip_cidrs: Vec::new(),
104 audit: false,
105 }
106 }
107
108 /// Spawn a tiny HTTP server that serves `bytes` at any path with 200 OK and
109 /// returns the bound URL. The server replies to *every* request (we re-use it
110 /// across multiple installs in the same test).
111 fn spawn_tarball_server(
112 bytes: Vec<u8>,
113 ) -> (
114 String,
115 std::sync::mpsc::Sender<()>,
116 std::thread::JoinHandle<()>,
117 ) {
118 let server = Server::http("127.0.0.1:0").expect("bind ephemeral port");
119 let url = format!(
120 "http://{}/skill.tar.gz",
121 server.server_addr().to_ip().expect("ip addr")
122 );
123 let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
124 let handle = std::thread::spawn(move || {
125 loop {
126 // Poll-style with a small recv timeout so we can break out cleanly.
127 match server.recv_timeout(std::time::Duration::from_millis(100)) {
128 Ok(Some(req)) => {
129 if req.method() != &Method::Get {
130 continue;
131 }
132 let response = Response::from_data(bytes.clone());
133 let _ = req.respond(response);
134 }
135 Ok(None) => {}
136 Err(_) => break,
137 }
138 if shutdown_rx.try_recv().is_ok() {
139 break;
140 }
141 }
142 });
143 (url, shutdown_tx, handle)
144 }
145
146 fn shutdown(tx: std::sync::mpsc::Sender<()>, handle: std::thread::JoinHandle<()>) {
147 let _ = tx.send(());
148 let _ = handle.join();
149 }
150
151 #[tokio::test]
152 async fn install_happy_path_writes_skill_and_marker() {
153 let tarball = make_tarball(&[
154 (
155 "test-skill-main/SKILL.md",
156 &skill_md("test-skill", "Test skill"),
157 ),
158 ("test-skill-main/notes.txt", b"hello world"),
159 ]);
160 let (url, tx, handle) = spawn_tarball_server(tarball);
161
162 let tmp = TempDir::new().unwrap();
163 let policy = allow_all_policy();
164
165 let outcome = install::install(
166 InstallSource::DirectUrl(url),
167 tmp.path(),
168 install::DEFAULT_MAX_SIZE_BYTES,
169 &policy,
170 false,
171 )
172 .await
173 .expect("install ok");
174
175 let installed = match outcome {
176 InstallOutcome::Installed(s) => s,
177 other => panic!("expected Installed, got {other:?}"),
178 };
179 assert_eq!(installed.name, "test-skill");
180
181 let installed_dir = tmp.path().join("test-skill");
182 assert!(installed_dir.is_dir(), "skill dir created");
183 assert!(installed_dir.join("SKILL.md").is_file(), "SKILL.md present");
184 assert!(
185 installed_dir.join("notes.txt").is_file(),
186 "extra file present"
187 );
188 assert!(
189 installed_dir.join(install::INSTALLED_FROM_MARKER).is_file(),
190 ".installed-from marker present"
191 );
192 let marker = std::fs::read_to_string(installed_dir.join(install::INSTALLED_FROM_MARKER))
193 .expect("read marker");
194 assert!(
195 marker.contains("\"schema_version\": 2") || marker.contains("\"schema_version\":2"),
196 "install must write metadata v2: {marker}"
197 );
198 assert!(
199 marker.contains("content_digest"),
200 "install must bind content_digest: {marker}"
201 );
202
203 shutdown(tx, handle);
204 }
205
206 #[tokio::test]
207 async fn install_rejects_path_traversal() {
208 // `tar::Builder::append_data` rejects `..` itself, so we craft the bad
209 // entry by writing the raw header bytes via `append`.
210 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
211 {
212 let mut builder = tar::Builder::new(&mut gz);
213 let body = skill_md("test-skill", "T");
214 let mut hdr = tar::Header::new_gnu();
215 hdr.set_size(body.len() as u64);
216 hdr.set_mode(0o644);
217 hdr.set_cksum();
218 builder
219 .append_data(&mut hdr, "test-skill-main/SKILL.md", body.as_slice())
220 .unwrap();
221
222 // Path-traversal entry. The `tar` crate's `set_path` rejects `..`
223 // itself, so we patch the raw 100-byte name field in the header.
224 let evil_body: &[u8] = b"not gonna happen";
225 let mut evil_hdr = tar::Header::new_gnu();
226 evil_hdr.set_size(evil_body.len() as u64);
227 evil_hdr.set_mode(0o644);
228 // Write a name with a `..` directly into the legacy "name" field.
229 let bytes = evil_hdr.as_old_mut();
230 let evil_name = b"../etc/passwd";
231 bytes.name[..evil_name.len()].copy_from_slice(evil_name);
232 evil_hdr.set_cksum();
233 builder.append(&evil_hdr, evil_body).unwrap();
234 builder.finish().unwrap();
235 }
236 let tarball = gz.finish().unwrap();
237 let (url, tx, handle) = spawn_tarball_server(tarball);
238
239 let tmp = TempDir::new().unwrap();
240 let policy = allow_all_policy();
241 let err = install::install(
242 InstallSource::DirectUrl(url),
243 tmp.path(),
244 install::DEFAULT_MAX_SIZE_BYTES,
245 &policy,
246 false,
247 )
248 .await
249 .expect_err("path traversal must be rejected");
250 let msg = format!("{err:#}");
251 assert!(
252 msg.contains("escapes destination"),
253 "expected path-traversal error, got: {msg}"
254 );
255
256 shutdown(tx, handle);
257 }
258
259 #[tokio::test]
260 async fn install_rejects_oversized_tarball() {
261 let big = vec![b'a'; 256 * 1024]; // 256 KiB per file
262 let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
263 entries.push((
264 "test-skill-main/SKILL.md".to_string(),
265 skill_md("test-skill", "T"),
266 ));
267 for i in 0..50 {
268 entries.push((format!("test-skill-main/big-{i}.bin"), big.clone()));
269 }
270 let entry_refs: Vec<(&str, &[u8])> = entries
271 .iter()
272 .map(|(p, b)| (p.as_str(), b.as_slice()))
273 .collect();
274 let tarball = make_tarball(&entry_refs);
275 let (url, tx, handle) = spawn_tarball_server(tarball);
276
277 let tmp = TempDir::new().unwrap();
278 let policy = allow_all_policy();
279 let small_cap = 1024 * 1024;
280 let err = install::install(
281 InstallSource::DirectUrl(url),
282 tmp.path(),
283 small_cap,
284 &policy,
285 false,
286 )
287 .await
288 .expect_err("oversized must be rejected");
289 let msg = format!("{err:#}");
290 assert!(
291 msg.contains("too large") || msg.contains("exceed"),
292 "expected size cap error, got: {msg}"
293 );
294
295 shutdown(tx, handle);
296 }
297
298 #[tokio::test]
299 async fn install_rejects_missing_skill_md() {
300 let tarball = make_tarball(&[("repo-main/README.md", b"not a skill")]);
301 let (url, tx, handle) = spawn_tarball_server(tarball);
302
303 let tmp = TempDir::new().unwrap();
304 let policy = allow_all_policy();
305 let err = install::install(
306 InstallSource::DirectUrl(url),
307 tmp.path(),
308 install::DEFAULT_MAX_SIZE_BYTES,
309 &policy,
310 false,
311 )
312 .await
313 .expect_err("missing SKILL.md must be rejected");
314 assert!(format!("{err:#}").contains("missing SKILL.md"), "{err:#}");
315
316 shutdown(tx, handle);
317 }
318
319 #[tokio::test]
320 async fn install_accepts_claude_compatible_skill_directory_archive() {
321 let tarball = make_tarball(&[
322 (
323 "repo-main/.claude/skills/workflow-pack/SKILL.md",
324 &skill_md("workflow-pack", "Workflow pack"),
325 ),
326 (
327 "repo-main/.claude/skills/workflow-pack/scripts/check.sh",
328 b"echo ok",
329 ),
330 ("repo-main/README.md", b"outside the selected skill subtree"),
331 ]);
332 let (url, tx, handle) = spawn_tarball_server(tarball);
333
334 let tmp = TempDir::new().unwrap();
335 let policy = allow_all_policy();
336 let outcome = install::install(
337 InstallSource::DirectUrl(url),
338 tmp.path(),
339 install::DEFAULT_MAX_SIZE_BYTES,
340 &policy,
341 false,
342 )
343 .await
344 .expect("claude-compatible skill dir should install");
345 let installed = match outcome {
346 InstallOutcome::Installed(installed) => installed,
347 other => panic!("expected Installed, got {other:?}"),
348 };
349
350 assert_eq!(installed.name, "workflow-pack");
351 assert!(installed.path.join("SKILL.md").is_file());
352 assert!(installed.path.join("scripts/check.sh").is_file());
353 assert!(!installed.path.join("README.md").exists());
354
355 shutdown(tx, handle);
356 }
357
358 #[tokio::test]
359 async fn install_accepts_nested_workflow_pack_skill_directory() {
360 let tarball = make_tarball(&[
361 (
362 "repo-main/packages/superpowers/5.1.0/skills/using-superpowers/SKILL.md",
363 &skill_md("using-superpowers", "Use Superpowers workflow"),
364 ),
365 (
366 "repo-main/packages/superpowers/5.1.0/skills/using-superpowers/references/guide.md",
367 b"guide",
368 ),
369 ]);
370 let (url, tx, handle) = spawn_tarball_server(tarball);
371
372 let tmp = TempDir::new().unwrap();
373 let policy = allow_all_policy();
374 let outcome = install::install(
375 InstallSource::DirectUrl(url),
376 tmp.path(),
377 install::DEFAULT_MAX_SIZE_BYTES,
378 &policy,
379 false,
380 )
381 .await
382 .expect("nested workflow-pack skill dir should install");
383 let installed = match outcome {
384 InstallOutcome::Installed(installed) => installed,
385 other => panic!("expected Installed, got {other:?}"),
386 };
387
388 assert_eq!(installed.name, "using-superpowers");
389 assert!(installed.path.join("SKILL.md").is_file());
390 assert!(installed.path.join("references/guide.md").is_file());
391
392 shutdown(tx, handle);
393 }
394
395 #[tokio::test]
396 async fn install_rejects_multi_skill_claude_plugin_archive() {
397 let tarball = make_tarball(&[
398 (
399 "repo-main/.claude-plugin/plugin.json",
400 br#"{"name":"workflow-pack","version":"1.0.0"}"#,
401 ),
402 (
403 "repo-main/skills/plan/SKILL.md",
404 &skill_md("plan", "Planning skill"),
405 ),
406 (
407 "repo-main/skills/review/SKILL.md",
408 &skill_md("review", "Review skill"),
409 ),
410 ]);
411 let (url, tx, handle) = spawn_tarball_server(tarball);
412
413 let tmp = TempDir::new().unwrap();
414 let policy = allow_all_policy();
415 let err = install::install(
416 InstallSource::DirectUrl(url),
417 tmp.path(),
418 install::DEFAULT_MAX_SIZE_BYTES,
419 &policy,
420 false,
421 )
422 .await
423 .expect_err("multi-skill Claude plugin archive should not be flattened");
424 let msg = format!("{err:#}");
425 assert!(
426 msg.contains("Claude Code plugin archive contains multiple SKILL.md entries"),
427 "expected Claude plugin compatibility error, got: {msg}"
428 );
429 assert!(
430 std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
431 "rejected plugin archive must not write an installed skill"
432 );
433
434 shutdown(tx, handle);
435 }
436
437 #[tokio::test]
438 async fn install_accepts_single_skill_subdirectory_archive() {
439 let tarball = make_tarball(&[
440 (
441 "repo-main/my-workflow/SKILL.md",
442 &skill_md("my-workflow", "Nested workflow"),
443 ),
444 ("repo-main/my-workflow/examples/example.md", b"example"),
445 ("repo-main/README.md", b"outside the selected skill subtree"),
446 ]);
447 let (url, tx, handle) = spawn_tarball_server(tarball);
448
449 let tmp = TempDir::new().unwrap();
450 let policy = allow_all_policy();
451 let outcome = install::install(
452 InstallSource::DirectUrl(url),
453 tmp.path(),
454 install::DEFAULT_MAX_SIZE_BYTES,
455 &policy,
456 false,
457 )
458 .await
459 .expect("single nested skill dir should install");
460 let installed = match outcome {
461 InstallOutcome::Installed(installed) => installed,
462 other => panic!("expected Installed, got {other:?}"),
463 };
464
465 assert_eq!(installed.name, "my-workflow");
466 assert!(installed.path.join("SKILL.md").is_file());
467 assert!(installed.path.join("examples/example.md").is_file());
468 assert!(!installed.path.join("README.md").exists());
469
470 shutdown(tx, handle);
471 }
472
473 #[tokio::test]
474 async fn install_rejects_missing_required_frontmatter() {
475 let tarball = make_tarball(&[("repo-main/SKILL.md", b"---\nname: test\n---\nbody\n")]);
476 let (url, tx, handle) = spawn_tarball_server(tarball);
477
478 let tmp = TempDir::new().unwrap();
479 let policy = allow_all_policy();
480 let err = install::install(
481 InstallSource::DirectUrl(url),
482 tmp.path(),
483 install::DEFAULT_MAX_SIZE_BYTES,
484 &policy,
485 false,
486 )
487 .await
488 .expect_err("missing description must be rejected");
489 assert!(format!("{err:#}").contains("description"), "{err:#}");
490
491 shutdown(tx, handle);
492 }
493
494 #[tokio::test]
495 async fn install_idempotent_then_uninstall_then_reinstall() {
496 let tarball_bytes =
497 make_tarball(&[("repo-main/SKILL.md", &skill_md("idem-skill", "Idempotent"))]);
498 let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
499
500 let tmp = TempDir::new().unwrap();
501 let policy = allow_all_policy();
502
503 install::install(
504 InstallSource::DirectUrl(url.clone()),
505 tmp.path(),
506 install::DEFAULT_MAX_SIZE_BYTES,
507 &policy,
508 false,
509 )
510 .await
511 .expect("first install ok");
512
513 // Second install with `update = false` must reject.
514 let err = install::install(
515 InstallSource::DirectUrl(url.clone()),
516 tmp.path(),
517 install::DEFAULT_MAX_SIZE_BYTES,
518 &policy,
519 false,
520 )
521 .await
522 .expect_err("second install must reject");
523 let msg = format!("{err:#}");
524 assert!(
525 msg.contains("already installed"),
526 "expected already-installed error, got: {msg}"
527 );
528
529 // Uninstall then reinstall.
530 install::uninstall("idem-skill", tmp.path()).expect("uninstall ok");
531 assert!(!tmp.path().join("idem-skill").exists());
532
533 install::install(
534 InstallSource::DirectUrl(url),
535 tmp.path(),
536 install::DEFAULT_MAX_SIZE_BYTES,
537 &policy,
538 false,
539 )
540 .await
541 .expect("reinstall ok");
542
543 assert!(tmp.path().join("idem-skill").join("SKILL.md").is_file());
544 shutdown(tx, handle);
545 }
546
547 #[tokio::test]
548 async fn update_no_change_returns_nochange_without_overwriting() {
549 let tarball_bytes =
550 make_tarball(&[("repo-main/SKILL.md", &skill_md("upd-skill", "Update test"))]);
551 let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
552 let tmp = TempDir::new().unwrap();
553 let policy = allow_all_policy();
554
555 install::install(
556 InstallSource::DirectUrl(url.clone()),
557 tmp.path(),
558 install::DEFAULT_MAX_SIZE_BYTES,
559 &policy,
560 false,
561 )
562 .await
563 .unwrap();
564
565 // Patch the marker so update() re-fetches the same URL.
566 let marker_path = tmp
567 .path()
568 .join("upd-skill")
569 .join(install::INSTALLED_FROM_MARKER);
570 let marker_body = std::fs::read_to_string(&marker_path).unwrap();
571 let mut marker_json: serde_json::Value = serde_json::from_str(&marker_body).unwrap();
572 marker_json["spec"] = serde_json::Value::String(url);
573 std::fs::write(&marker_path, marker_json.to_string()).unwrap();
574
575 // Capture mtime so we can confirm SKILL.md wasn't rewritten.
576 let skill_md_path = tmp.path().join("upd-skill").join("SKILL.md");
577 let mtime_before = std::fs::metadata(&skill_md_path)
578 .unwrap()
579 .modified()
580 .unwrap();
581
582 let result = install::update(
583 "upd-skill",
584 tmp.path(),
585 install::DEFAULT_MAX_SIZE_BYTES,
586 &policy,
587 )
588 .await
589 .expect("update ok");
590 assert!(matches!(result, UpdateResult::NoChange));
591
592 let mtime_after = std::fs::metadata(&skill_md_path)
593 .unwrap()
594 .modified()
595 .unwrap();
596 assert_eq!(mtime_before, mtime_after, "SKILL.md must not be rewritten");
597 shutdown(tx, handle);
598 }
599
600 #[tokio::test]
601 async fn install_with_deny_policy_returns_network_denied() {
602 let tmp = TempDir::new().unwrap();
603 let policy = deny_all_policy();
604 let outcome = install::install(
605 InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
606 tmp.path(),
607 install::DEFAULT_MAX_SIZE_BYTES,
608 &policy,
609 false,
610 )
611 .await
612 .expect("policy outcome should be Ok");
613 match outcome {
614 InstallOutcome::NetworkDenied(host) => {
615 assert!(host.contains("example.invalid"), "got host {host}");
616 }
617 other => panic!("expected NetworkDenied, got {other:?}"),
618 }
619
620 // Verify the temp dir is untouched.
621 assert!(
622 std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
623 "temp dir must be untouched"
624 );
625 }
626
627 #[tokio::test]
628 async fn install_with_prompt_policy_returns_needs_approval() {
629 let tmp = TempDir::new().unwrap();
630 let policy = prompt_all_policy();
631 let outcome = install::install(
632 InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
633 tmp.path(),
634 install::DEFAULT_MAX_SIZE_BYTES,
635 &policy,
636 false,
637 )
638 .await
639 .expect("policy outcome should be Ok");
640 match outcome {
641 InstallOutcome::NeedsApproval(host) => {
642 assert!(host.contains("example.invalid"), "got host {host}");
643 }
644 other => panic!("expected NeedsApproval, got {other:?}"),
645 }
646 assert!(
647 std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
648 "temp dir must be untouched on prompt"
649 );
650 }
651
652 #[tokio::test]
653 async fn install_rejects_symlink_entry() {
654 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
655 {
656 let mut builder = tar::Builder::new(&mut gz);
657
658 let body = skill_md("link-skill", "x");
659 let mut hdr = tar::Header::new_gnu();
660 hdr.set_size(body.len() as u64);
661 hdr.set_mode(0o644);
662 hdr.set_cksum();
663 builder
664 .append_data(&mut hdr, "repo-main/SKILL.md", body.as_slice())
665 .unwrap();
666
667 let mut link_hdr = tar::Header::new_gnu();
668 link_hdr.set_entry_type(tar::EntryType::Symlink);
669 link_hdr.set_size(0);
670 link_hdr.set_mode(0o777);
671 builder
672 .append_link(&mut link_hdr, "repo-main/escape", Path::new("/etc/passwd"))
673 .unwrap();
674 builder.finish().unwrap();
675 }
676 let tarball = gz.finish().unwrap();
677 let (url, tx, handle) = spawn_tarball_server(tarball);
678
679 let tmp = TempDir::new().unwrap();
680 let policy = allow_all_policy();
681 let err = install::install(
682 InstallSource::DirectUrl(url),
683 tmp.path(),
684 install::DEFAULT_MAX_SIZE_BYTES,
685 &policy,
686 false,
687 )
688 .await
689 .expect_err("symlinks must be rejected");
690 assert!(format!("{err:#}").contains("symlink"), "{err:#}");
691
692 shutdown(tx, handle);
693 }
694
695 #[tokio::test]
696 async fn install_ignores_symlink_outside_selected_skill_root() {
697 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
698 {
699 let mut builder = tar::Builder::new(&mut gz);
700
701 let mut link_hdr = tar::Header::new_gnu();
702 link_hdr.set_entry_type(tar::EntryType::Symlink);
703 link_hdr.set_size(0);
704 link_hdr.set_mode(0o777);
705 builder
706 .append_link(&mut link_hdr, "repo-main/AGENTS.md", Path::new("CLAUDE.md"))
707 .unwrap();
708
709 let body = skill_md("nested-skill", "Nested skill");
710 let mut hdr = tar::Header::new_gnu();
711 hdr.set_size(body.len() as u64);
712 hdr.set_mode(0o644);
713 hdr.set_cksum();
714 builder
715 .append_data(
716 &mut hdr,
717 "repo-main/skills/nested-skill/SKILL.md",
718 body.as_slice(),
719 )
720 .unwrap();
721
722 let notes = b"selected subtree only";
723 let mut notes_hdr = tar::Header::new_gnu();
724 notes_hdr.set_size(notes.len() as u64);
725 notes_hdr.set_mode(0o644);
726 notes_hdr.set_cksum();
727 builder
728 .append_data(
729 &mut notes_hdr,
730 "repo-main/skills/nested-skill/notes.txt",
731 notes.as_slice(),
732 )
733 .unwrap();
734
735 builder.finish().unwrap();
736 }
737 let tarball = gz.finish().unwrap();
738 let (url, tx, handle) = spawn_tarball_server(tarball);
739
740 let tmp = TempDir::new().unwrap();
741 let policy = allow_all_policy();
742 let outcome = install::install(
743 InstallSource::DirectUrl(url),
744 tmp.path(),
745 install::DEFAULT_MAX_SIZE_BYTES,
746 &policy,
747 false,
748 )
749 .await
750 .expect("repo-level symlink outside selected skill root should be ignored");
751 let installed = match outcome {
752 InstallOutcome::Installed(installed) => installed,
753 other => panic!("expected Installed, got {other:?}"),
754 };
755
756 assert_eq!(installed.name, "nested-skill");
757 assert!(installed.path.join("SKILL.md").exists());
758 assert!(installed.path.join("notes.txt").exists());
759 assert!(!installed.path.join("AGENTS.md").exists());
760
761 shutdown(tx, handle);
762 }
763
764 #[test]
765 fn uninstall_refuses_system_skill() {
766 let tmp = TempDir::new().unwrap();
767 let dir = tmp.path().join("system-skill");
768 std::fs::create_dir_all(&dir).unwrap();
769 let mut f = std::fs::File::create(dir.join("SKILL.md")).unwrap();
770 f.write_all(b"---\nname: system-skill\ndescription: x\n---\n")
771 .unwrap();
772 // No `.installed-from` marker — looks like a system skill.
773
774 let err = install::uninstall("system-skill", tmp.path()).expect_err("must refuse");
775 assert!(format!("{err:#}").contains("not installed via"));
776 assert!(dir.exists(), "directory must be left alone");
777 }
778
778 lines RUST