1use crate::models::field_names;
25use std::path::Path;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use rusqlite::{Connection, params};
29use serde_json::{Value, json};
30use sha2::{Digest as _, Sha256};
31use uuid::Uuid;
32
33use crate::identity::keypair::AgentKeypair;
34use crate::parsing::skill_md;
35use crate::signed_events::{SignedEvent, append_signed_event, payload_hash};
36
37pub(super) fn compute_skill_digest(
47 canonical_fm: &[u8],
48 body_bytes: &[u8],
49 mut resource_digests: Vec<Vec<u8>>,
50) -> Vec<u8> {
51 resource_digests.sort();
52 let mut hasher = Sha256::new();
53 hasher.update(canonical_fm);
54 hasher.update(body_bytes);
55 for rd in &resource_digests {
56 hasher.update(rd);
57 }
58 hasher.finalize().to_vec()
59}
60
61pub(super) fn resource_digest(content: &[u8]) -> Vec<u8> {
63 let mut hasher = Sha256::new();
64 hasher.update(content);
65 hasher.finalize().to_vec()
66}
67
68fn compress(data: &[u8]) -> Result<Vec<u8>, String> {
73 zstd::encode_all(data, 3).map_err(|e| format!("zstd compress error: {e}"))
74}
75
76pub(super) struct RegisterResult {
82 pub id: String,
83 pub digest: Vec<u8>,
84 pub superseded: Option<String>,
85}
86
87pub(super) fn register_core(
92 conn: &Connection,
93 namespace: &str,
94 name: &str,
95 description: &str,
96 license: Option<&str>,
97 compatibility: Option<&str>,
98 allowed_tools: &[String],
99 metadata: &Value,
100 body_bytes: &[u8],
101 resource_digests: Vec<Vec<u8>>,
102 resources: &[(String, String, Vec<u8>)], active_keypair: Option<&AgentKeypair>,
104) -> Result<RegisterResult, String> {
105 let canonical_fm = serde_json::to_vec(&json!({
107 "namespace": namespace,
108 "name": name,
109 (field_names::DESCRIPTION): description,
110 "license": license,
111 (field_names::COMPATIBILITY): compatibility,
112 (field_names::ALLOWED_TOOLS): allowed_tools,
113 }))
114 .map_err(|e| format!("frontmatter JSON error: {e}"))?;
115
116 let digest = compute_skill_digest(&canonical_fm, body_bytes, resource_digests);
117
118 let (signature_bytes, signing_agent_str): (Option<Vec<u8>>, Option<String>) =
120 if let Some(kp) = active_keypair {
121 use ed25519_dalek::Signer as _;
122 let sig = kp.private.as_ref().map(|sk| {
123 let signing_key = ed25519_dalek::SigningKey::from_bytes(
124 sk.as_bytes()
125 .try_into()
126 .expect("ed25519 signing key is always 32 bytes"),
127 );
128 signing_key.sign(&digest).to_bytes().to_vec()
129 });
130 (sig, Some(kp.agent_id.clone()))
131 } else {
132 (None, None)
133 };
134
135 let allowed_tools_json =
136 serde_json::to_string(allowed_tools).map_err(|e| format!("allowed_tools JSON: {e}"))?;
137 let metadata_json =
138 serde_json::to_string(metadata).map_err(|e| format!("metadata JSON: {e}"))?;
139
140 let body_blob = compress(body_bytes)?;
141
142 let now_secs = SystemTime::now()
143 .duration_since(UNIX_EPOCH)
144 .unwrap_or_default()
145 .as_secs() as i64;
146
147 let new_id = Uuid::new_v4().to_string();
148
149 let prev_id: Option<String> = conn
151 .query_row(
152 "SELECT id FROM skills WHERE namespace = ?1 AND name = ?2 AND superseded_by IS NULL",
153 params![namespace, name],
154 |row| row.get(0),
155 )
156 .ok();
157
158 conn.execute(
160 "INSERT INTO skills \
161 (id, namespace, name, description, license, compatibility, \
162 allowed_tools, metadata, body_blob, digest, signature, \
163 signing_agent, created_at) \
164 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)",
165 params![
166 new_id,
167 namespace,
168 name,
169 description,
170 license,
171 compatibility,
172 allowed_tools_json,
173 metadata_json,
174 body_blob,
175 digest,
176 signature_bytes,
177 signing_agent_str,
178 now_secs,
179 ],
180 )
181 .map_err(|e| format!("skills INSERT: {e}"))?;
182
183 for (res_path, res_kind, res_content) in resources {
185 let res_digest = resource_digest(res_content);
186 let res_blob = compress(res_content)?;
187 conn.execute(
188 "INSERT INTO skill_resources \
189 (skill_id, resource_path, resource_kind, content_blob, digest) \
190 VALUES (?1,?2,?3,?4,?5)",
191 params![new_id, res_path, res_kind, res_blob, res_digest],
192 )
193 .map_err(|e| format!("skill_resources INSERT ({res_path}): {e}"))?;
194 }
195
196 let superseded = if let Some(ref prev) = prev_id {
198 conn.execute(
199 "UPDATE skills SET superseded_by = ?1 WHERE id = ?2",
200 params![new_id, prev],
201 )
202 .map_err(|e| format!("superseded_by UPDATE: {e}"))?;
203 Some(prev.clone())
204 } else {
205 None
206 };
207
208 let event_payload = json!({
210 "skill_id": new_id,
211 "namespace": namespace,
212 "name": name,
213 "action": if superseded.is_some() { "supersede" } else { "register" },
214 });
215 let event_bytes = serde_json::to_vec(&event_payload).unwrap_or_default();
216 let ev_hash = payload_hash(&event_bytes);
217 let attest = if signature_bytes.is_some() {
218 crate::models::AttestLevel::SelfSigned.as_str()
219 } else {
220 crate::models::AttestLevel::Unsigned.as_str()
221 };
222 let event = SignedEvent {
223 id: Uuid::new_v4().to_string(),
224 agent_id: signing_agent_str
225 .clone()
226 .unwrap_or_else(|| "anonymous".to_string()),
227 event_type: crate::signed_events::event_types::SKILL_REGISTERED.to_string(),
228 payload_hash: ev_hash,
229 signature: signature_bytes.clone(),
230 attest_level: attest.to_string(),
231 timestamp: chrono::Utc::now().to_rfc3339(),
232 ..SignedEvent::default()
233 };
234 let _ = append_signed_event(conn, &event); Ok(RegisterResult {
237 id: new_id,
238 digest,
239 superseded,
240 })
241}
242
243pub fn handle_skill_register(
248 conn: &Connection,
249 params: &Value,
250 active_keypair: Option<&AgentKeypair>,
251) -> Result<Value, String> {
252 let (skill_md_text, resource_files): (String, Vec<(String, String, Vec<u8>)>) =
256 if let Some(folder_str) = params["folder_path"].as_str() {
257 let folder = Path::new(folder_str);
258 if !folder.is_dir() {
259 return Err(format!(
260 "folder_path '{folder_str}' is not a directory or does not exist"
261 ));
262 }
263 let md_path = folder.join("SKILL.md");
264 let text = std::fs::read_to_string(&md_path)
265 .map_err(|e| format!("cannot read SKILL.md in '{folder_str}': {e}"))?;
266
267 let mut res: Vec<(String, String, Vec<u8>)> = Vec::new();
269 let res_dir = folder.join("resources");
270 if res_dir.is_dir() {
271 collect_resources(&res_dir, &res_dir, &mut res)?;
272 }
273 (text, res)
274 } else if let Some(inline) = params["inline_skill"].as_str() {
275 (inline.to_string(), Vec::new())
276 } else {
277 return Err(
278 "memory_skill_register requires either 'folder_path' or 'inline_skill'".to_string(),
279 );
280 };
281
282 let manifest = skill_md::parse(&skill_md_text)?;
286
287 let caller = crate::identity::resolve_agent_id(params["agent_id"].as_str(), None)
293 .unwrap_or_else(|_| crate::identity::sentinels::ANONYMOUS_INVALID.to_string());
294 crate::governance::audit::record_decision(
295 &caller,
296 "allow",
297 "skill_register",
298 "",
299 json!({
300 "namespace": manifest.namespace,
301 "name": manifest.name,
302 "resource_count": resource_files.len(),
303 "signed": active_keypair.is_some(),
304 }),
305 );
306
307 let body_bytes = manifest.body.as_bytes();
308
309 let res_digests: Vec<Vec<u8>> = resource_files
311 .iter()
312 .map(|(_, _, content)| resource_digest(content))
313 .collect();
314
315 let result = register_core(
316 conn,
317 &manifest.namespace,
318 &manifest.name,
319 &manifest.description,
320 manifest.license.as_deref(),
321 manifest.compatibility.as_deref(),
322 &manifest.allowed_tools,
323 &manifest.metadata,
324 body_bytes,
325 res_digests,
326 &resource_files,
327 active_keypair,
328 )?;
329
330 let digest_hex = hex::encode(&result.digest);
331 let mut response = json!({
332 (field_names::REGISTERED): true,
333 "id": result.id,
334 "namespace": manifest.namespace,
335 "name": manifest.name,
336 "digest": digest_hex,
337 "signed": active_keypair.is_some(),
338 });
339 if let Some(prev) = result.superseded {
340 response[field_names::SUPERSEDED_ID] = json!(prev);
341 }
342 Ok(response)
343}
344
345fn collect_resources(
350 base: &Path,
351 dir: &Path,
352 out: &mut Vec<(String, String, Vec<u8>)>,
353) -> Result<(), String> {
354 let entries =
355 std::fs::read_dir(dir).map_err(|e| format!("read_dir '{}': {e}", dir.display()))?;
356 for entry in entries {
357 let entry = entry.map_err(|e| format!("dir entry error: {e}"))?;
358 let path = entry.path();
359 if path.is_dir() {
360 collect_resources(base, &path, out)?;
361 } else {
362 let rel = path
369 .strip_prefix(base)
370 .map_err(|_| "path prefix error".to_string())?
371 .components()
372 .map(|c| c.as_os_str().to_string_lossy())
373 .collect::<Vec<_>>()
374 .join("/");
375 let content = std::fs::read(&path)
376 .map_err(|e| format!("read resource '{}': {e}", path.display()))?;
377 let kind = infer_kind(&rel);
379 out.push((rel, kind, content));
380 }
381 }
382 Ok(())
383}
384
385fn infer_kind(rel_path: &str) -> String {
386 if rel_path.starts_with("scripts/") || rel_path.ends_with(".sh") || rel_path.ends_with(".py") {
387 "script".to_string()
388 } else if rel_path.starts_with("reference/") || rel_path.starts_with("references/") {
389 "reference".to_string()
390 } else {
391 "asset".to_string()
392 }
393}
394
395mod hex {
400 pub(super) fn encode(bytes: &[u8]) -> String {
401 bytes.iter().map(|b| format!("{b:02x}")).collect()
402 }
403}
404
405use crate::mcp::registry::McpTool;
408use schemars::JsonSchema;
409use serde::Deserialize;
410
411#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
420#[allow(dead_code)]
421pub struct SkillRegisterRequest {
422 #[serde(default)]
425 pub folder_path: Option<String>,
426
427 #[serde(default)]
429 pub inline_skill: Option<String>,
430}
431
432#[allow(dead_code)]
434pub struct SkillRegisterTool;
435
436impl McpTool for SkillRegisterTool {
437 fn name() -> &'static str {
438 crate::mcp::registry::tool_names::MEMORY_SKILL_REGISTER
439 }
440 fn description() -> &'static str {
441 "Register an agentskills.io SKILL.md from a folder or inline text."
442 }
443 fn docs() -> &'static str {
444 "L1-5: Ed25519-attested skill registration with version chaining. Re-register same (name, namespace) supersedes prior row."
445 }
446 fn input_schema() -> Value {
447 crate::mcp::registry::input_schema_for::<SkillRegisterRequest>()
448 }
449 fn family() -> &'static str {
450 crate::profile::Family::Other.name()
451 }
452}
453
454#[cfg(test)]
455mod d1_5_986_tests {
456 use super::*;
459 use crate::mcp::parity_test_helpers::{
460 assert_descriptions_match, assert_property_set_parity, derived_props_for,
461 };
462
463 #[test]
464 fn skill_register_parity_986() {
465 let derived = derived_props_for::<SkillRegisterRequest>();
466 assert_property_set_parity("memory_skill_register", &derived);
467 assert_descriptions_match("memory_skill_register", &derived);
468 }
469
470 #[test]
471 fn skill_register_tool_metadata_986() {
472 assert_eq!(SkillRegisterTool::name(), "memory_skill_register");
473 assert_eq!(SkillRegisterTool::family(), "other");
474 }
475}
476
477#[cfg(test)]
482mod tests {
483 use super::*;
484 use std::path::PathBuf;
485
486 fn open_db() -> (rusqlite::Connection, tempfile::TempDir) {
487 let dir = tempfile::tempdir().expect("tempdir");
488 let path = dir.path().join("test.db");
489 let conn = crate::db::open(&path).expect("db::open");
490 (conn, dir)
491 }
492
493 fn make_keypair() -> AgentKeypair {
494 use ed25519_dalek::{SigningKey, VerifyingKey};
495 let mut rng = rand_core::OsRng;
496 let sk = SigningKey::generate(&mut rng);
497 let vk: VerifyingKey = (&sk).into();
498 AgentKeypair {
499 agent_id: "test:signer".to_string(),
500 public: vk,
501 private: Some(sk),
502 }
503 }
504
505 fn minimal_skill_md(name: &str) -> String {
506 format!("---\nnamespace: testns\nname: {name}\ndescription: A demo skill.\n---\n\nBody.\n")
507 }
508
509 #[test]
512 fn compute_skill_digest_is_deterministic() {
513 let fm = b"{\"a\":1}";
514 let body = b"hello";
515 let d1 = compute_skill_digest(fm, body, vec![]);
516 let d2 = compute_skill_digest(fm, body, vec![]);
517 assert_eq!(d1, d2);
518 assert_eq!(d1.len(), 32);
519 }
520
521 #[test]
522 fn compute_skill_digest_resource_order_independent() {
523 let fm = b"fm";
525 let body = b"body";
526 let r_a = vec![1u8; 32];
527 let r_b = vec![2u8; 32];
528 let d_ab = compute_skill_digest(fm, body, vec![r_a.clone(), r_b.clone()]);
529 let d_ba = compute_skill_digest(fm, body, vec![r_b, r_a]);
530 assert_eq!(d_ab, d_ba);
531 }
532
533 #[test]
534 fn resource_digest_known_value() {
535 let d = resource_digest(b"");
537 assert_eq!(
538 hex::encode(&d),
539 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
540 );
541 }
542
543 #[test]
544 fn compress_round_trip() {
545 let input = b"hello world".repeat(100);
546 let compressed = compress(&input).unwrap();
547 let decompressed = zstd::decode_all(compressed.as_slice()).unwrap();
548 assert_eq!(decompressed, input);
549 }
550
551 #[test]
554 fn rejects_missing_input() {
555 let (conn, _dir) = open_db();
556 let err = handle_skill_register(&conn, &json!({}), None).unwrap_err();
557 assert!(err.contains("folder_path") || err.contains("inline_skill"));
558 }
559
560 #[test]
561 fn rejects_nonexistent_folder_path() {
562 let (conn, dir) = open_db();
563 let bad = dir.path().join("no-such-folder");
564 let err =
565 handle_skill_register(&conn, &json!({"folder_path": bad.to_str().unwrap()}), None)
566 .unwrap_err();
567 assert!(err.contains("is not a directory"));
568 }
569
570 #[test]
571 fn rejects_folder_without_skill_md() {
572 let (conn, dir) = open_db();
573 let target = dir.path().join("empty");
574 std::fs::create_dir_all(&target).unwrap();
575 let err = handle_skill_register(
576 &conn,
577 &json!({"folder_path": target.to_str().unwrap()}),
578 None,
579 )
580 .unwrap_err();
581 assert!(err.contains("cannot read SKILL.md"));
582 }
583
584 #[test]
587 fn registers_inline_skill_minimal() {
588 let (conn, _dir) = open_db();
589 let inline = minimal_skill_md("inline-skill");
590 let v = handle_skill_register(&conn, &json!({"inline_skill": inline}), None).unwrap();
591 assert_eq!(v["registered"], json!(true));
592 assert_eq!(v["namespace"], json!("testns"));
593 assert_eq!(v["name"], json!("inline-skill"));
594 assert_eq!(v["signed"], json!(false));
595 let hex_dig = v["digest"].as_str().unwrap();
596 assert_eq!(hex_dig.len(), 64);
597 assert!(v.get("superseded_id").is_none());
599 }
600
601 #[test]
602 fn supersede_returns_previous_id() {
603 let (conn, _dir) = open_db();
604 let v1 = handle_skill_register(
605 &conn,
606 &json!({"inline_skill": minimal_skill_md("chain-me")}),
607 None,
608 )
609 .unwrap();
610 let id1 = v1["id"].as_str().unwrap().to_string();
611
612 let v2 = handle_skill_register(
614 &conn,
615 &json!({"inline_skill": minimal_skill_md("chain-me")}),
616 None,
617 )
618 .unwrap();
619 assert_eq!(v2["superseded_id"], json!(id1));
620 }
621
622 #[test]
623 fn registers_with_active_keypair_signs() {
624 let (conn, _dir) = open_db();
625 let kp = make_keypair();
626 let v = handle_skill_register(
627 &conn,
628 &json!({"inline_skill": minimal_skill_md("signed-skill")}),
629 Some(&kp),
630 )
631 .unwrap();
632 assert_eq!(v["signed"], json!(true));
633 let sig: Option<Vec<u8>> = conn
635 .query_row(
636 "SELECT signature FROM skills WHERE id = ?1",
637 [v["id"].as_str().unwrap()],
638 |r| r.get(0),
639 )
640 .unwrap();
641 assert!(sig.is_some());
642 let sig = sig.unwrap();
643 assert_eq!(sig.len(), 64); let sa: Option<String> = conn
647 .query_row(
648 "SELECT signing_agent FROM skills WHERE id = ?1",
649 [v["id"].as_str().unwrap()],
650 |r| r.get(0),
651 )
652 .unwrap();
653 assert_eq!(sa.as_deref(), Some("test:signer"));
654 }
655
656 fn write_skill_md(dir: &PathBuf, content: &str) {
659 std::fs::create_dir_all(dir).unwrap();
660 std::fs::write(dir.join("SKILL.md"), content).unwrap();
661 }
662
663 #[test]
664 fn registers_from_folder_with_resources() {
665 let (conn, dir) = open_db();
666 let folder = dir.path().join("skill-folder");
667 write_skill_md(&folder, &minimal_skill_md("folder-skill"));
668 let scripts = folder.join("resources").join("scripts");
670 std::fs::create_dir_all(&scripts).unwrap();
671 std::fs::write(scripts.join("run.sh"), b"echo hi\n").unwrap();
672 let refer = folder.join("resources").join("reference");
674 std::fs::create_dir_all(&refer).unwrap();
675 std::fs::write(refer.join("notes.md"), b"# Notes\n").unwrap();
676 let asset = folder.join("resources").join("asset.png");
678 std::fs::write(&asset, b"\x89PNG\r\n").unwrap();
679
680 let v = handle_skill_register(
681 &conn,
682 &json!({"folder_path": folder.to_str().unwrap()}),
683 None,
684 )
685 .unwrap();
686 assert_eq!(v["registered"], json!(true));
687 let count: i64 = conn
689 .query_row(
690 "SELECT COUNT(*) FROM skill_resources WHERE skill_id = ?1",
691 [v["id"].as_str().unwrap()],
692 |r| r.get(0),
693 )
694 .unwrap();
695 assert_eq!(count, 3);
696 }
697
698 #[test]
699 fn registers_folder_with_no_resources_dir() {
700 let (conn, dir) = open_db();
702 let folder = dir.path().join("plain-skill");
703 write_skill_md(&folder, &minimal_skill_md("plain"));
704
705 let v = handle_skill_register(
706 &conn,
707 &json!({"folder_path": folder.to_str().unwrap()}),
708 None,
709 )
710 .unwrap();
711 assert_eq!(v["registered"], json!(true));
712 let count: i64 = conn
713 .query_row(
714 "SELECT COUNT(*) FROM skill_resources WHERE skill_id = ?1",
715 [v["id"].as_str().unwrap()],
716 |r| r.get(0),
717 )
718 .unwrap();
719 assert_eq!(count, 0);
720 }
721
722 #[test]
725 fn rejects_malformed_inline_skill() {
726 let (conn, _dir) = open_db();
727 let bad = "no frontmatter here, just body text";
728 let err = handle_skill_register(&conn, &json!({"inline_skill": bad}), None).unwrap_err();
729 assert!(!err.is_empty());
731 }
732
733 #[test]
736 fn infer_kind_classifies_scripts() {
737 assert_eq!(infer_kind("scripts/run.sh"), "script");
738 assert_eq!(infer_kind("a/b.sh"), "script");
739 assert_eq!(infer_kind("a/b.py"), "script");
740 }
741
742 #[test]
743 fn infer_kind_classifies_references() {
744 assert_eq!(infer_kind("reference/x.md"), "reference");
745 assert_eq!(infer_kind("references/y.md"), "reference");
746 }
747
748 #[test]
749 fn infer_kind_defaults_to_asset() {
750 assert_eq!(infer_kind("asset.png"), "asset");
751 assert_eq!(infer_kind("img/logo.svg"), "asset");
752 }
753
754 #[test]
757 fn collect_resources_walks_nested_dirs() {
758 let dir = tempfile::tempdir().unwrap();
759 let base = dir.path().to_path_buf();
760 std::fs::create_dir_all(base.join("a")).unwrap();
761 std::fs::create_dir_all(base.join("b").join("c")).unwrap();
762 std::fs::write(base.join("a").join("f1.txt"), b"f1").unwrap();
763 std::fs::write(base.join("b").join("c").join("f2.txt"), b"f2").unwrap();
764
765 let mut out: Vec<(String, String, Vec<u8>)> = Vec::new();
766 collect_resources(&base, &base, &mut out).unwrap();
767 assert_eq!(out.len(), 2);
768 let paths: Vec<&str> = out.iter().map(|(p, _, _)| p.as_str()).collect();
776 assert!(
777 paths.iter().any(|p| *p == "a/f1.txt"),
778 "expected exact path 'a/f1.txt'; got {paths:?}"
779 );
780 assert!(
781 paths.iter().any(|p| *p == "b/c/f2.txt"),
782 "expected exact path 'b/c/f2.txt'; got {paths:?}"
783 );
784 assert!(
785 paths.iter().all(|p| !p.contains('\\')),
786 "no resource path may contain a backslash (wire format is \
787 forward-slash-only); got {paths:?}"
788 );
789 }
790
791 #[test]
792 fn collect_resources_rejects_nonexistent() {
793 let mut out: Vec<(String, String, Vec<u8>)> = Vec::new();
794 let nonexistent = std::path::PathBuf::from("/does/not/exist/at/all");
795 let err = collect_resources(&nonexistent, &nonexistent, &mut out).unwrap_err();
796 assert!(err.contains("read_dir"));
797 }
798
799 #[test]
802 fn hex_encode_empty_and_bytes() {
803 assert_eq!(hex::encode(&[]), "");
804 assert_eq!(hex::encode(&[0x00, 0xff, 0xab]), "00ffab");
805 }
806}