1use std::path::PathBuf;
10
11use serde::Serialize;
12
13use zeroclaw_config::paths::{RootEscapeError, resolve_under};
14use zeroclaw_config::schema::Config;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17pub struct BrowseEntry {
18 pub name: String,
19 pub kind: &'static str,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub size: Option<u64>,
24 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
28 pub protected: bool,
29}
30
31#[derive(Debug, Clone)]
32pub struct BrowseResult {
33 pub path: String,
36 pub entries: Vec<BrowseEntry>,
37}
38
39#[derive(Debug, thiserror::Error)]
40pub enum BrowseError {
41 #[error(transparent)]
42 Escape(#[from] RootEscapeError),
43 #[error("path '{0}' does not exist")]
44 NotFound(String),
45 #[error("path '{0}' is not a directory")]
46 NotADirectory(String),
47 #[error("'{0}' is a system directory and cannot be removed via the dashboard")]
48 Protected(String),
49 #[error("'{0}' is a system file and cannot be modified or removed via the dashboard")]
50 ProtectedFile(String),
51 #[error("file '{0}' exceeds the {1}-byte read cap; download via CLI or zeroclaw shell")]
52 TooLarge(String, u64),
53 #[error(transparent)]
54 Io(#[from] std::io::Error),
55}
56
57pub fn list_directory(config: &Config, raw: &str) -> Result<BrowseResult, BrowseError> {
60 let mut result = list_under_root(&config.shared_workspace_dir(), raw)?;
61 if raw.trim_matches('/').is_empty() {
62 for entry in &mut result.entries {
63 if entry.kind == "dir" && PROTECTED_SHARED_TOP_LEVEL.contains(&entry.name.as_str()) {
64 entry.protected = true;
65 }
66 }
67 }
68 Ok(result)
69}
70
71fn list_under_root(root: &std::path::Path, raw: &str) -> Result<BrowseResult, BrowseError> {
72 let resolved: PathBuf = resolve_under(root, raw)?;
73
74 let metadata = match std::fs::metadata(&resolved) {
75 Ok(m) => m,
76 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
77 return Err(BrowseError::NotFound(raw.to_string()));
78 }
79 Err(err) => return Err(err.into()),
80 };
81 if !metadata.is_dir() {
82 return Err(BrowseError::NotADirectory(raw.to_string()));
83 }
84
85 let mut entries: Vec<BrowseEntry> = Vec::new();
86 for child in std::fs::read_dir(&resolved)?.flatten() {
87 let Ok(file_type) = child.file_type() else {
88 continue;
89 };
90 let name = child.file_name().to_string_lossy().into_owned();
91 if file_type.is_dir() {
92 entries.push(BrowseEntry {
93 name,
94 kind: "dir",
95 size: None,
96 protected: false,
97 });
98 } else if file_type.is_file() {
99 let size = child.metadata().ok().map(|m| m.len());
100 entries.push(BrowseEntry {
101 name,
102 kind: "file",
103 size,
104 protected: false,
105 });
106 }
107 }
108 entries.sort_by(|a, b| (a.kind, &a.name).cmp(&(b.kind, &b.name)));
109
110 Ok(BrowseResult {
111 path: raw.trim_matches('/').to_string(),
112 entries,
113 })
114}
115
116const PROTECTED_SHARED_TOP_LEVEL: &[&str] = &["skills", "skill-bundles", "knowledge"];
122
123pub fn make_directory(config: &Config, raw: &str) -> Result<(), BrowseError> {
127 let shared = config.shared_workspace_dir();
128 let resolved: PathBuf = resolve_under(&shared, raw)?;
129 if let Ok(meta) = std::fs::metadata(&resolved) {
130 if meta.is_dir() {
131 return Ok(());
132 }
133 return Err(BrowseError::NotADirectory(raw.to_string()));
134 }
135 std::fs::create_dir_all(&resolved)?;
136 Ok(())
137}
138
139pub fn remove_directory(config: &Config, raw: &str) -> Result<(), BrowseError> {
143 let trimmed = raw.trim_matches('/');
144 if trimmed.is_empty() {
145 return Err(BrowseError::Protected("shared".to_string()));
146 }
147 let top = trimmed.split('/').next().unwrap_or("");
148 if PROTECTED_SHARED_TOP_LEVEL.contains(&top) && !trimmed.contains('/') {
149 return Err(BrowseError::Protected(format!("shared/{top}")));
150 }
151 let shared = config.shared_workspace_dir();
152 let resolved: PathBuf = resolve_under(&shared, raw)?;
153 let metadata = match std::fs::metadata(&resolved) {
154 Ok(m) => m,
155 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
156 return Err(BrowseError::NotFound(raw.to_string()));
157 }
158 Err(err) => return Err(err.into()),
159 };
160 if !metadata.is_dir() {
161 return Err(BrowseError::NotADirectory(raw.to_string()));
162 }
163 std::fs::remove_dir_all(&resolved)?;
164 Ok(())
165}
166
167pub const AGENT_WORKSPACE_READ_CAP: u64 = 4 * 1024 * 1024; const AGENT_WORKSPACE_PROTECTED_FILES: &[&str] = &[
183 "IDENTITY.md",
184 "SOUL.md",
185 "USER.md",
186 "AGENTS.md",
187 "MEMORY.md",
188 "DAILY.md",
189];
190
191const AGENT_WORKSPACE_PROTECTED_DIRS: &[&str] = &["sessions"];
196
197fn agent_root(config: &Config, agent_alias: &str) -> PathBuf {
198 config.agent_workspace_dir(agent_alias)
199}
200
201fn protected_file(rel: &str) -> bool {
202 AGENT_WORKSPACE_PROTECTED_FILES.contains(&rel)
203}
204
205fn protected_dir(rel: &str) -> bool {
206 AGENT_WORKSPACE_PROTECTED_DIRS.contains(&rel)
207}
208
209pub fn list_agent_workspace(
213 config: &Config,
214 agent_alias: &str,
215 raw: &str,
216) -> Result<BrowseResult, BrowseError> {
217 let mut result = list_under_root(&agent_root(config, agent_alias), raw)?;
218 if raw.trim_matches('/').is_empty() {
219 for entry in &mut result.entries {
220 entry.protected = match entry.kind {
221 "file" => protected_file(&entry.name),
222 "dir" => protected_dir(&entry.name),
223 _ => false,
224 };
225 }
226 }
227 Ok(result)
228}
229
230pub fn make_agent_workspace_directory(
235 config: &Config,
236 agent_alias: &str,
237 raw: &str,
238) -> Result<(), BrowseError> {
239 let trimmed = raw.trim_matches('/');
240 if trimmed.is_empty() {
241 return Err(BrowseError::NotFound(raw.to_string()));
242 }
243 if protected_file(trimmed) {
244 return Err(BrowseError::ProtectedFile(trimmed.to_string()));
245 }
246 let root = agent_root(config, agent_alias);
247 let resolved: PathBuf = resolve_under(&root, raw)?;
248 if let Ok(meta) = std::fs::metadata(&resolved) {
249 if meta.is_dir() {
250 return Ok(());
251 }
252 return Err(BrowseError::NotADirectory(raw.to_string()));
253 }
254 std::fs::create_dir_all(&resolved)?;
255 Ok(())
256}
257
258#[derive(Debug, Clone)]
260pub struct FileReadResult {
261 pub path: String,
262 pub bytes: Vec<u8>,
263 pub size: u64,
264 pub is_text: bool,
267}
268
269pub fn read_agent_workspace_file(
272 config: &Config,
273 agent_alias: &str,
274 raw: &str,
275) -> Result<FileReadResult, BrowseError> {
276 let root = agent_root(config, agent_alias);
277 let resolved: PathBuf = resolve_under(&root, raw)?;
278 let metadata = match std::fs::metadata(&resolved) {
279 Ok(m) => m,
280 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
281 return Err(BrowseError::NotFound(raw.to_string()));
282 }
283 Err(err) => return Err(err.into()),
284 };
285 if !metadata.is_file() {
286 return Err(BrowseError::NotADirectory(raw.to_string()));
287 }
288 if metadata.len() > AGENT_WORKSPACE_READ_CAP {
289 return Err(BrowseError::TooLarge(
290 raw.to_string(),
291 AGENT_WORKSPACE_READ_CAP,
292 ));
293 }
294 let bytes = std::fs::read(&resolved)?;
295 let is_text = std::str::from_utf8(&bytes).is_ok();
296 Ok(FileReadResult {
297 path: raw.trim_matches('/').to_string(),
298 size: metadata.len(),
299 bytes,
300 is_text,
301 })
302}
303
304pub fn delete_agent_workspace_path(
308 config: &Config,
309 agent_alias: &str,
310 raw: &str,
311) -> Result<(), BrowseError> {
312 let trimmed = raw.trim_matches('/');
313 if trimmed.is_empty() {
314 return Err(BrowseError::Protected(format!(
315 "agents/{agent_alias}/workspace"
316 )));
317 }
318 if protected_file(trimmed) {
319 return Err(BrowseError::ProtectedFile(trimmed.to_string()));
320 }
321 if protected_dir(trimmed) {
322 return Err(BrowseError::Protected(format!(
323 "agents/{agent_alias}/workspace/{trimmed}"
324 )));
325 }
326 let root = agent_root(config, agent_alias);
327 let resolved: PathBuf = resolve_under(&root, raw)?;
328 let metadata = match std::fs::metadata(&resolved) {
329 Ok(m) => m,
330 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
331 return Err(BrowseError::NotFound(raw.to_string()));
332 }
333 Err(err) => return Err(err.into()),
334 };
335 if metadata.is_dir() {
336 std::fs::remove_dir_all(&resolved)?;
337 } else {
338 std::fs::remove_file(&resolved)?;
339 }
340 Ok(())
341}
342
343pub fn move_agent_workspace_path(
347 config: &Config,
348 agent_alias: &str,
349 from: &str,
350 to: &str,
351) -> Result<(), BrowseError> {
352 let from_trimmed = from.trim_matches('/');
353 let to_trimmed = to.trim_matches('/');
354 if from_trimmed.is_empty() || to_trimmed.is_empty() {
355 return Err(BrowseError::NotFound(from.to_string()));
356 }
357 if protected_file(from_trimmed) || protected_file(to_trimmed) {
358 return Err(BrowseError::ProtectedFile(
359 if protected_file(from_trimmed) {
360 from_trimmed
361 } else {
362 to_trimmed
363 }
364 .to_string(),
365 ));
366 }
367 if protected_dir(from_trimmed) || protected_dir(to_trimmed) {
368 return Err(BrowseError::Protected(format!(
369 "agents/{agent_alias}/workspace/{}",
370 if protected_dir(from_trimmed) {
371 from_trimmed
372 } else {
373 to_trimmed
374 }
375 )));
376 }
377 let root = agent_root(config, agent_alias);
378 let src: PathBuf = resolve_under(&root, from)?;
379 let dst: PathBuf = resolve_under(&root, to)?;
380 if !src.exists() {
381 return Err(BrowseError::NotFound(from.to_string()));
382 }
383 if dst.exists() {
384 return Err(BrowseError::NotADirectory(format!(
385 "target '{to_trimmed}' already exists"
386 )));
387 }
388 if let Some(parent) = dst.parent() {
389 std::fs::create_dir_all(parent)?;
390 }
391 std::fs::rename(&src, &dst)?;
392 Ok(())
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use tempfile::TempDir;
399
400 fn fixture() -> (TempDir, Config) {
401 let dir = tempfile::tempdir().unwrap();
402 std::fs::create_dir_all(dir.path().join("shared/skills/alpha")).unwrap();
403 std::fs::create_dir_all(dir.path().join("shared/skills/beta")).unwrap();
404 std::fs::write(dir.path().join("shared/readme.txt"), b"hi").unwrap();
405
406 let cfg = Config {
407 config_path: dir.path().join("config.toml"),
408 ..Config::default()
409 };
410 (dir, cfg)
411 }
412
413 #[test]
414 fn lists_shared_root_when_path_empty() {
415 let (_dir, cfg) = fixture();
416 let result = list_directory(&cfg, "").unwrap();
417 assert_eq!(result.entries.len(), 2);
418 assert_eq!(result.entries[0].name, "skills");
419 assert_eq!(result.entries[0].kind, "dir");
420 assert_eq!(result.entries[1].name, "readme.txt");
421 assert_eq!(result.entries[1].kind, "file");
422 }
423
424 #[test]
425 fn descends_one_level() {
426 let (_dir, cfg) = fixture();
427 let result = list_directory(&cfg, "skills").unwrap();
428 let names: Vec<_> = result.entries.iter().map(|e| e.name.as_str()).collect();
429 assert_eq!(names, vec!["alpha", "beta"]);
430 }
431
432 #[test]
433 fn rejects_escape() {
434 let (_dir, cfg) = fixture();
435 let err = list_directory(&cfg, "../etc").unwrap_err();
436 assert!(matches!(err, BrowseError::Escape(_)));
437 }
438
439 #[test]
440 fn errors_on_missing_path() {
441 let (_dir, cfg) = fixture();
442 let err = list_directory(&cfg, "ghost").unwrap_err();
443 assert!(matches!(err, BrowseError::NotFound(_)));
444 }
445
446 #[test]
447 fn errors_when_path_is_a_file() {
448 let (_dir, cfg) = fixture();
449 let err = list_directory(&cfg, "readme.txt").unwrap_err();
450 assert!(matches!(err, BrowseError::NotADirectory(_)));
451 }
452
453 #[test]
454 fn make_directory_creates_nested_path() {
455 let (dir, cfg) = fixture();
456 make_directory(&cfg, "skills/gamma/sub").unwrap();
457 assert!(dir.path().join("shared/skills/gamma/sub").is_dir());
458 }
459
460 #[test]
461 fn make_directory_is_idempotent() {
462 let (_dir, cfg) = fixture();
463 make_directory(&cfg, "skills/alpha").unwrap();
464 make_directory(&cfg, "skills/alpha").unwrap();
465 }
466
467 #[test]
468 fn make_directory_rejects_escape() {
469 let (_dir, cfg) = fixture();
470 let err = make_directory(&cfg, "../etc").unwrap_err();
471 assert!(matches!(err, BrowseError::Escape(_)));
472 }
473
474 #[test]
475 fn make_directory_refuses_over_existing_file() {
476 let (_dir, cfg) = fixture();
477 let err = make_directory(&cfg, "readme.txt").unwrap_err();
478 assert!(matches!(err, BrowseError::NotADirectory(_)));
479 }
480
481 #[test]
482 fn remove_directory_recursively_drops_subtree() {
483 let (dir, cfg) = fixture();
484 make_directory(&cfg, "skills/alpha/nested/deep").unwrap();
485 remove_directory(&cfg, "skills/alpha").unwrap();
486 assert!(!dir.path().join("shared/skills/alpha").exists());
487 assert!(dir.path().join("shared/skills/beta").is_dir());
489 }
490
491 #[test]
492 fn remove_directory_refuses_protected_top_level() {
493 let (_dir, cfg) = fixture();
494 for name in ["skills", "skill-bundles", "knowledge"] {
495 let err = remove_directory(&cfg, name).unwrap_err();
496 assert!(
497 matches!(err, BrowseError::Protected(_)),
498 "must refuse to remove protected top-level '{name}', got {err:?}"
499 );
500 }
501 }
502
503 #[test]
504 fn remove_directory_refuses_empty_path() {
505 let (_dir, cfg) = fixture();
506 let err = remove_directory(&cfg, "").unwrap_err();
507 assert!(matches!(err, BrowseError::Protected(_)));
508 }
509
510 #[test]
511 fn remove_directory_rejects_escape() {
512 let (_dir, cfg) = fixture();
513 let err = remove_directory(&cfg, "../etc").unwrap_err();
514 assert!(matches!(err, BrowseError::Escape(_)));
515 }
516
517 #[test]
518 fn remove_directory_errors_on_missing() {
519 let (_dir, cfg) = fixture();
520 let err = remove_directory(&cfg, "skills/ghost").unwrap_err();
521 assert!(matches!(err, BrowseError::NotFound(_)));
522 }
523
524 #[test]
525 fn remove_directory_allows_nested_under_protected_top_level() {
526 let (dir, cfg) = fixture();
528 remove_directory(&cfg, "skills/alpha").unwrap();
529 assert!(!dir.path().join("shared/skills/alpha").exists());
530 assert!(dir.path().join("shared/skills").is_dir());
531 }
532
533 fn workspace_fixture() -> (TempDir, Config) {
536 let dir = tempfile::tempdir().unwrap();
537 let ws = dir.path().join("agents/alpha/workspace");
538 std::fs::create_dir_all(ws.join("notes/sub")).unwrap();
539 std::fs::write(ws.join("notes/draft.md"), b"draft content").unwrap();
540 std::fs::write(ws.join("IDENTITY.md"), b"identity").unwrap();
541 std::fs::write(ws.join("SOUL.md"), b"soul").unwrap();
542 let cfg = Config {
543 config_path: dir.path().join("config.toml"),
544 ..Config::default()
545 };
546 (dir, cfg)
547 }
548
549 #[test]
550 fn list_agent_workspace_returns_one_level() {
551 let (_dir, cfg) = workspace_fixture();
552 let result = list_agent_workspace(&cfg, "alpha", "").unwrap();
553 let names: Vec<_> = result.entries.iter().map(|e| e.name.as_str()).collect();
554 assert!(names.contains(&"notes"));
555 assert!(names.contains(&"IDENTITY.md"));
556 }
557
558 #[test]
559 fn list_agent_workspace_rejects_escape() {
560 let (_dir, cfg) = workspace_fixture();
561 let err = list_agent_workspace(&cfg, "alpha", "../../etc").unwrap_err();
562 assert!(matches!(err, BrowseError::Escape(_)));
563 }
564
565 #[test]
566 fn read_agent_workspace_file_returns_bytes_and_text_flag() {
567 let (_dir, cfg) = workspace_fixture();
568 let r = read_agent_workspace_file(&cfg, "alpha", "notes/draft.md").unwrap();
569 assert_eq!(r.bytes, b"draft content");
570 assert!(r.is_text);
571 assert_eq!(r.size, 13);
572 }
573
574 #[test]
575 fn read_agent_workspace_file_errors_on_directory() {
576 let (_dir, cfg) = workspace_fixture();
577 let err = read_agent_workspace_file(&cfg, "alpha", "notes").unwrap_err();
578 assert!(matches!(err, BrowseError::NotADirectory(_)));
579 }
580
581 #[test]
582 fn read_agent_workspace_file_enforces_size_cap() {
583 let (dir, cfg) = workspace_fixture();
584 let ws = dir.path().join("agents/alpha/workspace");
585 let big = vec![b'x'; (AGENT_WORKSPACE_READ_CAP + 1) as usize];
586 std::fs::write(ws.join("big.bin"), &big).unwrap();
587 let err = read_agent_workspace_file(&cfg, "alpha", "big.bin").unwrap_err();
588 assert!(matches!(err, BrowseError::TooLarge(_, _)));
589 }
590
591 #[test]
592 fn delete_agent_workspace_path_removes_file() {
593 let (dir, cfg) = workspace_fixture();
594 delete_agent_workspace_path(&cfg, "alpha", "notes/draft.md").unwrap();
595 assert!(
596 !dir.path()
597 .join("agents/alpha/workspace/notes/draft.md")
598 .exists()
599 );
600 }
601
602 #[test]
603 fn delete_agent_workspace_path_removes_directory_recursively() {
604 let (dir, cfg) = workspace_fixture();
605 delete_agent_workspace_path(&cfg, "alpha", "notes").unwrap();
606 assert!(!dir.path().join("agents/alpha/workspace/notes").exists());
607 }
608
609 #[test]
610 fn delete_agent_workspace_path_refuses_protected_files() {
611 let (_dir, cfg) = workspace_fixture();
612 for name in ["IDENTITY.md", "SOUL.md"] {
613 let err = delete_agent_workspace_path(&cfg, "alpha", name).unwrap_err();
614 assert!(
615 matches!(err, BrowseError::ProtectedFile(_)),
616 "must refuse {name}, got {err:?}"
617 );
618 }
619 }
620
621 #[test]
622 fn delete_agent_workspace_path_refuses_root() {
623 let (_dir, cfg) = workspace_fixture();
624 let err = delete_agent_workspace_path(&cfg, "alpha", "").unwrap_err();
625 assert!(matches!(err, BrowseError::Protected(_)));
626 }
627
628 #[test]
629 fn delete_agent_workspace_path_rejects_escape() {
630 let (_dir, cfg) = workspace_fixture();
631 let err = delete_agent_workspace_path(&cfg, "alpha", "../../etc").unwrap_err();
632 assert!(matches!(err, BrowseError::Escape(_)));
633 }
634
635 #[test]
636 fn move_agent_workspace_path_renames_within_jail() {
637 let (dir, cfg) = workspace_fixture();
638 move_agent_workspace_path(&cfg, "alpha", "notes/draft.md", "notes/final.md").unwrap();
639 assert!(
640 !dir.path()
641 .join("agents/alpha/workspace/notes/draft.md")
642 .exists()
643 );
644 assert!(
645 dir.path()
646 .join("agents/alpha/workspace/notes/final.md")
647 .is_file()
648 );
649 }
650
651 #[test]
652 fn move_agent_workspace_path_creates_intermediate_dirs() {
653 let (dir, cfg) = workspace_fixture();
654 move_agent_workspace_path(&cfg, "alpha", "notes/draft.md", "archive/2026/draft.md")
655 .unwrap();
656 assert!(
657 dir.path()
658 .join("agents/alpha/workspace/archive/2026/draft.md")
659 .is_file()
660 );
661 }
662
663 #[test]
664 fn move_agent_workspace_path_refuses_protected_src() {
665 let (_dir, cfg) = workspace_fixture();
666 let err = move_agent_workspace_path(&cfg, "alpha", "IDENTITY.md", "id.md").unwrap_err();
667 assert!(matches!(err, BrowseError::ProtectedFile(_)));
668 }
669
670 #[test]
671 fn move_agent_workspace_path_refuses_protected_dst() {
672 let (_dir, cfg) = workspace_fixture();
673 let err =
674 move_agent_workspace_path(&cfg, "alpha", "notes/draft.md", "IDENTITY.md").unwrap_err();
675 assert!(matches!(err, BrowseError::ProtectedFile(_)));
676 }
677
678 #[test]
679 fn move_agent_workspace_path_rejects_escape() {
680 let (_dir, cfg) = workspace_fixture();
681 let err = move_agent_workspace_path(&cfg, "alpha", "notes/draft.md", "../../etc/draft.md")
682 .unwrap_err();
683 assert!(matches!(err, BrowseError::Escape(_)));
684 }
685
686 #[test]
687 fn move_agent_workspace_path_refuses_overwrite() {
688 let (_dir, cfg) = workspace_fixture();
689 let err =
690 move_agent_workspace_path(&cfg, "alpha", "notes/draft.md", "notes/sub").unwrap_err();
691 assert!(matches!(err, BrowseError::NotADirectory(_)));
692 }
693
694 #[test]
695 fn list_agent_workspace_tags_protected_top_level_entries() {
696 let (dir, cfg) = workspace_fixture();
697 std::fs::create_dir_all(dir.path().join("agents/alpha/workspace/sessions")).unwrap();
698 let result = list_agent_workspace(&cfg, "alpha", "").unwrap();
699 let sessions = result
700 .entries
701 .iter()
702 .find(|e| e.name == "sessions")
703 .unwrap();
704 assert!(sessions.protected, "sessions/ must be tagged protected");
705 let identity = result
706 .entries
707 .iter()
708 .find(|e| e.name == "IDENTITY.md")
709 .unwrap();
710 assert!(identity.protected, "IDENTITY.md must be tagged protected");
711 let notes = result.entries.iter().find(|e| e.name == "notes").unwrap();
712 assert!(!notes.protected, "operator dirs must not be tagged");
713 }
714
715 #[test]
716 fn list_agent_workspace_does_not_tag_protected_names_below_root() {
717 let (dir, cfg) = workspace_fixture();
718 std::fs::create_dir_all(dir.path().join("agents/alpha/workspace/notes/sessions")).unwrap();
719 let result = list_agent_workspace(&cfg, "alpha", "notes").unwrap();
720 let sessions = result
721 .entries
722 .iter()
723 .find(|e| e.name == "sessions")
724 .unwrap();
725 assert!(
726 !sessions.protected,
727 "protection only applies at workspace root"
728 );
729 }
730
731 #[test]
732 fn delete_agent_workspace_path_refuses_protected_dir() {
733 let (dir, cfg) = workspace_fixture();
734 std::fs::create_dir_all(dir.path().join("agents/alpha/workspace/sessions")).unwrap();
735 let err = delete_agent_workspace_path(&cfg, "alpha", "sessions").unwrap_err();
736 assert!(matches!(err, BrowseError::Protected(_)));
737 assert!(dir.path().join("agents/alpha/workspace/sessions").is_dir());
738 }
739
740 #[test]
741 fn move_agent_workspace_path_refuses_protected_src_dir() {
742 let (dir, cfg) = workspace_fixture();
743 std::fs::create_dir_all(dir.path().join("agents/alpha/workspace/sessions")).unwrap();
744 let err = move_agent_workspace_path(&cfg, "alpha", "sessions", "old_sessions").unwrap_err();
745 assert!(matches!(err, BrowseError::Protected(_)));
746 }
747
748 #[test]
749 fn move_agent_workspace_path_refuses_protected_dst_dir() {
750 let (_dir, cfg) = workspace_fixture();
751 let err = move_agent_workspace_path(&cfg, "alpha", "notes", "sessions").unwrap_err();
752 assert!(matches!(err, BrowseError::Protected(_)));
753 }
754
755 #[test]
756 fn make_agent_workspace_directory_creates_nested_path() {
757 let (dir, cfg) = workspace_fixture();
758 make_agent_workspace_directory(&cfg, "alpha", "archive/2026").unwrap();
759 assert!(
760 dir.path()
761 .join("agents/alpha/workspace/archive/2026")
762 .is_dir()
763 );
764 }
765
766 #[test]
767 fn make_agent_workspace_directory_is_idempotent() {
768 let (_dir, cfg) = workspace_fixture();
769 make_agent_workspace_directory(&cfg, "alpha", "notes").unwrap();
770 make_agent_workspace_directory(&cfg, "alpha", "notes").unwrap();
771 }
772
773 #[test]
774 fn make_agent_workspace_directory_rejects_escape() {
775 let (_dir, cfg) = workspace_fixture();
776 let err = make_agent_workspace_directory(&cfg, "alpha", "../../etc").unwrap_err();
777 assert!(matches!(err, BrowseError::Escape(_)));
778 }
779
780 #[test]
781 fn make_agent_workspace_directory_refuses_over_existing_file() {
782 let (_dir, cfg) = workspace_fixture();
783 let err = make_agent_workspace_directory(&cfg, "alpha", "notes/draft.md").unwrap_err();
784 assert!(matches!(err, BrowseError::NotADirectory(_)));
785 }
786
787 #[test]
788 fn make_agent_workspace_directory_refuses_protected_file_path() {
789 let (_dir, cfg) = workspace_fixture();
790 let err = make_agent_workspace_directory(&cfg, "alpha", "IDENTITY.md").unwrap_err();
791 assert!(matches!(err, BrowseError::ProtectedFile(_)));
792 }
793
794 #[test]
795 fn make_agent_workspace_directory_refuses_empty_path() {
796 let (_dir, cfg) = workspace_fixture();
797 let err = make_agent_workspace_directory(&cfg, "alpha", "").unwrap_err();
798 assert!(matches!(err, BrowseError::NotFound(_)));
799 }
800}