diff --git a/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs b/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs index b88171c4..cf9ce6dd 100644 --- a/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs +++ b/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs @@ -122,10 +122,10 @@ describe("VFS API", () => { assert.ok(entries.some((e) => e.name === "fsapi.txt")); }); - it("mountReal and unmount", () => { + it("mount and unmount", () => { const bash = new Bash(); - // Mount /tmp as a real filesystem at /host-tmp - bash.mountReal("/tmp", "/host-tmp", true); + // Mount /tmp as a real filesystem at /host-tmp (read-only by default) + bash.mount("/tmp", "/host-tmp"); // The mount should be accessible const r = bash.executeSync("ls /host-tmp 2>/dev/null; echo status=$?"); assert.ok(r.stdout.includes("status=0")); diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index 46d17d4b..a64c66e0 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -411,8 +411,8 @@ pub struct MountConfig { pub host_path: String, /// VFS path where mount appears (defaults to host_path). pub vfs_path: Option, - /// If true, mount is read-only (default: true). - pub read_only: Option, + /// If true, mount is read-write (default: false → read-only). + pub writable: Option, } /// Options for creating a Bash or BashTool instance. @@ -444,7 +444,7 @@ pub struct BashOptions { /// Files to mount in the virtual filesystem. /// Keys are absolute paths, values are file content strings. pub files: Option>, - /// Real filesystem mounts. Each entry: { hostPath, vfsPath?, readOnly? } + /// Real filesystem mounts. Each entry: { hostPath, vfsPath?, writable? } pub mounts: Option>, /// Enable embedded Python execution (`python`/`python3` builtins). pub python: Option, @@ -849,21 +849,20 @@ impl Bash { /// Mount a host directory into the VFS at runtime. /// - /// `readOnly` defaults to true when omitted. + /// Read-only by default; pass `writable: true` to enable writes. #[napi] - pub fn mount_real( + pub fn mount( &self, host_path: String, vfs_path: String, - read_only: Option, + writable: Option, ) -> napi::Result<()> { block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; - let ro = read_only.unwrap_or(true); - let mode = if ro { - bashkit::RealFsMode::ReadOnly - } else { + let mode = if writable.unwrap_or(false) { bashkit::RealFsMode::ReadWrite + } else { + bashkit::RealFsMode::ReadOnly }; let real_backend = bashkit::RealFs::new(&host_path, mode) .map_err(|e| napi::Error::from_reason(e.to_string()))?; @@ -1222,21 +1221,20 @@ impl BashTool { /// Mount a host directory into the VFS at runtime. /// - /// `readOnly` defaults to true when omitted. + /// Read-only by default; pass `writable: true` to enable writes. #[napi] - pub fn mount_real( + pub fn mount( &self, host_path: String, vfs_path: String, - read_only: Option, + writable: Option, ) -> napi::Result<()> { block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; - let ro = read_only.unwrap_or(true); - let mode = if ro { - bashkit::RealFsMode::ReadOnly - } else { + let mode = if writable.unwrap_or(false) { bashkit::RealFsMode::ReadWrite + } else { + bashkit::RealFsMode::ReadOnly }; let real_backend = bashkit::RealFs::new(&host_path, mode) .map_err(|e| napi::Error::from_reason(e.to_string()))?; @@ -1621,12 +1619,12 @@ fn build_bash_from_state(state: &SharedState, files: Option<&HashMap builder.mount_real_readonly(&m.host_path), - (true, Some(vfs)) => builder.mount_real_readonly_at(&m.host_path, vfs), - (false, None) => builder.mount_real_readwrite(&m.host_path), - (false, Some(vfs)) => builder.mount_real_readwrite_at(&m.host_path, vfs), + let writable = m.writable.unwrap_or(false); + builder = match (writable, &m.vfs_path) { + (false, None) => builder.mount_real_readonly(&m.host_path), + (false, Some(vfs)) => builder.mount_real_readonly_at(&m.host_path, vfs), + (true, None) => builder.mount_real_readwrite(&m.host_path), + (true, Some(vfs)) => builder.mount_real_readwrite_at(&m.host_path, vfs), }; } } diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index 5d27fd86..7a5a159f 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -70,12 +70,12 @@ export interface BashOptions { * ```typescript * const bash = new Bash({ * mounts: [ - * { path: "/docs", root: "/real/path/to/docs", readOnly: true }, + * { path: "/docs", root: "/real/path/to/docs" }, * ], * }); * ``` */ - mounts?: Array<{ path: string; root: string; readOnly?: boolean }>; + mounts?: Array<{ path: string; root: string; writable?: boolean }>; /** * Enable embedded Python execution (`python`/`python3` builtins). * @@ -152,7 +152,7 @@ function toNativeOptions( mounts: options?.mounts?.map((m) => ({ hostPath: m.root, vfsPath: m.path, - readOnly: m.readOnly, + writable: m.writable, })), python: options?.python, externalFunctions: options?.externalFunctions, @@ -421,9 +421,9 @@ export class Bash { return this.native.fs(); } - /** Mount a host directory into the VFS. readOnly defaults to true. */ - mountReal(hostPath: string, vfsPath: string, readOnly?: boolean): void { - this.native.mountReal(hostPath, vfsPath, readOnly); + /** Mount a host directory into the VFS. Read-only by default; pass writable: true to enable writes. */ + mount(hostPath: string, vfsPath: string, writable?: boolean): void { + this.native.mount(hostPath, vfsPath, writable); } /** Unmount a previously mounted filesystem. */ @@ -653,9 +653,9 @@ export class BashTool { return this.native.fs(); } - /** Mount a host directory into the VFS. readOnly defaults to true. */ - mountReal(hostPath: string, vfsPath: string, readOnly?: boolean): void { - this.native.mountReal(hostPath, vfsPath, readOnly); + /** Mount a host directory into the VFS. Read-only by default; pass writable: true to enable writes. */ + mount(hostPath: string, vfsPath: string, writable?: boolean): void { + this.native.mount(hostPath, vfsPath, writable); } /** Unmount a previously mounted filesystem. */ diff --git a/crates/bashkit-python/README.md b/crates/bashkit-python/README.md index c9111d33..cc02fed8 100644 --- a/crates/bashkit-python/README.md +++ b/crates/bashkit-python/README.md @@ -99,28 +99,34 @@ fs.symlink("/data/link", "/data/blob.bin") fs.chmod("/data/blob.bin", 0o644) ``` +### Files and Mounts + +```python +from bashkit import Bash, FileSystem + +# Text files (in-memory, writable) +bash = Bash(files={"/config/app.conf": "debug=true\n"}) + +# Real filesystem mounts (read-only by default) +bash = Bash(mounts=[ + {"host_path": "/path/to/data", "vfs_path": "/data"}, + {"host_path": "/path/to/workspace", "vfs_path": "/workspace", "writable": True}, +]) +``` + ### Live Mounts ```python from bashkit import Bash, FileSystem bash = Bash() -workspace = FileSystem.real("/path/to/workspace", readwrite=True) +workspace = FileSystem.real("/path/to/workspace", writable=True) bash.mount("/workspace", workspace) bash.execute_sync("echo 'hello' > /workspace/demo.txt") bash.unmount("/workspace") ``` -`Bash` and `BashTool` also still support constructor-time mounts: - -- `mount_text=[("/path", "content")]` -- `mount_readonly_text=[("/path", "content")]` -- `mount_real_readonly=["/host/path"]` -- `mount_real_readonly_at=[("/host/path", "/vfs/path")]` -- `mount_real_readwrite=["/host/path"]` -- `mount_real_readwrite_at=[("/host/path", "/vfs/path")]` - ### BashTool — Convenience Wrapper for AI Agents `BashTool` is a convenience wrapper specifically designed for AI agents. It wraps `Bash` and adds contract metadata (`description`, Markdown `help`, `system_prompt`, JSON schemas) needed by tool-use protocols. Use this when integrating with LangChain, PydanticAI, or similar agent frameworks. diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index a369f5bf..3e0b3fbd 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -8,7 +8,7 @@ class FileSystem: def __init__(self) -> None: ... @staticmethod - def real(host_path: str, readwrite: bool = False) -> FileSystem: ... + def real(host_path: str, writable: bool = False) -> FileSystem: ... def read_file(self, path: str) -> bytes: ... def write_file(self, path: str, content: bytes) -> None: ... def append_file(self, path: str, content: bytes) -> None: ... @@ -61,15 +61,12 @@ class Bash: hostname: str | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, + max_memory: int | None = None, python: bool = False, external_functions: list[str] | None = None, external_handler: ExternalHandler | None = None, - mount_text: list[tuple[str, str]] | None = None, - mount_readonly_text: list[tuple[str, str]] | None = None, - mount_real_readonly: list[str] | None = None, - mount_real_readonly_at: list[tuple[str, str]] | None = None, - mount_real_readwrite: list[str] | None = None, - mount_real_readwrite_at: list[tuple[str, str]] | None = None, + files: dict[str, str] | None = None, + mounts: list[dict[str, Any]] | None = None, ) -> None: ... async def execute(self, commands: str) -> ExecResult: ... def execute_sync(self, commands: str) -> ExecResult: ... @@ -122,12 +119,9 @@ class BashTool: hostname: str | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, - mount_text: list[tuple[str, str]] | None = None, - mount_readonly_text: list[tuple[str, str]] | None = None, - mount_real_readonly: list[str] | None = None, - mount_real_readonly_at: list[tuple[str, str]] | None = None, - mount_real_readwrite: list[str] | None = None, - mount_real_readwrite_at: list[tuple[str, str]] | None = None, + max_memory: int | None = None, + files: dict[str, str] | None = None, + mounts: list[dict[str, Any]] | None = None, ) -> None: ... async def execute(self, commands: str) -> ExecResult: ... def execute_sync(self, commands: str) -> ExecResult: ... diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index d6cf9fa4..f601d7fb 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -122,18 +122,12 @@ fn py_to_json_inner( Ok(serde_json::Value::String(s)) } -#[derive(Clone)] -struct MountedTextConfig { - path: String, - content: String, - readonly: bool, -} - +/// Real filesystem mount config (internal, parsed from Python dicts). #[derive(Clone)] struct RealMountConfig { host_path: String, vfs_mount: Option, - readwrite: bool, + writable: bool, } fn make_runtime() -> PyResult> { @@ -144,86 +138,51 @@ fn make_runtime() -> PyResult> { .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}"))) } -/// Parse the six mount kwargs into internal config structs. -/// Shared by both `PyBash::new()` and `BashTool::new()`. -fn parse_mount_configs( - mount_text: Option>, - mount_readonly_text: Option>, - mount_real_readonly: Option>, - mount_real_readonly_at: Option>, - mount_real_readwrite: Option>, - mount_real_readwrite_at: Option>, -) -> (Vec, Vec) { - let mounted_text_files = mount_text - .unwrap_or_default() - .into_iter() - .map(|(path, content)| MountedTextConfig { - path, - content, - readonly: false, - }) - .chain( - mount_readonly_text - .unwrap_or_default() - .into_iter() - .map(|(path, content)| MountedTextConfig { - path, - content, - readonly: true, - }), - ) - .collect::>(); - let real_mounts = mount_real_readonly - .unwrap_or_default() - .into_iter() - .map(|host_path| RealMountConfig { +/// Parse `mounts` kwarg (list of dicts) into internal config. +/// Each dict: { "host_path": str, "vfs_path"?: str, "writable"?: bool }. +fn parse_mounts(mounts: Option<&Bound<'_, PyList>>) -> PyResult> { + let Some(list) = mounts else { + return Ok(Vec::new()); + }; + let mut configs = Vec::with_capacity(list.len()); + for item in list.iter() { + let dict = item + .cast::() + .map_err(|_| PyValueError::new_err("each mount must be a dict with 'host_path' key"))?; + let host_path: String = dict + .get_item("host_path")? + .ok_or_else(|| PyValueError::new_err("mount dict missing required 'host_path' key"))? + .extract()?; + let vfs_mount: Option = dict + .get_item("vfs_path")? + .map(|v| v.extract()) + .transpose()?; + let writable: bool = dict + .get_item("writable")? + .map(|v| v.extract()) + .transpose()? + .unwrap_or(false); + configs.push(RealMountConfig { host_path, - vfs_mount: None, - readwrite: false, - }) - .chain(mount_real_readonly_at.unwrap_or_default().into_iter().map( - |(host_path, vfs_mount)| RealMountConfig { - host_path, - vfs_mount: Some(vfs_mount), - readwrite: false, - }, - )) - .chain( - mount_real_readwrite - .unwrap_or_default() - .into_iter() - .map(|host_path| RealMountConfig { - host_path, - vfs_mount: None, - readwrite: true, - }), - ) - .chain(mount_real_readwrite_at.unwrap_or_default().into_iter().map( - |(host_path, vfs_mount)| RealMountConfig { - host_path, - vfs_mount: Some(vfs_mount), - readwrite: true, - }, - )) - .collect::>(); - (mounted_text_files, real_mounts) + vfs_mount, + writable, + }); + } + Ok(configs) } +/// Apply `files` dict and `mounts` list to a builder. fn apply_fs_config( mut builder: bashkit::BashBuilder, - mounted_text_files: &[MountedTextConfig], + files: &std::collections::HashMap, real_mounts: &[RealMountConfig], ) -> bashkit::BashBuilder { - for mount in mounted_text_files { - builder = if mount.readonly { - builder.mount_readonly_text(&mount.path, mount.content.clone()) - } else { - builder.mount_text(&mount.path, mount.content.clone()) - }; + for (path, content) in files { + builder = builder.mount_text(path, content.clone()); } for mount in real_mounts { - builder = match (mount.readwrite, &mount.vfs_mount) { + builder = match (mount.writable, &mount.vfs_mount) { (false, None) => builder.mount_real_readonly(&mount.host_path), (false, Some(vfs_mount)) => builder.mount_real_readonly_at(&mount.host_path, vfs_mount), (true, None) => builder.mount_real_readwrite(&mount.host_path), @@ -327,10 +286,10 @@ impl PyFileSystem { } #[staticmethod] - #[pyo3(signature = (host_path, readwrite=false))] - fn real(host_path: String, readwrite: bool) -> PyResult { + #[pyo3(signature = (host_path, writable=false))] + fn real(host_path: String, writable: bool) -> PyResult { let rt = make_runtime()?; - let mode = if readwrite { + let mode = if writable { RealFsMode::ReadWrite } else { RealFsMode::ReadOnly @@ -658,7 +617,7 @@ pub struct PyBash { external_functions: Vec, /// Async Python callable invoked when Monty calls an external function. external_handler: Option>, - mounted_text_files: Vec, + files: std::collections::HashMap, real_mounts: Vec, max_commands: Option, max_loop_iterations: Option, @@ -677,12 +636,8 @@ impl PyBash { python=false, external_functions=None, external_handler=None, - mount_text=None, - mount_readonly_text=None, - mount_real_readonly=None, - mount_real_readonly_at=None, - mount_real_readwrite=None, - mount_real_readwrite_at=None, + files=None, + mounts=None, ))] #[allow(clippy::too_many_arguments)] fn new( @@ -695,12 +650,8 @@ impl PyBash { python: bool, external_functions: Option>, external_handler: Option>, - mount_text: Option>, - mount_readonly_text: Option>, - mount_real_readonly: Option>, - mount_real_readonly_at: Option>, - mount_real_readwrite: Option>, - mount_real_readwrite_at: Option>, + files: Option>, + mounts: Option<&Bound<'_, PyList>>, ) -> PyResult { let mut builder = Bash::builder(); @@ -724,14 +675,8 @@ impl PyBash { builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX)); } - let (mounted_text_files, real_mounts) = parse_mount_configs( - mount_text, - mount_readonly_text, - mount_real_readonly, - mount_real_readonly_at, - mount_real_readwrite, - mount_real_readwrite_at, - ); + let files = files.unwrap_or_default(); + let real_mounts = parse_mounts(mounts)?; let fn_names = external_functions.clone().unwrap_or_default(); if !fn_names.is_empty() && external_handler.is_none() { @@ -773,7 +718,7 @@ impl PyBash { } let handler_for_build = external_handler.as_ref().map(|h| h.clone_ref(py)); builder = apply_python_config(builder, python, fn_names, handler_for_build); - builder = apply_fs_config(builder, &mounted_text_files, &real_mounts); + builder = apply_fs_config(builder, &files, &real_mounts); let bash = builder.build(); let cancelled = Arc::new(RwLock::new(bash.cancellation_token())); @@ -789,7 +734,7 @@ impl PyBash { python, external_functions: external_functions.unwrap_or_default(), external_handler, - mounted_text_files, + files, real_mounts, max_commands, max_loop_iterations, @@ -946,7 +891,7 @@ impl PyBash { let max_memory = self.max_memory; let python = self.python; let external_functions = self.external_functions.clone(); - let mounted_text_files = self.mounted_text_files.clone(); + let files = self.files.clone(); let real_mounts = self.real_mounts.clone(); // Clone handler ref while still holding the GIL (before py.detach). let handler_clone = self.external_handler.as_ref().map(|h| h.clone_ref(py)); @@ -974,7 +919,7 @@ impl PyBash { builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX)); } builder = apply_python_config(builder, python, external_functions, handler_clone); - builder = apply_fs_config(builder, &mounted_text_files, &real_mounts); + builder = apply_fs_config(builder, &files, &real_mounts); *bash = builder.build(); // Swap the cancellation token to the new interpreter's token so // cancel() targets the current (not stale) interpreter. @@ -1075,7 +1020,7 @@ pub struct BashTool { cancelled: Arc>>, username: Option, hostname: Option, - mounted_text_files: Vec, + files: std::collections::HashMap, real_mounts: Vec, max_commands: Option, max_loop_iterations: Option, @@ -1115,25 +1060,18 @@ impl BashTool { max_commands=None, max_loop_iterations=None, max_memory=None, - mount_text=None, - mount_readonly_text=None, - mount_real_readonly=None, - mount_real_readonly_at=None, - mount_real_readwrite=None, - mount_real_readwrite_at=None, + files=None, + mounts=None, ))] fn new( + _py: Python<'_>, username: Option, hostname: Option, max_commands: Option, max_loop_iterations: Option, max_memory: Option, - mount_text: Option>, - mount_readonly_text: Option>, - mount_real_readonly: Option>, - mount_real_readonly_at: Option>, - mount_real_readwrite: Option>, - mount_real_readwrite_at: Option>, + files: Option>, + mounts: Option<&Bound<'_, PyList>>, ) -> PyResult { let mut builder = Bash::builder(); @@ -1157,15 +1095,9 @@ impl BashTool { builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX)); } - let (mounted_text_files, real_mounts) = parse_mount_configs( - mount_text, - mount_readonly_text, - mount_real_readonly, - mount_real_readonly_at, - mount_real_readwrite, - mount_real_readwrite_at, - ); - builder = apply_fs_config(builder, &mounted_text_files, &real_mounts); + let files = files.unwrap_or_default(); + let real_mounts = parse_mounts(mounts)?; + builder = apply_fs_config(builder, &files, &real_mounts); let bash = builder.build(); let cancelled = Arc::new(RwLock::new(bash.cancellation_token())); @@ -1178,7 +1110,7 @@ impl BashTool { cancelled, username, hostname, - mounted_text_files, + files, real_mounts, max_commands, max_loop_iterations, @@ -1311,7 +1243,7 @@ impl BashTool { let inner = self.inner.clone(); let username = self.username.clone(); let hostname = self.hostname.clone(); - let mounted_text_files = self.mounted_text_files.clone(); + let files = self.files.clone(); let real_mounts = self.real_mounts.clone(); let max_commands = self.max_commands; let max_loop_iterations = self.max_loop_iterations; @@ -1339,7 +1271,7 @@ impl BashTool { if let Some(mm) = max_memory { builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX)); } - builder = apply_fs_config(builder, &mounted_text_files, &real_mounts); + builder = apply_fs_config(builder, &files, &real_mounts); *bash = builder.build(); // Swap the cancellation token to the new interpreter's token so // cancel() targets the current (not stale) interpreter. diff --git a/crates/bashkit-python/tests/test_bashkit.py b/crates/bashkit-python/tests/test_bashkit.py index 09e3b377..036f4b81 100644 --- a/crates/bashkit-python/tests/test_bashkit.py +++ b/crates/bashkit-python/tests/test_bashkit.py @@ -91,19 +91,26 @@ def test_bash_fs_handle_bytes_roundtrip(): assert fs.exists("/data/blob.bin") is True -def test_bash_mount_text_and_readonly_text(): +def test_bash_files_dict(): bash = Bash( - mount_text=[("/config/app.conf", "debug=true\n")], - mount_readonly_text=[("/etc/version", "1.2.3\n")], + files={"/config/app.conf": "debug=true\n", "/etc/version": "1.2.3\n"}, ) assert bash.execute_sync("cat /config/app.conf").stdout == "debug=true\n" assert bash.execute_sync("cat /etc/version").stdout == "1.2.3\n" - mode = bash.fs().stat("/etc/version")["mode"] - assert mode == 0o444 -def test_bash_realfs_readwrite_at(tmp_path): - bash = Bash(mount_real_readwrite_at=[(str(tmp_path), "/workspace")]) +def test_bash_mounts_readonly_by_default(tmp_path): + (tmp_path / "data.txt").write_text("original\n") + bash = Bash(mounts=[{"host_path": str(tmp_path), "vfs_path": "/data"}]) + # Can read + assert bash.execute_sync("cat /data/data.txt").stdout == "original\n" + # Write goes to in-memory overlay, host file unchanged + bash.execute_sync("echo modified > /data/data.txt") + assert (tmp_path / "data.txt").read_text() == "original\n" + + +def test_bash_mounts_writable(tmp_path): + bash = Bash(mounts=[{"host_path": str(tmp_path), "vfs_path": "/workspace", "writable": True}]) result = bash.execute_sync("echo 'hello host' > /workspace/hello.txt") assert result.exit_code == 0 assert (tmp_path / "hello.txt").read_text().strip() == "hello host" @@ -113,7 +120,7 @@ def test_bash_live_mount_preserves_state_and_unmounts(tmp_path): bash = Bash() bash.execute_sync("export KEEP=1") - workspace = FileSystem.real(str(tmp_path), readwrite=True) + workspace = FileSystem.real(str(tmp_path), writable=True) bash.mount("/workspace", workspace) bash.execute_sync("echo live > /workspace/live.txt") @@ -172,15 +179,25 @@ def test_bash_unmount_nonexistent_raises(): bash.unmount("/nonexistent") -def test_bash_readonly_text_mount_has_readonly_mode(): - """Readonly text mount gets mode 0o444.""" - bash = Bash(mount_readonly_text=[("/etc/version", "1.0\n")]) - assert bash.fs().stat("/etc/version")["mode"] == 0o444 +def test_bash_mounts_missing_host_path_raises(): + with pytest.raises(Exception, match="host_path"): + Bash(mounts=[{"vfs_path": "/data"}]) + + +def test_bash_mounts_invalid_entry_raises(): + with pytest.raises(Exception): + Bash(mounts=["not a dict"]) + + +def test_bash_files_mount_has_writable_mode(): + """Files dict mounts get writable mode 0o644.""" + bash = Bash(files={"/etc/version": "1.0\n"}) + assert bash.fs().stat("/etc/version")["mode"] == 0o644 def test_filesystem_real_nonexistent_host_path_raises(): with pytest.raises(Exception): - FileSystem.real("/nonexistent_path_that_does_not_exist_abc123", readwrite=True) + FileSystem.real("/nonexistent_path_that_does_not_exist_abc123", writable=True) def test_filesystem_read_nonexistent_file_raises(): @@ -306,7 +323,7 @@ def test_file_persistence(): def test_bashtool_realfs_and_fs_handle(tmp_path): - tool = BashTool(mount_real_readwrite_at=[(str(tmp_path), "/workspace")]) + tool = BashTool(mounts=[{"host_path": str(tmp_path), "vfs_path": "/workspace", "writable": True}]) tool.execute_sync("echo 'from tool' > /workspace/tool.txt") assert (tmp_path / "tool.txt").read_text().strip() == "from tool" assert tool.fs().read_file("/workspace/tool.txt") == b"from tool\n" @@ -316,7 +333,7 @@ def test_bashtool_live_mount_preserves_state(tmp_path): tool = BashTool() tool.execute_sync("export KEEP=1") - workspace = FileSystem.real(str(tmp_path), readwrite=True) + workspace = FileSystem.real(str(tmp_path), writable=True) tool.mount("/workspace", workspace) tool.execute_sync("echo tool > /workspace/tool.txt") diff --git a/specs/003-vfs.md b/specs/003-vfs.md index 9028af68..52c11c6b 100644 --- a/specs/003-vfs.md +++ b/specs/003-vfs.md @@ -482,6 +482,41 @@ All operations return `Result` with IO errors: - `Other("not a directory")` - expected directory (read_dir on file) - `Other("directory not empty")` - non-recursive delete of non-empty dir +## Binding API Parity + +All language bindings (Rust builder, Node/JS, Python) must expose the same +conceptual mount API. The Rust builder is the canonical internal API; bindings +map their idiomatic config shapes onto it. + +### Unified config shape + +``` +files: { "/path": "content" } # text files (writable, in-memory) +mounts: [{ host_path, vfs_path?, writable? }] # real FS (read-only by default) +``` + +### Runtime methods + +All bindings expose: + +- `mount(host_path, vfs_path, writable=false)` — mount real FS at runtime +- `unmount(vfs_path)` — unmount + +### Per-binding mapping + +| Config field | Rust builder | Node/JS | Python | +|---|---|---|---| +| `files` | `mount_text()` | `files: Record` | `files: dict[str, str]` | +| `mounts` | `mount_real_readonly[_at]()` / `mount_real_readwrite[_at]()` | `mounts: [{ hostPath, vfsPath?, writable? }]` | `mounts: [{ "host_path", "vfs_path"?, "writable"? }]` | +| runtime mount | `Bash::mount(path, fs)` | `bash.mount(host, vfs, writable?)` | `bash.mount(vfs, FileSystem.real(host, writable))` | +| runtime unmount | `Bash::unmount(path)` | `bash.unmount(vfs)` | `bash.unmount(vfs)` | + +### Safety defaults + +- Real mounts are **read-only by default** — `writable` defaults to `false` +- Text files are writable (in-memory, sandboxed, no security risk) +- Callers must explicitly opt-in to writable mounts + ## Alternatives Considered ### Real filesystem with chroot diff --git a/specs/012-maintenance.md b/specs/012-maintenance.md index 79bef7a6..8b6fe944 100644 --- a/specs/012-maintenance.md +++ b/specs/012-maintenance.md @@ -98,6 +98,7 @@ dependency rot, or security gaps ship in a release. - Core classes: `Bash`, `BashTool`, `ExecResult`, `ScriptedTool`, `BashError` - Execution methods: `execute`, `execute_sync`, `executeOrThrow`/`execute_or_throw` - Configuration: `username`, `hostname`, `max_commands`, `max_loop_iterations`, `python`, `external_functions`/`external_handler` + - Mount API: `files` dict, `mounts` list (read-only default), runtime `mount`/`unmount` (see `specs/003-vfs.md` § Binding API Parity) - Tool metadata: `name`, `description`, `help`, `system_prompt`, `input_schema`, `output_schema`, `version` - Module functions: `getVersion`/`get_version` - Framework integrations: LangChain available in both bindings