# Libvirt Powered Claude Sandbox

> Source: <https://gist.github.com/smith153/04b4068b5a2d7b234f1c3d5992dafe25>
> Published: 2026-07-18 18:04:04+00:00

|
#!/usr/bin/env bash |
|
set -euo pipefail |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# uat provisioner — LXQt + Chrome acceptance-testing desktop under qemu:///system. |
|
# Runs entirely as your normal user (no sudo): disks live in a path you own, |
|
# and libvirtd (already root) does the privileged VM/network work. |
|
# Keep uat-cloud-init.yaml and uat-net.xml next to this script. |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
# Pin a clean PATH so a custom python (pyenv/conda/etc.) can't shadow the system |
|
# python3 this script uses to render cloud-init. |
|
export PATH=/home/user/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games/:/usr/sbin/ |
|
|
|
VM=uat |
|
RAM=8192 # MB boot/resting target -> <currentMemory> (Chrome is hungry) |
|
MAXRAM=10240 # MB runtime ceiling -> <memory> |
|
VCPUS=4 |
|
DISK_GB=10 |
|
IMG_DIR=/home/Programs/VM/storage |
|
BASE=debian-13-generic-amd64.qcow2 |
|
# Official host cloud.debian.org is frequently down/slow. This ICM mirror is |
|
# reliable; swap back to the official URL if it ever lags. |
|
BASE_URL="https://ftp.icm.edu.pl/packages/linux-debian-cdimage/cloud/trixie/latest/${BASE}" |
|
#BASE_URL="https://cloud.debian.org/images/cloud/trixie/latest/${BASE}" |
|
MIN_SIZE=$((100 * 1024 * 1024)) # a real image is ~300 MB+; reject truncated files |
|
DISK="${IMG_DIR}/${VM}.qcow2" |
|
NET=uat-net |
|
CONN=qemu:///system |
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
|
CI_TEMPLATE="${HERE}/uat-cloud-init.yaml" |
|
CI_RENDERED="${HERE}/.uat-cloud-init.rendered.yaml" |
|
|
|
msg(){ printf '\033[1;36m==>\033[0m %s\n' "$*"; } |
|
die(){ printf '\033[1;31mERR\033[0m %s\n' "$*" >&2; exit 1; } |
|
trap 'rm -f "$CI_RENDERED"' EXIT |
|
|
|
# ── Teardown: completely remove the VM (storage + snapshots) and its network ── |
|
do_delete() { |
|
command -v virsh >/dev/null || die "virsh not found; nothing to tear down with." |
|
|
|
# Confirm before doing anything destructive, unless --force/--yes was given. |
|
if (( ! FORCE )); then |
|
printf '\033[1;31m' |
|
printf 'This will PERMANENTLY delete VM %s (disk + all snapshots) and network %s.\n' "$VM" "$NET" |
|
printf '\033[0m' |
|
if [[ -t 0 ]]; then |
|
read -r -p "Type 'y' to proceed [y/N]: " reply |
|
case "$reply" in |
|
y|Y|yes|YES) ;; |
|
*) die "Aborted; nothing was deleted." ;; |
|
esac |
|
else |
|
die "Refusing to delete without a TTY. Re-run with --force to skip the prompt." |
|
fi |
|
fi |
|
|
|
# Domain: destroy if running (else undefine would leave a transient VM), then |
|
# undefine with its storage and snapshot metadata. |
|
if virsh -c "$CONN" dominfo "$VM" >/dev/null 2>&1; then |
|
state=$(virsh -c "$CONN" domstate "$VM" 2>/dev/null || echo unknown) |
|
if [[ "$state" != "shut off" ]]; then |
|
msg "Domain $VM is '$state'; destroying it first" |
|
virsh -c "$CONN" destroy "$VM" >/dev/null 2>&1 || true |
|
fi |
|
msg "Undefining $VM (removing all storage + snapshot metadata)" |
|
virsh -c "$CONN" undefine "$VM" --remove-all-storage --snapshots-metadata \ |
|
|| die "Failed to undefine $VM" |
|
else |
|
msg "Domain $VM not defined; skipping undefine" |
|
fi |
|
|
|
# Belt-and-suspenders: drop the per-VM disk if undefine didn't (e.g. the disk |
|
# wasn't in a libvirt-managed pool). The shared base image is never touched. |
|
if [[ -f "$DISK" ]]; then |
|
msg "Removing leftover disk $DISK" |
|
rm -f "$DISK" |
|
fi |
|
|
|
# NAT network: this net is dedicated to this VM, so stop + undefine it. Always |
|
# destroy BEFORE undefining: 'net-undefine' on a still-running network only |
|
# strips its persistent config and leaves it running as a *transient* network, |
|
# which then blocks the next provision. Both steps are idempotent (|| true). |
|
if virsh -c "$CONN" net-info "$NET" >/dev/null 2>&1; then |
|
msg "Stopping network $NET (if running)" |
|
virsh -c "$CONN" net-destroy "$NET" >/dev/null 2>&1 || true |
|
msg "Undefining network $NET (if persistent)" |
|
virsh -c "$CONN" net-undefine "$NET" >/dev/null 2>&1 || true |
|
else |
|
msg "Network $NET not defined; skipping" |
|
fi |
|
|
|
msg "Teardown complete. Base image ${IMG_DIR}/${BASE} left in place for reuse." |
|
} |
|
|
|
# ── Argument parsing ───────────────────────────────────────────────────────── |
|
DELETE=0 |
|
FORCE=0 |
|
for arg in "$@"; do |
|
case "$arg" in |
|
--delete) DELETE=1 ;; |
|
--force|--yes|-y) FORCE=1 ;; |
|
-h|--help) |
|
cat <<EOF |
|
Usage: ${0##*/} [--delete [--force]] |
|
|
|
(no args) Provision the ${VM} VM: fetch base image, create disk, define |
|
the ${NET} NAT network, and launch via virt-install. |
|
--delete COMPLETELY remove ${VM}: destroy it if running, undefine it |
|
with all storage and snapshot metadata, and tear down the |
|
${NET} network. Prompts for confirmation. The shared base |
|
image (${BASE}) is left in place. |
|
--force, --yes, -y |
|
Skip the --delete confirmation prompt (for scripted/no-TTY use). |
|
EOF |
|
exit 0 ;; |
|
*) die "Unknown argument: $arg (try --help)" ;; |
|
esac |
|
done |
|
|
|
if (( FORCE )) && (( ! DELETE )); then |
|
die "--force/--yes only applies to --delete." |
|
fi |
|
|
|
if (( DELETE )); then |
|
do_delete |
|
exit 0 |
|
fi |
|
|
|
# 1. Host tooling ------------------------------------------------------------ |
|
msg "Checking host tools" |
|
missing=() |
|
for t in virt-install virsh qemu-img qemu-system-x86_64 wget osinfo-query python3; do |
|
command -v "$t" >/dev/null || missing+=("$t") |
|
done |
|
if ((${#missing[@]})); then |
|
die "Missing: ${missing[*]} |
|
Install the stack with: |
|
sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients virtinst \\ |
|
libosinfo-bin wget python3 virt-viewer \\ |
|
qemu-system-modules-spice |
|
sudo adduser \"\$USER\" libvirt # then log out/in (or: newgrp libvirt)" |
|
fi |
|
# virt-viewer is the SPICE client for connecting to the desktop: |
|
command -v virt-viewer >/dev/null || msg "NOTE: 'virt-viewer' not found — install it to view the desktop: sudo apt install virt-viewer" |
|
|
|
if ! id -nG "$USER" | grep -qw libvirt; then |
|
die "You're not in the 'libvirt' group yet: |
|
sudo adduser \"$USER\" libvirt |
|
then log out and back in (or run 'newgrp libvirt') and re-run this script." |
|
fi |
|
|
|
# 2. Storage dir + traversal preflight -------------------------------------- |
|
mkdir -p "$IMG_DIR" |
|
p="$IMG_DIR" |
|
while :; do |
|
mode=$(stat -c '%a' "$p" 2>/dev/null || echo 000) |
|
if (( (10#${mode: -1} & 1) == 0 )); then |
|
msg "WARNING: $p (mode $mode) isn't traversable by the qemu user." |
|
msg " Fix: chmod o+x '$p' (no sudo if you own it)" |
|
fi |
|
[[ "$p" == "/" ]] && break |
|
p=$(dirname "$p") |
|
done |
|
|
|
# 3. SSH key ----------------------------------------------------------------- |
|
PUBKEY="" |
|
for k in "$HOME/.ssh/id_ed25519.pub" "$HOME/.ssh/id_rsa.pub"; do |
|
[[ -f "$k" ]] && { PUBKEY="$(cat "$k")"; break; } |
|
done |
|
[[ -n "$PUBKEY" ]] || die "No SSH public key in ~/.ssh/. Create one: ssh-keygen -t ed25519" |
|
msg "Using SSH key ${PUBKEY%% *} …" |
|
|
|
# 4. Render cloud-init (literal string replace; no regex surprises) ---------- |
|
[[ -f "$CI_TEMPLATE" ]] || die "Missing $CI_TEMPLATE next to this script." |
|
msg "Rendering cloud-init" |
|
KEY="$PUBKEY" TPL="$CI_TEMPLATE" OUT="$CI_RENDERED" python3 - <<'PY' |
|
import os |
|
tpl = open(os.environ["TPL"]).read().replace("__SSH_PUBKEY__", os.environ["KEY"]) |
|
open(os.environ["OUT"], "w").write(tpl) |
|
PY |
|
|
|
# 5. Base image |
|
img_size() { stat -c '%s' "$1" 2>/dev/null || echo 0; } |
|
if [[ -f "${IMG_DIR}/${BASE}" ]] && (( $(img_size "${IMG_DIR}/${BASE}") >= MIN_SIZE )); then |
|
msg "Base image already present ($(( $(img_size "${IMG_DIR}/${BASE}") / 1024 / 1024 )) MB)" |
|
else |
|
[[ -f "${IMG_DIR}/${BASE}" ]] && { msg "Existing image is truncated; refetching"; rm -f "${IMG_DIR}/${BASE}"; } |
|
msg "Downloading Debian 13 genericcloud image (~300 MB)" |
|
tmp="${IMG_DIR}/.${BASE}.partial" |
|
rm -f "$tmp" |
|
if ! wget -T 10 --tries=20 -O "$tmp" "$BASE_URL"; then |
|
rm -f "$tmp" |
|
die "Download failed (network/timeout). Try again, or edit BASE_URL to another mirror." |
|
fi |
|
if (( $(img_size "$tmp") < MIN_SIZE )); then |
|
rm -f "$tmp" |
|
die "Downloaded file is too small ($(img_size "$tmp") bytes) — mirror served an error page? Retry or switch BASE_URL." |
|
fi |
|
mv "$tmp" "${IMG_DIR}/${BASE}" |
|
msg "Downloaded $(( $(img_size "${IMG_DIR}/${BASE}") / 1024 / 1024 )) MB" |
|
fi |
|
|
|
# 6. Per-VM disk (full copy: base stays pristine) ---------------------------- |
|
if [[ -f "$DISK" ]]; then |
|
# A disk with a live domain behind it is a real VM — never clobber it here. |
|
# A disk with NO domain is a leftover from a provision that died before |
|
# virt-install; offer to remove it (confirmed) so a retry isn't blocked. |
|
if virsh -c "$CONN" dominfo "$VM" >/dev/null 2>&1; then |
|
die "$DISK exists and domain $VM is still defined. Tear the VM down first: |
|
virsh -c $CONN undefine $VM --remove-all-storage" |
|
fi |
|
msg "WARNING: $DISK exists but no '$VM' domain does — a leftover from a failed provision." |
|
if [[ -t 0 ]]; then |
|
read -r -p "Delete this leftover disk and continue? [y/N]: " reply |
|
case "$reply" in |
|
y|Y|yes|YES) rm -f "$DISK" ;; |
|
*) die "Aborted. Remove it yourself when ready: rm -f '$DISK'" ;; |
|
esac |
|
else |
|
die "Refusing to delete without a TTY. Remove it yourself: rm -f '$DISK'" |
|
fi |
|
fi |
|
msg "Creating ${VM} disk (${DISK_GB} G, thin-provisioned)" |
|
cp --reflink=auto "${IMG_DIR}/${BASE}" "$DISK" |
|
qemu-img resize "$DISK" "${DISK_GB}G" |
|
|
|
# 7. Network ----------------------------------------------------------------- |
|
if ! virsh -c "$CONN" net-info "$NET" >/dev/null 2>&1; then |
|
msg "Defining NAT network $NET" |
|
virsh -c "$CONN" net-define "${HERE}/uat-net.xml" |
|
else |
|
msg "Network $NET already defined" |
|
fi |
|
# Start it (no-op if already running) and CONFIRM it's actually up before letting |
|
# virt-install attach to it. Don't probe state with 'net-info | grep -q': grep -q |
|
# exits on the first match and, under 'set -o pipefail', that can make the whole |
|
# pipeline report failure even on a match — misreading a running network as |
|
# inactive (the bug that left a transient net and broke reprovisioning). Parse the |
|
# field with awk in $(...), which reads to EOF and can't trip pipefail. |
|
virsh -c "$CONN" net-start "$NET" 2>/dev/null || true |
|
active=$(virsh -c "$CONN" net-info "$NET" 2>/dev/null | awk '$1=="Active:"{print $2}') |
|
[[ "$active" == yes ]] || die "Network $NET did not come up (net-start failed)." |
|
virsh -c "$CONN" net-autostart "$NET" 2>/dev/null || true |
|
|
|
# 8. os-variant (fall back if the host's osinfo-db predates trixie) ---------- |
|
if osinfo-query --fields=short-id os 2>/dev/null | grep -w debian13 >/dev/null; then |
|
OSV=debian13 |
|
else |
|
OSV=debian12 |
|
msg "osinfo-db has no 'debian13'; using $OSV (affects device defaults only)" |
|
fi |
|
|
|
# 9. Create the VM ----------------------------------------------------------- |
|
: "${MAXRAM:=$RAM}" # unset/blank MAXRAM -> flat, like before |
|
(( MAXRAM >= RAM )) || die "MAXRAM (${MAXRAM} MB) must be >= RAM (${RAM} MB)." |
|
# Video: QXL with bumped memory. QXL keeps 2D-over-SPICE snappy (it streams draw |
|
# commands to the SPICE server, with client-side caching) — virtio-gpu's 2D path |
|
# is a framebuffer scrape that feels noticeably laggier for typing. BUT QXL's video |
|
# memory is fixed at launch and the defaults (16M vgamem / 64M surfaces) are too |
|
# small: a long-lived LXQt session exhausts the surface pool, TTM can't evict, and |
|
# Xorg hard-hangs in qxl_fence_wait ([TTM] Buffer eviction failed) — desktop freezes |
|
# until a host power-cycle. The bumped sizes below (64M vgamem, 128M ram/vram) give |
|
# ample headroom. Keep gl=off above (no virgl 3D — its own eviction bugs); UAT is 2D. |
|
msg "Launching virt-install (boot ${RAM}M, balloon ceiling ${MAXRAM}M)" |
|
virt-install --connect "$CONN" \ |
|
--events on_reboot=restart \ |
|
--name "$VM" \ |
|
--memory "memory=${RAM},maxmemory=${MAXRAM}" \ |
|
--vcpus "$VCPUS" \ |
|
--cpu host-passthrough \ |
|
--disk "path=${DISK},bus=virtio,discard=unmap,driver.detect_zeroes=unmap,cache=none" \ |
|
--network "network=${NET},model=virtio" \ |
|
--memballoon model=virtio,autodeflate=on,freePageReporting=on \ |
|
--graphics spice,gl=off,listen=127.0.0.1 \ |
|
--video model.type=qxl,model.ram=131072,model.vram=131072,model.vgamem=65536,model.heads=1 \ |
|
--os-variant "$OSV" \ |
|
--cloud-init user-data="${CI_RENDERED}" \ |
|
--import \ |
|
--noautoconsole |
|
|
|
msg "Done — first boot installs the desktop (~a few minutes), then the VM POWERS OFF (expected)." |
|
cat <<EOF |
|
|
|
NOTE: first boot installs the desktop, then the VM powers itself off. Once it |
|
shows 'shut off' (virsh -c ${CONN} domstate ${VM}), start it — the one manual step: |
|
Start the VM: virsh -c ${CONN} start ${VM} |
|
|
|
View the desktop: virt-viewer -c ${CONN} ${VM} (SPICE) |
|
Watch first boot: virsh -c ${CONN} console ${VM} (serial; Ctrl-] to exit) |
|
Find its IP: virsh -c ${CONN} domifaddr ${VM} |
|
SSH in: ssh user@<ip> |
|
Grow/shrink RAM live: virsh -c ${CONN} setmem ${VM} 9G --live --config (${RAM}M..${MAXRAM}M) |
|
Check RAM (max/used): virsh -c ${CONN} dominfo ${VM} |
|
Snapshot clean state: virsh -c ${CONN} snapshot-create-as ${VM} clean "post-provision" |
|
Revert after bad run: virsh -c ${CONN} snapshot-revert ${VM} clean |
|
|
|
First boot installs LXQt + Chrome (a few minutes), then powers off. After you |
|
start it (above), it boots into the desktop autologged in as 'user'. Then, in |
|
the desktop's terminal: |
|
- install the "Claude in Chrome" extension from the Chrome Web Store |
|
- run 'claude' to authenticate, then 'claude --chrome' |
|
Greeter/console login is 'user' / 'changeme' — change it with: passwd |
|
EOF |
