Skip to main content

vm_sdk/
lib.rs

1use std::collections::HashMap;
2use std::io::{BufReader, Read, Write};
3use std::net::TcpStream;
4use std::sync::Arc;
5
6use anyhow::{bail, Result};
7use tokio::sync::{mpsc, oneshot};
8use tracing::info;
9
10// Re-exports
11pub use vm::{default_data_dir, MountConfig};
12pub use vm_proto::{DirEntry, ReadDirResponse, StatResponse, WatchEvent};
13pub use vm_proxy::config::{ExposeHostMapping, NetworkConfig, ProxyConfig, SecretConfig};
14
15// ---------------------------------------------------------------------------
16// Public types
17// ---------------------------------------------------------------------------
18
19/// A raw bidirectional byte stream to a TCP port inside the guest,
20/// established via [`AsyncSandbox::dial_guest`].
21///
22/// Implements blocking [`Read`]/[`Write`] over the host-side (vsock-backed)
23/// connection. For async use, take the inner stream with
24/// [`GuestStream::into_inner`], set it non-blocking, and wrap it with
25/// `tokio::net::TcpStream::from_std`.
26pub struct GuestStream(TcpStream);
27
28impl GuestStream {
29    /// Consume the wrapper and return the underlying host-side stream.
30    pub fn into_inner(self) -> TcpStream {
31        self.0
32    }
33
34    /// Clone the stream. Both handles reference the same guest connection.
35    pub fn try_clone(&self) -> std::io::Result<Self> {
36        self.0.try_clone().map(GuestStream)
37    }
38}
39
40impl Read for GuestStream {
41    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
42        self.0.read(buf)
43    }
44}
45
46impl Write for GuestStream {
47    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
48        self.0.write(buf)
49    }
50
51    fn flush(&mut self) -> std::io::Result<()> {
52        self.0.flush()
53    }
54}
55
56/// Storage backend for the VM's root disk.
57#[derive(Debug, Clone, Default)]
58pub enum StorageMode {
59    /// Direct disk image attachment (CoW clone on macOS, copy on Linux).
60    #[default]
61    Direct,
62    /// Content-addressable chunk store via NBD. Requires the `cas` feature.
63    #[cfg(feature = "cas")]
64    Cas {
65        /// Directory for the CAS chunk store. Defaults to `<data_dir>/cas`.
66        cas_dir: Option<String>,
67    },
68}
69
70/// Configuration for booting a sandbox VM.
71#[derive(Debug, Clone)]
72pub struct SandboxConfig {
73    /// Data directory containing kernel, rootfs, initramfs.
74    /// Defaults to `~/.hanzo/vm`.
75    pub data_dir: Option<String>,
76    /// Number of CPUs. Default: 2.
77    pub cpus: usize,
78    /// Memory in MB. Default: 2048.
79    pub memory_mb: u64,
80    /// Disk size in MB. Default: 4096.
81    pub disk_size_mb: u64,
82    /// Host → guest directory mounts (VirtioFS).
83    pub mounts: Vec<MountConfig>,
84    /// Enable networking via proxy.
85    pub allow_net: bool,
86    /// Secrets for proxy injection.
87    pub secrets: HashMap<String, SecretConfig>,
88    /// Allowed domain patterns for network access.
89    pub allowed_hosts: Vec<String>,
90    /// Port forwards (host → guest).
91    pub ports: Vec<vm_proto::PortMapping>,
92    /// Host ports exposed to the guest via host.vm.internal.
93    pub expose_host: Vec<ExposeHostMapping>,
94    /// Boot from a named checkpoint instead of base rootfs.
95    pub from: Option<String>,
96    /// Storage backend mode. Default: Direct (flat file with CoW).
97    pub storage: StorageMode,
98    /// Default environment variables for all commands.
99    /// Merged with proxy placeholders; caller extra_env overrides both.
100    pub env: HashMap<String, String>,
101}
102
103impl Default for SandboxConfig {
104    fn default() -> Self {
105        Self {
106            data_dir: None,
107            cpus: 2,
108            memory_mb: 2048,
109            disk_size_mb: 4096,
110            mounts: vec![],
111            allow_net: false,
112            secrets: HashMap::new(),
113            allowed_hosts: vec![],
114            ports: vec![],
115            expose_host: vec![],
116            from: None,
117            storage: StorageMode::default(),
118            env: HashMap::new(),
119        }
120    }
121}
122
123/// Result of executing a command in the VM.
124#[derive(Debug)]
125pub struct ExecResult {
126    pub stdout: String,
127    pub stderr: String,
128    pub exit_code: i32,
129}
130
131/// Events from an interactive shell session.
132#[derive(Debug)]
133pub enum ShellEvent {
134    /// Terminal output bytes (PTY stdout).
135    Output(Vec<u8>),
136    /// Process exited with code.
137    Exit(i32),
138    /// Error from guest.
139    Error(String),
140}
141
142/// Writer half of a shell session. Cloneable — used to send input and resize.
143#[derive(Clone)]
144pub struct ShellWriter {
145    writer: Arc<std::sync::Mutex<TcpStream>>,
146}
147
148impl ShellWriter {
149    /// Send input bytes (keystrokes) to the shell.
150    pub fn send_input(&self, data: &[u8]) -> Result<()> {
151        use std::io::Write;
152        let mut w = self.writer.lock().unwrap();
153        vm_proto::frame::write_frame(&mut *w, vm_proto::frame::STDIN, data)?;
154        w.flush()?;
155        Ok(())
156    }
157
158    /// Send a terminal resize event.
159    pub fn resize(&self, rows: u16, cols: u16) -> Result<()> {
160        let mut w = self.writer.lock().unwrap();
161        let payload = vm_proto::frame::resize_payload(rows, cols);
162        vm_proto::frame::write_frame(&mut *w, vm_proto::frame::RESIZE, &payload)?;
163        Ok(())
164    }
165}
166
167/// Reader half of a shell session. Receives output events asynchronously.
168pub struct ShellReader {
169    output_rx: mpsc::UnboundedReceiver<ShellEvent>,
170}
171
172impl ShellReader {
173    /// Receive the next shell event. Returns `None` when the session ends.
174    pub async fn recv(&mut self) -> Option<ShellEvent> {
175        self.output_rx.recv().await
176    }
177}
178
179/// Handle to an interactive shell session with PTY support.
180pub struct ShellHandle {
181    writer: ShellWriter,
182    reader: ShellReader,
183    _reader_thread: std::thread::JoinHandle<()>,
184}
185
186impl ShellHandle {
187    /// Split into writer (cloneable, for input) and reader (for output).
188    pub fn split(self) -> (ShellWriter, ShellReader) {
189        // Leak the thread handle so it runs to completion
190        std::mem::forget(self._reader_thread);
191        (self.writer, self.reader)
192    }
193}
194
195/// Reader half of a file watch session. Receives filesystem events asynchronously.
196pub struct WatchReceiver {
197    event_rx: mpsc::UnboundedReceiver<WatchEvent>,
198}
199
200impl WatchReceiver {
201    /// Receive the next watch event. Returns `None` when the watch ends.
202    pub async fn recv(&mut self) -> Option<WatchEvent> {
203        self.event_rx.recv().await
204    }
205
206    /// Try to receive a buffered event without blocking.
207    pub fn try_recv(&mut self) -> Result<WatchEvent, tokio::sync::mpsc::error::TryRecvError> {
208        self.event_rx.try_recv()
209    }
210}
211
212/// Handle to a filesystem watch session. Drop to stop watching.
213pub struct WatchHandle {
214    pub receiver: WatchReceiver,
215    _reader_thread: std::thread::JoinHandle<()>,
216}
217
218// ---------------------------------------------------------------------------
219// Internal command protocol (async ↔ VM thread)
220// ---------------------------------------------------------------------------
221
222enum SandboxCmd {
223    Exec {
224        argv: Vec<String>,
225        reply: oneshot::Sender<Result<ExecResult>>,
226    },
227    ReadFile {
228        path: String,
229        reply: oneshot::Sender<Result<Vec<u8>>>,
230    },
231    WriteFile {
232        path: String,
233        content: Vec<u8>,
234        reply: oneshot::Sender<Result<()>>,
235    },
236    ReadDir {
237        path: String,
238        reply: oneshot::Sender<Result<ReadDirResponse>>,
239    },
240    Mkdir {
241        path: String,
242        recursive: bool,
243        reply: oneshot::Sender<Result<()>>,
244    },
245    Rename {
246        old_path: String,
247        new_path: String,
248        reply: oneshot::Sender<Result<()>>,
249    },
250    Chmod {
251        path: String,
252        mode: u32,
253        reply: oneshot::Sender<Result<()>>,
254    },
255    Download {
256        url: String,
257        path: String,
258        extract: bool,
259        strip_components: u32,
260        progress_tx: std::sync::mpsc::Sender<vm_proto::DownloadProgress>,
261        reply: oneshot::Sender<Result<()>>,
262    },
263    OpenShell {
264        argv: Option<Vec<String>>,
265        rows: u16,
266        cols: u16,
267        cwd: Option<String>,
268        extra_env: HashMap<String, String>,
269        reply: oneshot::Sender<Result<TcpStream>>,
270    },
271    Watch {
272        path: String,
273        recursive: bool,
274        reply: oneshot::Sender<Result<TcpStream>>,
275    },
276    Remove {
277        path: String,
278        recursive: bool,
279        reply: oneshot::Sender<Result<()>>,
280    },
281    DiscardOverlay {
282        path: String,
283        reply: oneshot::Sender<Result<()>>,
284    },
285    AddPortForward {
286        mapping: vm_proto::PortMapping,
287        reply: oneshot::Sender<Result<()>>,
288    },
289    DialGuest {
290        port: u16,
291        reply: oneshot::Sender<Result<TcpStream>>,
292    },
293    Checkpoint {
294        name: String,
295        reply: oneshot::Sender<Result<()>>,
296    },
297    Stop {
298        reply: oneshot::Sender<Result<()>>,
299    },
300}
301
302// ---------------------------------------------------------------------------
303// AsyncSandbox — the main public interface
304// ---------------------------------------------------------------------------
305
306/// Async wrapper around a Hanzo VM VM sandbox.
307///
308/// All VM operations are dispatched to a dedicated OS thread that owns
309/// the sandbox. This avoids Send/Sync constraints from the Apple
310/// Virtualization framework's Objective-C objects.
311pub struct AsyncSandbox {
312    cmd_tx: std::sync::mpsc::Sender<SandboxCmd>,
313    instance_dir: String,
314}
315
316impl AsyncSandbox {
317    /// Boot a new sandbox VM with the given configuration.
318    pub async fn boot(config: SandboxConfig) -> Result<Self> {
319        let (ready_tx, ready_rx) = oneshot::channel::<Result<String>>();
320        let (cmd_tx, cmd_rx) = std::sync::mpsc::channel();
321
322        std::thread::Builder::new()
323            .name("hanzo-vm".into())
324            .spawn(move || match boot_vm(config) {
325                Ok(booted) => {
326                    if ready_tx.send(Ok(booted.instance_dir.clone())).is_err() {
327                        return;
328                    }
329                    run_vm_loop(
330                        booted.sandbox,
331                        &booted.instance_dir,
332                        &booted.data_dir,
333                        cmd_rx,
334                        booted.proxy_handle,
335                        booted.fwd_handle,
336                        #[cfg(feature = "cas")]
337                        booted.nbd_handle,
338                        booted.default_env,
339                    );
340                }
341                Err(e) => {
342                    let _ = ready_tx.send(Err(e));
343                }
344            })?;
345
346        let instance_dir = ready_rx.await??;
347
348        Ok(Self {
349            cmd_tx,
350            instance_dir,
351        })
352    }
353
354    /// Execute a command and wait for the result.
355    pub async fn exec(&self, argv: &[&str]) -> Result<ExecResult> {
356        let (reply_tx, reply_rx) = oneshot::channel();
357        self.cmd_tx
358            .send(SandboxCmd::Exec {
359                argv: argv.iter().map(|s| s.to_string()).collect(),
360                reply: reply_tx,
361            })
362            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
363        reply_rx.await?
364    }
365
366    /// Execute a command string via the given shell (defaults to `/bin/sh`).
367    pub async fn exec_shell(&self, command: &str) -> Result<ExecResult> {
368        self.exec_in("/bin/sh", command).await
369    }
370
371    /// Execute a command string via a specific shell.
372    ///
373    /// Use `exec_in("bash", cmd)` when you need login profile (PATH etc.),
374    /// or `exec_in("/bin/sh", cmd)` for basic POSIX shell.
375    pub async fn exec_in(&self, shell: &str, command: &str) -> Result<ExecResult> {
376        self.exec(&[shell, "-c", command]).await
377    }
378
379    /// Spawn an interactive PTY session.
380    /// If `argv` is None, opens a login shell (`bash -l`).
381    /// If `argv` is Some, runs that command directly (no shell wrapper).
382    /// If `cwd` is Some, the process starts in that directory.
383    pub async fn open_shell(
384        &self,
385        rows: u16,
386        cols: u16,
387        cwd: Option<&str>,
388        argv: Option<&[&str]>,
389        extra_env: HashMap<String, String>,
390    ) -> Result<ShellHandle> {
391        let (reply_tx, reply_rx) = oneshot::channel();
392        self.cmd_tx
393            .send(SandboxCmd::OpenShell {
394                argv: argv.map(|a| a.iter().map(|s| s.to_string()).collect()),
395                rows,
396                cols,
397                cwd: cwd.map(|s| s.to_string()),
398                extra_env,
399                reply: reply_tx,
400            })
401            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
402        let stream = reply_rx.await??;
403
404        // Split the stream for bidirectional I/O
405        let writer_stream = stream.try_clone()?;
406        let reader_stream = stream;
407
408        let (event_tx, event_rx) = mpsc::unbounded_channel();
409
410        let reader_thread = std::thread::Builder::new()
411            .name("vm-shell-reader".into())
412            .spawn(move || {
413                let mut reader = BufReader::new(reader_stream);
414                loop {
415                    match vm_proto::frame::read_frame(&mut reader) {
416                        Ok(Some((vm_proto::frame::STDOUT, payload))) => {
417                            if event_tx.send(ShellEvent::Output(payload)).is_err() {
418                                break;
419                            }
420                        }
421                        Ok(Some((vm_proto::frame::EXIT, payload))) => {
422                            let code = vm_proto::frame::parse_exit_code(&payload).unwrap_or(0);
423                            let _ = event_tx.send(ShellEvent::Exit(code));
424                            break;
425                        }
426                        Ok(Some((vm_proto::frame::ERROR, payload))) => {
427                            let msg = String::from_utf8_lossy(&payload).to_string();
428                            let _ = event_tx.send(ShellEvent::Error(msg));
429                            break;
430                        }
431                        Ok(Some(_)) => {} // skip unknown frame types
432                        Ok(None) | Err(_) => break,
433                    }
434                }
435            })?;
436
437        Ok(ShellHandle {
438            writer: ShellWriter {
439                writer: Arc::new(std::sync::Mutex::new(writer_stream)),
440            },
441            reader: ShellReader {
442                output_rx: event_rx,
443            },
444            _reader_thread: reader_thread,
445        })
446    }
447
448    /// Read a file from the VM. Returns raw bytes.
449    pub async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
450        let (reply_tx, reply_rx) = oneshot::channel();
451        self.cmd_tx
452            .send(SandboxCmd::ReadFile {
453                path: path.to_string(),
454                reply: reply_tx,
455            })
456            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
457        reply_rx.await?
458    }
459
460    /// Write a file to the VM.
461    pub async fn write_file(&self, path: &str, content: &[u8]) -> Result<()> {
462        let (reply_tx, reply_rx) = oneshot::channel();
463        self.cmd_tx
464            .send(SandboxCmd::WriteFile {
465                path: path.to_string(),
466                content: content.to_vec(),
467                reply: reply_tx,
468            })
469            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
470        reply_rx.await?
471    }
472
473    /// Discard overlay changes for a file. Removes it from the overlay upper dir,
474    /// revealing the original host version from the lower layer.
475    pub async fn discard_overlay(&self, path: &str) -> Result<()> {
476        let (reply_tx, reply_rx) = oneshot::channel();
477        self.cmd_tx
478            .send(SandboxCmd::DiscardOverlay {
479                path: path.to_string(),
480                reply: reply_tx,
481            })
482            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
483        reply_rx.await?
484    }
485
486    /// Add a port forward to a running sandbox.
487    pub async fn add_port_forward(&self, host_port: u16, guest_port: u16) -> Result<()> {
488        let (reply_tx, reply_rx) = oneshot::channel();
489        self.cmd_tx
490            .send(SandboxCmd::AddPortForward {
491                mapping: vm_proto::PortMapping {
492                    host_port,
493                    guest_port,
494                },
495                reply: reply_tx,
496            })
497            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
498        reply_rx.await?
499    }
500
501    /// Open a raw byte stream to a TCP port listening inside the guest.
502    ///
503    /// This is the ingress counterpart to outbound proxying: it lets host-side
504    /// code reach a server running in the sandbox without binding a local
505    /// listener (unlike [`add_port_forward`](Self::add_port_forward)). The
506    /// returned [`GuestStream`] is a blocking bidirectional pipe to
507    /// `127.0.0.1:<port>` inside the guest, suitable for bridging the guest
508    /// service onto any transport (for example, a tunnel to the public
509    /// internet). It works whether or not `--allow-net` is enabled.
510    ///
511    /// For async I/O, take the inner stream with [`GuestStream::into_inner`],
512    /// set it non-blocking, and wrap it with `tokio::net::TcpStream::from_std`.
513    pub async fn dial_guest(&self, port: u16) -> Result<GuestStream> {
514        let (reply_tx, reply_rx) = oneshot::channel();
515        self.cmd_tx
516            .send(SandboxCmd::DialGuest {
517                port,
518                reply: reply_tx,
519            })
520            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
521        let stream = reply_rx.await??;
522        Ok(GuestStream(stream))
523    }
524
525    /// Remove a file or directory inside the VM.
526    pub async fn remove(&self, path: &str, recursive: bool) -> Result<()> {
527        let (reply_tx, reply_rx) = oneshot::channel();
528        self.cmd_tx
529            .send(SandboxCmd::Remove {
530                path: path.to_string(),
531                recursive,
532                reply: reply_tx,
533            })
534            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
535        reply_rx.await?
536    }
537
538    /// List directory contents in the VM.
539    pub async fn read_dir(&self, path: &str) -> Result<ReadDirResponse> {
540        let (reply_tx, reply_rx) = oneshot::channel();
541        self.cmd_tx
542            .send(SandboxCmd::ReadDir {
543                path: path.to_string(),
544                reply: reply_tx,
545            })
546            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
547        reply_rx.await?
548    }
549
550    /// Create a directory inside the VM. If `recursive` is true, creates
551    /// parent directories as needed (like `mkdir -p`).
552    pub async fn mkdir(&self, path: &str, recursive: bool) -> Result<()> {
553        let (reply_tx, reply_rx) = oneshot::channel();
554        self.cmd_tx
555            .send(SandboxCmd::Mkdir {
556                path: path.to_string(),
557                recursive,
558                reply: reply_tx,
559            })
560            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
561        reply_rx.await?
562    }
563
564    /// Rename a file or directory inside the VM.
565    pub async fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
566        let (reply_tx, reply_rx) = oneshot::channel();
567        self.cmd_tx
568            .send(SandboxCmd::Rename {
569                old_path: old_path.to_string(),
570                new_path: new_path.to_string(),
571                reply: reply_tx,
572            })
573            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
574        reply_rx.await?
575    }
576
577    /// Change file permissions inside the VM.
578    pub async fn chmod(&self, path: &str, mode: u32) -> Result<()> {
579        let (reply_tx, reply_rx) = oneshot::channel();
580        self.cmd_tx
581            .send(SandboxCmd::Chmod {
582                path: path.to_string(),
583                mode,
584                reply: reply_tx,
585            })
586            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
587        reply_rx.await?
588    }
589
590    /// Download a URL into the sandbox. If `extract` is true, the download
591    /// is treated as a .tar.gz and extracted to `path` as a directory.
592    /// `strip_components` mirrors `tar --strip-components=N` — set to `1`
593    /// for tarballs wrapped in a single top-level directory (Node, Pi)
594    /// and `0` for flat tarballs (single binary at root).
595    /// Returns a channel that receives progress updates.
596    pub async fn download(
597        &self,
598        url: &str,
599        path: &str,
600        extract: bool,
601        strip_components: u32,
602    ) -> Result<(
603        tokio::sync::oneshot::Receiver<Result<()>>,
604        std::sync::mpsc::Receiver<vm_proto::DownloadProgress>,
605    )> {
606        let (reply_tx, reply_rx) = oneshot::channel();
607        let (progress_tx, progress_rx) = std::sync::mpsc::channel();
608        self.cmd_tx
609            .send(SandboxCmd::Download {
610                url: url.to_string(),
611                path: path.to_string(),
612                extract,
613                strip_components,
614                progress_tx,
615                reply: reply_tx,
616            })
617            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
618        Ok((reply_rx, progress_rx))
619    }
620
621    /// Save the current rootfs state as a named checkpoint (CoW clone).
622    /// Future VMs can boot from this checkpoint via `SandboxConfig::from`.
623    pub async fn checkpoint(&self, name: &str) -> Result<()> {
624        let (reply_tx, reply_rx) = oneshot::channel();
625        self.cmd_tx
626            .send(SandboxCmd::Checkpoint {
627                name: name.to_string(),
628                reply: reply_tx,
629            })
630            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
631        reply_rx.await?
632    }
633
634    /// Watch a path inside the VM for filesystem changes (inotify-backed).
635    /// Returns a handle whose receiver emits `WatchEvent`s. Drop the handle
636    /// to stop watching.
637    pub async fn open_watch(&self, path: &str, recursive: bool) -> Result<WatchHandle> {
638        let (reply_tx, reply_rx) = oneshot::channel();
639        self.cmd_tx
640            .send(SandboxCmd::Watch {
641                path: path.to_string(),
642                recursive,
643                reply: reply_tx,
644            })
645            .map_err(|_| anyhow::anyhow!("VM thread exited"))?;
646        let stream = reply_rx.await??;
647
648        let (event_tx, event_rx) = mpsc::unbounded_channel();
649
650        let reader_thread = std::thread::Builder::new()
651            .name("vm-watch-reader".into())
652            .spawn(move || {
653                let mut reader = BufReader::new(stream);
654                loop {
655                    match vm_proto::frame::read_frame(&mut reader) {
656                        Ok(Some((vm_proto::frame::WATCH_EVENT, payload))) => {
657                            if let Some(event) = WatchEvent::decode(&payload) {
658                                if event_tx.send(event).is_err() {
659                                    break;
660                                }
661                            }
662                        }
663                        Ok(Some(_)) => {} // skip unknown frame types
664                        Ok(None) | Err(_) => break,
665                    }
666                }
667            })?;
668
669        Ok(WatchHandle {
670            receiver: WatchReceiver { event_rx },
671            _reader_thread: reader_thread,
672        })
673    }
674
675    /// Stop the VM and clean up resources.
676    pub async fn stop(&self) -> Result<()> {
677        let (reply_tx, reply_rx) = oneshot::channel();
678        let _ = self.cmd_tx.send(SandboxCmd::Stop { reply: reply_tx });
679        reply_rx.await.unwrap_or(Ok(()))
680    }
681
682    /// Get the instance directory path (contains the working rootfs copy).
683    pub fn instance_dir(&self) -> &str {
684        &self.instance_dir
685    }
686}
687
688impl Drop for AsyncSandbox {
689    fn drop(&mut self) {
690        // Signal the VM thread to stop
691        let (reply_tx, _) = oneshot::channel();
692        let _ = self.cmd_tx.send(SandboxCmd::Stop { reply: reply_tx });
693        // Clean up instance directory
694        let _ = std::fs::remove_dir_all(&self.instance_dir);
695    }
696}
697
698// ---------------------------------------------------------------------------
699// Internal: VM boot & command loop (runs on dedicated OS thread)
700// ---------------------------------------------------------------------------
701
702use std::sync::atomic::{AtomicU64, Ordering};
703
704/// Monotonic counter to ensure each SDK sandbox gets a unique instance directory,
705/// even when multiple sandboxes boot concurrently in the same process.
706static INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0);
707
708#[cfg(target_os = "macos")]
709extern "C" {
710    fn clonefile(src: *const libc::c_char, dst: *const libc::c_char, flags: u32) -> libc::c_int;
711}
712
713#[cfg(target_os = "macos")]
714fn clone_file_cow(src: &str, dst: &str) -> Result<()> {
715    use anyhow::Context as _;
716    let c_src = std::ffi::CString::new(src).context("invalid source path")?;
717    let c_dst = std::ffi::CString::new(dst).context("invalid destination path")?;
718    let ret = unsafe { clonefile(c_src.as_ptr(), c_dst.as_ptr(), 0) };
719    if ret != 0 {
720        let err = std::io::Error::last_os_error();
721        bail!("clonefile({} -> {}) failed: {}", src, dst, err);
722    }
723    Ok(())
724}
725
726#[cfg(target_os = "linux")]
727use vm::clone_file as clone_file_cow;
728
729struct BootedVm {
730    sandbox: vm::Sandbox,
731    instance_dir: String,
732    data_dir: String,
733    proxy_handle: Option<vm_proxy::ProxyHandle>,
734    fwd_handle: Option<vm::PortForwardHandle>,
735    #[cfg(feature = "cas")]
736    nbd_handle: Option<vm_store::NbdHandle>,
737    default_env: HashMap<String, String>,
738}
739
740fn boot_vm(config: SandboxConfig) -> Result<BootedVm> {
741    let default_env = config.env.clone();
742    let data_dir = config.data_dir.unwrap_or_else(vm::default_data_dir);
743
744    // Resolve asset paths
745    let kernel_path = format!("{}/Image", data_dir);
746    let rootfs_path = format!("{}/rootfs.ext4", data_dir);
747    let initrd_path_str = format!("{}/initramfs.cpio.gz", data_dir);
748
749    if !std::path::Path::new(&kernel_path).exists() {
750        bail!(
751            "Kernel not found at {}. Run `hanzo-vm init` to download.",
752            kernel_path
753        );
754    }
755
756    // Determine rootfs source (checkpoint or base)
757    let checkpoints_dir = format!("{}/checkpoints", data_dir);
758    #[allow(unused_mut, unused_variables)]
759    let mut cas_index: Option<String> = None;
760
761    let source = match &config.from {
762        Some(name) => {
763            vm::validate_checkpoint_name(name).map_err(|e| anyhow::anyhow!(e))?;
764
765            // Check .idx (CAS) first, then .ext4 (legacy)
766            #[cfg(feature = "cas")]
767            {
768                let idx_path = format!("{}/{}.idx", checkpoints_dir, name);
769                let ext4_path = format!("{}/{}.ext4", checkpoints_dir, name);
770                if std::path::Path::new(&idx_path).exists() {
771                    cas_index = Some(idx_path.clone());
772                    idx_path
773                } else if std::path::Path::new(&ext4_path).exists() {
774                    ext4_path
775                } else {
776                    bail!("Checkpoint '{}' not found", name);
777                }
778            }
779
780            #[cfg(not(feature = "cas"))]
781            {
782                let path = format!("{}/{}.ext4", checkpoints_dir, name);
783                if !std::path::Path::new(&path).exists() {
784                    bail!("Checkpoint '{}' not found", name);
785                }
786                path
787            }
788        }
789        None => {
790            if !std::path::Path::new(&rootfs_path).exists() {
791                bail!(
792                    "Rootfs not found at {}. Run `hanzo-vm init` to download.",
793                    rootfs_path
794                );
795            }
796            rootfs_path
797        }
798    };
799
800    // Determine whether to use NBD-backed CAS storage
801    #[cfg(feature = "cas")]
802    let use_nbd = cas_index.is_some() || matches!(config.storage, StorageMode::Cas { .. });
803    #[cfg(not(feature = "cas"))]
804    let use_nbd = false;
805
806    // Create per-instance working copy.
807    // Use PID + atomic counter so concurrent boots in the same process
808    // each get their own directory (avoids remove_dir_all racing).
809    let seq = INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed);
810    let instance_dir = format!("{}/instances/sdk-{}-{}", data_dir, std::process::id(), seq,);
811    std::fs::create_dir_all(&instance_dir)?;
812    let work_rootfs = format!("{}/rootfs.ext4", instance_dir);
813
814    if use_nbd {
815        // CAS mode: NBD server handles I/O, just need a placeholder file
816        std::fs::File::create(&work_rootfs)?;
817    } else {
818        // Direct mode: CoW clone the source rootfs
819        clone_file_cow(&source, &work_rootfs)?;
820    }
821
822    // Extend disk to requested size (only meaningful for direct mode)
823    if !use_nbd {
824        let f = std::fs::OpenOptions::new().write(true).open(&work_rootfs)?;
825        let target = config.disk_size_mb * 1024 * 1024;
826        let current = f.metadata()?.len();
827        if target > current {
828            f.set_len(target)?;
829        }
830        drop(f);
831    }
832
833    // Start CAS NBD server if needed
834    #[cfg(feature = "cas")]
835    let nbd_handle = if use_nbd {
836        let socket_path = format!("{}/nbd.sock", instance_dir);
837        let cas_dir_str = match &config.storage {
838            StorageMode::Cas { cas_dir } => cas_dir
839                .clone()
840                .unwrap_or_else(|| format!("{}/cas", data_dir)),
841            _ => format!("{}/cas", data_dir),
842        };
843        let index_path = if let Some(ref idx) = cas_index {
844            idx.clone()
845        } else {
846            let source_hash = blake3::hash(source.as_bytes()).to_hex();
847            format!("{}/cas/indexes/{}.idx", data_dir, &source_hash[..16])
848        };
849        let target_size = config.disk_size_mb * 1024 * 1024;
850        Some(vm_store::start_cas_nbd_server(
851            &source,
852            &cas_dir_str,
853            &index_path,
854            &socket_path,
855            target_size,
856        )?)
857    } else {
858        None
859    };
860
861    let initrd_path = if std::path::Path::new(&initrd_path_str).exists() {
862        Some(initrd_path_str)
863    } else {
864        None
865    };
866
867    // Set up proxy networking if enabled
868    let (vm_fd, proxy_handle) = if config.allow_net {
869        let proxy_config = ProxyConfig {
870            secrets: config.secrets,
871            expose_host: config.expose_host,
872            network: NetworkConfig {
873                allow: config.allowed_hosts,
874            },
875            ..Default::default()
876        };
877
878        let (vm_fd, host_fd) = vm_proxy::create_socketpair()?;
879        let handle = vm_proxy::start(host_fd, proxy_config)?;
880        (Some(vm_fd), Some(handle))
881    } else {
882        (None, None)
883    };
884
885    // Build the VM
886    let mut builder = vm::Sandbox::builder()
887        .kernel(&kernel_path)
888        .rootfs(&work_rootfs)
889        .cpus(config.cpus)
890        .memory_mb(config.memory_mb)
891        .console(false); // No serial console in SDK mode
892
893    if let Some(fd) = vm_fd {
894        builder = builder.network_fd(fd);
895    }
896    #[cfg(feature = "cas")]
897    if let Some(ref handle) = nbd_handle {
898        builder = builder.nbd_uri(handle.uri());
899    }
900    if let Some(ref initrd) = initrd_path {
901        builder = builder.initrd(initrd);
902    }
903    for m in &config.mounts {
904        builder = builder.mount(m.clone());
905    }
906
907    let sandbox = builder.build()?;
908
909    info!(
910        "booting VM ({}cpus, {}MB RAM, {}MB disk, storage={:?})",
911        config.cpus, config.memory_mb, config.disk_size_mb, config.storage
912    );
913
914    sandbox.start()?;
915
916    // Start port forwarding
917    let fwd_handle = if !config.ports.is_empty() {
918        Some(sandbox.start_port_forwarding(&config.ports)?)
919    } else {
920        None
921    };
922
923    // Inject CA cert and secret placeholders when proxy is active
924    if let Some(ref handle) = proxy_handle {
925        if !handle.placeholders.is_empty() {
926            sandbox.write_file(
927                "/usr/local/share/ca-certificates/vm-proxy.crt",
928                &handle.ca_cert_pem,
929            )?;
930            sandbox.exec(
931                &["update-ca-certificates", "--fresh"],
932                &mut std::io::sink(),
933                &mut std::io::sink(),
934            )?;
935        }
936    }
937
938    info!("VM ready");
939
940    Ok(BootedVm {
941        sandbox,
942        instance_dir,
943        data_dir,
944        proxy_handle,
945        fwd_handle,
946        #[cfg(feature = "cas")]
947        nbd_handle,
948        default_env,
949    })
950}
951
952fn run_vm_loop(
953    sandbox: vm::Sandbox,
954    instance_dir: &str,
955    data_dir: &str,
956    cmd_rx: std::sync::mpsc::Receiver<SandboxCmd>,
957    proxy_handle: Option<vm_proxy::ProxyHandle>,
958    _fwd_handle: Option<vm::PortForwardHandle>,
959    #[cfg(feature = "cas")] nbd_handle: Option<vm_store::NbdHandle>,
960    default_env: HashMap<String, String>,
961) {
962    // Base env: config defaults, then proxy placeholders on top
963    let mut env = default_env;
964    if let Some(ref handle) = proxy_handle {
965        env.extend(handle.placeholders.clone());
966    }
967
968    // Keep proxy_handle alive for the lifetime of the VM
969    let _proxy = proxy_handle;
970    // Additional port forward handles added at runtime
971    let mut extra_fwd_handles: Vec<vm::PortForwardHandle> = Vec::new();
972
973    // Wrap in Arc so read-type commands (ReadFile/ReadDir) can fan out to
974    // worker threads and run concurrently — each `sandbox.read_file` opens
975    // its own vsock connection, so there's no contention to serialize.
976    let sandbox = Arc::new(sandbox);
977
978    while let Ok(cmd) = cmd_rx.recv() {
979        match cmd {
980            SandboxCmd::Exec { argv, reply } => {
981                let result = exec_command(&sandbox, &argv, &env);
982                let _ = reply.send(result);
983            }
984            // Reads open their own vsock connection per call and don't mutate
985            // state, so fan them out onto worker threads — otherwise a burst
986            // of UI stats requests serializes behind the dispatcher.
987            SandboxCmd::ReadFile { path, reply } => {
988                let sb = sandbox.clone();
989                std::thread::spawn(move || {
990                    let _ = reply.send(sb.read_file(&path));
991                });
992            }
993            SandboxCmd::ReadDir { path, reply } => {
994                let sb = sandbox.clone();
995                std::thread::spawn(move || {
996                    let _ = reply.send(sb.read_dir(&path));
997                });
998            }
999            SandboxCmd::WriteFile {
1000                path,
1001                content,
1002                reply,
1003            } => {
1004                let _ = reply.send(sandbox.write_file(&path, &content));
1005            }
1006            SandboxCmd::Mkdir {
1007                path,
1008                recursive,
1009                reply,
1010            } => {
1011                let _ = reply.send(sandbox.mkdir(&path, recursive));
1012            }
1013            SandboxCmd::Rename {
1014                old_path,
1015                new_path,
1016                reply,
1017            } => {
1018                let _ = reply.send(sandbox.rename(&old_path, &new_path));
1019            }
1020            SandboxCmd::Chmod { path, mode, reply } => {
1021                let _ = reply.send(sandbox.chmod(&path, mode));
1022            }
1023            SandboxCmd::Download {
1024                url,
1025                path,
1026                extract,
1027                strip_components,
1028                progress_tx,
1029                reply,
1030            } => {
1031                let result = sandbox.download(&url, &path, extract, strip_components, |p| {
1032                    let _ = progress_tx.send(p);
1033                });
1034                let _ = reply.send(result);
1035            }
1036            SandboxCmd::OpenShell {
1037                argv,
1038                rows,
1039                cols,
1040                cwd,
1041                extra_env,
1042                reply,
1043            } => {
1044                let default_argv = ["/bin/bash".to_string(), "-l".to_string()];
1045                let shell_argv: Vec<String> = argv.unwrap_or_else(|| default_argv.to_vec());
1046                let shell_argv_refs: Vec<&str> = shell_argv.iter().map(|s| s.as_str()).collect();
1047                let mut merged_env = env.clone();
1048                merged_env.extend(extra_env);
1049                let result = sandbox.open_shell_with_cwd(
1050                    &shell_argv_refs,
1051                    &merged_env,
1052                    rows,
1053                    cols,
1054                    cwd.as_deref(),
1055                );
1056                let _ = reply.send(result);
1057            }
1058            SandboxCmd::Watch {
1059                path,
1060                recursive,
1061                reply,
1062            } => {
1063                let result = sandbox.open_watch(&path, recursive);
1064                let _ = reply.send(result);
1065            }
1066            SandboxCmd::Remove {
1067                path,
1068                recursive,
1069                reply,
1070            } => {
1071                let _ = reply.send(sandbox.remove(&path, recursive));
1072            }
1073            SandboxCmd::DiscardOverlay { path, reply } => {
1074                let _ = reply.send(sandbox.discard_overlay(&path));
1075            }
1076            SandboxCmd::AddPortForward { mapping, reply } => {
1077                let result = sandbox.start_port_forwarding(&[mapping]);
1078                match result {
1079                    Ok(handle) => {
1080                        extra_fwd_handles.push(handle);
1081                        let _ = reply.send(Ok(()));
1082                    }
1083                    Err(e) => {
1084                        let _ = reply.send(Err(e));
1085                    }
1086                }
1087            }
1088            SandboxCmd::DialGuest { port, reply } => {
1089                // Handshake blocks on the guest — run off the command loop.
1090                let sb = sandbox.clone();
1091                std::thread::spawn(move || {
1092                    let _ = reply.send(sb.connect_forward(port));
1093                });
1094            }
1095            SandboxCmd::Checkpoint { name, reply } => {
1096                let result = (|| -> Result<()> {
1097                    vm::validate_checkpoint_name(&name).map_err(|e| anyhow::anyhow!(e))?;
1098                    let checkpoints_dir = format!("{}/checkpoints", data_dir);
1099                    std::fs::create_dir_all(&checkpoints_dir)?;
1100
1101                    // CAS path: save as .idx via NbdHandle
1102                    #[cfg(feature = "cas")]
1103                    if let Some(ref handle) = nbd_handle {
1104                        let index_path = format!("{}/{}.idx", checkpoints_dir, name);
1105                        if std::path::Path::new(&index_path).exists() {
1106                            std::fs::remove_file(&index_path)?;
1107                        }
1108                        handle.save_checkpoint(&index_path)?;
1109                        info!("checkpoint '{}' saved (CAS)", name);
1110                        return Ok(());
1111                    }
1112
1113                    // Direct path: save as .ext4 via CoW clone
1114                    let checkpoint_path = format!("{}/{}.ext4", checkpoints_dir, name);
1115                    if std::path::Path::new(&checkpoint_path).exists() {
1116                        std::fs::remove_file(&checkpoint_path)?;
1117                    }
1118                    let work_rootfs = format!("{}/rootfs.ext4", instance_dir);
1119                    clone_file_cow(&work_rootfs, &checkpoint_path)?;
1120                    info!("checkpoint '{}' saved", name);
1121                    Ok(())
1122                })();
1123                let _ = reply.send(result);
1124            }
1125            SandboxCmd::Stop { reply } => {
1126                let _ = reply.send(sandbox.stop());
1127                break;
1128            }
1129        }
1130    }
1131
1132    // Ensure cleanup
1133    let _ = sandbox.stop();
1134}
1135
1136fn exec_command(
1137    sandbox: &vm::Sandbox,
1138    argv: &[String],
1139    env: &HashMap<String, String>,
1140) -> Result<ExecResult> {
1141    let mut stdout_buf = Vec::new();
1142    let mut stderr_buf = Vec::new();
1143    let argv_refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1144
1145    let exit_code = sandbox.exec_with_env(&argv_refs, env, &mut stdout_buf, &mut stderr_buf)?;
1146
1147    Ok(ExecResult {
1148        stdout: String::from_utf8_lossy(&stdout_buf).to_string(),
1149        stderr: String::from_utf8_lossy(&stderr_buf).to_string(),
1150        exit_code,
1151    })
1152}