wren-linux / bin / claude-vm-session
#!/bin/bash
# claude-vm-session SUBCOMMAND: written by Wren Linux extras/claude-vm.sh
# Installed as /usr/local/libexec/claude-vm-session. Your user may run exactly
# this, as the claude-vm user, and nothing else (a rule in /etc/sudoers.d).
# The claude-vm command calls it. It drives the disposable VM (one
# qemu-system-x86_64 process, via the bundled mockvps-kvm) and logs you into
# the guest. Running as claude-vm is what keeps the guest off your network:
# QEMU's sockets are claude-vm's, and a firewall rule on that user allows the
# internet only (data/claude-vm-firewall.nft). Another account's user,
# claude-vm-NAME (claude-vm --account NAME), runs its own VM the same way, with
# the console port root gave it in /etc/wren/claude-vm-ports.
set -euo pipefail
me=$(id -un)
[[ $me == claude-vm || $me =~ ^claude-vm-[a-z][a-z0-9]{0,14}$ ]] ||
{ echo "claude-vm-session: only for the claude-vm users." >&2; exit 1; }
# (|| true: with set -e, a failed lookup would end this without a word)
home=$(getent passwd "$me" | cut -d: -f6) || true
[ "$home" = "/home/$me" ] || { echo "claude-vm-session: unexpected home folder for $me." >&2; exit 1; }
port=2242 setup_arg=
if [ "$me" != claude-vm ]; then
setup_arg=" --account ${me#claude-vm-}"
port=$(awk -v n="${me#claude-vm-}" '$1 == n && $2 ~ /^[0-9]+$/ && $2 >= 2250 && $2 <= 2299 { print $2; exit }' \
/etc/wren/claude-vm-ports 2>/dev/null) || true
[ -n "$port" ] || { echo "claude-vm-session: no console port for $me; run: wrenctl install claude-vm$setup_arg" >&2; exit 1; }
fi
# Run again without a way to reach XWayland's abstract socket, which any user
# can connect to (qemu and ssh below inherit this)
noabs=/usr/local/libexec/wren-no-abstract-sockets
if [ -z "${WREN_NOABS:-}" ]; then
[ -x "$noabs" ] || { echo "claude-vm-session: $noabs is missing. Run: bash extras/claude-vm.sh" >&2; exit 1; }
export WREN_NOABS=1
exec "$noabs" "$0" "$@"
fi
# Fixed configuration. The port must match the firewall rule (data/claude-vm-firewall.nft,
# and for an account the line extras/claude-vm.sh adds).
export HOME=$home
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export LANG=${LANG:-C.UTF-8}
# The VM engine reads SUDO_USER to decide whose kvm-group membership to check;
# we are already claude-vm (in the kvm group), so make it see that and never
# try to change another user's groups with a sudo it does not have.
unset SUDO_USER
export USER=$me LOGNAME=$me
export VM_NAME=$me
export VM_USER=agent
export SSH_HOST_IP=127.0.0.1
export SSH_HOST_PORT=$port
export WORKDIR=$HOME/vm
# Its size, as root chose it (extras/claude-vm.sh, lib/vm-size.sh: "USER MEMORY DISK"
# in MiB): the memory counts from the next start, the disk from the next build.
# Without a line (or with one out of all reason), the engine's own 2048 and 20000.
size=$(awk -v u="$me" '$1 == u && $2 ~ /^[0-9]+$/ && $3 ~ /^[0-9]+$/ { print $2, $3; exit }' \
/etc/wren/claude-vm-size 2>/dev/null) || true
if [ -n "$size" ]; then
read -r mem disk <<<"$size"
if [ "$mem" -ge 1024 ] && [ "$mem" -le 131072 ] && [ "$disk" -ge 10000 ] && [ "$disk" -le 4000000 ]; then
export VM_MEMORY=$mem VM_DISK_SIZE_MB=$disk
else
echo "claude-vm-session: ignoring the size in /etc/wren/claude-vm-size ($size MiB): out of range." >&2
fi
fi
# How many CPUs, likewise (extras/claude-vm.sh --cpus, lib/vm-size.sh): without a line, the engine's 2.
cpus=$(awk -v u="$me" '$1 == u && $2 ~ /^[0-9]+$/ { print $2; exit }' /etc/wren/claude-vm-cpus 2>/dev/null) || true
if [ -n "$cpus" ] && [ "$cpus" -ge 1 ] && [ "$cpus" -le 32 ]; then export VM_VCPUS=$cpus; fi
export WEB_TUNNEL=0
# QEMU's own syscall filter: the guest has root, so a bug in QEMU is what it would
# aim for, and this limits what that process could then do (libvirt's usual set)
export QEMU_SANDBOX=on,obsolete=deny,elevateprivileges=deny,spawn=deny,resourcecontrol=deny
MOCKVPS=/usr/local/libexec/claude-vm-mockvps-kvm
NETCHECK=/usr/local/libexec/claude-vm-net-check
PROVISION=$HOME/.config/claude-vm/provision.sh
KEY_FILE=$HOME/.ssh/${VM_NAME}_id
die() { printf 'claude-vm-session: %s\n' "$*" >&2; exit 1; }
# The firewall that keeps the VM off your computer and network, seen from this
# user (bin/sandbox-net-check): checked before anything starts a VM (the first
# install too, which has the internet), and watched while it runs: if the rule
# goes, the VM goes too.
NET_CHECK=/usr/local/libexec/wren-sandbox-net-check
net_ok() {
[ -x "$NET_CHECK" ] || die "$NET_CHECK is missing; run extras/claude-vm.sh again."
"$NET_CHECK" || die "the firewall that keeps the VM off your network isn't active, so the VM wasn't started. Run: sudo systemctl restart wren-claude-vm-firewall"
# (and while this runs: an install has the internet for minutes before QEMU's own watch begins)
"$NET_CHECK" --watch $$ </dev/null >/dev/null 2>&1 &
}
watch_vm() {
local pid
pid=$(head -n1 "$WORKDIR/run/$VM_NAME.pid" 2>/dev/null) || return 0
[[ $pid =~ ^[0-9]+$ ]] || return 0
setsid "$NET_CHECK" --watch "$pid" </dev/null >/dev/null 2>&1 &
}
mockvps() {
local rc=0 starts=false
[ -x "$MOCKVPS" ] || die "the VM engine is missing ($MOCKVPS); run extras/claude-vm.sh again."
case " $* " in *" --uninstall "*) ;; *" --start "* | *" --yes "*) starts=true ;; esac
if $starts; then net_ok; fi
"$MOCKVPS" "$@" || rc=$?
if $starts; then watch_vm; fi
return "$rc"
}
# The guest has root, so what answers on its port isn't to be trusted: nothing of
# ours goes to it but the login, whatever a config file says.
SSH_SAFE=(-F /dev/null -o ForwardAgent=no -o ForwardX11=no -o ClearAllForwardings=yes -o PermitLocalCommand=no)
# Non-interactive, key-only ssh into the guest, independent of ~/.ssh/config.
guest_ssh() {
ssh "${SSH_SAFE[@]}" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
-o HostKeyAlias="$VM_NAME" -o IdentitiesOnly=yes -o LogLevel=ERROR \
-i "$KEY_FILE" -p "$SSH_HOST_PORT" "$VM_USER@$SSH_HOST_IP" "$@"
}
ensure_up() {
is_installed || die "no VM yet. Set it up first: wrenctl install claude-vm${setup_arg}"
# The firewall is what keeps a guest with root off your network: seen from here
if [ -x "$NETCHECK" ] && ! "$NETCHECK"; then
die "the firewall that keeps the VM off your network isn't active, so the VM wasn't started. Run: sudo systemctl restart wren-claude-vm-firewall"
fi
if ! mockvps --status 2>/dev/null | grep -q Running; then
# Say why the window is quiet: mockvps's own steps ("==> Waiting for
# SSH"...) and errors, but not the summary it ends with
echo "Starting the VM; this takes about a minute."
mockvps --start 2>&1 | sed -un 's/^==> / /p; /^\(ERROR\|WARNING\):/p' ||
die "could not start the VM (see $WORKDIR/${VM_NAME}-run.log)."
fi
}
# Power the VM off unless a window is still logged in (our own ssh has ended,
# so who lists only others). The disk is kept, so work is still there next time.
stop_if_unused() {
if [ "$(guest_ssh 'who | wc -l' 2>/dev/null || echo 1)" = 0 ]; then
echo "Powering the VM off." >&2
mockvps --stop >/dev/null 2>&1 || echo "claude-vm-session: could not power off; run: claude-vm stop" >&2
fi
}
# Run a command in the guest over ssh, in a terminal, then stop_if_unused.
session_then_stop() {
local rc=0
ssh "${SSH_SAFE[@]}" -t -o StrictHostKeyChecking=accept-new -o HostKeyAlias="$VM_NAME" \
-o IdentitiesOnly=yes -i "$KEY_FILE" -p "$SSH_HOST_PORT" "$VM_USER@$SSH_HOST_IP" "$1" || rc=$?
stop_if_unused
return "$rc"
}
is_installed() { [ -s "$WORKDIR/$VM_NAME.qcow2" ]; }
# clean_snapshot: the state to go back to, taken with the VM off, in the disk file
# on this side: root in the guest can't reach it. Before your Claude login.
clean_snapshot() {
mockvps --stop >/dev/null 2>&1 || true
qemu-img snapshot -c clean "$WORKDIR/$VM_NAME.qcow2" || echo "claude-vm-session: couldn't take the clean snapshot; claude-vm reset won't work." >&2
}
case "${1:-connect}" in
connect)
ensure_up
# -t for a real terminal; run Claude Code in the guest's projects folder
session_then_stop 'cd ~/projects && exec claude'
;;
resume)
ensure_up
# Claude Code lists the conversations it kept in the guest, to pick one and go on
session_then_stop 'cd ~/projects && exec claude --resume'
;;
unattended)
ensure_up
session_then_stop 'cd ~/projects && exec claude --dangerously-skip-permissions'
;;
bundle)
# The branch claude of ~/projects/NAME in the guest, as a git bundle on
# standard output, for claude-pull. Over guest_ssh, with no terminal (one
# would change the bytes). NAME is a plain folder name, so nothing of it
# can reach the guest's shell as anything but that.
repo=${2:-}
[[ $repo =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ && $repo != *..* ]] ||
die "bundle needs the name of a folder in the VM's ~/projects (letters, digits, . _ -)."
ensure_up >&2
rc=0
guest_ssh "cd ~/projects/$repo && git bundle create - refs/heads/claude" || rc=$?
stop_if_unused
exit "$rc"
;;
give)
# A tar stream on standard input (claude-vm give made it from your files),
# unpacked in the guest in a new folder of ~/given that this names itself
ensure_up >&2
dest=given/$(date -u +%Y%m%d-%H%M%S)
rc=0
guest_ssh "mkdir -p ~/$dest && tar -x --no-same-owner -C ~/$dest" || rc=$?
if [ "$rc" -eq 0 ]; then echo "Given: ~/$dest in the VM. Tell the agent to look there."; fi
stop_if_unused
exit "$rc"
;;
shell)
ensure_up
session_then_stop 'cd ~/projects 2>/dev/null; exec bash -l'
;;
status)
mockvps --status
# Its account and the size it starts with, all known on this side (the
# config files, read above): nothing is asked of the guest.
acct=${me#claude-vm}; acct=${acct#-}
printf ' %-8s %s\n' account "${acct:-default}"
printf ' %-8s %s\n' CPUs "${VM_VCPUS:-2}"
printf ' %-8s %s MB\n' memory "${VM_MEMORY:-2048}"
printf ' %-8s up to %s MB\n' disk "${VM_DISK_SIZE_MB:-20000}"
;;
stop) mockvps --stop ;;
pubkey)
ensure_up
# From the guest, so checked before it reaches your terminal
key=$(guest_ssh 'cat ~/.ssh/id_ed25519.pub' | head -c 1024) || true
LC_ALL=C # in en_US.UTF-8, [!-~] holds no letters or digits
[[ $key =~ ^ssh-ed25519\ [A-Za-z0-9+/=]+(\ [!-~]+)?$ ]] || die "that doesn't look like an ssh key, so it isn't shown."
printf '%s\n' "$key"
;;
install)
# First build: the real user triggers this from extras/claude-vm.sh.
# PROVISION installs Claude Code in the guest. It never replaces a VM
# that is already there (mockvps --yes would delete it unasked, with
# whatever the agent hasn't pushed): that is what rebuild is for.
if is_installed; then
echo "The VM is already built; kept as it is. For a fresh one: claude-vm rebuild"
exit 0
fi
[ -f "$PROVISION" ] || die "the provisioning script is missing ($PROVISION); run extras/claude-vm.sh again."
PROVISION_FILES="$PROVISION" mockvps --yes
clean_snapshot
;;
rebuild)
[ -f "$PROVISION" ] || die "the provisioning script is missing ($PROVISION); run extras/claude-vm.sh again."
mockvps --uninstall --yes >/dev/null 2>&1 || true
PROVISION_FILES="$PROVISION" mockvps --yes
clean_snapshot
;;
reset)
is_installed || die "no VM yet. Set it up first: wrenctl install claude-vm${setup_arg}"
mockvps --stop >/dev/null 2>&1 || true
qemu-img snapshot -a clean "$WORKDIR/$VM_NAME.qcow2" ||
die "this VM has no clean snapshot (it was built before they existed): claude-vm rebuild makes one."
echo "The VM is back to how it was built."
;;
uninstall) mockvps --uninstall --yes ;;
*) die "unknown subcommand '$1' (connect, unattended, shell, status, stop, pubkey, install, rebuild, reset, uninstall)." ;;
esac