diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index 6fc37a99..39b04911 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -365,6 +365,60 @@ impl Bash { }) } + // ======================================================================== + // Snapshot / Resume + // ======================================================================== + + /// Serialize interpreter state (shell variables, VFS contents, counters) to bytes. + /// + /// Returns a `Buffer` (Uint8Array) that can be persisted and used with + /// `Bash.fromSnapshot()` to restore the session later. + #[napi] + pub fn snapshot(&self) -> napi::Result { + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + let bytes = bash + .snapshot() + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(napi::bindgen_prelude::Buffer::from(bytes)) + }) + } + + /// Restore interpreter state from a snapshot previously created with `snapshot()`. + #[napi] + pub fn restore_snapshot(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<()> { + block_on_with(&self.state, |s| async move { + let mut bash = s.inner.lock().await; + bash.restore_snapshot(&data) + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } + + /// Create a new Bash instance from a snapshot. + #[napi(factory)] + pub fn from_snapshot(data: napi::bindgen_prelude::Buffer) -> napi::Result { + let bash = + RustBash::from_snapshot(&data).map_err(|e| napi::Error::from_reason(e.to_string()))?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| napi::Error::from_reason(format!("Failed to create runtime: {e}")))?; + Ok(Self { + state: Arc::new(SharedState { + inner: Mutex::new(bash), + rt: tokio::sync::Mutex::new(rt), + cancelled: Arc::new(AtomicBool::new(false)), + username: None, + hostname: None, + max_commands: None, + max_loop_iterations: None, + python: false, + external_functions: Vec::new(), + external_handler: None, + }), + }) + } + // ======================================================================== // VFS — direct filesystem access // ======================================================================== diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index 45583073..8d844ac6 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -282,6 +282,52 @@ export class Bash { this.native.reset(); } + // Snapshot / Resume + + /** + * Serialize interpreter state (variables, VFS, counters) to a Uint8Array. + * + * The snapshot can be persisted to disk, sent over the network, and later + * used with `Bash.fromSnapshot()` to restore the session. + * + * @example + * ```typescript + * const bash = new Bash(); + * await bash.execute("x=42"); + * const snapshot = bash.snapshot(); + * // persist snapshot... + * const bash2 = Bash.fromSnapshot(snapshot); + * const r = await bash2.execute("echo $x"); // "42\n" + * ``` + */ + snapshot(): Uint8Array { + return this.native.snapshot(); + } + + /** + * Restore interpreter state from a previously captured snapshot. + * Preserves current configuration (limits, builtins) but replaces + * shell state and VFS contents. + */ + restoreSnapshot(data: Uint8Array): void { + this.native.restoreSnapshot(Buffer.from(data)); + } + + /** + * Create a new Bash instance from a snapshot. + * + * @example + * ```typescript + * const snapshot = existingBash.snapshot(); + * const restored = Bash.fromSnapshot(snapshot); + * ``` + */ + static fromSnapshot(data: Uint8Array): Bash { + const instance = new Bash(); + instance.native = NativeBash.fromSnapshot(Buffer.from(data)); + return instance; + } + // VFS — direct filesystem access /** Read a file from the virtual filesystem as a UTF-8 string. */ diff --git a/crates/bashkit/src/fs/memory.rs b/crates/bashkit/src/fs/memory.rs index 09135774..54db292d 100644 --- a/crates/bashkit/src/fs/memory.rs +++ b/crates/bashkit/src/fs/memory.rs @@ -1612,6 +1612,15 @@ impl FileSystemExt for InMemoryFs { fn limits(&self) -> FsLimits { self.limits.clone() } + + fn vfs_snapshot(&self) -> Option { + Some(self.snapshot()) + } + + fn vfs_restore(&self, snapshot: &VfsSnapshot) -> bool { + self.restore(snapshot); + true + } } #[cfg(test)] diff --git a/crates/bashkit/src/fs/mountable.rs b/crates/bashkit/src/fs/mountable.rs index c56a15df..a1eabd01 100644 --- a/crates/bashkit/src/fs/mountable.rs +++ b/crates/bashkit/src/fs/mountable.rs @@ -496,6 +496,16 @@ impl FileSystemExt for MountableFs { let (fs, resolved) = self.resolve(path); fs.mkfifo(&resolved, mode).await } + + fn vfs_snapshot(&self) -> Option { + // Delegate to root filesystem + self.root.vfs_snapshot() + } + + fn vfs_restore(&self, snapshot: &super::VfsSnapshot) -> bool { + // Delegate to root filesystem + self.root.vfs_restore(snapshot) + } } #[cfg(test)] diff --git a/crates/bashkit/src/fs/overlay.rs b/crates/bashkit/src/fs/overlay.rs index 5488d248..1cc2ce65 100644 --- a/crates/bashkit/src/fs/overlay.rs +++ b/crates/bashkit/src/fs/overlay.rs @@ -806,6 +806,15 @@ impl FileSystemExt for OverlayFs { fn limits(&self) -> FsLimits { self.limits.clone() } + + fn vfs_snapshot(&self) -> Option { + Some(self.upper.snapshot()) + } + + fn vfs_restore(&self, snapshot: &super::VfsSnapshot) -> bool { + self.upper.restore(snapshot); + true + } } #[cfg(test)] diff --git a/crates/bashkit/src/fs/traits.rs b/crates/bashkit/src/fs/traits.rs index a4176cca..0c0c2f8d 100644 --- a/crates/bashkit/src/fs/traits.rs +++ b/crates/bashkit/src/fs/traits.rs @@ -153,6 +153,23 @@ pub trait FileSystemExt: Send + Sync { fn limits(&self) -> FsLimits { FsLimits::unlimited() } + + /// Take a snapshot of the filesystem contents for serialization. + /// + /// Returns `None` if this filesystem implementation doesn't support snapshots. + /// The default implementation returns `None`. `InMemoryFs` and filesystems + /// wrapping it (e.g. `MountableFs`, `OverlayFs`) return `Some(snapshot)`. + fn vfs_snapshot(&self) -> Option { + None + } + + /// Restore filesystem contents from a snapshot. + /// + /// Returns `false` if this filesystem doesn't support restore. + /// The default implementation returns `false`. + fn vfs_restore(&self, _snapshot: &super::VfsSnapshot) -> bool { + false + } } /// Async virtual filesystem trait. diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 06d42a11..44c2e210 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1004,6 +1004,17 @@ impl Interpreter { self.traps = state.traps.clone(); } + /// Get a reference to the current execution counters. + pub fn counters(&self) -> &crate::limits::ExecutionCounters { + &self.counters + } + + /// Restore session-level counters from a snapshot. + pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) { + self.counters.session_commands = session_commands; + self.counters.session_exec_calls = session_exec_calls; + } + /// Set an output callback for streaming output during execution. /// /// When set, the interpreter calls this callback with `(stdout_chunk, stderr_chunk)` diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index a92bec24..4e07c84a 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -412,6 +412,7 @@ pub mod parser; /// Requires the `scripted_tool` feature. #[cfg(feature = "scripted_tool")] pub mod scripted_tool; +mod snapshot; /// Tool contract for LLM integration pub mod tool; /// Structured execution trace events. @@ -434,6 +435,7 @@ pub use limits::{ ExecutionCounters, ExecutionLimits, LimitExceeded, MemoryBudget, MemoryLimits, SessionLimits, }; pub use network::NetworkAllowlist; +pub use snapshot::Snapshot; pub use tool::BashToolBuilder as ToolBuilder; pub use tool::{ BashTool, BashToolBuilder, Tool, ToolError, ToolExecution, ToolImage, ToolOutput, diff --git a/crates/bashkit/src/snapshot.rs b/crates/bashkit/src/snapshot.rs new file mode 100644 index 00000000..10caec62 --- /dev/null +++ b/crates/bashkit/src/snapshot.rs @@ -0,0 +1,199 @@ +// Decision: Snapshot format uses serde_json for Phase 1 (debuggable, human-readable). +// Phase 2 can add bincode/postcard for compactness. +// VFS contents are included by default (opt-out via SnapshotOptions in future). +// Session limit budgets are transferred (not reset) to preserve resource accounting. + +//! Snapshot/resume — serialize interpreter state between `exec()` calls. +//! +//! Captures shell state (variables, env, cwd, arrays, aliases, traps) and +//! VFS contents into a serializable [`Snapshot`] that can be persisted to disk, +//! sent over a network, or used to restore a Bash instance later. +//! +//! # Example +//! +//! ```rust +//! use bashkit::Bash; +//! +//! # #[tokio::main] +//! # async fn main() -> bashkit::Result<()> { +//! let mut bash = Bash::new(); +//! bash.exec("x=42; mkdir /tmp/work").await?; +//! +//! // Snapshot to bytes +//! let snapshot = bash.snapshot()?; +//! +//! // Resume from bytes (possibly in a different process) +//! let mut bash2 = Bash::from_snapshot(&snapshot)?; +//! let result = bash2.exec("echo $x").await?; +//! assert_eq!(result.stdout.trim(), "42"); +//! # Ok(()) +//! # } +//! ``` +//! +//! # What is captured +//! +//! - Shell variables (scalar, indexed arrays, associative arrays) +//! - Environment variables +//! - Current working directory +//! - Last exit code (`$?`) +//! - Shell aliases +//! - Trap handlers +//! - VFS contents (files, directories, symlinks) +//! - Session-level resource counters (commands used, exec calls) +//! +//! # What is NOT captured +//! +//! - Function definitions (AST is not serializable; define functions after restore) +//! - Builtins (immutable configuration, not state) +//! - Active execution stack (snapshot only between `exec()` calls) +//! - Tokio runtime state +//! - File descriptors, pipes, background jobs (ephemeral) +//! - Execution limits configuration (caller should configure on restore) + +use crate::fs::VfsSnapshot; +use crate::interpreter::ShellState; + +/// Schema version for snapshot format compatibility. +const SNAPSHOT_VERSION: u32 = 1; + +/// A serializable snapshot of a Bash interpreter's state. +/// +/// Combines shell state (variables, env, cwd, etc.) with VFS contents +/// into a single serializable unit. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Snapshot { + /// Schema version for forward compatibility. + pub version: u32, + /// Shell interpreter state (variables, env, cwd, aliases, traps, etc.). + pub shell: ShellState, + /// Virtual filesystem contents. `None` if the filesystem doesn't support snapshots. + pub vfs: Option, + /// Session-level command counter (total commands across all prior exec() calls). + pub session_commands: u64, + /// Session-level exec() call counter. + pub session_exec_calls: u64, +} + +impl Snapshot { + /// Serialize this snapshot to JSON bytes. + pub fn to_bytes(&self) -> crate::Result> { + serde_json::to_vec(self).map_err(|e| crate::Error::Internal(e.to_string())) + } + + /// Deserialize a snapshot from JSON bytes. + pub fn from_bytes(data: &[u8]) -> crate::Result { + let snap: Self = + serde_json::from_slice(data).map_err(|e| crate::Error::Internal(e.to_string()))?; + if snap.version != SNAPSHOT_VERSION { + return Err(crate::Error::Internal(format!( + "unsupported snapshot version {} (expected {})", + snap.version, SNAPSHOT_VERSION + ))); + } + Ok(snap) + } +} + +impl crate::Bash { + /// Capture the current interpreter state as a serializable snapshot. + /// + /// The snapshot includes shell state (variables, env, cwd, arrays, aliases, + /// traps) and VFS contents. It can be serialized to bytes with + /// [`Snapshot::to_bytes()`] or directly via `serde_json`. + /// + /// # Errors + /// + /// Returns an error if serialization fails. + /// + /// # Example + /// + /// ```rust + /// use bashkit::Bash; + /// + /// # #[tokio::main] + /// # async fn main() -> bashkit::Result<()> { + /// let mut bash = Bash::new(); + /// bash.exec("x=42; mkdir /work").await?; + /// + /// let bytes = bash.snapshot()?; + /// assert!(!bytes.is_empty()); + /// + /// let mut bash2 = Bash::from_snapshot(&bytes)?; + /// let r = bash2.exec("echo $x").await?; + /// assert_eq!(r.stdout.trim(), "42"); + /// # Ok(()) + /// # } + /// ``` + pub fn snapshot(&self) -> crate::Result> { + let shell = self.interpreter.shell_state(); + let vfs = self.fs.vfs_snapshot(); + let counters = self.interpreter.counters(); + let snap = Snapshot { + version: SNAPSHOT_VERSION, + shell, + vfs, + session_commands: counters.session_commands, + session_exec_calls: counters.session_exec_calls, + }; + snap.to_bytes() + } + + /// Create a new Bash instance restored from a snapshot. + /// + /// Restores shell state and VFS contents from previously captured bytes. + /// The returned instance uses default execution limits and no custom builtins. + /// Configure limits via the builder if needed, then call + /// [`restore_snapshot()`](Self::restore_snapshot) instead. + /// + /// # Errors + /// + /// Returns an error if deserialization fails or the snapshot version is + /// incompatible. + /// + /// # Example + /// + /// ```rust + /// use bashkit::Bash; + /// + /// # #[tokio::main] + /// # async fn main() -> bashkit::Result<()> { + /// let mut bash = Bash::new(); + /// bash.exec("greeting='hello world'").await?; + /// let bytes = bash.snapshot()?; + /// + /// let mut restored = Bash::from_snapshot(&bytes)?; + /// let r = restored.exec("echo $greeting").await?; + /// assert_eq!(r.stdout.trim(), "hello world"); + /// # Ok(()) + /// # } + /// ``` + pub fn from_snapshot(data: &[u8]) -> crate::Result { + let snap = Snapshot::from_bytes(data)?; + let mut bash = Self::new(); + bash.restore_snapshot_inner(&snap); + Ok(bash) + } + + /// Restore state from a snapshot into this Bash instance. + /// + /// Preserves the current instance's configuration (limits, builtins, + /// filesystem type) while restoring shell state and VFS contents. + /// + /// # Errors + /// + /// Returns an error if deserialization fails. + pub fn restore_snapshot(&mut self, data: &[u8]) -> crate::Result<()> { + let snap = Snapshot::from_bytes(data)?; + self.restore_snapshot_inner(&snap); + Ok(()) + } + + fn restore_snapshot_inner(&mut self, snap: &Snapshot) { + self.interpreter.restore_shell_state(&snap.shell); + if let Some(ref vfs) = snap.vfs { + self.fs.vfs_restore(vfs); + } + self.interpreter + .restore_session_counters(snap.session_commands, snap.session_exec_calls); + } +} diff --git a/crates/bashkit/tests/snapshot_tests.rs b/crates/bashkit/tests/snapshot_tests.rs index 47442031..ae0aeb51 100644 --- a/crates/bashkit/tests/snapshot_tests.rs +++ b/crates/bashkit/tests/snapshot_tests.rs @@ -1,6 +1,6 @@ //! Tests for VFS snapshot/restore and shell state snapshot/restore -use bashkit::{Bash, FileSystem, InMemoryFs}; +use bashkit::{Bash, FileSystem, InMemoryFs, Snapshot}; use std::path::Path; use std::sync::Arc; @@ -255,3 +255,135 @@ async fn shell_options_survive_snapshot_roundtrip() { "pipefail should survive snapshot/restore roundtrip" ); } + +// ==================== Byte-level snapshot / from_snapshot ==================== + +#[tokio::test] +async fn snapshot_to_bytes_and_restore() { + let mut bash = Bash::new(); + bash.exec("x=42; mkdir /tmp/work; echo 'data' > /tmp/work/file.txt") + .await + .unwrap(); + + let bytes = bash.snapshot().unwrap(); + assert!(!bytes.is_empty()); + + let mut bash2 = Bash::from_snapshot(&bytes).unwrap(); + + // Verify shell state + let r = bash2.exec("echo $x").await.unwrap(); + assert_eq!(r.stdout.trim(), "42"); + + // Verify VFS contents + let r = bash2.exec("cat /tmp/work/file.txt").await.unwrap(); + assert_eq!(r.stdout.trim(), "data"); +} + +#[tokio::test] +async fn snapshot_preserves_arrays() { + let mut bash = Bash::new(); + bash.exec("arr=(one two three); declare -A map=([k1]=v1 [k2]=v2)") + .await + .unwrap(); + + let bytes = bash.snapshot().unwrap(); + let mut bash2 = Bash::from_snapshot(&bytes).unwrap(); + + let r = bash2.exec("echo ${arr[1]}").await.unwrap(); + assert_eq!(r.stdout.trim(), "two"); + + let r = bash2.exec("echo ${map[k2]}").await.unwrap(); + assert_eq!(r.stdout.trim(), "v2"); +} + +#[tokio::test] +async fn snapshot_preserves_env() { + let mut bash = Bash::new(); + bash.exec("export MY_VAR=hello").await.unwrap(); + + let bytes = bash.snapshot().unwrap(); + let mut bash2 = Bash::from_snapshot(&bytes).unwrap(); + + let r = bash2.exec("echo $MY_VAR").await.unwrap(); + assert_eq!(r.stdout.trim(), "hello"); +} + +#[tokio::test] +async fn snapshot_preserves_cwd() { + let mut bash = Bash::new(); + bash.exec("mkdir -p /project && cd /project").await.unwrap(); + + let bytes = bash.snapshot().unwrap(); + let mut bash2 = Bash::from_snapshot(&bytes).unwrap(); + + let r = bash2.exec("pwd").await.unwrap(); + assert_eq!(r.stdout.trim(), "/project"); +} + +#[tokio::test] +async fn snapshot_restore_into_existing_instance() { + let mut bash = Bash::new(); + bash.exec("x=42; echo 'data' > /tmp/saved.txt") + .await + .unwrap(); + + let bytes = bash.snapshot().unwrap(); + + // Make changes + bash.exec("x=99; echo 'changed' > /tmp/saved.txt") + .await + .unwrap(); + + // Restore into same instance + bash.restore_snapshot(&bytes).unwrap(); + + let r = bash.exec("echo $x").await.unwrap(); + assert_eq!(r.stdout.trim(), "42"); + + let r = bash.exec("cat /tmp/saved.txt").await.unwrap(); + assert_eq!(r.stdout.trim(), "data"); +} + +#[tokio::test] +async fn snapshot_struct_serialization() { + let mut bash = Bash::new(); + bash.exec("greeting='hello world'").await.unwrap(); + + let bytes = bash.snapshot().unwrap(); + let snap = Snapshot::from_bytes(&bytes).unwrap(); + + assert_eq!(snap.version, 1); + assert_eq!( + snap.shell.variables.get("greeting").map(|s| s.as_str()), + Some("hello world") + ); + + // Re-serialize and verify roundtrip + let bytes2 = snap.to_bytes().unwrap(); + let snap2 = Snapshot::from_bytes(&bytes2).unwrap(); + assert_eq!( + snap2.shell.variables.get("greeting"), + snap.shell.variables.get("greeting") + ); +} + +#[tokio::test] +async fn snapshot_invalid_data_returns_error() { + let result = Bash::from_snapshot(b"not valid json"); + assert!(result.is_err()); +} + +#[tokio::test] +async fn snapshot_session_counters_transferred() { + let mut bash = Bash::new(); + // Run some commands to increment session counters + bash.exec("echo 1; echo 2; echo 3").await.unwrap(); + bash.exec("echo 4").await.unwrap(); + + let bytes = bash.snapshot().unwrap(); + let snap = Snapshot::from_bytes(&bytes).unwrap(); + + // Session counters should be > 0 + assert!(snap.session_commands > 0); + assert!(snap.session_exec_calls > 0); +}