Skip to main content

zeroclaw_runtime/
browse.rs

1//! Scoped one-level directory browser. Gateway (`api_browse.rs`), CLI
2//! (`src/browse.rs`), and the future TUI directory picker all reach the
3//! same canonical implementation here.
4//!
5//! Hard-scoped to `<install>/shared/` — the only place skills, knowledge
6//! bundles, and other host-wide content live. `..` traversal that escapes
7//! the root is rejected before any I/O.
8
9use 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    /// `"dir"` or `"file"`. Symlinks resolve through their target.
20    pub kind: &'static str,
21    /// File size in bytes. `None` for directories.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub size: Option<u64>,
24    /// Set when this entry is on the runtime's protected list and the
25    /// dashboard must hide delete/rename affordances. Server-side checks
26    /// (delete/move/mkdir) reject mutations on these regardless of UI.
27    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
28    pub protected: bool,
29}
30
31#[derive(Debug, Clone)]
32pub struct BrowseResult {
33    /// Path relative to `<install>/shared/` that the result describes.
34    /// Useful for breadcrumb rendering.
35    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
57/// Browse one level of `<install>/shared/<raw>`. Returns entries sorted by
58/// (kind, name) — directories first, then files, alphabetical within each.
59pub 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
116/// Top-level shared/ entries that the runtime owns and the operator must
117/// not be able to remove via the dashboard. Backend-enforced so a
118/// compromised or buggy frontend cannot bypass this. Names match what
119/// the install scaffolds via `migrate_v2_to_v3_install_filesystem`
120/// and the `<install>/shared/` initializer.
121const PROTECTED_SHARED_TOP_LEVEL: &[&str] = &["skills", "skill-bundles", "knowledge"];
122
123/// Create a new directory at `<install>/shared/<raw>`. Idempotent — if the
124/// path already exists as a directory, returns Ok without re-creating.
125/// Rejects path traversal and refuses to create over an existing file.
126pub 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
139/// Delete the directory at `<install>/shared/<raw>` recursively. Refuses
140/// to remove protected top-level entries (skills/, skill-bundles/,
141/// knowledge/) or the shared root itself. Rejects path traversal.
142pub 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
167// ── Agent-workspace operations ────────────────────────────────────────────
168//
169// All four functions are scoped to `<install>/agents/<alias>/workspace/`
170// (or the explicit per-agent override at `[agents.<alias>.workspace.path]`).
171// Containment is enforced by `resolve_under`, same as the shared/ browser.
172//
173// Protected files: the per-agent bootstrap markdown files the runtime
174// expects on disk. The dashboard refuses to delete or overwrite these via
175// READ/DELETE/MOVE; operators with a need to wipe them go through the
176// CLI / shell.
177
178/// Hard byte cap on file-read responses. Anything larger surfaces as
179/// `BrowseError::TooLarge`; the dashboard can offer a CLI hint.
180pub const AGENT_WORKSPACE_READ_CAP: u64 = 4 * 1024 * 1024; // 4 MiB
181
182const 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
191/// Top-level agent-workspace directories the runtime owns. `sessions/`
192/// holds the per-agent session DB (`sessions/sessions.db`) created on first
193/// session write by `zeroclaw_infra::session_sqlite`. Deleting it wipes
194/// session history.
195const 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
209/// One-level listing inside the agent's workspace. Top-level entries that
210/// match the protected file/dir lists are tagged so the dashboard hides
211/// destructive affordances; server-side mutations still re-check.
212pub 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
230/// Create a directory under the agent's workspace. Idempotent — if the
231/// path already exists as a directory, returns Ok. Rejects path traversal
232/// and refuses to create over an existing file or to overwrite a protected
233/// top-level file path.
234pub 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/// Result of reading a file from the agent workspace.
259#[derive(Debug, Clone)]
260pub struct FileReadResult {
261    pub path: String,
262    pub bytes: Vec<u8>,
263    pub size: u64,
264    /// True when the bytes look like UTF-8 text. Drives whether the
265    /// dashboard renders inline or offers a download.
266    pub is_text: bool,
267}
268
269/// Read a file from the agent's workspace. Refuses paths that don't
270/// resolve to a regular file; enforces the size cap.
271pub 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
304/// Delete a file or directory inside the agent's workspace. Recursive
305/// for directories. Refuses to delete the workspace root itself or any
306/// of the protected bootstrap files.
307pub 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
343/// Move (rename) a path inside the agent's workspace. Both `from` and
344/// `to` are relative to the workspace root; both must stay inside it.
345/// Refuses to touch protected files on either side.
346pub 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        // sibling not touched
488        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        // skills/ is protected, but skills/alpha is operator-owned.
527        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    // ── agent workspace ──────────────────────────────────────────────
534
535    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}