1use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29
30use serde::{Deserialize, Serialize};
31use tracing::{debug, warn};
32
33use clawft_types::{ClawftError, Result};
34
35use crate::security::{
36 MAX_AGENT_FILE_SIZE, validate_directory_name, validate_file_size, validate_model_string,
37};
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AgentDefinition {
47 pub name: String,
49
50 pub description: String,
52
53 #[serde(default)]
55 pub model: Option<String>,
56
57 #[serde(default)]
59 pub system_prompt: Option<String>,
60
61 #[serde(default)]
63 pub skills: Vec<String>,
64
65 #[serde(default)]
67 pub allowed_tools: Vec<String>,
68
69 #[serde(default)]
72 pub max_turns: Option<u32>,
73
74 #[serde(default)]
77 pub variables: HashMap<String, String>,
78
79 #[serde(skip)]
81 pub source_path: Option<PathBuf>,
82}
83
84pub struct AgentLoader;
90
91impl AgentLoader {
92 pub fn load_file(path: &Path) -> Result<AgentDefinition> {
101 if let Ok(meta) = std::fs::metadata(path) {
103 validate_file_size(meta.len() as usize, MAX_AGENT_FILE_SIZE, "agent")?;
104 }
105
106 let content = std::fs::read_to_string(path).map_err(|e| ClawftError::PluginLoadFailed {
107 plugin: format!("agent: cannot read {}: {e}", path.display()),
108 })?;
109
110 let mut def = Self::parse_content(&content, path)?;
111 def.source_path = Some(path.to_path_buf());
112
113 if let Some(ref model) = def.model {
115 validate_model_string(model)?;
116 }
117
118 Ok(def)
119 }
120
121 pub fn load(path: &Path) -> Result<AgentDefinition> {
132 if path.is_file() {
133 return Self::load_file(path);
134 }
135
136 if path.is_dir() {
137 for filename in &["agent.yaml", "agent.yml", "agent.json"] {
138 let candidate = path.join(filename);
139 if candidate.is_file() {
140 let mut def = Self::load_file(&candidate)?;
141 def.source_path = Some(path.to_path_buf());
142 return Ok(def);
143 }
144 }
145 return Err(ClawftError::PluginLoadFailed {
146 plugin: format!("agent: no agent.yaml/agent.json in {}", path.display()),
147 });
148 }
149
150 Err(ClawftError::PluginLoadFailed {
151 plugin: format!("agent: path does not exist: {}", path.display()),
152 })
153 }
154
155 pub fn load_dir(dir: &Path) -> Result<Vec<AgentDefinition>> {
162 if !dir.is_dir() {
163 return Ok(Vec::new());
164 }
165
166 let entries = std::fs::read_dir(dir).map_err(|e| ClawftError::PluginLoadFailed {
167 plugin: format!("agent: cannot read dir {}: {e}", dir.display()),
168 })?;
169
170 let mut defs = Vec::new();
171 for entry in entries {
172 let entry = match entry {
173 Ok(e) => e,
174 Err(e) => {
175 warn!(error = %e, "failed to read directory entry");
176 continue;
177 }
178 };
179 let entry_path = entry.path();
180
181 let entry_name = entry.file_name();
183 let entry_name_str = entry_name.to_string_lossy();
184 if let Err(e) = validate_directory_name(&entry_name_str) {
185 warn!(
186 path = %entry_path.display(),
187 error = %e,
188 "rejected agent entry with unsafe name"
189 );
190 continue;
191 }
192
193 match Self::load(&entry_path) {
194 Ok(def) => {
195 debug!(agent = %def.name, path = %entry_path.display(), "loaded agent");
196 defs.push(def);
197 }
198 Err(e) => {
199 let looks_like_agent = entry_path.is_dir()
201 || entry_path
202 .extension()
203 .is_some_and(|ext| ext == "yaml" || ext == "yml" || ext == "json");
204 if looks_like_agent {
205 warn!(
206 path = %entry_path.display(),
207 error = %e,
208 "failed to load agent, skipping"
209 );
210 }
211 }
212 }
213 }
214
215 Ok(defs)
216 }
217
218 fn parse_content(content: &str, path: &Path) -> Result<AgentDefinition> {
220 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
221
222 match ext {
223 "json" => serde_json::from_str(content).map_err(|e| ClawftError::PluginLoadFailed {
224 plugin: format!("agent: invalid JSON in {}: {e}", path.display()),
225 }),
226 _ => serde_yaml::from_str(content).map_err(|e| ClawftError::PluginLoadFailed {
228 plugin: format!("agent: invalid YAML in {}: {e}", path.display()),
229 }),
230 }
231 }
232}
233
234pub struct AgentRegistry {
244 agents: HashMap<String, AgentDefinition>,
245}
246
247impl AgentRegistry {
248 pub fn discover(
259 workspace_dir: Option<&Path>,
260 user_dir: Option<&Path>,
261 builtin_agents: Vec<AgentDefinition>,
262 ) -> Result<Self> {
263 let mut agents = HashMap::new();
264
265 for def in builtin_agents {
267 agents.insert(def.name.clone(), def);
268 }
269
270 if let Some(dir) = user_dir {
272 match AgentLoader::load_dir(dir) {
273 Ok(defs) => {
274 for def in defs {
275 debug!(
276 agent = %def.name,
277 level = "user",
278 "registering user agent"
279 );
280 agents.insert(def.name.clone(), def);
281 }
282 }
283 Err(e) => {
284 warn!(error = %e, "failed to load user agents");
285 }
286 }
287 }
288
289 if let Some(dir) = workspace_dir {
291 match AgentLoader::load_dir(dir) {
292 Ok(defs) => {
293 for def in defs {
294 debug!(
295 agent = %def.name,
296 level = "workspace",
297 "registering workspace agent (overrides lower levels)"
298 );
299 agents.insert(def.name.clone(), def);
300 }
301 }
302 Err(e) => {
303 warn!(error = %e, "failed to load workspace agents");
304 }
305 }
306 }
307
308 debug!(count = agents.len(), "agent registry populated");
309 Ok(Self { agents })
310 }
311
312 pub fn empty() -> Self {
314 Self {
315 agents: HashMap::new(),
316 }
317 }
318
319 pub fn get(&self, name: &str) -> Option<&AgentDefinition> {
321 self.agents.get(name)
322 }
323
324 pub fn list(&self) -> Vec<&AgentDefinition> {
326 let mut defs: Vec<_> = self.agents.values().collect();
327 defs.sort_by_key(|d| &d.name);
328 defs
329 }
330
331 pub fn len(&self) -> usize {
333 self.agents.len()
334 }
335
336 pub fn is_empty(&self) -> bool {
338 self.agents.is_empty()
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use std::sync::atomic::{AtomicU64, Ordering};
346
347 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
348
349 fn temp_dir(prefix: &str) -> PathBuf {
350 let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
351 let pid = std::process::id();
352 std::env::temp_dir().join(format!("clawft_agents_test_{prefix}_{pid}_{id}"))
353 }
354
355 fn write_yaml_agent(dir: &Path, name: &str, description: &str) {
357 let agent_dir = dir.join(name);
358 std::fs::create_dir_all(&agent_dir).unwrap();
359 let yaml = format!(
360 "name: {name}\n\
361 description: {description}\n\
362 model: test-model/v1\n\
363 system_prompt: \"You are a {name} agent.\"\n\
364 skills:\n - research\n\
365 allowed_tools:\n - read_file\n\
366 max_turns: 5\n\
367 variables:\n lang: rust\n"
368 );
369 std::fs::write(agent_dir.join("agent.yaml"), yaml).unwrap();
370 }
371
372 fn write_json_agent(dir: &Path, name: &str, description: &str) {
374 let agent_dir = dir.join(name);
375 std::fs::create_dir_all(&agent_dir).unwrap();
376 let json = serde_json::json!({
377 "name": name,
378 "description": description,
379 "model": "json-model/v2",
380 "system_prompt": format!("You are a {name} agent."),
381 "skills": ["coding"],
382 "allowed_tools": ["write_file"],
383 "max_turns": 10,
384 "variables": {"framework": "axum"}
385 });
386 std::fs::write(
387 agent_dir.join("agent.json"),
388 serde_json::to_string_pretty(&json).unwrap(),
389 )
390 .unwrap();
391 }
392
393 #[test]
396 fn load_yaml_agent_definition() {
397 let dir = temp_dir("load_yaml");
398 write_yaml_agent(&dir, "researcher", "Deep research agent");
399
400 let def = AgentLoader::load(&dir.join("researcher")).unwrap();
401 assert_eq!(def.name, "researcher");
402 assert_eq!(def.description, "Deep research agent");
403 assert_eq!(def.model.as_deref(), Some("test-model/v1"));
404 assert_eq!(
405 def.system_prompt.as_deref(),
406 Some("You are a researcher agent.")
407 );
408 assert_eq!(def.skills, vec!["research"]);
409 assert_eq!(def.allowed_tools, vec!["read_file"]);
410 assert_eq!(def.max_turns, Some(5));
411 assert_eq!(def.variables.get("lang").map(|s| s.as_str()), Some("rust"));
412 assert!(def.source_path.is_some());
413
414 let _ = std::fs::remove_dir_all(&dir);
415 }
416
417 #[test]
418 fn load_json_agent_definition() {
419 let dir = temp_dir("load_json");
420 write_json_agent(&dir, "coder", "Coding agent");
421
422 let def = AgentLoader::load(&dir.join("coder")).unwrap();
423 assert_eq!(def.name, "coder");
424 assert_eq!(def.description, "Coding agent");
425 assert_eq!(def.model.as_deref(), Some("json-model/v2"));
426 assert_eq!(def.skills, vec!["coding"]);
427 assert_eq!(def.allowed_tools, vec!["write_file"]);
428 assert_eq!(def.max_turns, Some(10));
429 assert_eq!(
430 def.variables.get("framework").map(|s| s.as_str()),
431 Some("axum")
432 );
433
434 let _ = std::fs::remove_dir_all(&dir);
435 }
436
437 #[test]
438 fn load_file_directly() {
439 let dir = temp_dir("load_file");
440 std::fs::create_dir_all(&dir).unwrap();
441 let yaml = "name: standalone\ndescription: Standalone agent\n";
442 let path = dir.join("standalone.yaml");
443 std::fs::write(&path, yaml).unwrap();
444
445 let def = AgentLoader::load_file(&path).unwrap();
446 assert_eq!(def.name, "standalone");
447 assert_eq!(def.source_path.unwrap(), path);
448
449 let _ = std::fs::remove_dir_all(&dir);
450 }
451
452 #[test]
453 fn load_dir_collects_all_agents() {
454 let dir = temp_dir("load_dir");
455 write_yaml_agent(&dir, "agent_a", "Agent A");
456 write_json_agent(&dir, "agent_b", "Agent B");
457
458 let mut defs = AgentLoader::load_dir(&dir).unwrap();
459 defs.sort_by(|a, b| a.name.cmp(&b.name));
460
461 assert_eq!(defs.len(), 2);
462 assert_eq!(defs[0].name, "agent_a");
463 assert_eq!(defs[1].name, "agent_b");
464
465 let _ = std::fs::remove_dir_all(&dir);
466 }
467
468 #[test]
469 fn load_dir_returns_empty_for_missing_dir() {
470 let dir = temp_dir("missing_dir");
471 let defs = AgentLoader::load_dir(&dir).unwrap();
472 assert!(defs.is_empty());
473 }
474
475 #[test]
476 fn load_dir_skips_invalid_entries() {
477 let dir = temp_dir("skip_invalid");
478 write_yaml_agent(&dir, "good", "Good agent");
479
480 let bad_dir = dir.join("bad");
482 std::fs::create_dir_all(&bad_dir).unwrap();
483 std::fs::write(bad_dir.join("agent.yaml"), "{{invalid yaml").unwrap();
484
485 let defs = AgentLoader::load_dir(&dir).unwrap();
486 assert_eq!(defs.len(), 1);
487 assert_eq!(defs[0].name, "good");
488
489 let _ = std::fs::remove_dir_all(&dir);
490 }
491
492 #[test]
493 fn load_nonexistent_returns_error() {
494 let result = AgentLoader::load(Path::new("/tmp/does_not_exist_at_all"));
495 assert!(result.is_err());
496 }
497
498 #[test]
499 fn load_dir_with_no_agent_file_in_subdir() {
500 let dir = temp_dir("no_agent_file");
501 let sub = dir.join("empty_agent");
502 std::fs::create_dir_all(&sub).unwrap();
503 std::fs::write(sub.join("readme.md"), "not an agent").unwrap();
504
505 let defs = AgentLoader::load_dir(&dir).unwrap();
506 assert!(defs.is_empty());
507
508 let _ = std::fs::remove_dir_all(&dir);
509 }
510
511 #[test]
512 fn default_fields_when_omitted() {
513 let dir = temp_dir("defaults");
514 std::fs::create_dir_all(&dir).unwrap();
515 let yaml = "name: minimal\ndescription: Minimal agent\n";
516 std::fs::write(dir.join("agent.yaml"), yaml).unwrap();
517
518 let def = AgentLoader::load_file(&dir.join("agent.yaml")).unwrap();
519 assert_eq!(def.name, "minimal");
520 assert!(def.model.is_none());
521 assert!(def.system_prompt.is_none());
522 assert!(def.skills.is_empty());
523 assert!(def.allowed_tools.is_empty());
524 assert!(def.max_turns.is_none());
525 assert!(def.variables.is_empty());
526
527 let _ = std::fs::remove_dir_all(&dir);
528 }
529
530 #[test]
533 fn registry_3_level_priority() {
534 let workspace_dir = temp_dir("reg_workspace");
535 let user_dir = temp_dir("reg_user");
536
537 let builtin = vec![AgentDefinition {
539 name: "shared".into(),
540 description: "builtin".into(),
541 model: None,
542 system_prompt: None,
543 skills: vec![],
544 allowed_tools: vec![],
545 max_turns: None,
546 variables: HashMap::new(),
547 source_path: None,
548 }];
549
550 write_yaml_agent(&user_dir, "shared", "user");
552
553 write_yaml_agent(&workspace_dir, "shared", "workspace");
555
556 let registry =
557 AgentRegistry::discover(Some(&workspace_dir), Some(&user_dir), builtin).unwrap();
558
559 let agent = registry.get("shared").unwrap();
561 assert_eq!(agent.description, "workspace");
562
563 let _ = std::fs::remove_dir_all(&workspace_dir);
564 let _ = std::fs::remove_dir_all(&user_dir);
565 }
566
567 #[test]
568 fn registry_user_overrides_builtin() {
569 let user_dir = temp_dir("reg_user_override");
570
571 let builtin = vec![AgentDefinition {
572 name: "helper".into(),
573 description: "builtin helper".into(),
574 model: None,
575 system_prompt: None,
576 skills: vec![],
577 allowed_tools: vec![],
578 max_turns: None,
579 variables: HashMap::new(),
580 source_path: None,
581 }];
582
583 write_yaml_agent(&user_dir, "helper", "user helper");
584
585 let registry = AgentRegistry::discover(None, Some(&user_dir), builtin).unwrap();
586 let agent = registry.get("helper").unwrap();
587 assert_eq!(agent.description, "user helper");
588
589 let _ = std::fs::remove_dir_all(&user_dir);
590 }
591
592 #[test]
593 fn registry_list_returns_sorted() {
594 let builtin = vec![
595 AgentDefinition {
596 name: "zulu".into(),
597 description: "Z agent".into(),
598 model: None,
599 system_prompt: None,
600 skills: vec![],
601 allowed_tools: vec![],
602 max_turns: None,
603 variables: HashMap::new(),
604 source_path: None,
605 },
606 AgentDefinition {
607 name: "alpha".into(),
608 description: "A agent".into(),
609 model: None,
610 system_prompt: None,
611 skills: vec![],
612 allowed_tools: vec![],
613 max_turns: None,
614 variables: HashMap::new(),
615 source_path: None,
616 },
617 ];
618
619 let registry = AgentRegistry::discover(None, None, builtin).unwrap();
620 let list = registry.list();
621 assert_eq!(list.len(), 2);
622 assert_eq!(list[0].name, "alpha");
623 assert_eq!(list[1].name, "zulu");
624 }
625
626 #[test]
627 fn registry_empty() {
628 let registry = AgentRegistry::empty();
629 assert!(registry.is_empty());
630 assert_eq!(registry.len(), 0);
631 assert!(registry.get("anything").is_none());
632 assert!(registry.list().is_empty());
633 }
634
635 #[test]
636 fn registry_get_missing_returns_none() {
637 let registry = AgentRegistry::discover(None, None, vec![]).unwrap();
638 assert!(registry.get("nonexistent").is_none());
639 }
640
641 #[test]
642 fn agent_definition_serde_roundtrip() {
643 let def = AgentDefinition {
644 name: "test".into(),
645 description: "Test agent".into(),
646 model: Some("model/v1".into()),
647 system_prompt: Some("You are test.".into()),
648 skills: vec!["research".into()],
649 allowed_tools: vec!["read_file".into()],
650 max_turns: Some(15),
651 variables: {
652 let mut m = HashMap::new();
653 m.insert("lang".into(), "rust".into());
654 m
655 },
656 source_path: Some(PathBuf::from("/tmp/test")),
657 };
658
659 let json = serde_json::to_string(&def).unwrap();
660 let restored: AgentDefinition = serde_json::from_str(&json).unwrap();
661
662 assert_eq!(restored.name, "test");
663 assert_eq!(restored.description, "Test agent");
664 assert_eq!(restored.model.as_deref(), Some("model/v1"));
665 assert_eq!(restored.skills, vec!["research"]);
666 assert_eq!(restored.max_turns, Some(15));
667 assert!(restored.source_path.is_none());
669 }
670
671 #[test]
672 fn load_agent_yml_extension() {
673 let dir = temp_dir("yml_ext");
674 let agent_dir = dir.join("yml_agent");
675 std::fs::create_dir_all(&agent_dir).unwrap();
676 let yaml = "name: yml_agent\ndescription: Uses .yml extension\n";
677 std::fs::write(agent_dir.join("agent.yml"), yaml).unwrap();
678
679 let def = AgentLoader::load(&agent_dir).unwrap();
680 assert_eq!(def.name, "yml_agent");
681
682 let _ = std::fs::remove_dir_all(&dir);
683 }
684
685 #[test]
688 fn sec_skill_02_agent_traversal_rejected() {
689 let dir = temp_dir("sec02_agent_traversal");
690
691 let evil_dir = dir.join("..%2Fevil");
693 std::fs::create_dir_all(&evil_dir).unwrap();
694 let yaml = "name: evil\ndescription: Evil agent\n";
695 std::fs::write(evil_dir.join("agent.yaml"), yaml).unwrap();
696
697 write_yaml_agent(&dir, "good", "Good agent");
699
700 let defs = AgentLoader::load_dir(&dir).unwrap();
701 let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
702 assert!(names.contains(&"good"));
703 let _ = std::fs::remove_dir_all(&dir);
706 }
707
708 #[test]
711 fn sec_skill_04_shell_metachar_model_rejected() {
712 let dir = temp_dir("sec04_model");
713 let agent_dir = dir.join("evil_model");
714 std::fs::create_dir_all(&agent_dir).unwrap();
715 let yaml = "name: evil_model\ndescription: Has evil model\nmodel: \"; rm -rf /\"\n";
716 std::fs::write(agent_dir.join("agent.yaml"), yaml).unwrap();
717
718 let result = AgentLoader::load(&agent_dir);
719 assert!(result.is_err());
720 let err = result.unwrap_err().to_string();
721 assert!(
722 err.contains("forbidden character") || err.contains("security violation"),
723 "expected security error, got: {err}"
724 );
725
726 let _ = std::fs::remove_dir_all(&dir);
727 }
728
729 #[test]
730 fn sec_skill_04_valid_model_accepted() {
731 let dir = temp_dir("sec04_valid");
732 let agent_dir = dir.join("valid_model");
733 std::fs::create_dir_all(&agent_dir).unwrap();
734 let yaml = "name: valid_model\ndescription: Valid model\nmodel: openai/gpt-4o\n";
735 std::fs::write(agent_dir.join("agent.yaml"), yaml).unwrap();
736
737 let def = AgentLoader::load(&agent_dir).unwrap();
738 assert_eq!(def.model.as_deref(), Some("openai/gpt-4o"));
739
740 let _ = std::fs::remove_dir_all(&dir);
741 }
742
743 #[test]
746 fn sec_skill_07_oversized_agent_rejected() {
747 let dir = temp_dir("sec07_agent_size");
748 let agent_dir = dir.join("big_agent");
749 std::fs::create_dir_all(&agent_dir).unwrap();
750
751 let padding = "x".repeat(11 * 1024);
753 let yaml = format!("name: big_agent\ndescription: {padding}\n");
754 std::fs::write(agent_dir.join("agent.yaml"), yaml).unwrap();
755
756 let result = AgentLoader::load(&agent_dir);
757 assert!(result.is_err());
758 let err = result.unwrap_err().to_string();
759 assert!(
760 err.contains("exceeds") || err.contains("size"),
761 "expected size error, got: {err}"
762 );
763
764 let _ = std::fs::remove_dir_all(&dir);
765 }
766}