feat(sbx,ai): snapshot all backends w/ local export; robust AI + rejoin
- /sbx save [--local] and new /sbx vmsave/vmsnaps: snapshot Docker, Multipass, and VirtualBox; --local also writes a portable artifact (docker .tar / VBox .ova) under hh-snapshots/ that survives pruning. - sandbox reappears for anyone who leaves and rejoins (host replays status + screen snapshot + ACL on Joined); SbxStatus ready handler is now idempotent so it never wipes scrollback. - received files auto-bridge into the hosted sandbox (ft::tar_path). - AI agent: translate Ollama's cryptic 404 into model/host/fix guidance. - bootstrap installs the AI layer (Ollama + default model) by default, with consent gates; --no-ai opts out, --yes skips prompts. - help menu lists the new save/vm flags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+29
-7
@@ -12,7 +12,8 @@
|
|||||||
# ./bootstrap-ai.sh # baseline setup + Ollama + default model
|
# ./bootstrap-ai.sh # baseline setup + Ollama + default model
|
||||||
# ./bootstrap-ai.sh --release # ...and build the client in release mode
|
# ./bootstrap-ai.sh --release # ...and build the client in release mode
|
||||||
# ./bootstrap-ai.sh --check # report only; install/pull nothing
|
# ./bootstrap-ai.sh --check # report only; install/pull nothing
|
||||||
# ./bootstrap-ai.sh --yes # don't prompt before installing Ollama
|
# ./bootstrap-ai.sh --yes # don't prompt before installing Ollama / pulling the model
|
||||||
|
# ./bootstrap-ai.sh --ai-only # only the AI layer; skip the baseline ./bootstrap.sh
|
||||||
# ./bootstrap-ai.sh -h | --help # this help
|
# ./bootstrap-ai.sh -h | --help # this help
|
||||||
#
|
#
|
||||||
# environment:
|
# environment:
|
||||||
@@ -28,13 +29,15 @@ INSTALLER_URL="https://ollama.com/install.sh"
|
|||||||
RELEASE_ARGS=()
|
RELEASE_ARGS=()
|
||||||
CHECK_ONLY=0
|
CHECK_ONLY=0
|
||||||
ASSUME_YES=0
|
ASSUME_YES=0
|
||||||
|
AI_ONLY=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--release) RELEASE_ARGS+=(--release) ;;
|
--release) RELEASE_ARGS+=(--release) ;;
|
||||||
--check) CHECK_ONLY=1 ;;
|
--check) CHECK_ONLY=1 ;;
|
||||||
--yes|-y) ASSUME_YES=1 ;;
|
--yes|-y) ASSUME_YES=1 ;;
|
||||||
|
--ai-only) AI_ONLY=1 ;;
|
||||||
-h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
-h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
*) echo "✖ unknown arg: $arg (try --release / --check / --yes / --help)" >&2; exit 2 ;;
|
*) echo "✖ unknown arg: $arg (try --release / --check / --yes / --ai-only / --help)" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -44,10 +47,14 @@ ollama_up() { curl -s --max-time 3 "$OLLAMA_HOST/api/tags" >/dev/null 2>&1; }
|
|||||||
# 0. Baseline setup (venv + server/agent deps + client build). The agent's own
|
# 0. Baseline setup (venv + server/agent deps + client build). The agent's own
|
||||||
# runtime deps (requests, websockets) are already in requirements.txt, so the
|
# runtime deps (requests, websockets) are already in requirements.txt, so the
|
||||||
# baseline install covers them — this script adds only the model runtime.
|
# baseline install covers them — this script adds only the model runtime.
|
||||||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
# --no-ai stops bootstrap.sh re-entering this script (it now runs the AI layer
|
||||||
"$ROOT/bootstrap.sh" --check || exit $?
|
# by default). --ai-only skips the baseline entirely (caller already ran it).
|
||||||
else
|
if [[ $AI_ONLY -ne 1 ]]; then
|
||||||
"$ROOT/bootstrap.sh" "${RELEASE_ARGS[@]}" || exit $?
|
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||||||
|
"$ROOT/bootstrap.sh" --no-ai --check || exit $?
|
||||||
|
else
|
||||||
|
"$ROOT/bootstrap.sh" --no-ai "${RELEASE_ARGS[@]}" || exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
@@ -95,10 +102,25 @@ if ! ollama_up; then
|
|||||||
echo " ✓ daemon up"
|
echo " ✓ daemon up"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Pull the default model (idempotent).
|
# 4. Pull the default model (idempotent). Pulling downloads several GB onto THIS
|
||||||
|
# machine, so ask first on an interactive terminal (skip with --yes) — never
|
||||||
|
# pull a model onto someone's box without their say-so.
|
||||||
if ollama list 2>/dev/null | awk 'NR>1{print $1}' | grep -Fxq "$MODEL"; then
|
if ollama list 2>/dev/null | awk 'NR>1{print $1}' | grep -Fxq "$MODEL"; then
|
||||||
echo " ✓ model '$MODEL' already present"
|
echo " ✓ model '$MODEL' already present"
|
||||||
else
|
else
|
||||||
|
echo " model '$MODEL' is not present — pulling it downloads several GB to THIS machine."
|
||||||
|
if [[ $ASSUME_YES -ne 1 ]]; then
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
read -r -p " pull '$MODEL' now? [y/N] " ans
|
||||||
|
[[ "$ans" == [yY]* ]] || { echo " aborted — no model pulled. pull it yourself with: ollama pull $MODEL" >&2; exit 1; }
|
||||||
|
else
|
||||||
|
# No terminal to confirm on — never pull onto someone's machine
|
||||||
|
# unasked. Require explicit --yes (or an interactive yes) instead.
|
||||||
|
echo " ✖ not pulling '$MODEL' without confirmation (no terminal to ask)." >&2
|
||||||
|
echo " re-run with --yes to allow it, or pull manually: ollama pull $MODEL" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
echo " pulling model '$MODEL' (first pull can take a while)…"
|
echo " pulling model '$MODEL' (first pull can take a while)…"
|
||||||
ollama pull "$MODEL" || { echo " ✖ failed to pull '$MODEL'" >&2; exit 1; }
|
ollama pull "$MODEL" || { echo " ✖ failed to pull '$MODEL'" >&2; exit 1; }
|
||||||
echo " ✓ model '$MODEL' ready"
|
echo " ✓ model '$MODEL' ready"
|
||||||
|
|||||||
+28
-3
@@ -5,11 +5,18 @@
|
|||||||
# for the server, installs its dependencies, and builds the Rust client.
|
# for the server, installs its dependencies, and builds the Rust client.
|
||||||
#
|
#
|
||||||
# usage:
|
# usage:
|
||||||
# ./bootstrap.sh # full setup (venv + deps + cargo build)
|
# ./bootstrap.sh # full setup (venv + deps + client + AI layer)
|
||||||
# ./bootstrap.sh --release # build the client in release mode
|
# ./bootstrap.sh --release # build the client in release mode
|
||||||
|
# ./bootstrap.sh --no-ai # skip the AI layer (no Ollama install / model pull)
|
||||||
|
# ./bootstrap.sh --yes # don't prompt during the AI layer (install / pull)
|
||||||
# ./bootstrap.sh --check # only report what's installed; change nothing
|
# ./bootstrap.sh --check # only report what's installed; change nothing
|
||||||
# ./bootstrap.sh -h | --help # this help
|
# ./bootstrap.sh -h | --help # this help
|
||||||
#
|
#
|
||||||
|
# The AI layer (local Ollama + a default model for the /ai agent) is set up by
|
||||||
|
# default so the agent works out of the box and nobody hits "model not found".
|
||||||
|
# It still prompts before installing/pulling on an interactive terminal — skip
|
||||||
|
# the whole thing with --no-ai, or skip the prompts with --yes.
|
||||||
|
#
|
||||||
# After it finishes, spin up a local test session with: cd hh && ./lets-hack.sh
|
# After it finishes, spin up a local test session with: cd hh && ./lets-hack.sh
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -19,12 +26,16 @@ HH_DIR="$ROOT/hh"
|
|||||||
|
|
||||||
RELEASE=0
|
RELEASE=0
|
||||||
CHECK_ONLY=0
|
CHECK_ONLY=0
|
||||||
|
DO_AI=1
|
||||||
|
ASSUME_YES=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--release) RELEASE=1 ;;
|
--release) RELEASE=1 ;;
|
||||||
--check) CHECK_ONLY=1 ;;
|
--check) CHECK_ONLY=1 ;;
|
||||||
|
--no-ai) DO_AI=0 ;;
|
||||||
|
--yes|-y) ASSUME_YES=1 ;;
|
||||||
-h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
-h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
*) echo "✖ unknown arg: $arg (try --release / --check / --help)" >&2; exit 2 ;;
|
*) echo "✖ unknown arg: $arg (try --release / --no-ai / --yes / --check / --help)" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -73,8 +84,22 @@ else
|
|||||||
echo " ✓ client built: hh/target/debug/hack-house"
|
echo " ✓ client built: hh/target/debug/hack-house"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 4. AI layer (on by default): install Ollama + pull the default model so the
|
||||||
|
# /ai agent works out of the box and nobody hits "model not found" later. The
|
||||||
|
# logic lives in bootstrap-ai.sh; --ai-only skips its baseline re-run (we just
|
||||||
|
# did it). Declining/failing here is non-fatal — the baseline setup still
|
||||||
|
# stands and the AI layer can be added later with ./bootstrap-ai.sh.
|
||||||
|
if [[ $DO_AI -eq 1 ]]; then
|
||||||
|
ai_args=(--ai-only)
|
||||||
|
[[ $ASSUME_YES -eq 1 ]] && ai_args+=(--yes)
|
||||||
|
"$ROOT/bootstrap-ai.sh" "${ai_args[@]}" \
|
||||||
|
|| echo "⚠ AI layer not completed — re-run ./bootstrap-ai.sh when ready" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "ready. next steps:"
|
echo "ready. next steps:"
|
||||||
echo " cd hh && ./lets-hack.sh # local test session (server + clients in tmux)"
|
echo " cd hh && ./lets-hack.sh # local test session (server + clients in tmux)"
|
||||||
echo " # or run the server + client by hand — see README.MD"
|
echo " # or run the server + client by hand — see README.MD"
|
||||||
echo " # want the local AI agent? ./bootstrap-ai.sh (installs Ollama + a model)"
|
if [[ $DO_AI -eq 0 ]]; then
|
||||||
|
echo " # AI layer skipped (--no-ai); add it later with ./bootstrap-ai.sh"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -63,6 +63,35 @@ class OllamaProvider:
|
|||||||
opts["num_thread"] = self.num_thread
|
opts["num_thread"] = self.num_thread
|
||||||
return opts
|
return opts
|
||||||
|
|
||||||
|
def _raise_for_status(self, r: requests.Response) -> None:
|
||||||
|
"""Turn an Ollama HTTP error into an actionable message.
|
||||||
|
|
||||||
|
Ollama answers /api/chat with 404 + ``{"error": "model ... not found"}``
|
||||||
|
when the model isn't pulled on the box running this agent. Because the
|
||||||
|
agent talks to *its own* localhost:11434, a teammate who summoned /ai
|
||||||
|
without that model pulled hits this even when the host has it. The bare
|
||||||
|
``raise_for_status`` only reports "404 Not Found for url", hiding the
|
||||||
|
cause — so name the model, the host, and the fix instead. This text is
|
||||||
|
what the bridge posts to the room as ``[ai error: …]``.
|
||||||
|
"""
|
||||||
|
if r.ok:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
detail = (r.json().get("error") or "").strip()
|
||||||
|
except ValueError:
|
||||||
|
detail = (r.text or "").strip()
|
||||||
|
if r.status_code == 404:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"model '{self.model}' isn't pulled on the ollama at {self.host} "
|
||||||
|
f"(the agent uses ollama on the machine that ran /ai, not the host). "
|
||||||
|
f"fix: `ollama pull {self.model}` there, or `/ai start <profile>` "
|
||||||
|
f"for a cloud model. [{detail or 'model not found'}]"
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ollama at {self.host} returned {r.status_code}"
|
||||||
|
+ (f": {detail}" if detail else "")
|
||||||
|
)
|
||||||
|
|
||||||
def complete(self, system: str, messages: list[Msg]) -> str:
|
def complete(self, system: str, messages: list[Msg]) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
@@ -73,7 +102,7 @@ class OllamaProvider:
|
|||||||
+ [{"role": m.role, "content": m.content} for m in messages],
|
+ [{"role": m.role, "content": m.content} for m in messages],
|
||||||
}
|
}
|
||||||
r = requests.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
|
r = requests.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
|
||||||
r.raise_for_status()
|
self._raise_for_status(r)
|
||||||
return (r.json().get("message", {}).get("content") or "").strip()
|
return (r.json().get("message", {}).get("content") or "").strip()
|
||||||
|
|
||||||
def stream(self, system: str, messages: list[Msg]):
|
def stream(self, system: str, messages: list[Msg]):
|
||||||
@@ -89,7 +118,7 @@ class OllamaProvider:
|
|||||||
}
|
}
|
||||||
with requests.post(f"{self.host}/api/chat", json=payload,
|
with requests.post(f"{self.host}/api/chat", json=payload,
|
||||||
timeout=self.timeout, stream=True) as r:
|
timeout=self.timeout, stream=True) as r:
|
||||||
r.raise_for_status()
|
self._raise_for_status(r)
|
||||||
for line in r.iter_lines():
|
for line in r.iter_lines():
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|||||||
+162
-22
@@ -374,11 +374,25 @@ impl App {
|
|||||||
cols,
|
cols,
|
||||||
} => {
|
} => {
|
||||||
if ready {
|
if ready {
|
||||||
self.sandbox = Some(SbxView {
|
// Converge on the shared shell. If we already track this
|
||||||
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
|
// sandbox, just match its size — recreating the parser would
|
||||||
backend: backend.clone(),
|
// wipe scrollback and re-announce on every re-broadcast. The
|
||||||
});
|
// owner re-sends `status:ready` whenever a member (re)joins, so
|
||||||
self.sys(format!("⛧ sandbox summoned ({backend}) — F2 to drive"));
|
// this MUST be idempotent for everyone already in the room; only
|
||||||
|
// a genuinely new sandbox (none yet) is created and announced.
|
||||||
|
match &mut self.sandbox {
|
||||||
|
Some(v) => {
|
||||||
|
v.parser.set_size(rows.max(1), cols.max(1));
|
||||||
|
v.backend = backend.clone();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.sandbox = Some(SbxView {
|
||||||
|
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
|
||||||
|
backend: backend.clone(),
|
||||||
|
});
|
||||||
|
self.sys(format!("⛧ sandbox summoned ({backend}) — F2 to drive"));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.sandbox = None;
|
self.sandbox = None;
|
||||||
self.driving = false;
|
self.driving = false;
|
||||||
@@ -534,11 +548,14 @@ fn handle_ft(
|
|||||||
out: &UnboundedSender<WsMsg>,
|
out: &UnboundedSender<WsMsg>,
|
||||||
room: &Arc<fernet::Fernet>,
|
room: &Arc<fernet::Fernet>,
|
||||||
downloads: &std::path::Path,
|
downloads: &std::path::Path,
|
||||||
) {
|
) -> Option<std::path::PathBuf> {
|
||||||
|
// Set to the saved path when a transfer completes & verifies, so the caller
|
||||||
|
// can auto-bridge it into a running sandbox.
|
||||||
|
let mut saved = None;
|
||||||
match f {
|
match f {
|
||||||
ft::Ft::Offer(o) => {
|
ft::Ft::Offer(o) => {
|
||||||
if o.from == app.me {
|
if o.from == app.me {
|
||||||
return; // our own offer echo
|
return None; // our own offer echo
|
||||||
}
|
}
|
||||||
app.sys(format!(
|
app.sys(format!(
|
||||||
"⛧ {} offers {} ({}{}) — /accept or /reject",
|
"⛧ {} offers {} ({}{}) — /accept or /reject",
|
||||||
@@ -604,11 +621,14 @@ fn handle_ft(
|
|||||||
app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
|
app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
|
||||||
} else {
|
} else {
|
||||||
match ft::save(downloads, &t.meta, &t.buf) {
|
match ft::save(downloads, &t.meta, &t.buf) {
|
||||||
Ok(p) => app.sys(format!(
|
Ok(p) => {
|
||||||
"⛧ saved {} ({}) — verified ✓",
|
app.sys(format!(
|
||||||
p.display(),
|
"⛧ saved {} ({}) — verified ✓",
|
||||||
ft::human(t.buf.len())
|
p.display(),
|
||||||
)),
|
ft::human(t.buf.len())
|
||||||
|
));
|
||||||
|
saved = Some(p);
|
||||||
|
}
|
||||||
Err(e) => app.err(format!("save failed: {e}")),
|
Err(e) => app.err(format!("save failed: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -624,6 +644,7 @@ fn handle_ft(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
saved
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Put the terminal back the way we found it: leave raw mode, leave the
|
/// Put the terminal back the way we found it: leave raw mode, leave the
|
||||||
@@ -970,7 +991,42 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Net::Ft(f) => handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads),
|
Net::Ft(f) => {
|
||||||
|
// On a received, SHA-verified file, auto-bridge it into
|
||||||
|
// a sandbox we host so the whole clergy can use it from
|
||||||
|
// the shared shell. Runs off the UI thread (docker/mp
|
||||||
|
// exec + tar stream) and reports back via the app channel.
|
||||||
|
// A local backend already shares the host fs — nothing to do.
|
||||||
|
if let Some(path) =
|
||||||
|
handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads)
|
||||||
|
{
|
||||||
|
if let Some((be, name)) = &broker_meta {
|
||||||
|
if !matches!(be, sbx::Backend::Local) {
|
||||||
|
let (be, name) = (*be, name.clone());
|
||||||
|
let run_user = sbx::run_user_for(
|
||||||
|
be,
|
||||||
|
app.owner.as_deref().unwrap_or(app.me.as_str()),
|
||||||
|
);
|
||||||
|
let tx = app_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res = tokio::task::spawn_blocking(move || {
|
||||||
|
sbx::push(be, &name, &run_user, &path)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let _ = match res {
|
||||||
|
Ok(Ok(dest)) => tx.send(Net::Sys(format!(
|
||||||
|
"⛧ bridged into sandbox → {dest}"
|
||||||
|
))),
|
||||||
|
Ok(Err(e)) => tx.send(Net::Sys(format!(
|
||||||
|
"(received file not bridged into sandbox: {e})"
|
||||||
|
))),
|
||||||
|
Err(e) => tx.send(Net::Err(format!("bridge task: {e}"))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// The broker renders its sandbox locally from the PTY, so it
|
// The broker renders its sandbox locally from the PTY, so it
|
||||||
// ignores its own echoed status/data; everyone else uses them.
|
// ignores its own echoed status/data; everyone else uses them.
|
||||||
Net::SbxData(b) => {
|
Net::SbxData(b) => {
|
||||||
@@ -980,12 +1036,27 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
}
|
}
|
||||||
Net::SbxStatus { .. } if broker.is_some() => {}
|
Net::SbxStatus { .. } if broker.is_some() => {}
|
||||||
ev @ Net::Joined(_) => {
|
ev @ Net::Joined(_) => {
|
||||||
// A late joiner (e.g. a just-summoned agent) missed any
|
// Someone (re)entered and missed everything broadcast
|
||||||
// ACL broadcast sent before they connected. If we host the
|
// before they connected. If we host the sandbox, replay
|
||||||
// sandbox, re-broadcast so their local grant state syncs —
|
// it so it reappears for them: `status:ready` makes the
|
||||||
// this is what makes `/ai start <m> allow` actually reach
|
// pane show up, the screen snapshot (`_sbx:data`) paints
|
||||||
// the agent.
|
// its current contents instead of a blank pane, and the
|
||||||
|
// ACL re-broadcast re-syncs grant state (also what makes
|
||||||
|
// `/ai start <m> allow` reach a freshly-joined agent).
|
||||||
|
// The status/data handlers are idempotent, so members
|
||||||
|
// already in the room aren't disturbed by the replay.
|
||||||
if broker.is_some() {
|
if broker.is_some() {
|
||||||
|
if let (Some(v), Some((be, _))) = (&app.sandbox, &broker_meta) {
|
||||||
|
let (rows, cols) = v.parser.screen().size();
|
||||||
|
send_frame(&out_tx, &session.room, json!({
|
||||||
|
"_sbx":"status","state":"ready",
|
||||||
|
"backend": be.label(),"rows": rows,"cols": cols
|
||||||
|
}));
|
||||||
|
let snap = v.parser.screen().contents_formatted();
|
||||||
|
send_frame(&out_tx, &session.room, json!({
|
||||||
|
"_sbx":"data","b64": STANDARD.encode(&snap)
|
||||||
|
}));
|
||||||
|
}
|
||||||
broadcast_acl(&out_tx, &session.room, &app);
|
broadcast_acl(&out_tx, &session.room, &app);
|
||||||
}
|
}
|
||||||
app.apply(ev);
|
app.apply(ev);
|
||||||
@@ -1360,14 +1431,28 @@ fn handle_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("save") => {
|
Some("save") => {
|
||||||
let label = p.next().unwrap_or("snap").to_string();
|
// `--local` (alias `-l`) also writes a portable, standalone copy
|
||||||
|
// under hh-snapshots/ (a docker `.tar`) the saver fully owns; the
|
||||||
|
// default keeps just the in-backend snapshot. The label is the
|
||||||
|
// first non-flag arg.
|
||||||
|
let args: Vec<&str> = p.collect();
|
||||||
|
let local = args.iter().any(|a| matches!(*a, "--local" | "-l"));
|
||||||
|
let label = args
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|a| !a.starts_with('-'))
|
||||||
|
.unwrap_or("snap")
|
||||||
|
.to_string();
|
||||||
if !is_snap_label(&label) {
|
if !is_snap_label(&label) {
|
||||||
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
|
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
|
||||||
} else if let Some((be, name)) = broker_meta.clone() {
|
} else if let Some((be, name)) = broker_meta.clone() {
|
||||||
app.sys(format!("saving sandbox state as '{label}'…"));
|
app.sys(format!(
|
||||||
|
"saving sandbox state as '{label}'{}…",
|
||||||
|
if local { " (+ local copy)" } else { "" }
|
||||||
|
));
|
||||||
let (tx, lbl) = (app_tx.clone(), label.clone());
|
let (tx, lbl) = (app_tx.clone(), label.clone());
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label)).await;
|
let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label, local)).await;
|
||||||
let _ = match res {
|
let _ = match res {
|
||||||
Ok(Ok(desc)) => tx.send(Net::Sys(format!(
|
Ok(Ok(desc)) => tx.send(Net::Sys(format!(
|
||||||
"⛧ saved sandbox → {desc} · reload with `/sbx load {lbl}`"))),
|
"⛧ saved sandbox → {desc} · reload with `/sbx load {lbl}`"))),
|
||||||
@@ -1447,6 +1532,61 @@ fn handle_command(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Some("vmsave") => {
|
||||||
|
// Snapshot a local VirtualBox VM. `--local` (alias `-l`) also
|
||||||
|
// exports a portable `.ova` appliance under hh-snapshots/.
|
||||||
|
let args: Vec<&str> = p.collect();
|
||||||
|
let local = args.iter().any(|a| matches!(*a, "--local" | "-l"));
|
||||||
|
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
|
||||||
|
match pos.next() {
|
||||||
|
None => app.sys(
|
||||||
|
"usage: /sbx vmsave <vm> [label] [--local] (snapshot a VirtualBox VM; list VMs with /sbx vms)",
|
||||||
|
),
|
||||||
|
Some(vm) => {
|
||||||
|
let label = pos.next().unwrap_or("snap").to_string();
|
||||||
|
if !is_snap_label(&label) {
|
||||||
|
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
|
||||||
|
} else {
|
||||||
|
app.sys(format!(
|
||||||
|
"snapshotting VM '{vm}' as '{label}'{}…",
|
||||||
|
if local { " (+ local .ova)" } else { "" }
|
||||||
|
));
|
||||||
|
let (tx, vm) = (app_tx.clone(), vm.to_string());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res = tokio::task::spawn_blocking(move || {
|
||||||
|
sbx::vm_save_state(&vm, &label, local)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let _ = match res {
|
||||||
|
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ saved VM → {desc}"))),
|
||||||
|
Ok(Err(e)) => tx.send(Net::Err(format!("vmsave failed: {e}"))),
|
||||||
|
Err(e) => tx.send(Net::Err(format!("vmsave task: {e}"))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("vmsnaps") => {
|
||||||
|
match p.next() {
|
||||||
|
None => app.sys("usage: /sbx vmsnaps <vm> (list a VirtualBox VM's snapshots)"),
|
||||||
|
Some(vm) => {
|
||||||
|
let (tx, vm) = (app_tx.clone(), vm.to_string());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res = tokio::task::spawn_blocking(move || sbx::vm_snapshots(&vm)).await;
|
||||||
|
let _ = match res {
|
||||||
|
Ok(Ok(v)) if !v.is_empty() => {
|
||||||
|
tx.send(Net::Sys(format!("VM snapshots: {}", v.join(", "))))
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => tx.send(Net::Sys(
|
||||||
|
"no VM snapshots yet — `/sbx vmsave <vm> [label]` to make one".into())),
|
||||||
|
Ok(Err(e)) => tx.send(Net::Err(format!("vmsnaps: {e}"))),
|
||||||
|
Err(e) => tx.send(Net::Err(format!("vmsnaps task: {e}"))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some("gui") => {
|
Some("gui") => {
|
||||||
let gargs: Vec<&str> = p.collect();
|
let gargs: Vec<&str> = p.collect();
|
||||||
let first = gargs.iter().copied().find(|a| !a.starts_with('-'));
|
let first = gargs.iter().copied().find(|a| !a.starts_with('-'));
|
||||||
@@ -1511,7 +1651,7 @@ fn handle_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => app.sys(
|
_ => app.sys(
|
||||||
"usage: /sbx launch [local|docker|multipass] [image] · gui <vm> · vms · stop · save [label] · load <label> · snaps",
|
"usage: /sbx launch [local|docker|multipass] [image] · gui <vm> · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmsnaps <vm>",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
} else if let Some(rest) = line.strip_prefix("/unsudo") {
|
} else if let Some(rest) = line.strip_prefix("/unsudo") {
|
||||||
|
|||||||
@@ -88,6 +88,31 @@ fn tar_dir(dir: &Path) -> Result<Vec<u8>> {
|
|||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tar any local path (file or directory) to bytes for injecting into a
|
||||||
|
/// sandbox — the archive's single top-level entry is named after the path's
|
||||||
|
/// basename, so it extracts as `<dest>/<base>`. Enforces `MAX_SIZE`. Returns
|
||||||
|
/// `(base_name, tar_bytes)`.
|
||||||
|
pub fn tar_path(path: &Path) -> Result<(String, Vec<u8>)> {
|
||||||
|
let meta = std::fs::metadata(path).with_context(|| format!("not found: {}", path.display()))?;
|
||||||
|
let base = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.context("path has no final component")?
|
||||||
|
.to_string();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut tb = tar::Builder::new(&mut buf);
|
||||||
|
if meta.is_dir() {
|
||||||
|
tb.append_dir_all(&base, path).context("tar directory")?;
|
||||||
|
} else {
|
||||||
|
tb.append_path_with_name(path, &base).context("tar file")?;
|
||||||
|
}
|
||||||
|
tb.finish()?;
|
||||||
|
}
|
||||||
|
anyhow::ensure!(buf.len() <= MAX_SIZE, "too large ({})", human(buf.len()));
|
||||||
|
Ok((base, buf))
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist received bytes under `downloads`. Directories (tar) are extracted
|
/// Persist received bytes under `downloads`. Directories (tar) are extracted
|
||||||
/// with a guard rejecting absolute paths and `..` escapes (zip-slip).
|
/// with a guard rejecting absolute paths and `..` escapes (zip-slip).
|
||||||
pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
|
pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
|
||||||
|
|||||||
+185
-1
@@ -428,6 +428,19 @@ pub fn teardown(backend: Backend, name: &str) {
|
|||||||
/// (`hh-snap:<label>`). Owner-side only — never pushed anywhere.
|
/// (`hh-snap:<label>`). Owner-side only — never pushed anywhere.
|
||||||
pub const SNAP_REPO: &str = "hh-snap";
|
pub const SNAP_REPO: &str = "hh-snap";
|
||||||
|
|
||||||
|
/// Directory (relative to the working dir) where portable snapshot artifacts
|
||||||
|
/// land when the saver opts into a local file copy (`/sbx save … --local`).
|
||||||
|
/// Created on demand. These are standalone files the owner keeps — they survive
|
||||||
|
/// even if the in-backend image/snapshot is later pruned.
|
||||||
|
pub const SNAP_DIR: &str = "hh-snapshots";
|
||||||
|
|
||||||
|
/// Ensure `hh-snapshots/` exists and return it. Blocking.
|
||||||
|
fn snap_dir() -> Result<std::path::PathBuf> {
|
||||||
|
let dir = std::path::PathBuf::from(SNAP_DIR);
|
||||||
|
std::fs::create_dir_all(&dir).with_context(|| format!("creating {SNAP_DIR}/"))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshot the running sandbox's filesystem state to a named artifact on the
|
/// Snapshot the running sandbox's filesystem state to a named artifact on the
|
||||||
/// owner's machine. Blocking — run off the UI thread.
|
/// owner's machine. Blocking — run off the UI thread.
|
||||||
///
|
///
|
||||||
@@ -439,8 +452,15 @@ pub const SNAP_REPO: &str = "hh-snap";
|
|||||||
/// error is surfaced verbatim.
|
/// error is surfaced verbatim.
|
||||||
/// - **Local:** no backing VM/container, so nothing to save.
|
/// - **Local:** no backing VM/container, so nothing to save.
|
||||||
///
|
///
|
||||||
|
/// `local` adds a portable, standalone copy under `hh-snapshots/` (a file the
|
||||||
|
/// owner fully controls) on top of the in-backend snapshot:
|
||||||
|
/// - **Docker:** `docker save` the committed image to a `.tar`.
|
||||||
|
/// - **Multipass:** snapshots already live on this host under multipass's own
|
||||||
|
/// data dir; the CLI has no single-file export, so we note that and skip the
|
||||||
|
/// extra file rather than fake one.
|
||||||
|
///
|
||||||
/// Returns a short human description of what was written on success.
|
/// Returns a short human description of what was written on success.
|
||||||
pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
|
pub fn save_state(backend: Backend, name: &str, label: &str, local: bool) -> Result<String> {
|
||||||
match backend {
|
match backend {
|
||||||
Backend::Docker => {
|
Backend::Docker => {
|
||||||
let tag = format!("{SNAP_REPO}:{label}");
|
let tag = format!("{SNAP_REPO}:{label}");
|
||||||
@@ -455,6 +475,22 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
|
|||||||
err.lines().last().unwrap_or("").trim()
|
err.lines().last().unwrap_or("").trim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if local {
|
||||||
|
let path = snap_dir()?.join(format!("hh-snap-{label}.tar"));
|
||||||
|
let out = Command::new("docker")
|
||||||
|
.args(["save", &tag, "-o"])
|
||||||
|
.arg(&path)
|
||||||
|
.output()
|
||||||
|
.context("docker save")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
anyhow::bail!(
|
||||||
|
"image {tag} committed, but local export failed: {}",
|
||||||
|
err.lines().last().unwrap_or("").trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(format!("image {tag} + local file {}", path.display()));
|
||||||
|
}
|
||||||
Ok(format!("image {tag}"))
|
Ok(format!("image {tag}"))
|
||||||
}
|
}
|
||||||
Backend::Multipass => {
|
Backend::Multipass => {
|
||||||
@@ -469,6 +505,13 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
|
|||||||
err.lines().last().unwrap_or("").trim()
|
err.lines().last().unwrap_or("").trim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if local {
|
||||||
|
// multipass stores snapshots on this host already; no portable
|
||||||
|
// single-file export exists in the CLI, so be honest about it.
|
||||||
|
return Ok(format!(
|
||||||
|
"snapshot {name}.{label} (already stored locally by multipass; no portable file)"
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(format!("snapshot {name}.{label}"))
|
Ok(format!("snapshot {name}.{label}"))
|
||||||
}
|
}
|
||||||
Backend::Local => {
|
Backend::Local => {
|
||||||
@@ -477,6 +520,74 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot a VirtualBox VM's state. VirtualBox isn't a brokered `Backend` (its
|
||||||
|
/// GUI runs locally, never relayed), so it has its own save path. Blocking.
|
||||||
|
///
|
||||||
|
/// `VBoxManage snapshot <vm> take <label>` works whether the VM is running
|
||||||
|
/// (live snapshot) or powered off. `local` additionally exports the whole VM to
|
||||||
|
/// a portable `.ova` appliance under `hh-snapshots/` — the standalone artifact
|
||||||
|
/// you'd hand to someone else (or re-import yourself) via `/sbx gui`.
|
||||||
|
pub fn vm_save_state(vm: &str, label: &str, local: bool) -> Result<String> {
|
||||||
|
let out = Command::new("VBoxManage")
|
||||||
|
.args(["snapshot", vm, "take", label])
|
||||||
|
.output()
|
||||||
|
.context("VBoxManage snapshot take (is VirtualBox installed?)")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
anyhow::bail!(
|
||||||
|
"VBoxManage snapshot failed: {}",
|
||||||
|
err.lines().last().unwrap_or("").trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if local {
|
||||||
|
let path = snap_dir()?.join(format!("{vm}-{label}.ova"));
|
||||||
|
let out = Command::new("VBoxManage")
|
||||||
|
.args(["export", vm, "-o"])
|
||||||
|
.arg(&path)
|
||||||
|
.output()
|
||||||
|
.context("VBoxManage export")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
anyhow::bail!(
|
||||||
|
"snapshot '{label}' taken, but OVA export failed: {}",
|
||||||
|
err.lines().last().unwrap_or("").trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(format!("snapshot '{label}' + local appliance {}", path.display()));
|
||||||
|
}
|
||||||
|
Ok(format!("snapshot '{label}' of {vm}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot names for a VirtualBox VM (`VBoxManage snapshot <vm> list
|
||||||
|
/// --machinereadable` → `SnapshotName*="..."` lines). Blocking. An empty list
|
||||||
|
/// (exit 1 "does not have any snapshots") is reported as no snapshots, not an
|
||||||
|
/// error.
|
||||||
|
pub fn vm_snapshots(vm: &str) -> Result<Vec<String>> {
|
||||||
|
let out = Command::new("VBoxManage")
|
||||||
|
.args(["snapshot", vm, "list", "--machinereadable"])
|
||||||
|
.output()
|
||||||
|
.context("VBoxManage snapshot list (is VirtualBox installed?)")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
if err.contains("does not have any snapshots") {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
anyhow::bail!(
|
||||||
|
"VBoxManage snapshot list failed: {}",
|
||||||
|
err.lines().last().unwrap_or("").trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let v = l.strip_prefix("SnapshotName")?;
|
||||||
|
let q = v.split_once('=')?.1.trim();
|
||||||
|
Some(q.trim_matches('"').to_string())
|
||||||
|
})
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// List saved snapshot labels for a backend (Docker image tags under
|
/// List saved snapshot labels for a backend (Docker image tags under
|
||||||
/// `hh-snap`, or multipass snapshots of the instance). Blocking.
|
/// `hh-snap`, or multipass snapshots of the instance). Blocking.
|
||||||
pub fn list_snapshots(backend: Backend, name: &str) -> Result<Vec<String>> {
|
pub fn list_snapshots(backend: Backend, name: &str) -> Result<Vec<String>> {
|
||||||
@@ -637,6 +748,79 @@ pub fn set_sudo(backend: Backend, name: &str, user: &str, enable: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The unix user the shared shell runs as for a backend — mirrors `provision`'s
|
||||||
|
/// return (owner on Multipass, root on Docker, none on Local) but side-effect
|
||||||
|
/// free, so callers like file-push can resolve the destination home without
|
||||||
|
/// threading extra state through the broker.
|
||||||
|
pub fn run_user_for(backend: Backend, owner: &str) -> String {
|
||||||
|
match backend {
|
||||||
|
Backend::Multipass => unix_name(owner),
|
||||||
|
Backend::Docker => "root".to_string(),
|
||||||
|
Backend::Local => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject a local file/dir into the running sandbox, dropping it under the
|
||||||
|
/// shared shell user's home and leaving it owned by that user. The path is
|
||||||
|
/// streamed in as a tar (built + size-capped by `ft::tar_path`) and unpacked by
|
||||||
|
/// `tar` inside the container/VM — uniform for files and directories, and no
|
||||||
|
/// shell interpolation of the path. Blocking — run off the UI thread. Returns
|
||||||
|
/// the in-sandbox destination path on success.
|
||||||
|
pub fn push(backend: Backend, name: &str, run_user: &str, local: &std::path::Path) -> Result<String> {
|
||||||
|
let (base, tar) = crate::ft::tar_path(local)?;
|
||||||
|
match backend {
|
||||||
|
Backend::Local => anyhow::bail!(
|
||||||
|
"local sandbox shares the host filesystem — {} is already reachable",
|
||||||
|
local.display()
|
||||||
|
),
|
||||||
|
Backend::Docker => {
|
||||||
|
// Shell runs as root with $HOME=/root (see `prepare`'s `-w /root`).
|
||||||
|
let mut c = Command::new("docker");
|
||||||
|
c.args(["exec", "-i", name, "tar", "-C", "/root", "-xf", "-"]);
|
||||||
|
extract_tar(c, &tar)?;
|
||||||
|
Ok(format!("/root/{base}"))
|
||||||
|
}
|
||||||
|
Backend::Multipass => {
|
||||||
|
let home = format!("/home/{run_user}");
|
||||||
|
let mut c = Command::new("multipass");
|
||||||
|
c.args(["exec", name, "--", "sudo", "tar", "-C", &home, "-xf", "-"]);
|
||||||
|
extract_tar(c, &tar)?;
|
||||||
|
// Hand ownership to the account whose login shell the clergy share
|
||||||
|
// (the tar unpacked as root via sudo).
|
||||||
|
let owns = format!("{run_user}:{run_user}");
|
||||||
|
let target = format!("{home}/{base}");
|
||||||
|
mp(name, &["sudo", "chown", "-R", &owns, &target]);
|
||||||
|
Ok(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed `tar` bytes to a `tar -x` process over stdin, surfacing the extractor's
|
||||||
|
/// own last error line on failure.
|
||||||
|
fn extract_tar(mut cmd: Command, tar: &[u8]) -> Result<()> {
|
||||||
|
cmd.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.context("spawn tar extractor (is the backend CLI installed?)")?;
|
||||||
|
{
|
||||||
|
let mut si = child.stdin.take().context("tar stdin")?;
|
||||||
|
si.write_all(tar).context("stream tar to sandbox")?;
|
||||||
|
} // drop closes stdin → tar sees EOF and finishes
|
||||||
|
let out = child.wait_with_output().context("await tar extractor")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
out.status.success(),
|
||||||
|
"extract failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
.lines()
|
||||||
|
.last()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Sandbox {
|
pub struct Sandbox {
|
||||||
// Held for the PTY's lifetime (dropping it closes the terminal) + resize.
|
// Held for the PTY's lifetime (dropping it closes the terminal) + resize.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
+7
-2
@@ -155,8 +155,8 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
),
|
),
|
||||||
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
||||||
kv(
|
kv(
|
||||||
"/sbx save [label]",
|
"/sbx save [label] [--local]",
|
||||||
"snapshot state (docker image; survives stop)",
|
"snapshot state (docker image; --local also writes a portable .tar)",
|
||||||
),
|
),
|
||||||
kv(
|
kv(
|
||||||
"/sbx load <label>",
|
"/sbx load <label>",
|
||||||
@@ -185,6 +185,11 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
"/sbx gui cancel",
|
"/sbx gui cancel",
|
||||||
"abort a pending launch (nothing stopped or installed)",
|
"abort a pending launch (nothing stopped or installed)",
|
||||||
),
|
),
|
||||||
|
kv(
|
||||||
|
"/sbx vmsave <vm> [label] [--local]",
|
||||||
|
"snapshot a VM (--local also exports a portable .ova)",
|
||||||
|
),
|
||||||
|
kv("/sbx vmsnaps <vm>", "list a VM's snapshots"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
HelpCluster {
|
HelpCluster {
|
||||||
|
|||||||
Reference in New Issue
Block a user