diff --git a/crates/bashkit/src/fs/memory.rs b/crates/bashkit/src/fs/memory.rs index c93e8a029..c650e762e 100644 --- a/crates/bashkit/src/fs/memory.rs +++ b/crates/bashkit/src/fs/memory.rs @@ -905,6 +905,54 @@ impl InMemoryFs { ); } + /// Add a directory, creating parent directories as needed (synchronous, + /// for initial setup). + /// + /// Like [`add_file`](Self::add_file), this is intended for pre-populating + /// the filesystem during construction (e.g. provisioning a user's `$HOME`). + /// For runtime operations use the async [`FileSystem::mkdir`] method. + /// Existing directories are left untouched (idempotent). + /// + /// # Arguments + /// + /// * `path` - Absolute path of the directory to create + /// * `mode` - Unix permission mode (e.g., `0o755`) + /// + /// # Example + /// + /// ```rust + /// use bashkit::InMemoryFs; + /// + /// let fs = InMemoryFs::new(); + /// fs.add_dir("/home/agent", 0o755); + /// ``` + pub fn add_dir(&self, path: impl AsRef, mode: u32) { + let path = Self::normalize_path(path.as_ref()); + + if self.limits.validate_path(&path).is_err() { + return; + } + + let mut entries = self.entries.write().unwrap(); + + // Create the directory and every ancestor that does not already exist. + let mut current = PathBuf::from("/"); + for component in path.components().skip(1) { + current.push(component); + entries + .entry(current.clone()) + .or_insert_with(|| FsEntry::Directory { + metadata: Metadata { + file_type: FileType::Directory, + size: 0, + mode, + modified: SystemTime::now(), + created: SystemTime::now(), + }, + }); + } + } + /// Add a lazy file whose content is loaded on first read. /// /// The `loader` closure is called at most once when the file is first read. diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 917bfc177..cfd07aafe 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -681,7 +681,13 @@ impl Default for Bash { impl Bash { /// Create a new Bash instance with default settings. pub fn new() -> Self { - let base_fs: Arc = Arc::new(InMemoryFs::new()); + // Provision the default user's home directory so `$HOME` / `~` is a + // real, writable directory. HOME defaults to `/home/` + // (see Interpreter), which InMemoryFs::new does not create on its own. + // See issue #2128. (BashBuilder::build does the same for custom users.) + let base_inmem = InMemoryFs::new(); + base_inmem.add_dir(format!("/home/{}", builtins::DEFAULT_USERNAME), 0o755); + let base_fs: Arc = Arc::new(base_inmem); let mountable = Arc::new(MountableFs::new(base_fs)); let fs: Arc = Arc::clone(&mountable) as Arc; let interpreter = Interpreter::new(Arc::clone(&fs)); @@ -2656,8 +2662,24 @@ impl BashBuilder { pub fn build(self) -> Bash { let base_fs: Arc = if self.shell_profile.is_logic_only() { Arc::new(fs::DisabledFs) + } else if let Some(fs) = self.fs { + fs } else { - self.fs.unwrap_or_else(|| Arc::new(InMemoryFs::new())) + // No custom filesystem was supplied: provision the default + // in-memory VFS with a home directory for the configured user so + // that `$HOME` / `~` is a real, writable directory. HOME defaults + // to `/home/` (see Interpreter::with_config), and + // InMemoryFs::new only ever creates `/home/user` — so a non-default + // `username("eval")` would leave HOME=/home/eval pointing at a + // nonexistent directory and writes to `~` fail with "parent + // directory not found". See issue #2128. + let fs = InMemoryFs::new(); + let username = self + .username + .as_deref() + .unwrap_or(builtins::DEFAULT_USERNAME); + fs.add_dir(format!("/home/{username}"), 0o755); + Arc::new(fs) }; // Layer 1: Apply real filesystem mounts (if any) @@ -4690,6 +4712,45 @@ fn assert_eq!(result.stdout, "charlie\n"); } + #[tokio::test] + async fn test_custom_username_provisions_home_dir() { + // Regression for #2128: a configured username must make $HOME a real, + // writable directory. Previously HOME=/home/eval pointed at a + // nonexistent directory and writes to ~ failed with + // "parent directory not found". + let mut bash = Bash::builder().username("eval").build(); + let result = bash + .exec("echo hi > /home/eval/x.sh && cat /home/eval/x.sh") + .await + .unwrap(); + assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr); + assert_eq!(result.stdout, "hi\n"); + } + + #[tokio::test] + async fn test_custom_username_home_tilde_write() { + // `~` / `$HOME` must resolve to the provisioned, writable home dir. + let mut bash = Bash::builder().username("agent").build(); + let result = bash + .exec("echo $HOME; echo data > ~/file.txt && cat ~/file.txt") + .await + .unwrap(); + assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr); + assert_eq!(result.stdout, "/home/agent\ndata\n"); + } + + #[tokio::test] + async fn test_default_username_provisions_home_dir() { + // The default user's $HOME must also exist and be writable. + let mut bash = Bash::new(); + let result = bash + .exec("echo data > $HOME/f && cat $HOME/f") + .await + .unwrap(); + assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr); + assert_eq!(result.stdout, "data\n"); + } + #[tokio::test] async fn test_default_ppid_is_sandboxed() { let mut bash = Bash::new();