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