clawft_core/workspace/
agent.rs1use std::path::{Path, PathBuf};
24
25use clawft_types::{ClawftError, Result};
26
27use super::WorkspaceManager;
28
29const AGENT_WORKSPACE_SUBDIRS: &[&str] = &[
31 "sessions",
32 "memory",
33 "skills",
34 "tool_state",
35];
36
37impl WorkspaceManager {
38 pub(crate) fn agents_root(&self) -> PathBuf {
43 self.registry_path
44 .parent()
45 .unwrap_or(Path::new("."))
46 .join("agents")
47 }
48
49 pub fn ensure_agent_workspace(&self, agent_id: &str) -> Result<PathBuf> {
57 validate_agent_id(agent_id)?;
58
59 let agent_dir = self.agents_root().join(agent_id);
60
61 for subdir in AGENT_WORKSPACE_SUBDIRS {
63 let dir = agent_dir.join(subdir);
64 std::fs::create_dir_all(&dir)?;
65 set_dir_permissions_0700(&dir);
66 }
67
68 set_dir_permissions_0700(&agent_dir);
70
71 let files = [
73 ("SOUL.md", format!("# Agent: {agent_id}\n\nPersonality and directives for this agent.\n")),
74 ("AGENTS.md", format!("# Agent: {agent_id}\n\nCapabilities and tool access.\n")),
75 ("USER.md", format!("# Agent: {agent_id}\n\nUser preferences for this agent.\n")),
76 ("config.toml", format!("# Per-agent configuration overrides for {agent_id}\n")),
77 ];
78
79 for (filename, content) in &files {
80 let path = agent_dir.join(filename);
81 if !path.exists() {
82 std::fs::write(&path, content)?;
83 }
84 }
85
86 Ok(agent_dir)
87 }
88
89 pub fn create_agent_workspace(
95 &self,
96 agent_id: &str,
97 template: Option<&Path>,
98 ) -> Result<PathBuf> {
99 validate_agent_id(agent_id)?;
100
101 let agent_dir = self.agents_root().join(agent_id);
102 if agent_dir.exists() {
103 return Err(ClawftError::ConfigInvalid {
104 reason: format!("agent workspace already exists: {agent_id}"),
105 });
106 }
107
108 let template_dir = match template {
110 Some(p) => {
111 if p.is_dir() {
112 Some(p.to_path_buf())
113 } else {
114 return Err(ClawftError::ConfigInvalid {
115 reason: format!(
116 "template path is not a directory: {}",
117 p.display()
118 ),
119 });
120 }
121 }
122 None => {
123 let default_template = self.agents_root().join("default");
124 if default_template.is_dir() {
125 Some(default_template)
126 } else {
127 None
128 }
129 }
130 };
131
132 if let Some(ref src) = template_dir {
133 copy_dir_recursive(src, &agent_dir)?;
135 set_dir_permissions_0700(&agent_dir);
136 } else {
137 self.ensure_agent_workspace(agent_id)?;
139 }
140
141 Ok(agent_dir)
142 }
143
144 pub fn delete_agent_workspace(&self, agent_id: &str) -> Result<()> {
148 validate_agent_id(agent_id)?;
149
150 let agent_dir = self.agents_root().join(agent_id);
151 if !agent_dir.exists() {
152 return Err(ClawftError::ConfigInvalid {
153 reason: format!("agent workspace not found: {agent_id}"),
154 });
155 }
156
157 std::fs::remove_dir_all(&agent_dir)?;
158 Ok(())
159 }
160
161 pub fn list_agent_workspaces(&self) -> Result<Vec<String>> {
165 let agents_dir = self.agents_root();
166 if !agents_dir.is_dir() {
167 return Ok(Vec::new());
168 }
169
170 let mut agent_ids: Vec<String> = std::fs::read_dir(&agents_dir)?
171 .filter_map(|entry| {
172 let entry = entry.ok()?;
173 if entry.file_type().ok()?.is_dir() {
174 Some(entry.file_name().to_string_lossy().into_owned())
175 } else {
176 None
177 }
178 })
179 .collect();
180
181 agent_ids.sort();
182 Ok(agent_ids)
183 }
184
185 pub fn link_shared_namespace(
197 &self,
198 exporter_id: &str,
199 importer_id: &str,
200 namespace: &str,
201 ) -> Result<PathBuf> {
202 validate_agent_id(exporter_id)?;
203 validate_agent_id(importer_id)?;
204 validate_agent_id(namespace)?; let exporter_ns = self
207 .agents_root()
208 .join(exporter_id)
209 .join("memory")
210 .join(namespace);
211
212 if !exporter_ns.is_dir() {
213 std::fs::create_dir_all(&exporter_ns)?;
215 }
216
217 let importer_link = self
218 .agents_root()
219 .join(importer_id)
220 .join("memory")
221 .join(format!("{exporter_id}--{namespace}"));
222
223 let importer_memory = self.agents_root().join(importer_id).join("memory");
225 std::fs::create_dir_all(&importer_memory)?;
226
227 let agents_root = self.agents_root();
229 let canonical_target = exporter_ns
230 .canonicalize()
231 .unwrap_or_else(|_| exporter_ns.clone());
232 let canonical_root = agents_root
233 .canonicalize()
234 .unwrap_or_else(|_| agents_root.clone());
235 if !canonical_target.starts_with(&canonical_root) {
236 return Err(ClawftError::ConfigInvalid {
237 reason: "symlink target escapes agents directory".into(),
238 });
239 }
240
241 if importer_link.exists() || importer_link.is_symlink() {
243 std::fs::remove_file(&importer_link)?;
244 }
245
246 #[cfg(unix)]
247 std::os::unix::fs::symlink(&exporter_ns, &importer_link)?;
248
249 #[cfg(not(unix))]
250 {
251 return Err(ClawftError::ConfigInvalid {
253 reason: "symlink-based sharing requires Unix".into(),
254 });
255 }
256
257 Ok(importer_link)
258 }
259}
260
261pub(crate) fn validate_agent_id(agent_id: &str) -> Result<()> {
268 if agent_id.is_empty() {
269 return Err(ClawftError::ConfigInvalid {
270 reason: "agent ID cannot be empty".into(),
271 });
272 }
273 if agent_id.starts_with('.') {
274 return Err(ClawftError::ConfigInvalid {
275 reason: format!("agent ID must not start with dot: {agent_id}"),
276 });
277 }
278 if agent_id.contains('/')
279 || agent_id.contains('\\')
280 || agent_id.contains('\0')
281 {
282 return Err(ClawftError::ConfigInvalid {
283 reason: format!("agent ID contains invalid characters: {agent_id}"),
284 });
285 }
286 if agent_id == ".." {
287 return Err(ClawftError::ConfigInvalid {
288 reason: "agent ID cannot be '..'".into(),
289 });
290 }
291 Ok(())
292}
293
294pub(crate) fn set_dir_permissions_0700(path: &Path) {
296 #[cfg(unix)]
297 {
298 use std::os::unix::fs::PermissionsExt;
299 let _ = std::fs::set_permissions(
300 path,
301 std::fs::Permissions::from_mode(0o700),
302 );
303 }
304 #[cfg(not(unix))]
305 {
306 let _ = path; }
308}
309
310pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
312 std::fs::create_dir_all(dst)?;
313 for entry in std::fs::read_dir(src)? {
314 let entry = entry?;
315 let file_type = entry.file_type()?;
316 let src_path = entry.path();
317 let dst_path = dst.join(entry.file_name());
318
319 if file_type.is_dir() {
320 copy_dir_recursive(&src_path, &dst_path)?;
321 } else {
322 std::fs::copy(&src_path, &dst_path)?;
323 }
324 }
325 Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::workspace::tests::temp_registry;
332
333 #[test]
334 fn ensure_agent_workspace_creates_structure() {
335 let (dir, registry_path) = temp_registry("agent-ensure");
336 let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
337
338 let agent_dir = wm.ensure_agent_workspace("test-agent").unwrap();
339 assert!(agent_dir.is_dir());
340
341 for subdir in &["sessions", "memory", "skills", "tool_state"] {
342 assert!(
343 agent_dir.join(subdir).is_dir(),
344 "missing subdir: {subdir}"
345 );
346 }
347
348 for file in &["SOUL.md", "AGENTS.md", "USER.md", "config.toml"] {
349 assert!(agent_dir.join(file).exists(), "missing file: {file}");
350 }
351
352 let _ = std::fs::remove_dir_all(&dir);
353 }
354
355 #[test]
356 fn ensure_agent_workspace_is_idempotent() {
357 let (dir, registry_path) = temp_registry("agent-idempotent");
358 let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
359
360 let path1 = wm.ensure_agent_workspace("idempotent-agent").unwrap();
361 std::fs::write(path1.join("SOUL.md"), "custom content").unwrap();
362
363 let path2 = wm.ensure_agent_workspace("idempotent-agent").unwrap();
364 assert_eq!(path1, path2);
365
366 let content = std::fs::read_to_string(path2.join("SOUL.md")).unwrap();
367 assert_eq!(content, "custom content");
368
369 let _ = std::fs::remove_dir_all(&dir);
370 }
371
372 #[test]
373 fn delete_agent_workspace_removes_dir() {
374 let (dir, registry_path) = temp_registry("agent-delete");
375 let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
376
377 let agent_dir = wm.ensure_agent_workspace("del-agent").unwrap();
378 assert!(agent_dir.is_dir());
379
380 wm.delete_agent_workspace("del-agent").unwrap();
381 assert!(!agent_dir.exists());
382
383 let _ = std::fs::remove_dir_all(&dir);
384 }
385
386 #[test]
387 fn delete_nonexistent_agent_workspace_errors() {
388 let (dir, registry_path) = temp_registry("agent-delete-missing");
389 let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
390 assert!(wm.delete_agent_workspace("nonexistent").is_err());
391 let _ = std::fs::remove_dir_all(&dir);
392 }
393
394 #[test]
395 fn list_agent_workspaces_returns_sorted() {
396 let (dir, registry_path) = temp_registry("agent-list");
397 let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
398
399 assert!(wm.list_agent_workspaces().unwrap().is_empty());
400
401 wm.ensure_agent_workspace("charlie").unwrap();
402 wm.ensure_agent_workspace("alpha").unwrap();
403 wm.ensure_agent_workspace("bravo").unwrap();
404
405 let agents = wm.list_agent_workspaces().unwrap();
406 assert_eq!(agents, vec!["alpha", "bravo", "charlie"]);
407
408 let _ = std::fs::remove_dir_all(&dir);
409 }
410
411 #[test]
412 fn validate_agent_id_rejects_traversal() {
413 assert!(validate_agent_id("").is_err());
414 assert!(validate_agent_id("..").is_err());
415 assert!(validate_agent_id(".hidden").is_err());
416 assert!(validate_agent_id("../escape").is_err());
417 assert!(validate_agent_id("path/traversal").is_err());
418 assert!(validate_agent_id("back\\slash").is_err());
419 assert!(validate_agent_id("agent-1").is_ok());
421 assert!(validate_agent_id("my_agent_2").is_ok());
422 assert!(validate_agent_id("Agent.v3").is_ok());
423 }
424
425 #[test]
426 fn create_agent_workspace_from_template() {
427 let (dir, registry_path) = temp_registry("agent-template");
428 let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
429
430 let template = dir.join("my-template");
431 std::fs::create_dir_all(template.join("skills")).unwrap();
432 std::fs::write(template.join("SOUL.md"), "template soul").unwrap();
433 std::fs::write(
434 template.join("skills/custom.md"),
435 "skill data",
436 )
437 .unwrap();
438
439 let agent_dir = wm
440 .create_agent_workspace("from-template", Some(&template))
441 .unwrap();
442
443 assert_eq!(
444 std::fs::read_to_string(agent_dir.join("SOUL.md")).unwrap(),
445 "template soul"
446 );
447 assert_eq!(
448 std::fs::read_to_string(agent_dir.join("skills/custom.md")).unwrap(),
449 "skill data"
450 );
451
452 let _ = std::fs::remove_dir_all(&dir);
453 }
454
455 #[test]
456 fn create_agent_workspace_rejects_duplicate() {
457 let (dir, registry_path) = temp_registry("agent-dup");
458 let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
459
460 wm.ensure_agent_workspace("dup-agent").unwrap();
461 assert!(wm.create_agent_workspace("dup-agent", None).is_err());
462
463 let _ = std::fs::remove_dir_all(&dir);
464 }
465
466 #[cfg(unix)]
467 #[test]
468 fn agent_workspace_has_0700_permissions() {
469 use std::os::unix::fs::PermissionsExt;
470
471 let (dir, registry_path) = temp_registry("agent-perms");
472 let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
473
474 let agent_dir = wm.ensure_agent_workspace("perms-agent").unwrap();
475 let mode = std::fs::metadata(&agent_dir)
476 .unwrap()
477 .permissions()
478 .mode()
479 & 0o777;
480 assert_eq!(mode, 0o700, "agent dir should have 0700 permissions");
481
482 let _ = std::fs::remove_dir_all(&dir);
483 }
484
485 #[cfg(unix)]
486 #[test]
487 fn cross_agent_symlink_sharing() {
488 let (dir, registry_path) = temp_registry("agent-symlink");
489 let wm = WorkspaceManager::with_registry_path(registry_path).unwrap();
490
491 wm.ensure_agent_workspace("exporter").unwrap();
492 wm.ensure_agent_workspace("importer").unwrap();
493
494 let exporter_ns =
495 wm.agents_root().join("exporter/memory/project-ctx");
496 std::fs::create_dir_all(&exporter_ns).unwrap();
497 std::fs::write(exporter_ns.join("data.txt"), "shared data").unwrap();
498
499 let link = wm
500 .link_shared_namespace("exporter", "importer", "project-ctx")
501 .unwrap();
502
503 assert!(link.is_symlink());
504
505 let content =
506 std::fs::read_to_string(link.join("data.txt")).unwrap();
507 assert_eq!(content, "shared data");
508
509 let _ = std::fs::remove_dir_all(&dir);
510 }
511}