feat(sandbox): VM library — pointer catalog + local build via /sbx vmlib
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
Add an installable-VM catalog that ships pointers only (no multi-GB images in the repo). When a VM is chosen it is BUILT LOCALLY on the caller's own machine — nothing is relayed to the room. - scripts/vbox-library.json: 7-entry manifest (Windows 11, macOS Sonoma, Kali, Parrot, Ubuntu 24.04, Fedora 41, Debian 12) with download pointers, ostype, cpu/mem/disk, and build kind. - scripts/vbox-library.sh: --list / --info / --plan / --install. Build kinds: iso (download/--iso + createvm + boot installer; EFI+TPM for Win11), cloudimg (delegate to vbox-new.sh, unattended), ova (import), manual (pointer-only, e.g. macOS per Apple licensing). Detect-first: --plan changes nothing, install refuses to clobber and rolls back half-built VMs, direct URLs fall back to the page + --iso <path>. - sbx.rs: vbox_library()/library_vm()/vbox_library_install() loaders + running_vms() for live-state markers. - app.rs: /sbx vmlib (catalog ✓installed/↓available), /sbx vmlib <id> (pointer/notes), /sbx vmlib <id> install [--iso path] (local build); /sbx vms now flags running VMs (▶). Registered in SBX_SUBCOMMANDS + usage. - ui.rs: help entry for the VM library. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+110
-6
@@ -2291,15 +2291,23 @@ fn handle_command(
|
||||
"VirtualBox isn't installed — install it with `/sbx gui <vm> --install`, or run ./scripts/ensure-vbox.sh".to_string()
|
||||
})?;
|
||||
let vms = sbx::list_vms().map_err(|e| e.to_string())?;
|
||||
Ok::<_, String>((ver, vms))
|
||||
let running = sbx::running_vms();
|
||||
Ok::<_, String>((ver, vms, running))
|
||||
})
|
||||
.await;
|
||||
let _ = match res {
|
||||
Ok(Ok((ver, v))) if !v.is_empty() => tx.send(Net::Sys(format!(
|
||||
Ok(Ok((ver, v, running))) if !v.is_empty() => tx.send(Net::Sys(format!(
|
||||
"VirtualBox {ver} detected · VMs: {}",
|
||||
v.join(", ")
|
||||
v.iter()
|
||||
.map(|n| if running.iter().any(|r| r == n) {
|
||||
format!("{n} ▶")
|
||||
} else {
|
||||
n.clone()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
))),
|
||||
Ok(Ok((ver, _))) => tx.send(Net::Sys(format!(
|
||||
Ok(Ok((ver, _, _))) => tx.send(Net::Sys(format!(
|
||||
"VirtualBox {ver} detected · no VMs registered"
|
||||
))),
|
||||
Ok(Err(e)) => tx.send(Net::Err(e)),
|
||||
@@ -2401,6 +2409,102 @@ fn handle_command(
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("vmlib") => {
|
||||
// VM library: a catalog of pointers to installable VMs (no
|
||||
// images are bundled). Bare `/sbx vmlib` lists the catalog
|
||||
// marking which are already installed locally; `/sbx vmlib <id>`
|
||||
// shows the pointer/notes; `/sbx vmlib <id> install [--iso path]`
|
||||
// builds the chosen VM locally on the caller's own machine.
|
||||
let args: Vec<&str> = p.collect();
|
||||
let iso = args
|
||||
.iter()
|
||||
.position(|a| *a == "--iso")
|
||||
.and_then(|i| args.get(i + 1).copied())
|
||||
.map(str::to_string);
|
||||
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
|
||||
match pos.next() {
|
||||
None => {
|
||||
// Catalog listing
|
||||
let tx = app_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let res =
|
||||
tokio::task::spawn_blocking(sbx::vbox_library).await;
|
||||
let _ = match res {
|
||||
Ok(Ok(vms)) => {
|
||||
let body = vms
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let mark = if v.installed { "✓" } else { "↓" };
|
||||
format!("{mark} {} — {} ({})", v.id, v.name, v.os)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
tx.send(Net::Sys(format!(
|
||||
"VM library (✓ installed · ↓ available) — `/sbx vmlib <id>` for details, `/sbx vmlib <id> install` to build locally:\n{body}"
|
||||
)))
|
||||
}
|
||||
Ok(Err(e)) => tx.send(Net::Err(format!("vmlib: {e}"))),
|
||||
Err(e) => tx.send(Net::Err(format!("vmlib task: {e}"))),
|
||||
};
|
||||
});
|
||||
}
|
||||
Some(id) => {
|
||||
let id = id.to_string();
|
||||
let action = pos.next();
|
||||
if matches!(action, Some("install")) {
|
||||
app.sys(format!(
|
||||
"building VM '{id}' locally{}… (this downloads + provisions on your own machine; watch for a VirtualBox window)",
|
||||
iso.as_deref().map(|p| format!(" from {p}")).unwrap_or_default()
|
||||
));
|
||||
let (tx, iso) = (app_tx.clone(), iso.clone());
|
||||
tokio::spawn(async move {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
sbx::vbox_library_install(&id, iso)
|
||||
})
|
||||
.await;
|
||||
let _ = match res {
|
||||
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ {desc}"))),
|
||||
Ok(Err(e)) => tx.send(Net::Err(format!("vmlib install failed: {e}"))),
|
||||
Err(e) => tx.send(Net::Err(format!("vmlib install task: {e}"))),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Info / pointer for one entry
|
||||
let tx = app_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
sbx::library_vm(&id).ok_or(id)
|
||||
})
|
||||
.await;
|
||||
let _ = match res {
|
||||
Ok(Ok(v)) => {
|
||||
let state = if v.installed {
|
||||
"already installed".to_string()
|
||||
} else {
|
||||
"available — `/sbx vmlib <id> install` to build locally".to_string()
|
||||
};
|
||||
let mut body = format!(
|
||||
"{} ({}) · {} · {}\n{}",
|
||||
v.name, v.id, v.os, v.size, state
|
||||
);
|
||||
if !v.notes.is_empty() {
|
||||
body.push_str(&format!("\n{}", v.notes));
|
||||
}
|
||||
if !v.page.is_empty() {
|
||||
body.push_str(&format!("\nsource: {}", v.page));
|
||||
}
|
||||
tx.send(Net::Sys(body))
|
||||
}
|
||||
Ok(Err(bad)) => tx.send(Net::Err(format!(
|
||||
"no VM library entry '{bad}' — `/sbx vmlib` lists the catalog"
|
||||
))),
|
||||
Err(e) => tx.send(Net::Err(format!("vmlib task: {e}"))),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("gui") => {
|
||||
// Convenience alias for `/sbx vbox gui <vm> [yes]` — opens a
|
||||
// local VirtualBox VM's GUI on your own machine. Bare `/sbx gui`
|
||||
@@ -2416,7 +2520,7 @@ fn handle_command(
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let usage = "usage: /sbx <type> <option> — /sbx docker|podman|multipass|local [image] · /sbx vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx vbox new [name] (fresh VM) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>";
|
||||
let usage = "usage: /sbx <type> <option> — /sbx docker|podman|multipass|local [image] · /sbx vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx vbox new [name] (fresh VM) · vmlib [<id> [install [--iso path]]] (VM library — build locally) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>";
|
||||
// Bare `/sbx` → usage. An unrecognised subcommand → suggest the
|
||||
// closest documented one before the usage line.
|
||||
match other.and_then(|bad| closest(bad, SBX_SUBCOMMANDS).map(|s| (bad, s))) {
|
||||
@@ -2686,7 +2790,7 @@ const KNOWN_COMMANDS: &[&str] = &[
|
||||
/// omitted so suggestions point at the documented form.
|
||||
const SBX_SUBCOMMANDS: &[&str] = &[
|
||||
"docker", "podman", "multipass", "local", "vbox", "stop", "save", "load", "snaps", "vms",
|
||||
"vmsave", "vmload", "vmsnaps",
|
||||
"vmsave", "vmload", "vmsnaps", "vmlib",
|
||||
];
|
||||
|
||||
/// Classic Levenshtein edit distance (insert/delete/substitute, each cost 1).
|
||||
|
||||
+120
@@ -26,6 +26,11 @@ const SBX_BOOTSTRAP: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbo
|
||||
const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-tools.json");
|
||||
/// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/).
|
||||
const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh");
|
||||
/// Browse + locally-build VMs from the curated library (hh/scripts/).
|
||||
const VBOX_LIBRARY_SH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-library.sh");
|
||||
/// The library manifest: pointers (URLs/pages) to VMs, never the images. The
|
||||
/// loader cross-references `list_vms` to mark which are already built locally.
|
||||
const VBOX_LIBRARY_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-library.json");
|
||||
|
||||
/// Is the `docker` binary installed? (`docker --version` succeeds.) This is a
|
||||
/// weaker check than `docker_daemon_up`: the CLI can be present while the daemon
|
||||
@@ -379,6 +384,121 @@ pub fn vbox_new(name: &str, user: &str) -> Result<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Names of VMs that are currently running (`VBoxManage list runningvms`), so the
|
||||
/// `/sbx vms` listing can flag which of the registered VMs are live.
|
||||
pub fn running_vms() -> Vec<String> {
|
||||
Command::new("VBoxManage")
|
||||
.args(["list", "runningvms"])
|
||||
.output()
|
||||
.map(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let start = l.find('"')? + 1;
|
||||
let end = l[start..].find('"')? + start;
|
||||
Some(l[start..end].to_string())
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---- VM library (curated pointers; built locally on demand) -----------------
|
||||
//
|
||||
// The repo ships scripts/vbox-library.json — POINTERS only (download URLs/pages),
|
||||
// never the multi-GB images. Choosing a VM builds it on the caller's own machine
|
||||
// via scripts/vbox-library.sh. Here we parse the manifest for listing/info; the
|
||||
// heavy build is shelled out to the script (which already handles download +
|
||||
// VBoxManage create/import + boot, with rollback).
|
||||
|
||||
/// One curated VM as declared in the manifest. Only the fields we surface in the
|
||||
/// TUI are deserialized; the rest of the JSON (specs, firmware, etc.) is consumed
|
||||
/// by the build script.
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct LibraryVm {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub os: String,
|
||||
pub size: String,
|
||||
#[serde(default)]
|
||||
pub page: String,
|
||||
#[serde(default)]
|
||||
pub notes: String,
|
||||
/// Filled in by `vbox_library()` (not in the JSON): is a VM of this name
|
||||
/// already registered in VirtualBox on this machine?
|
||||
#[serde(skip)]
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LibraryManifest {
|
||||
vms: Vec<LibraryVm>,
|
||||
}
|
||||
|
||||
/// Parse the VM library manifest, marking each entry `installed` if a VM of that
|
||||
/// display name is already registered locally (`list_vms`). The catalog is the
|
||||
/// same for everyone; `installed` is per-machine, so this works identically for a
|
||||
/// host or any room member — VirtualBox VMs are always local to the caller.
|
||||
pub fn vbox_library() -> Result<Vec<LibraryVm>> {
|
||||
let text = std::fs::read_to_string(VBOX_LIBRARY_JSON)
|
||||
.with_context(|| format!("reading VM library manifest ({VBOX_LIBRARY_JSON})"))?;
|
||||
let manifest: LibraryManifest =
|
||||
serde_json::from_str(&text).context("parsing vbox-library.json")?;
|
||||
let have = list_vms().unwrap_or_default();
|
||||
Ok(manifest
|
||||
.vms
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.installed = have.iter().any(|n| n == &v.name);
|
||||
v
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Look up a single library entry by its id (with `installed` resolved).
|
||||
pub fn library_vm(id: &str) -> Option<LibraryVm> {
|
||||
vbox_library().ok()?.into_iter().find(|v| v.id == id)
|
||||
}
|
||||
|
||||
/// Build a library VM locally by driving scripts/vbox-library.sh `--install`.
|
||||
/// Blocking and potentially slow (large download); run off the UI thread. No
|
||||
/// sudo is needed — VBoxManage create/import run as the user. `iso` supplies a
|
||||
/// locally-downloaded installer for entries with no auto-download (e.g. Windows).
|
||||
/// Returns the script's final status line(s).
|
||||
pub fn vbox_library_install(id: &str, iso: Option<String>) -> Result<String> {
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(VBOX_LIBRARY_SH).args(["--install", id, "--yes"]);
|
||||
if let Some(path) = iso.as_deref() {
|
||||
cmd.args(["--iso", path]);
|
||||
}
|
||||
let out = cmd
|
||||
.output()
|
||||
.context("running vbox-library.sh (is bash available?)")?;
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if !out.status.success() {
|
||||
// The script narrates on stderr and ends with a ✖ reason / pointer.
|
||||
let reason = stderr
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("build failed")
|
||||
.trim();
|
||||
anyhow::bail!("{reason}");
|
||||
}
|
||||
// Surface the final ✓/ⓘ status line.
|
||||
let last = stderr
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with('✓') || t.starts_with('ⓘ')
|
||||
})
|
||||
.unwrap_or("done")
|
||||
.trim();
|
||||
Ok(last.to_string())
|
||||
}
|
||||
|
||||
/// A running hypervisor that holds the CPU's VT-x/VMX root mode. While one is
|
||||
/// live, VirtualBox can't boot a *hardware* VM (it aborts with
|
||||
/// `VERR_VMX_IN_VMX_ROOT_MODE`). We only mark holders we know how to stop
|
||||
|
||||
@@ -293,6 +293,10 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||
"/sbx vbox new [name]",
|
||||
"build a FRESH Ubuntu VM from a cloud image (cloud-init installs the dev toolchain)",
|
||||
),
|
||||
kv(
|
||||
"/sbx vmlib [<id> [install]]",
|
||||
"VM library — catalog of installable VMs (Win11, macOS, Kali…); <id> install builds it LOCALLY on your machine (pointers only, no images bundled)",
|
||||
),
|
||||
kv(
|
||||
"/sbx vbox [gui] <vm> [yes]",
|
||||
"boot a VirtualBox VM's GUI on YOUR machine (non-host appends yes to install/import first)",
|
||||
|
||||
Reference in New Issue
Block a user