refactor(sandbox): remove container browser-GUI (noVNC), keep vbox VM GUI
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
The docker/podman headless-container desktop (XFCE + TigerVNC + websockify + noVNC published on host 127.0.0.1:6080) is removed. It was unreliable: the desktop stack installed asynchronously during provisioning with every step wrapped in `|| true` (silent failures), while the host port mapping existed the moment the container ran — so opening the browser before websockify bound 6080 inside the container reset the connection (ERR_CONNECTION_RESET), with no readiness signal and no loud failure. Containers also can't be rendered as a real desktop by VirtualBox (no framebuffer/display), so the only desktop path that stays is the native VirtualBox VM GUI. Removed: - sbx.rs: GUI_PORT const; PortHolder/port_holder()/kill_port_holder() (the port-consent gate existed only for the noVNC publish); the `gui` param + `-p 127.0.0.1:6080:6080` block in prepare(); HH_SBX_GUI env in dk_bootstrap(); the `gui` param on provision(). - app.rs: PendingGuiLaunch + PortPrompt structs; the port_prompt App field; the `gui` field on PendingSudoLaunch; the port-consent modal + the sudo-submit port check; want_gui/gui parsing + the container-GUI launch message; `gui` threaded through spawn_launch and all call sites. - sandbox-bootstrap.sh: the entire HH_SBX_GUI=1 desktop block. - ui.rs / usage strings: dropped "[gui]"/"noVNC desktop" from the docker/podman help and the /sbx usage line. Kept (unchanged): the VirtualBox VM GUI — gui_launch() (VBoxManage startvm --type gui), launch_vbox_gui, `/sbx vbox gui <vm>` and the `/sbx gui` alias. The `gui` keyword is still stripped from positionals so `/sbx vbox gui <vm>` parses. cargo check passes clean (no warnings). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,63 +50,6 @@ if ! apt-get install -y --no-install-recommends $PKGS; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---- Optional GUI desktop over noVNC -----------------------------------------
|
|
||||||
# Opt-in via HH_SBX_GUI=1 (set by the launcher for `/sbx <docker|podman> gui`).
|
|
||||||
# Containers are headless, so a GUI means: install a lightweight XFCE desktop +
|
|
||||||
# a VNC server, and bridge it to a browser-reachable noVNC websocket on port
|
|
||||||
# $HH_SBX_GUI_PORT (default 6080). The launcher publishes that port on the host
|
|
||||||
# loopback (127.0.0.1) only, so the desktop is reachable from this machine's
|
|
||||||
# browser but never the LAN. Heavy (pulls a desktop), hence strictly opt-in.
|
|
||||||
if [[ "${HH_SBX_GUI:-0}" == "1" ]]; then
|
|
||||||
GUI_PORT="${HH_SBX_GUI_PORT:-6080}"
|
|
||||||
GUI_PASS="${HH_SBX_GUI_PASS:-hackhouse}"
|
|
||||||
# XFCE (minimal) + a terminal, the X session glue, a VNC server, and the
|
|
||||||
# noVNC web client + websockify bridge. All apt — works on Ubuntu/Debian/Kali.
|
|
||||||
# tigervnc-tools carries `vncpasswd`, which --no-install-recommends would
|
|
||||||
# otherwise drop (it ships only as a recommend of the standalone server) —
|
|
||||||
# without it the passwd write below is a silent no-op and VNC never auths.
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
xfce4 xfce4-terminal dbus-x11 xfonts-base x11-xserver-utils \
|
|
||||||
tigervnc-standalone-server tigervnc-tools novnc websockify || true
|
|
||||||
|
|
||||||
export USER=root HOME=/root
|
|
||||||
# TigerVNC 1.15 (Kali/Debian trixie) reads its config from ~/.config/tigervnc
|
|
||||||
# and aborts with a fatal "Could not migrate /root/.vnc" if the legacy ~/.vnc
|
|
||||||
# exists, so write straight to the new path. /tmp/.X11-unix must exist + be
|
|
||||||
# sticky for the X socket; a fresh container may not have it.
|
|
||||||
VNCDIR=/root/.config/tigervnc
|
|
||||||
mkdir -p "$VNCDIR" /tmp/.X11-unix
|
|
||||||
chmod 1777 /tmp/.X11-unix 2>/dev/null || true
|
|
||||||
# Non-interactive VNC password (obfuscated, not strong auth — the real gate is
|
|
||||||
# the loopback-only publish + the room password). vncpasswd -f reads stdin.
|
|
||||||
printf '%s\n' "$GUI_PASS" | vncpasswd -f > "$VNCDIR/passwd" 2>/dev/null || true
|
|
||||||
chmod 600 "$VNCDIR/passwd" 2>/dev/null || true
|
|
||||||
cat > "$VNCDIR/xstartup" <<'XS'
|
|
||||||
#!/bin/sh
|
|
||||||
unset SESSION_MANAGER DBUS_SESSION_BUS_ADDRESS
|
|
||||||
exec dbus-launch startxfce4
|
|
||||||
XS
|
|
||||||
chmod +x "$VNCDIR/xstartup"
|
|
||||||
|
|
||||||
# Start the VNC server on display :1 (TCP 5901). -localhost no lets websockify
|
|
||||||
# (running in the same netns) reach it; nothing outside the container can —
|
|
||||||
# only $GUI_PORT is published, and only to host loopback.
|
|
||||||
if command -v vncserver >/dev/null 2>&1; then
|
|
||||||
vncserver -kill :1 >/dev/null 2>&1 || true
|
|
||||||
rm -f /tmp/.X1-lock /tmp/.X11-unix/X1 2>/dev/null || true
|
|
||||||
vncserver :1 -geometry 1280x800 -depth 24 -localhost no >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
# noVNC web client → websocket → local VNC. Daemonize (nohup, detached stdio)
|
|
||||||
# so it outlives this `exec bash -s` provision session and keeps serving for
|
|
||||||
# the life of the container (PID 1 is `sleep infinity`).
|
|
||||||
if command -v websockify >/dev/null 2>&1; then
|
|
||||||
NOVNC_WEB=/usr/share/novnc
|
|
||||||
[[ -d "$NOVNC_WEB" ]] || NOVNC_WEB=/usr/share/webapps/novnc
|
|
||||||
nohup websockify --web="$NOVNC_WEB" "$GUI_PORT" localhost:5901 \
|
|
||||||
>/var/log/hh-novnc.log 2>&1 &
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- Goose harness (the /ai agent's sandbox `!task` path) --------------------
|
# ---- Goose harness (the /ai agent's sandbox `!task` path) --------------------
|
||||||
# Goose ships as a release binary (not apt), so install it via its official
|
# Goose ships as a release binary (not apt), so install it via its official
|
||||||
# installer into a system path so every container user can run it. Best-effort:
|
# installer into a system path so every container user can run it. Best-effort:
|
||||||
|
|||||||
+11
-160
@@ -185,7 +185,6 @@ pub struct PendingSudoLaunch {
|
|||||||
pub cols: u16,
|
pub cols: u16,
|
||||||
pub start_daemon: bool,
|
pub start_daemon: bool,
|
||||||
pub install_first: bool,
|
pub install_first: bool,
|
||||||
pub gui: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch parameters captured when installing VirtualBox needs root but sudo
|
/// Launch parameters captured when installing VirtualBox needs root but sudo
|
||||||
@@ -218,31 +217,6 @@ pub struct SudoPrompt {
|
|||||||
pub pending: PendingPrivileged,
|
pub pending: PendingPrivileged,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch parameters captured when a GUI sandbox's noVNC port is already bound
|
|
||||||
/// by another process. Held aside while the port-consent modal asks whether to
|
|
||||||
/// kill the holder; the launch fires once the owner confirms. Carries the
|
|
||||||
/// (already-collected) sudo password, if any, so the gate works on both the
|
|
||||||
/// no-sudo and sudo-submit launch paths.
|
|
||||||
pub struct PendingGuiLaunch {
|
|
||||||
pub backend: sbx::Backend,
|
|
||||||
pub image: String,
|
|
||||||
pub members: Vec<String>,
|
|
||||||
pub rows: u16,
|
|
||||||
pub cols: u16,
|
|
||||||
pub start_daemon: bool,
|
|
||||||
pub install_first: bool,
|
|
||||||
pub gui: bool,
|
|
||||||
pub password: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Active "port in use — kill it?" consent prompt for a GUI sandbox launch.
|
|
||||||
/// While `Some`, y/n keystrokes resolve this instead of feeding chat.
|
|
||||||
pub struct PortPrompt {
|
|
||||||
pub pid: i32,
|
|
||||||
pub holder: String,
|
|
||||||
pub pending: PendingGuiLaunch,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub me: String,
|
pub me: String,
|
||||||
pub lines: Vec<ChatLine>,
|
pub lines: Vec<ChatLine>,
|
||||||
@@ -306,10 +280,6 @@ pub struct App {
|
|||||||
/// and creds aren't cached). While `Some`, keystrokes feed this masked
|
/// and creds aren't cached). While `Some`, keystrokes feed this masked
|
||||||
/// buffer instead of chat — the secret never leaves the client.
|
/// buffer instead of chat — the secret never leaves the client.
|
||||||
pub sudo_prompt: Option<SudoPrompt>,
|
pub sudo_prompt: Option<SudoPrompt>,
|
||||||
/// Active "noVNC port already in use — kill the holder?" consent prompt for a
|
|
||||||
/// GUI sandbox launch (None unless the port is busy). While `Some`, y/n keys
|
|
||||||
/// resolve it instead of feeding chat.
|
|
||||||
pub port_prompt: Option<PortPrompt>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -346,7 +316,6 @@ impl App {
|
|||||||
layout: Layout::default(),
|
layout: Layout::default(),
|
||||||
focused_pane: None,
|
focused_pane: None,
|
||||||
sudo_prompt: None,
|
sudo_prompt: None,
|
||||||
port_prompt: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +431,7 @@ impl App {
|
|||||||
self.connected = true;
|
self.connected = true;
|
||||||
self.chat_scroll = 0;
|
self.chat_scroll = 0;
|
||||||
self.sys(format!("joined as {} ⛧", self.me));
|
self.sys(format!("joined as {} ⛧", self.me));
|
||||||
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");
|
self.sys("/sbx <docker|podman|multipass|vbox|local> · /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::Message(l) => self.push_line(l),
|
||||||
Net::Roster { users, capacity } => {
|
Net::Roster { users, capacity } => {
|
||||||
@@ -1079,33 +1048,6 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
} else {
|
} else {
|
||||||
match pending {
|
match pending {
|
||||||
PendingPrivileged::Launch(pending) => {
|
PendingPrivileged::Launch(pending) => {
|
||||||
if let Some(h) =
|
|
||||||
if pending.gui { sbx::port_holder(sbx::GUI_PORT) } else { None }
|
|
||||||
{
|
|
||||||
// Root's now authenticated, but the noVNC port
|
|
||||||
// is taken — carry the password into the
|
|
||||||
// port-consent gate so the launch can still fire
|
|
||||||
// (with sudo) once the holder is cleared.
|
|
||||||
app.sys(format!(
|
|
||||||
"⚠ port {} (noVNC) in use by pid {} ({}) — kill it and proceed? [y/N] · Esc cancels",
|
|
||||||
sbx::GUI_PORT, h.pid, h.name
|
|
||||||
));
|
|
||||||
app.port_prompt = Some(PortPrompt {
|
|
||||||
pid: h.pid,
|
|
||||||
holder: h.name,
|
|
||||||
pending: PendingGuiLaunch {
|
|
||||||
backend: pending.backend,
|
|
||||||
image: pending.image,
|
|
||||||
members: pending.members,
|
|
||||||
rows: pending.rows,
|
|
||||||
cols: pending.cols,
|
|
||||||
start_daemon: pending.start_daemon,
|
|
||||||
install_first: pending.install_first,
|
|
||||||
gui: pending.gui,
|
|
||||||
password: Some(password),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
launching = true;
|
launching = true;
|
||||||
app.sys("🔒 authenticating + launching…");
|
app.sys("🔒 authenticating + launching…");
|
||||||
spawn_launch(
|
spawn_launch(
|
||||||
@@ -1117,14 +1059,12 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
pending.cols,
|
pending.cols,
|
||||||
pending.start_daemon,
|
pending.start_daemon,
|
||||||
pending.install_first,
|
pending.install_first,
|
||||||
pending.gui,
|
|
||||||
Some(password),
|
Some(password),
|
||||||
pty_tx.clone(),
|
pty_tx.clone(),
|
||||||
broker_tx.clone(),
|
broker_tx.clone(),
|
||||||
app_tx.clone(),
|
app_tx.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
PendingPrivileged::VboxInstall(p) => {
|
PendingPrivileged::VboxInstall(p) => {
|
||||||
// Root authenticated → install VirtualBox
|
// Root authenticated → install VirtualBox
|
||||||
// (sudo -S reads this password) then boot
|
// (sudo -S reads this password) then boot
|
||||||
@@ -1163,50 +1103,6 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
} else if app.port_prompt.is_some() {
|
|
||||||
// GUI-launch port-consent modal: the noVNC port is held
|
|
||||||
// by another process. y/Y kills the named pid then fires
|
|
||||||
// the held launch; n/N/Esc aborts. Swallows keys so the
|
|
||||||
// y/n never lands in chat.
|
|
||||||
match k.code {
|
|
||||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
|
||||||
let PortPrompt { pid, holder, pending } =
|
|
||||||
app.port_prompt.take().unwrap();
|
|
||||||
app.sys(format!(
|
|
||||||
"⛧ killing {holder} (pid {pid}) to free port {}…",
|
|
||||||
sbx::GUI_PORT
|
|
||||||
));
|
|
||||||
if sbx::kill_port_holder(sbx::GUI_PORT, pid) {
|
|
||||||
launching = true;
|
|
||||||
app.sys("🖥 port freed — launching…");
|
|
||||||
spawn_launch(
|
|
||||||
pending.backend,
|
|
||||||
pending.image,
|
|
||||||
app.me.clone(),
|
|
||||||
pending.members,
|
|
||||||
pending.rows,
|
|
||||||
pending.cols,
|
|
||||||
pending.start_daemon,
|
|
||||||
pending.install_first,
|
|
||||||
pending.gui,
|
|
||||||
pending.password,
|
|
||||||
pty_tx.clone(),
|
|
||||||
broker_tx.clone(),
|
|
||||||
app_tx.clone(),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
app.sys(format!(
|
|
||||||
"⚠ couldn't free port {} — launch aborted",
|
|
||||||
sbx::GUI_PORT
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
|
||||||
app.port_prompt = None;
|
|
||||||
app.sys("⛧ launch aborted — port left untouched");
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
} else if k.modifiers.contains(KeyModifiers::CONTROL)
|
} else if k.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
&& matches!(k.code, KeyCode::Char('x'))
|
&& matches!(k.code, KeyCode::Char('x'))
|
||||||
&& !app.driving
|
&& !app.driving
|
||||||
@@ -2060,7 +1956,7 @@ fn handle_command(
|
|||||||
let mut p = rest.split_whitespace();
|
let mut p = rest.split_whitespace();
|
||||||
match p.next() {
|
match p.next() {
|
||||||
// Grammar: `/sbx <vm type> <option>` — the backend leads, e.g.
|
// Grammar: `/sbx <vm type> <option>` — the backend leads, e.g.
|
||||||
// `/sbx podman gui`, `/sbx docker`, `/sbx multipass`, `/sbx local`.
|
// `/sbx podman`, `/sbx docker`, `/sbx multipass`, `/sbx local`.
|
||||||
// vbox is the exception that takes extra options (a VM name, `new`,
|
// vbox is the exception that takes extra options (a VM name, `new`,
|
||||||
// confirm): `/sbx vbox gui <vm>`, `/sbx vbox new [name]`.
|
// confirm): `/sbx vbox gui <vm>`, `/sbx vbox new [name]`.
|
||||||
// `launch` stays accepted as a transitional alias (`/sbx launch
|
// `launch` stays accepted as a transitional alias (`/sbx launch
|
||||||
@@ -2087,10 +1983,9 @@ fn handle_command(
|
|||||||
// `install` opts in to installing it (detect-then-install). It's
|
// `install` opts in to installing it (detect-then-install). It's
|
||||||
// a positional keyword (not the image), so filter it out below.
|
// a positional keyword (not the image), so filter it out below.
|
||||||
let want_install = args.iter().any(|a| matches!(*a, "install" | "--install"));
|
let want_install = args.iter().any(|a| matches!(*a, "install" | "--install"));
|
||||||
// `gui` option (`/sbx podman gui`): provision an XFCE/noVNC desktop
|
// `gui` is only meaningful for the vbox path (`/sbx vbox gui <vm>`);
|
||||||
// in the container and publish it on the host loopback. Keyword, not
|
// it's a keyword, not an image, so strip it from the positionals like
|
||||||
// an image, so it's filtered out of the positionals like `install`.
|
// `install` so the VM name is the next positional.
|
||||||
let want_gui = args.iter().any(|a| matches!(*a, "gui"));
|
|
||||||
let mut pos = args
|
let mut pos = args
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
@@ -2144,16 +2039,6 @@ fn handle_command(
|
|||||||
_ => true,
|
_ => true,
|
||||||
};
|
};
|
||||||
let token = first.unwrap_or("docker");
|
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 {
|
if !installed && !want_install {
|
||||||
app.err(format!(
|
app.err(format!(
|
||||||
"{} is not installed — retry with `/sbx {token} install` to install it (needs sudo)",
|
"{} is not installed — retry with `/sbx {token} install` to install it (needs sudo)",
|
||||||
@@ -2197,33 +2082,9 @@ fn handle_command(
|
|||||||
cols,
|
cols,
|
||||||
start_daemon,
|
start_daemon,
|
||||||
install_first,
|
install_first,
|
||||||
gui,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
app.sys("🔒 sudo password needed — type it here (hidden), Enter to launch · Esc cancels. Local only: never sent to the room.");
|
app.sys("🔒 sudo password needed — type it here (hidden), Enter to launch · Esc cancels. Local only: never sent to the room.");
|
||||||
} else if let Some(h) = if gui { sbx::port_holder(sbx::GUI_PORT) } else { None } {
|
|
||||||
// The noVNC port is already bound (a stale sandbox, a
|
|
||||||
// leftover websockify, etc.). Don't blindly collide —
|
|
||||||
// ask the owner whether to kill the holder, then launch.
|
|
||||||
app.sys(format!(
|
|
||||||
"⚠ port {} (noVNC) in use by pid {} ({}) — kill it and proceed? [y/N] · Esc cancels",
|
|
||||||
sbx::GUI_PORT, h.pid, h.name
|
|
||||||
));
|
|
||||||
app.port_prompt = Some(PortPrompt {
|
|
||||||
pid: h.pid,
|
|
||||||
holder: h.name,
|
|
||||||
pending: PendingGuiLaunch {
|
|
||||||
backend,
|
|
||||||
image,
|
|
||||||
members,
|
|
||||||
rows,
|
|
||||||
cols,
|
|
||||||
start_daemon,
|
|
||||||
install_first,
|
|
||||||
gui,
|
|
||||||
password: None,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
*launching = true;
|
*launching = true;
|
||||||
if install_first {
|
if install_first {
|
||||||
@@ -2237,12 +2098,6 @@ fn handle_command(
|
|||||||
backend.label()
|
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(
|
spawn_launch(
|
||||||
backend,
|
backend,
|
||||||
image,
|
image,
|
||||||
@@ -2252,7 +2107,6 @@ fn handle_command(
|
|||||||
cols,
|
cols,
|
||||||
start_daemon,
|
start_daemon,
|
||||||
install_first,
|
install_first,
|
||||||
gui,
|
|
||||||
None,
|
None,
|
||||||
pty_tx.clone(),
|
pty_tx.clone(),
|
||||||
broker_tx.clone(),
|
broker_tx.clone(),
|
||||||
@@ -2363,7 +2217,7 @@ fn handle_command(
|
|||||||
let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}…")));
|
let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}…")));
|
||||||
spawn_launch(
|
spawn_launch(
|
||||||
sbx::Backend::Docker, image, owner, members, rows,
|
sbx::Backend::Docker, image, owner, members, rows,
|
||||||
cols, false, false, false, None, pty, btx, atx,
|
cols, false, false, None, pty, btx, atx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
sbx::SnapKind::Podman => {
|
sbx::SnapKind::Podman => {
|
||||||
@@ -2373,7 +2227,7 @@ fn handle_command(
|
|||||||
let _ = atx.send(Net::Sys(format!("loading podman sandbox from {image}…")));
|
let _ = atx.send(Net::Sys(format!("loading podman sandbox from {image}…")));
|
||||||
spawn_launch(
|
spawn_launch(
|
||||||
sbx::Backend::Podman, image, owner, members, rows,
|
sbx::Backend::Podman, image, owner, members, rows,
|
||||||
cols, false, false, false, None, pty, btx, atx,
|
cols, false, false, None, pty, btx, atx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
sbx::SnapKind::Multipass => {
|
sbx::SnapKind::Multipass => {
|
||||||
@@ -2388,7 +2242,7 @@ fn handle_command(
|
|||||||
spawn_launch(
|
spawn_launch(
|
||||||
sbx::Backend::Multipass,
|
sbx::Backend::Multipass,
|
||||||
sbx::Backend::Multipass.default_image().to_string(),
|
sbx::Backend::Multipass.default_image().to_string(),
|
||||||
owner, members, rows, cols, false, false, false, None, pty, btx, atx,
|
owner, members, rows, cols, false, false, None, pty, btx, atx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
@@ -2562,7 +2416,7 @@ fn handle_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
other => {
|
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>";
|
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>";
|
||||||
// Bare `/sbx` → usage. An unrecognised subcommand → suggest the
|
// Bare `/sbx` → usage. An unrecognised subcommand → suggest the
|
||||||
// closest documented one before the usage line.
|
// closest documented one before the usage line.
|
||||||
match other.and_then(|bad| closest(bad, SBX_SUBCOMMANDS).map(|s| (bad, s))) {
|
match other.and_then(|bad| closest(bad, SBX_SUBCOMMANDS).map(|s| (bad, s))) {
|
||||||
@@ -3145,9 +2999,6 @@ fn spawn_launch(
|
|||||||
cols: u16,
|
cols: u16,
|
||||||
start_daemon: bool,
|
start_daemon: bool,
|
||||||
install_first: 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
|
// 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
|
// 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.
|
// install/prepare steps and never enters chat, the PTY, or any outbound frame.
|
||||||
@@ -3178,7 +3029,7 @@ fn spawn_launch(
|
|||||||
let name = SBX_NAME.to_string();
|
let name = SBX_NAME.to_string();
|
||||||
let prep = {
|
let prep = {
|
||||||
let (n, img, pw) = (name.clone(), image.clone(), password.clone());
|
let (n, img, pw) = (name.clone(), image.clone(), password.clone());
|
||||||
tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon, pw, gui))
|
tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon, pw))
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
if let Err(e) = prep.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
|
if let Err(e) = prep.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
|
||||||
@@ -3189,7 +3040,7 @@ fn spawn_launch(
|
|||||||
// Provision real unix accounts (owner = sudoer) → the shell's run-user.
|
// Provision real unix accounts (owner = sudoer) → the shell's run-user.
|
||||||
let run_user = {
|
let run_user = {
|
||||||
let (n, o, ms) = (name.clone(), owner.clone(), members.clone());
|
let (n, o, ms) = (name.clone(), owner.clone(), members.clone());
|
||||||
tokio::task::spawn_blocking(move || sbx::provision(backend, &n, &o, &ms, gui))
|
tokio::task::spawn_blocking(move || sbx::provision(backend, &n, &o, &ms))
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-112
@@ -27,99 +27,6 @@ const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandb
|
|||||||
/// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/).
|
/// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/).
|
||||||
const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh");
|
const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh");
|
||||||
|
|
||||||
/// Port a GUI sandbox serves noVNC on, both inside the container and on the host
|
|
||||||
/// loopback (`-p 127.0.0.1:6080:6080`). The bootstrap bridges the container's VNC
|
|
||||||
/// server (:1 / 5901) to this port via websockify; the owner opens it in a browser.
|
|
||||||
pub const GUI_PORT: u16 = 6080;
|
|
||||||
|
|
||||||
/// A process currently bound to a TCP port we want to use.
|
|
||||||
pub struct PortHolder {
|
|
||||||
pub pid: i32,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the process listening on `port` (host loopback/any), if any.
|
|
||||||
///
|
|
||||||
/// Parses `ss -H -ltnp` — one listening socket per line, e.g.
|
|
||||||
/// `LISTEN 0 4096 127.0.0.1:6080 0.0.0.0:* users:(("websockify",pid=3395944,fd=3))`.
|
|
||||||
/// We match the local-address column (index 3) ending in `:port` and pull the
|
|
||||||
/// first `("name",pid=N` out of the `users:(...)` column. Returns `None` if the
|
|
||||||
/// port is free or `ss` can't report the owner (no `-p` perms → no name/pid).
|
|
||||||
pub fn port_holder(port: u16) -> Option<PortHolder> {
|
|
||||||
let out = Command::new("ss")
|
|
||||||
.args(["-H", "-ltnp"])
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
|
||||||
let suffix = format!(":{port}");
|
|
||||||
for line in text.lines() {
|
|
||||||
let cols: Vec<&str> = line.split_whitespace().collect();
|
|
||||||
if cols.len() < 4 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Local address is the 4th column (Recv-Q is dropped by some ss builds,
|
|
||||||
// so guard by scanning for the column that ends in `:<port>`).
|
|
||||||
let local_match = cols.iter().any(|c| c.ends_with(&suffix));
|
|
||||||
if !local_match {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// users:(("websockify",pid=3395944,fd=3),...)
|
|
||||||
let users = match line.split_once("users:(") {
|
|
||||||
Some((_, rest)) => rest,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let name = users
|
|
||||||
.split_once("(\"")
|
|
||||||
.and_then(|(_, r)| r.split_once('"'))
|
|
||||||
.map(|(n, _)| n.to_string())
|
|
||||||
.unwrap_or_else(|| "?".to_string());
|
|
||||||
let pid = users
|
|
||||||
.split_once("pid=")
|
|
||||||
.and_then(|(_, r)| {
|
|
||||||
let end = r.find(|c: char| !c.is_ascii_digit()).unwrap_or(r.len());
|
|
||||||
r[..end].parse::<i32>().ok()
|
|
||||||
});
|
|
||||||
if let Some(pid) = pid {
|
|
||||||
return Some(PortHolder { pid, name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Kill the process holding `port` and wait for the port to free up.
|
|
||||||
///
|
|
||||||
/// Sends SIGTERM first (graceful), then SIGKILL if it lingers, polling
|
|
||||||
/// `port_holder` until the port is free or a short timeout elapses. Returns
|
|
||||||
/// `true` once the port is free. Used by the GUI-launch consent gate after the
|
|
||||||
/// owner confirms killing the named pid.
|
|
||||||
pub fn kill_port_holder(port: u16, pid: i32) -> bool {
|
|
||||||
let _ = Command::new("kill")
|
|
||||||
.arg(pid.to_string())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.status();
|
|
||||||
for _ in 0..15 {
|
|
||||||
if port_holder(port).is_none() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
// Still bound after SIGTERM — escalate to SIGKILL.
|
|
||||||
let _ = Command::new("kill")
|
|
||||||
.args(["-9", &pid.to_string()])
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.status();
|
|
||||||
for _ in 0..15 {
|
|
||||||
if port_holder(port).is_none() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
port_holder(port).is_none()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is the `docker` binary installed? (`docker --version` succeeds.) This is a
|
/// 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
|
/// weaker check than `docker_daemon_up`: the CLI can be present while the daemon
|
||||||
/// is down. We need both before a Docker sandbox can launch.
|
/// is down. We need both before a Docker sandbox can launch.
|
||||||
@@ -690,11 +597,6 @@ pub fn prepare(
|
|||||||
image: &str,
|
image: &str,
|
||||||
start_daemon: bool,
|
start_daemon: bool,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
// GUI sandbox: publish the in-container noVNC port (6080) on the host loopback
|
|
||||||
// so a browser can reach the desktop. Containers are headless, so this is the
|
|
||||||
// only way out for a GUI — bound to 127.0.0.1 (never 0.0.0.0) to keep the
|
|
||||||
// surface local. Only meaningful for Docker/Podman; ignored otherwise.
|
|
||||||
gui: bool,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
match backend {
|
match backend {
|
||||||
Backend::Local => Ok(()),
|
Backend::Local => Ok(()),
|
||||||
@@ -768,13 +670,6 @@ pub fn prepare(
|
|||||||
} else if backend == Backend::Podman {
|
} else if backend == Backend::Podman {
|
||||||
run.arg("--network=slirp4netns:allow_host_loopback=true");
|
run.arg("--network=slirp4netns:allow_host_loopback=true");
|
||||||
}
|
}
|
||||||
// GUI sandbox: publish noVNC (container 6080 → host 127.0.0.1:6080).
|
|
||||||
// Loopback-only so the desktop is reachable from this machine's browser
|
|
||||||
// but not the LAN. The desktop+VNC stack itself is installed by the
|
|
||||||
// bootstrap when HH_SBX_GUI=1 (see dk_bootstrap / sandbox-bootstrap.sh).
|
|
||||||
if gui {
|
|
||||||
run.args(["-p", &format!("127.0.0.1:{GUI_PORT}:{GUI_PORT}")]);
|
|
||||||
}
|
|
||||||
run.args([image, "sleep", "infinity"]);
|
run.args([image, "sleep", "infinity"]);
|
||||||
let out = run
|
let out = run
|
||||||
.output()
|
.output()
|
||||||
@@ -1224,7 +1119,7 @@ fn sandbox_pkgs() -> String {
|
|||||||
/// Run the sandbox bootstrap inside the container, piping the script to `bash -s`
|
/// Run the sandbox bootstrap inside the container, piping the script to `bash -s`
|
||||||
/// on stdin (keeps it out of argv) with the resolved package list in the env.
|
/// on stdin (keeps it out of argv) with the resolved package list in the env.
|
||||||
/// Blocking — provision() is already off the UI thread.
|
/// Blocking — provision() is already off the UI thread.
|
||||||
fn dk_bootstrap(engine: &str, name: &str, gui: bool) {
|
fn dk_bootstrap(engine: &str, name: &str) {
|
||||||
let script = std::fs::read_to_string(SBX_BOOTSTRAP).unwrap_or_else(|_| {
|
let script = std::fs::read_to_string(SBX_BOOTSTRAP).unwrap_or_else(|_| {
|
||||||
// Degraded fallback if the script file is missing: still refresh the
|
// Degraded fallback if the script file is missing: still refresh the
|
||||||
// index and install whatever the env carries (at minimum vim+curl).
|
// index and install whatever the env carries (at minimum vim+curl).
|
||||||
@@ -1243,12 +1138,9 @@ fn dk_bootstrap(engine: &str, name: &str, gui: bool) {
|
|||||||
} else {
|
} else {
|
||||||
"HH_OLLAMA_HOST=http://host.docker.internal:11434"
|
"HH_OLLAMA_HOST=http://host.docker.internal:11434"
|
||||||
};
|
};
|
||||||
// HH_SBX_GUI=1 tells the bootstrap to also install + start the XFCE/noVNC
|
|
||||||
// desktop stack (heavy — only when the launcher asked for a GUI sandbox).
|
|
||||||
let gui_env = format!("HH_SBX_GUI={}", if gui { "1" } else { "0" });
|
|
||||||
let child = Command::new(engine)
|
let child = Command::new(engine)
|
||||||
.args([
|
.args([
|
||||||
"exec", "-i", "-e", &pkgs_env, "-e", gateway, "-e", &gui_env, name, "bash", "-s",
|
"exec", "-i", "-e", &pkgs_env, "-e", gateway, name, "bash", "-s",
|
||||||
])
|
])
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
@@ -1292,7 +1184,7 @@ fn mp_revoke_sudo(name: &str, u: &str) {
|
|||||||
/// Provision a real unix account per clergy member inside the VM/container and
|
/// Provision a real unix account per clergy member inside the VM/container and
|
||||||
/// make the owner a superuser (sudoer). Returns the unix user the shared shell
|
/// make the owner a superuser (sudoer). Returns the unix user the shared shell
|
||||||
/// should run as. Blocking — call off the UI thread.
|
/// should run as. Blocking — call off the UI thread.
|
||||||
pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String], gui: bool) -> String {
|
pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String]) -> String {
|
||||||
let run = unix_name(owner);
|
let run = unix_name(owner);
|
||||||
match backend {
|
match backend {
|
||||||
Backend::Multipass => {
|
Backend::Multipass => {
|
||||||
@@ -1315,7 +1207,7 @@ pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String],
|
|||||||
// sandbox comes up usable instead of bare. The script also refreshes
|
// sandbox comes up usable instead of bare. The script also refreshes
|
||||||
// the apt index (base images ship without /var/lib/apt/lists) and is
|
// the apt index (base images ship without /var/lib/apt/lists) and is
|
||||||
// idempotent + sentinel-guarded, so re-provisions stay fast.
|
// idempotent + sentinel-guarded, so re-provisions stay fast.
|
||||||
dk_bootstrap(engine, name, gui);
|
dk_bootstrap(engine, name);
|
||||||
for m in members {
|
for m in members {
|
||||||
let u = unix_name(m);
|
let u = unix_name(m);
|
||||||
if !u.is_empty() {
|
if !u.is_empty() {
|
||||||
|
|||||||
+4
-4
@@ -274,12 +274,12 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
items: vec![
|
items: vec![
|
||||||
// ── launch: /sbx <type> <option> (one backend token per line) ──
|
// ── launch: /sbx <type> <option> (one backend token per line) ──
|
||||||
kv(
|
kv(
|
||||||
"/sbx docker [gui] [image] [install]",
|
"/sbx docker [image] [install]",
|
||||||
"Linux container — shared shell relayed to the room (default parrotsec/core + auto dev toolchain; gui = noVNC desktop; append install if Docker is missing)",
|
"Linux container — shared shell relayed to the room (default parrotsec/core + auto dev toolchain; append install if Docker is missing)",
|
||||||
),
|
),
|
||||||
kv(
|
kv(
|
||||||
"/sbx podman [gui] [image] [install]",
|
"/sbx podman [image] [install]",
|
||||||
"rootless/daemonless container — no sudo modal (default kalilinux/kali-rolling; gui = noVNC desktop; append install if Podman is missing)",
|
"rootless/daemonless container — no sudo modal (default kalilinux/kali-rolling; append install if Podman is missing)",
|
||||||
),
|
),
|
||||||
kv(
|
kv(
|
||||||
"/sbx multipass [image] [install]",
|
"/sbx multipass [image] [install]",
|
||||||
|
|||||||
Reference in New Issue
Block a user