Skip to content

Instantly share code, notes, and snippets.

@smith153
Created July 18, 2026 18:04
Show Gist options
  • Select an option

  • Save smith153/04b4068b5a2d7b234f1c3d5992dafe25 to your computer and use it in GitHub Desktop.

Select an option

Save smith153/04b4068b5a2d7b234f1c3d5992dafe25 to your computer and use it in GitHub Desktop.
Libvirt Powered Claude Sandbox
#!/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
hostname: uat
fqdn: uat.local
manage_etc_hosts: true
timezone: America/Cancun
users:
- name: user
shell: /bin/bash
groups: [sudo]
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: false
ssh_authorized_keys:
- __SSH_PUBKEY__
ssh_pwauth: false
chpasswd:
expire: false
users:
- name: user
password: changeme # desktop greeter / console login. CHANGE IT: passwd
type: text
apt:
generate_mirrorlists: false
package_update: true
package_upgrade: false
packages:
- lxqt-core # the LXQt desktop (lean metapackage, incl. qterminal + pcmanfm-qt)
- lightdm # display manager; autologin configured below
- xserver-xorg
- xserver-xorg-video-qxl # X driver for the QXL/SPICE video device
- dbus-x11
- spice-vdagent # clipboard + auto-resize with the SPICE viewer
- qemu-guest-agent # lets `virsh domifaddr` see the IP, clean shutdown
- curl
- ca-certificates
# Terminal/console environment + GitHub tooling
- git
- gh # GitHub CLI
- jq # JSON parsing for gh output / scripts
- python-is-python3 # `python` -> python3 (many tools/scripts assume plain `python`)
- vim # $EDITOR/$VISUAL; uses the .vimrc fetched below
- screen
- bash-completion
- fonts-powerline
- less
- fzf
- earlyoom # userspace OOM killer — safety net beneath the balloon ceiling
- zram-tools # compressed in-RAM swap (zram) — first pressure-relief valve
write_files:
# Force the apt mirrorlist to plain http so the LAN Squid can cache .debs. The
# genericcloud image ships these with https URLs; write-files runs before the
# package phase, so the first update+install already use http.
- path: /etc/apt/mirrors/debian.list
content: |
http://deb.debian.org/debian
- path: /etc/apt/mirrors/debian-security.list
content: |
http://deb.debian.org/debian-security
- path: /etc/lightdm/lightdm.conf.d/50-autologin.conf
content: |
[Seat:*]
autologin-user=user
autologin-session=lxqt
autologin-user-timeout=0
- path: /etc/motd
content: |
uat — browser acceptance-testing desktop (LXQt + Chrome)
Connect from the host: virt-viewer -c qemu:///system uat
A 'clean' snapshot exists on the host; revert freely.
- path: /etc/profile.d/uat.sh
content: |
export EDITOR=vim
export VISUAL=vim
- path: /home/user/.gitconfig
owner: user:user
defer: true
content: |
[user]
name = Me
email = Me@gmail.com
[core]
editor = vim -c'set spell'
- path: /home/user/.bashrc
owner: user:user
append: true
defer: true
content: |
alias screen='screen -U'
HISTSIZE=10000
HISTFILESIZE=10000
# Short bootloader menu delay. A grub.d drop-in overrides whatever timeout the
# base image sets (grub-mkconfig sources /etc/default/grub.d/*.cfg last).
- path: /etc/default/grub.d/99-timeout.cfg
content: |
GRUB_TIMEOUT=1
- path: /etc/default/earlyoom
content: |
EARLYOOM_ARGS="-r 0 -m 2"
# zram: compressed swap in RAM (no disk). PERCENT = % of RAM.
- path: /etc/default/zramswap
content: |
ALGO=zstd
PERCENT=50
PRIORITY=100
# swappiness=100: favour fast zram swap. page-cluster=0: no swap readahead.
- path: /etc/sysctl.d/99-zram.conf
content: |
vm.swappiness = 100
vm.page-cluster = 0
runcmd:
- [ systemctl, mask, tmp.mount ]
# Autologin prerequisites: Debian's lightdm wants these groups to exist and the
# user to be a member. Harmless if autologin still falls back to a manual login.
- [ groupadd, -f, autologin ]
- [ gpasswd, -a, user, autologin ]
- [ groupadd, -f, nopasswdlogin ]
- [ gpasswd, -a, user, nopasswdlogin ]
- [ systemctl, set-default, graphical.target ]
- [ systemctl, enable, lightdm ]
- [ systemctl, enable, --now, qemu-guest-agent ]
- [ systemctl, enable, --now, earlyoom ] # OOM safety net (args in /etc/default/earlyoom)
- [ systemctl, restart, earlyoom ] # ensure it picked up our EARLYOOM_ARGS
- [ systemctl, enable, --now, zramswap ] # compressed RAM swap (config in /etc/default/zramswap)
- [ systemctl, restart, zramswap ] # ensure it picked up our PERCENT/ALGO
- [ sysctl, --system ] # apply /etc/sysctl.d/99-zram.conf now
# Root fs tuning. Enable weekly batch trim (Debian doesn't by default), then
# rewrite the root fstab line to drop the base image's continuous 'discard' and
# add noatime — the timer now reclaims host space instead. Enable ext4
# fast_commit; regenerate grub.cfg for the new timeout. All active on the next
# boot after the first-boot poweroff below (noatime is also remounted live).
- [ systemctl, enable, --now, fstrim.timer ]
- sed -i -E 's#^(\S+\s+/\s+ext4\s+)\S+#\1rw,noatime,errors=remount-ro,x-systemd.growfs#' /etc/fstab
- mount -o remount,noatime /
- tune2fs -O fast_commit "$(findmnt -no SOURCE /)" || true
- update-grub
# Google Chrome — needed for the "Claude in Chrome" extension used in UAT.
- curl -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o /tmp/chrome.deb
- apt-get install -y /tmp/chrome.deb
- rm -f /tmp/chrome.deb
# Claude Code + personal shell config, for the 'user' account.
- [ install, -d, -o, user, -g, user, /home/user/projects ]
- runuser -l user -c 'curl -fsSL https://claude.ai/install.sh | bash' || true
# Power the VM off once first-boot provisioning finishes. virt-install's
# --cloud-init forces a full shutdown on the first guest reboot anyway (to drop
# the one-time cloud-init media), so we poweroff outright. Bring it up with
# `virsh start uat`: it boots into the graphical target (lightdm -> autologin ->
# LXQt) on its persistent config, with reboots working normally.
power_state:
mode: poweroff
message: "First-boot cloud-init done — powering off; run 'virsh start uat'"
timeout: 30
condition: true
final_message: "uat provisioned in $UPTIME s — now powering off. Start it with 'virsh start uat' to boot into the LXQt desktop."
<network>
<name>uat-net</name>
<forward mode='nat'/>
<bridge name='virbr-uat' stp='on' delay='0'/>
<ip address='192.168.151.1' netmask='255.255.255.0'>
<dhcp>
<range start='192.168.151.10' end='192.168.151.100'/>
</dhcp>
</ip>
</network>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment