feat(sandbox): podman backend + goose AI harness + noVNC GUI sandbox
Add Podman as a rootless/daemonless sandbox backend alongside Docker, Multipass and Local, and wire Goose in as the default agentic harness for the granted `!task` path (bridge execs `<engine> exec <name> goose run` and streams output to chat; auto-degrades to the simple one-shot injector when goose is absent). Add an optional GUI sandbox track (XFCE + TigerVNC + websockify/noVNC on :6080) summoned via `/sbx <engine> gui`, plus container-side provisioning in sandbox-bootstrap.sh and a host-side ensure-podman.sh prereq helper. Refresh the in-app command help to the backend-led `/sbx <engine> [gui]` grammar and minor ui tweaks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+213
-55
@@ -152,9 +152,9 @@ pub struct SbxView {
|
||||
pub backend: String,
|
||||
}
|
||||
|
||||
/// The arrow-navigable VirtualBox VM picker, opened by a bare `/sbx launch vbox`
|
||||
/// The arrow-navigable VirtualBox VM picker, opened by a bare `/sbx vbox`
|
||||
/// (or `/sbx gui`). Holds the locally-registered VM names and the highlighted
|
||||
/// row; Enter/Tab fills `/sbx launch vbox gui <vm>` into the input, Esc dismisses.
|
||||
/// row; Enter/Tab fills `/sbx vbox gui <vm>` into the input, Esc dismisses.
|
||||
#[derive(Clone)]
|
||||
pub struct VboxPicker {
|
||||
pub vms: Vec<String>,
|
||||
@@ -185,6 +185,7 @@ pub struct PendingSudoLaunch {
|
||||
pub cols: u16,
|
||||
pub start_daemon: bool,
|
||||
pub install_first: bool,
|
||||
pub gui: bool,
|
||||
}
|
||||
|
||||
/// A local, masked sudo-password prompt. The typed password lives ONLY here —
|
||||
@@ -410,7 +411,7 @@ impl App {
|
||||
self.connected = true;
|
||||
self.chat_scroll = 0;
|
||||
self.sys(format!("joined as {} ⛧", self.me));
|
||||
self.sys("/sbx launch <docker|multipass|vbox> · /drive (F2 releases) · /ai start · /ai <question> · /send <user> <file> · /sendroom <file> · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit");
|
||||
self.sys("/sbx <docker|podman|multipass|vbox|local> [gui] · /drive (F2 releases) · /ai start · /ai <question> · /send <user> <file> · /sendroom <file> · /pw show password · /help full command list · PgUp/PgDn scroll chat · ctrl-q quit");
|
||||
}
|
||||
Net::Message(l) => self.push_line(l),
|
||||
Net::Roster { users, capacity } => {
|
||||
@@ -1036,6 +1037,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
pending.cols,
|
||||
pending.start_daemon,
|
||||
pending.install_first,
|
||||
pending.gui,
|
||||
Some(password),
|
||||
pty_tx.clone(),
|
||||
broker_tx.clone(),
|
||||
@@ -1112,7 +1114,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
});
|
||||
}
|
||||
} else if app.vbox_picker.is_some() {
|
||||
// Modal VM picker (from a bare `/sbx launch vbox`): arrow
|
||||
// Modal VM picker (from a bare `/sbx vbox`): arrow
|
||||
// keys move the highlight, Enter/Tab boots the selected VM
|
||||
// on this machine (host-frictionless path; a non-host who
|
||||
// needs install/import is steered to re-issue with `yes`),
|
||||
@@ -1218,7 +1220,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
announced_dims = None; // re-sync PTY to the new height
|
||||
app.sys(format!("⛧ {}", app.layout.describe()));
|
||||
} else {
|
||||
app.sys("no sandbox to fullscreen — /sbx launch first");
|
||||
app.sys("no sandbox to fullscreen — /sbx <type> first");
|
||||
}
|
||||
} else if k.code == KeyCode::F(5) {
|
||||
// Enter/cycle interactive layout editing: pick a pane to
|
||||
@@ -1429,7 +1431,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
{
|
||||
// A received VirtualBox appliance auto-imports so the VM
|
||||
// registers locally and the recipient can immediately
|
||||
// `/sbx launch vbox gui <vm>`. Off-thread (VBoxManage import
|
||||
// `/sbx vbox gui <vm>`. Off-thread (VBoxManage import
|
||||
// is slow); reports back via the app channel.
|
||||
let ext = path
|
||||
.extension()
|
||||
@@ -1445,7 +1447,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
.await;
|
||||
let _ = match res {
|
||||
Ok(Ok(vm)) => tx.send(Net::Sys(format!(
|
||||
"⛧ imported VM ‘{vm}’ — `/sbx launch vbox gui {vm}` to boot it"
|
||||
"⛧ imported VM ‘{vm}’ — `/sbx vbox gui {vm}` to boot it"
|
||||
))),
|
||||
Ok(Err(e)) => tx.send(Net::Sys(format!(
|
||||
"(received .ova not auto-imported: {e})"
|
||||
@@ -1500,11 +1502,12 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
// The status/data handlers are idempotent, so members
|
||||
// already in the room aren't disturbed by the replay.
|
||||
if broker.is_some() {
|
||||
if let (Some(v), Some((be, _))) = (&app.sandbox, &broker_meta) {
|
||||
if let (Some(v), Some((be, sbx_name))) = (&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
|
||||
"backend": be.label(),"engine": be.engine(),"name": sbx_name,
|
||||
"rows": rows,"cols": cols
|
||||
}));
|
||||
let snap = v.parser.screen().contents_formatted();
|
||||
send_frame(&out_tx, &session.room, json!({
|
||||
@@ -1541,7 +1544,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
match msg {
|
||||
Some(BrokerMsg::Ready { sb, backend, name, rows, cols }) => {
|
||||
broker = Some(sb);
|
||||
broker_meta = Some((backend, name));
|
||||
broker_meta = Some((backend, name.clone()));
|
||||
announced_dims = Some((rows, cols));
|
||||
launching = false;
|
||||
// Local sandbox view — broker renders straight from the PTY.
|
||||
@@ -1562,7 +1565,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
}
|
||||
}
|
||||
send_frame(&out_tx, &session.room, json!({
|
||||
"_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols
|
||||
"_sbx":"status","state":"ready","backend": backend.label(),
|
||||
"engine": backend.engine(),"name": name,"rows": rows,"cols": cols
|
||||
}));
|
||||
broadcast_acl(&out_tx, &session.room, &app);
|
||||
}
|
||||
@@ -1582,12 +1586,13 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||
app.sys("⛧ websocket re-attached — syncing…");
|
||||
// If we host the sandbox, re-announce it so the
|
||||
// rest of the house re-syncs the shared shell.
|
||||
if let Some((be, _)) = &broker_meta {
|
||||
if let Some((be, sbx_name)) = &broker_meta {
|
||||
if let Some(v) = &app.sandbox {
|
||||
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
|
||||
"backend": be.label(),"engine": be.engine(),"name": sbx_name,
|
||||
"rows": rows,"cols": cols
|
||||
}));
|
||||
broadcast_acl(&out_tx, &session.room, &app);
|
||||
}
|
||||
@@ -1851,7 +1856,7 @@ fn handle_command(
|
||||
} else if line == "/drive" {
|
||||
// Mobile-friendly alternative to F2 (no function key needed).
|
||||
if app.sandbox.is_none() {
|
||||
app.sys("no sandbox running — /sbx launch first");
|
||||
app.sys("no sandbox running — /sbx <type> first");
|
||||
} else if app.can_drive() {
|
||||
app.driving = true;
|
||||
app.sys("⛧ drive mode ON — type into the shell (Esc reaches vim etc.) · press F2 to release");
|
||||
@@ -1908,12 +1913,27 @@ fn handle_command(
|
||||
} else if let Some(rest) = line.strip_prefix("/sbx") {
|
||||
let mut p = rest.split_whitespace();
|
||||
match p.next() {
|
||||
Some("launch") => {
|
||||
// Grammar: `/sbx <vm type> <option>` — the backend leads, e.g.
|
||||
// `/sbx podman gui`, `/sbx docker`, `/sbx multipass`, `/sbx local`.
|
||||
// vbox is the exception that takes extra options (a VM name, `new`,
|
||||
// confirm): `/sbx vbox gui <vm>`, `/sbx vbox new [name]`.
|
||||
// `launch` stays accepted as a transitional alias (`/sbx launch
|
||||
// <backend> …`) so older muscle memory and help strings keep working.
|
||||
Some(
|
||||
sub @ ("launch" | "docker" | "podman" | "multipass" | "local" | "vbox"
|
||||
| "virtualbox"),
|
||||
) => {
|
||||
// `--start` (alias `--start-daemon` / `-y`) opts in to booting a
|
||||
// stopped Docker daemon; everything else is positional. The first
|
||||
// positional selects the backend: docker | multipass | vbox |
|
||||
// local. Each runs on the *invoker's* own machine.
|
||||
let args: Vec<&str> = p.collect();
|
||||
// positional selects the backend: docker | podman | multipass |
|
||||
// vbox | local. Each runs on the *invoker's* own machine.
|
||||
let mut args: Vec<&str> = p.collect();
|
||||
// Backend-led form: the matched token IS the backend, so push it to
|
||||
// the front of the positional args where the `first` logic below
|
||||
// expects it. The `launch` alias leaves the backend in `args` as-is.
|
||||
if sub != "launch" {
|
||||
args.insert(0, sub);
|
||||
}
|
||||
let start_daemon = args
|
||||
.iter()
|
||||
.any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
|
||||
@@ -1921,28 +1941,32 @@ fn handle_command(
|
||||
// `install` opts in to installing it (detect-then-install). It's
|
||||
// a positional keyword (not the image), so filter it out below.
|
||||
let want_install = args.iter().any(|a| matches!(*a, "install" | "--install"));
|
||||
// `gui` option (`/sbx podman gui`): provision an XFCE/noVNC desktop
|
||||
// in the container and publish it on the host loopback. Keyword, not
|
||||
// an image, so it's filtered out of the positionals like `install`.
|
||||
let want_gui = args.iter().any(|a| matches!(*a, "gui"));
|
||||
let mut pos = args
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|a| !a.starts_with('-') && !matches!(*a, "install"));
|
||||
.filter(|a| !a.starts_with('-') && !matches!(*a, "install" | "gui"));
|
||||
let first = pos.next();
|
||||
if matches!(first, Some("virtualbox") | Some("vbox")) {
|
||||
// VirtualBox runs locally in its own GUI — host & guest each
|
||||
// open their own copy, nothing is relayed. It's independent of
|
||||
// the shared-PTY sandbox, so it bypasses the "already running"
|
||||
// guard. Grammar: `/sbx launch vbox [gui] <vm> [yes]`; a bare
|
||||
// `/sbx launch vbox` opens the arrow-navigable VM picker.
|
||||
// guard. Grammar: `/sbx vbox [gui] <vm> [yes]`; a bare
|
||||
// `/sbx vbox` opens the arrow-navigable VM picker.
|
||||
let mut rest = pos.peekable();
|
||||
if rest.peek() == Some(&"new") {
|
||||
// `/sbx launch vbox new [name]` — build a brand-new VM from
|
||||
// `/sbx vbox new [name]` — build a brand-new VM from
|
||||
// a cloud image (cloud-init toolchain), not an existing one.
|
||||
rest.next();
|
||||
let name = rest.next().unwrap_or("hh-vbox").to_string();
|
||||
launch_vbox_new(app, name, app.me.clone(), app_tx);
|
||||
} else {
|
||||
if rest.peek() == Some(&"gui") {
|
||||
rest.next(); // optional `gui` keyword (sugar)
|
||||
}
|
||||
// The optional `gui` keyword is already stripped from `pos`
|
||||
// (it's a global option filter), so the next positional is
|
||||
// the VM name directly.
|
||||
match rest.next() {
|
||||
Some(vm) => {
|
||||
let confirmed = rest
|
||||
@@ -1962,18 +1986,31 @@ fn handle_command(
|
||||
.next()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| backend.default_image().to_string());
|
||||
// Is this backend's binary present? Docker/Multipass can be
|
||||
// installed on consent; Local needs nothing and vbox is
|
||||
// handled in its own branch above.
|
||||
// Is this backend's binary present? Docker/Podman/Multipass can
|
||||
// be installed on consent; Local needs nothing and vbox is
|
||||
// handled in its own branch above. Podman is daemonless and
|
||||
// rootless, so unlike Docker it skips the daemon/sudo gates
|
||||
// below entirely (those are guarded by `== Backend::Docker`).
|
||||
let installed = match backend {
|
||||
sbx::Backend::Docker => sbx::docker_installed(),
|
||||
sbx::Backend::Podman => sbx::podman_installed(),
|
||||
sbx::Backend::Multipass => sbx::multipass_installed(),
|
||||
_ => true,
|
||||
};
|
||||
let token = first.unwrap_or("docker");
|
||||
// GUI is a container-only feature (headless containers get a
|
||||
// published noVNC port). Asking for it on multipass/local is a
|
||||
// no-op we flag rather than silently drop.
|
||||
let gui = want_gui && matches!(backend, sbx::Backend::Docker | sbx::Backend::Podman);
|
||||
if want_gui && !gui {
|
||||
app.sys(format!(
|
||||
"`gui` only applies to docker/podman sandboxes — ignoring it for {}",
|
||||
backend.label()
|
||||
));
|
||||
}
|
||||
if !installed && !want_install {
|
||||
app.err(format!(
|
||||
"{} is not installed — retry with `/sbx launch {token} install` to install it (needs sudo)",
|
||||
"{} is not installed — retry with `/sbx {token} install` to install it (needs sudo)",
|
||||
backend.label()
|
||||
));
|
||||
} else if installed
|
||||
@@ -1981,7 +2018,7 @@ fn handle_command(
|
||||
&& !start_daemon
|
||||
&& !sbx::docker_daemon_up()
|
||||
{
|
||||
app.err("docker daemon is not running — retry with `/sbx launch docker --start` to boot it (sudo), or run ./scripts/ensure-docker.sh in a terminal first");
|
||||
app.err("docker daemon is not running — retry with `/sbx docker --start` to boot it (sudo), or run ./scripts/ensure-docker.sh in a terminal first");
|
||||
} else {
|
||||
// install_first ⇒ binary missing + consent given: install
|
||||
// it off-thread before provisioning (a fresh Docker
|
||||
@@ -2014,6 +2051,7 @@ fn handle_command(
|
||||
cols,
|
||||
start_daemon,
|
||||
install_first,
|
||||
gui,
|
||||
},
|
||||
});
|
||||
app.sys("🔒 sudo password needed — type it here (hidden), Enter to launch · Esc cancels. Local only: never sent to the room.");
|
||||
@@ -2030,6 +2068,12 @@ fn handle_command(
|
||||
backend.label()
|
||||
));
|
||||
}
|
||||
if gui {
|
||||
app.sys(format!(
|
||||
"🖥 gui sandbox: installing the desktop (heavy first pull) — when it's up, open http://127.0.0.1:{} in a browser (noVNC, default password 'hackhouse')",
|
||||
sbx::GUI_PORT
|
||||
));
|
||||
}
|
||||
spawn_launch(
|
||||
backend,
|
||||
image,
|
||||
@@ -2039,6 +2083,7 @@ fn handle_command(
|
||||
cols,
|
||||
start_daemon,
|
||||
install_first,
|
||||
gui,
|
||||
None,
|
||||
pty_tx.clone(),
|
||||
broker_tx.clone(),
|
||||
@@ -2109,7 +2154,7 @@ fn handle_command(
|
||||
}
|
||||
}
|
||||
Some("load") => match p.next() {
|
||||
None => app.sys("usage: /sbx load <label> (a docker or multipass snapshot saved via /sbx save)"),
|
||||
None => app.sys("usage: /sbx load <label> (a docker, podman or multipass snapshot saved via /sbx save)"),
|
||||
Some(label) if !is_snap_label(label) => {
|
||||
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
|
||||
}
|
||||
@@ -2141,7 +2186,7 @@ fn handle_command(
|
||||
match kind {
|
||||
sbx::SnapKind::Docker => {
|
||||
if !sbx::docker_daemon_up() {
|
||||
let _ = atx.send(Net::Err("docker daemon is not running — `/sbx launch docker --start` once to boot it, then retry".into()));
|
||||
let _ = atx.send(Net::Err("docker daemon is not running — `/sbx docker --start` once to boot it, then retry".into()));
|
||||
let _ = btx.send(BrokerMsg::Failed);
|
||||
return;
|
||||
}
|
||||
@@ -2149,7 +2194,17 @@ fn handle_command(
|
||||
let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}…")));
|
||||
spawn_launch(
|
||||
sbx::Backend::Docker, image, owner, members, rows,
|
||||
cols, false, false, None, pty, btx, atx,
|
||||
cols, false, false, false, None, pty, btx, atx,
|
||||
);
|
||||
}
|
||||
sbx::SnapKind::Podman => {
|
||||
// Podman is daemonless — no daemon-up check; the
|
||||
// committed image reruns directly via the engine.
|
||||
let image = format!("{}:{}", sbx::SNAP_REPO, label);
|
||||
let _ = atx.send(Net::Sys(format!("loading podman sandbox from {image}…")));
|
||||
spawn_launch(
|
||||
sbx::Backend::Podman, image, owner, members, rows,
|
||||
cols, false, false, false, None, pty, btx, atx,
|
||||
);
|
||||
}
|
||||
sbx::SnapKind::Multipass => {
|
||||
@@ -2164,7 +2219,7 @@ fn handle_command(
|
||||
spawn_launch(
|
||||
sbx::Backend::Multipass,
|
||||
sbx::Backend::Multipass.default_image().to_string(),
|
||||
owner, members, rows, cols, false, false, None, pty, btx, atx,
|
||||
owner, members, rows, cols, false, false, false, None, pty, btx, atx,
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
@@ -2324,9 +2379,9 @@ fn handle_command(
|
||||
}
|
||||
}
|
||||
Some("gui") => {
|
||||
// Convenience alias for `/sbx launch vbox gui <vm> [yes]` — opens a
|
||||
// Convenience alias for `/sbx vbox gui <vm> [yes]` — opens a
|
||||
// local VirtualBox VM's GUI on your own machine. Bare `/sbx gui`
|
||||
// opens the VM picker, same as `/sbx launch vbox`.
|
||||
// opens the VM picker, same as `/sbx vbox`.
|
||||
let mut gpos = p.filter(|a| !a.starts_with('-'));
|
||||
match gpos.next() {
|
||||
Some(vm) => {
|
||||
@@ -2337,9 +2392,17 @@ fn handle_command(
|
||||
None => open_vbox_picker(app),
|
||||
}
|
||||
}
|
||||
_ => app.sys(
|
||||
"usage: /sbx launch vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx launch vbox new [name] (fresh VM) · /sbx gui <vm> alias · launch <docker|multipass> [image] (or local) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>",
|
||||
),
|
||||
other => {
|
||||
let usage = "usage: /sbx <type> <option> — /sbx docker|podman|multipass|local [gui] [image] (gui = noVNC desktop, docker/podman only) · /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>";
|
||||
// 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))) {
|
||||
Some((bad, s)) => {
|
||||
app.sys(format!("unknown `/sbx {bad}` — did you mean `/sbx {s}`? {usage}"))
|
||||
}
|
||||
None => app.sys(usage.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("/unsudo") {
|
||||
let target = rest.trim();
|
||||
@@ -2433,12 +2496,32 @@ fn handle_command(
|
||||
if agent.is_some() {
|
||||
app.sys("an AI agent is already running from this client — /ai stop first");
|
||||
} else {
|
||||
// A trailing `allow` flag auto-grants the agent sandbox drive on launch.
|
||||
let raw = rest.trim();
|
||||
let (raw, grant_sbx) = match raw.strip_suffix("allow") {
|
||||
Some(head) if head.is_empty() || head.ends_with(' ') => (head.trim(), true),
|
||||
_ => (raw, false),
|
||||
};
|
||||
// Trailing flag words (any order): `allow` auto-grants the agent
|
||||
// sandbox drive on launch; `plain` selects the legacy one-shot
|
||||
// injector instead of the default Goose harness.
|
||||
let mut raw = rest.trim();
|
||||
let mut grant_sbx = false;
|
||||
let mut plain = false;
|
||||
loop {
|
||||
if let Some(head) = raw
|
||||
.strip_suffix("allow")
|
||||
.filter(|h| h.is_empty() || h.ends_with(' '))
|
||||
{
|
||||
grant_sbx = true;
|
||||
raw = head.trim();
|
||||
continue;
|
||||
}
|
||||
if let Some(head) = raw
|
||||
.strip_suffix("plain")
|
||||
.filter(|h| h.is_empty() || h.ends_with(' '))
|
||||
{
|
||||
plain = true;
|
||||
raw = head.trim();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
let harness = if plain { Some("simple") } else { None };
|
||||
// A bare name (no ':' tag, no '/' path) is a models.toml profile;
|
||||
// anything else is treated as a literal Ollama model tag.
|
||||
let (profile, model): (Option<&str>, &str) = if raw.is_empty() {
|
||||
@@ -2452,7 +2535,7 @@ fn handle_command(
|
||||
// profile keeps its label; a direct Ollama model uses its tag
|
||||
// (e.g. "qwen2.5:3b" — model name + parameter size).
|
||||
let name = profile.unwrap_or(model);
|
||||
match spawn_agent(params, &app.password, name, profile, model) {
|
||||
match spawn_agent(params, &app.password, name, profile, model, harness) {
|
||||
Ok(child) => {
|
||||
*agent = Some(child);
|
||||
app.agent_name = Some(name.to_string());
|
||||
@@ -2461,8 +2544,9 @@ fn handle_command(
|
||||
Some(p) => format!("profile {p}"),
|
||||
None => format!("ollama/{model}"),
|
||||
};
|
||||
let hdesc = if plain { ", simple harness" } else { "" };
|
||||
app.sys(format!(
|
||||
"⛧ summoning {name} ({desc})… it will announce when online"
|
||||
"⛧ summoning {name} ({desc}{hdesc})… it will announce when online"
|
||||
));
|
||||
if grant_sbx {
|
||||
// Grant now if a sandbox is already running; otherwise the
|
||||
@@ -2512,6 +2596,21 @@ fn handle_command(
|
||||
let _ = tx.send(Net::Sys(msg));
|
||||
});
|
||||
}
|
||||
} else if line.starts_with('/') {
|
||||
// Leading slash but no command branch matched. Either a known command
|
||||
// family that intentionally falls through to chat (notably `/ai
|
||||
// <question>`, which a running agent reads from the room), or a typo we
|
||||
// should help with instead of silently broadcasting as a chat message.
|
||||
let cmd = line.split_whitespace().next().unwrap_or(line);
|
||||
if KNOWN_COMMANDS.contains(&cmd) {
|
||||
if app.connected {
|
||||
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
|
||||
}
|
||||
} else if let Some(s) = closest(cmd, KNOWN_COMMANDS) {
|
||||
app.sys(format!("unknown command ‘{cmd}’ — did you mean `{s}`? (/help lists all)"));
|
||||
} else {
|
||||
app.sys(format!("unknown command ‘{cmd}’ — /help lists all commands"));
|
||||
}
|
||||
} else if !line.is_empty() && app.connected {
|
||||
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
|
||||
}
|
||||
@@ -2550,6 +2649,55 @@ fn local_ollama_models() -> Result<Vec<String>, String> {
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// Every top-level slash command (the leading token). Used both to recognise a
|
||||
/// known command family that legitimately falls through to chat (e.g. `/ai
|
||||
/// <question>`) and as the candidate set for the "did you mean" suggester.
|
||||
const KNOWN_COMMANDS: &[&str] = &[
|
||||
"/help", "/?", "/clear", "/cls", "/pw", "/password", "/theme", "/layout", "/drive",
|
||||
"/sendroom", "/send", "/accept", "/reject", "/sbx", "/unsudo", "/sudo", "/grant", "/revoke",
|
||||
"/ai",
|
||||
];
|
||||
|
||||
/// Canonical `/sbx` subcommands (backends + actions) for the subcommand-level
|
||||
/// "did you mean". Aliases (`launch`, `virtualbox`, `snapshots`, `gui`) are
|
||||
/// 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",
|
||||
];
|
||||
|
||||
/// Classic Levenshtein edit distance (insert/delete/substitute, each cost 1).
|
||||
fn levenshtein(a: &str, b: &str) -> usize {
|
||||
let a: Vec<char> = a.chars().collect();
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
let mut cur = vec![0usize; b.len() + 1];
|
||||
for (i, &ca) in a.iter().enumerate() {
|
||||
cur[0] = i + 1;
|
||||
for (j, &cb) in b.iter().enumerate() {
|
||||
let cost = usize::from(ca != cb);
|
||||
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev[b.len()]
|
||||
}
|
||||
|
||||
/// The nearest candidate to `input` by edit distance, if one is close enough to
|
||||
/// be a plausible typo: distance ≤ 3 and strictly less than the input length (so
|
||||
/// a tiny stray token doesn't "match" everything). Case-insensitive; ties go to
|
||||
/// the first candidate. Returns `None` when nothing is close.
|
||||
fn closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
|
||||
let input = input.to_ascii_lowercase();
|
||||
let len = input.chars().count();
|
||||
candidates
|
||||
.iter()
|
||||
.map(|c| (levenshtein(&input, &c.to_ascii_lowercase()), *c))
|
||||
.filter(|(d, _)| *d <= 3 && *d < len)
|
||||
.min_by_key(|(d, _)| *d)
|
||||
.map(|(_, c)| c)
|
||||
}
|
||||
|
||||
/// A safe snapshot label — compatible with both a Docker image tag and a
|
||||
/// multipass snapshot name (alphanumerics plus `.`, `_`, `-`).
|
||||
fn is_snap_label(s: &str) -> bool {
|
||||
@@ -2597,7 +2745,7 @@ fn find_local_appliance(vm: &str) -> Option<std::path::PathBuf> {
|
||||
/// Empty/uninstalled cases steer the user instead of opening an empty list.
|
||||
fn open_vbox_picker(app: &mut App) {
|
||||
if !sbx::vbox_installed() {
|
||||
app.sys("VirtualBox isn't installed — `/sbx launch vbox gui <vm> yes` installs it, or run ./scripts/ensure-vbox.sh");
|
||||
app.sys("VirtualBox isn't installed — `/sbx vbox gui <vm> yes` installs it, or run ./scripts/ensure-vbox.sh");
|
||||
return;
|
||||
}
|
||||
match sbx::list_vms() {
|
||||
@@ -2606,7 +2754,7 @@ fn open_vbox_picker(app: &mut App) {
|
||||
app.sys("⛧ pick a VM — ↑/↓ move · Enter/Tab choose · Esc dismiss");
|
||||
}
|
||||
Ok(_) => app.sys(
|
||||
"no VirtualBox VMs registered — a host can /send you a .ova, then `/sbx launch vbox gui <vm> yes` to import it",
|
||||
"no VirtualBox VMs registered — a host can /send you a .ova, then `/sbx vbox gui <vm> yes` to import it",
|
||||
),
|
||||
Err(e) => app.err(format!("listing VMs: {e}")),
|
||||
}
|
||||
@@ -2614,7 +2762,7 @@ fn open_vbox_picker(app: &mut App) {
|
||||
|
||||
/// Launch (or pull-then-launch) a local VirtualBox VM's GUI on the caller's OWN
|
||||
/// machine — nothing is relayed; every member opens their own copy. Shared by
|
||||
/// `/sbx launch vbox gui <vm>` and the `/sbx gui <vm>` alias.
|
||||
/// `/sbx vbox gui <vm>` and the `/sbx gui <vm>` alias.
|
||||
///
|
||||
/// Frictionless for a "host" who already has VirtualBox AND the VM imported with
|
||||
/// no other VM holding VT-x: it just boots. A non-host missing VirtualBox or the
|
||||
@@ -2695,7 +2843,7 @@ fn launch_vbox_gui(
|
||||
Some(p) => steps.push(format!("import {}", p.display())),
|
||||
None => {
|
||||
app.sys(format!(
|
||||
"no local appliance for ‘{vm}’ yet — have the host export it (`/sbx vmsave {vm} --local`) and /send you the .ova, then `/sbx launch vbox gui {vm} yes`"
|
||||
"no local appliance for ‘{vm}’ yet — have the host export it (`/sbx vmsave {vm} --local`) and /send you the .ova, then `/sbx vbox gui {vm} yes`"
|
||||
));
|
||||
return;
|
||||
}
|
||||
@@ -2705,7 +2853,7 @@ fn launch_vbox_gui(
|
||||
steps.push(format!("stop {} other VM(s) holding VT-x", conflicts.len()));
|
||||
}
|
||||
app.sys(format!(
|
||||
"opening ‘{vm}’ on YOUR machine will {}. → `/sbx launch vbox gui {vm} yes` to proceed",
|
||||
"opening ‘{vm}’ on YOUR machine will {}. → `/sbx vbox gui {vm} yes` to proceed",
|
||||
steps.join(", ")
|
||||
));
|
||||
return;
|
||||
@@ -2714,7 +2862,7 @@ fn launch_vbox_gui(
|
||||
// Confirmed. Refuse VT-x holders we can't cleanly restart rather than kill them.
|
||||
if let Some(bad) = conflicts.iter().find(|h| !h.stoppable) {
|
||||
app.err(format!(
|
||||
"can't free VT-x automatically — {} is running and I won't kill it. Stop it yourself, then retry `/sbx launch vbox gui {vm} yes`.",
|
||||
"can't free VT-x automatically — {} is running and I won't kill it. Stop it yourself, then retry `/sbx vbox gui {vm} yes`.",
|
||||
bad.label
|
||||
));
|
||||
return;
|
||||
@@ -2801,6 +2949,9 @@ fn spawn_launch(
|
||||
cols: u16,
|
||||
start_daemon: bool,
|
||||
install_first: bool,
|
||||
// GUI sandbox: publish noVNC on the host loopback and provision the desktop
|
||||
// stack inside the container. Only honoured for Docker/Podman.
|
||||
gui: bool,
|
||||
// The sudo password captured by the in-TUI masked prompt, if escalation was
|
||||
// needed. Local-only: it is fed to `sudo -S` via stdin inside the blocking
|
||||
// install/prepare steps and never enters chat, the PTY, or any outbound frame.
|
||||
@@ -2817,6 +2968,7 @@ fn spawn_launch(
|
||||
let pw = password.clone();
|
||||
let res = tokio::task::spawn_blocking(move || match backend {
|
||||
sbx::Backend::Docker => sbx::ensure_docker_install(pw),
|
||||
sbx::Backend::Podman => sbx::ensure_podman_install(),
|
||||
sbx::Backend::Multipass => sbx::ensure_multipass_install(),
|
||||
_ => Ok(()),
|
||||
})
|
||||
@@ -2830,7 +2982,7 @@ fn spawn_launch(
|
||||
let name = SBX_NAME.to_string();
|
||||
let prep = {
|
||||
let (n, img, pw) = (name.clone(), image.clone(), password.clone());
|
||||
tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon, pw))
|
||||
tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon, pw, gui))
|
||||
.await
|
||||
};
|
||||
if let Err(e) = prep.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
|
||||
@@ -2841,7 +2993,7 @@ fn spawn_launch(
|
||||
// Provision real unix accounts (owner = sudoer) → the shell's run-user.
|
||||
let run_user = {
|
||||
let (n, o, ms) = (name.clone(), owner.clone(), members.clone());
|
||||
tokio::task::spawn_blocking(move || sbx::provision(backend, &n, &o, &ms))
|
||||
tokio::task::spawn_blocking(move || sbx::provision(backend, &n, &o, &ms, gui))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
@@ -2904,6 +3056,7 @@ fn spawn_agent(
|
||||
name: &str,
|
||||
profile: Option<&str>,
|
||||
model: &str,
|
||||
harness: Option<&str>,
|
||||
) -> std::result::Result<std::process::Child, String> {
|
||||
use std::process::{Command, Stdio};
|
||||
let root = find_repo_root().ok_or_else(|| {
|
||||
@@ -2941,6 +3094,11 @@ fn spawn_agent(
|
||||
.arg(model);
|
||||
}
|
||||
}
|
||||
// Override the agent's default `!task` harness (goose) when the launcher
|
||||
// asked for the legacy one-shot injector via `/ai start … plain`.
|
||||
if let Some(h) = harness {
|
||||
cmd.arg("--harness").arg(h);
|
||||
}
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(log))
|
||||
.stderr(Stdio::from(log_err));
|
||||
|
||||
Reference in New Issue
Block a user