cd /news/developer-tools/libvirt-powered-claude-sandbox · home topics developer-tools article
[ARTICLE · art-64798] src=gist.github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Libvirt Powered Claude Sandbox

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.

read12 min views1 publishedJul 18, 2026

| #!/usr/bin/env bash | | set -euo pipefail | | | | | | | | | | | | 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 | | | | 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 | | | | | do_delete() { | | command -v virsh >/dev/null || die "virsh not found; nothing to tear down with." | | | | | 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 | | | | | | 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 | | | | | | if [[ -f "$DISK" ]]; then | | msg "Removing leftover disk $DISK" | | rm -f "$DISK" | | fi | | | | | | | | 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." | | } | | | | | 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 | | | | | 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 | | | 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 | | | | | 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 | | | | | 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%% *} …" | | | | | [[ -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 | | | | | 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 "Down 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 | | | | | if [[ -f "$DISK" ]]; then | | | | | 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" | | | | | 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 | | | | | | | | 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 | | | | | 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 | | | | | : "${MAXRAM:=$RAM}" # unset/blank MAXRAM -> flat, like before | | (( MAXRAM >= RAM )) || die "MAXRAM (${MAXRAM} MB) must be >= RAM (${RAM} MB)." | | | | | | | | | | 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 |
── more in #developer-tools 4 stories · sorted by recency
── more on @libvirt 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/libvirt-powered-clau…] indexed:0 read:12min 2026-07-18 ·