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

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:
leetcrypt
2026-06-08 11:38:43 -07:00
parent 18a8936caa
commit 15aa2027c4
4 changed files with 19 additions and 333 deletions
-57
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]",