wren-linux / bin / claude-vm
#!/bin/bash
# claude-vm [connect|resume|unattended|shell|status|stop|pubkey|rebuild]: written by Wren Linux
# extras/claude-vm.sh
# Claude Code inside a disposable virtual machine, not on your computer. The
# VM has its own disk and no shared folder with you: the agent's only way to
# hand work back is its branch claude, which claude-pull fetches over the VM's
# ssh login as a git bundle (no git host in between). It can
# reach the internet but nothing on your machine or local network.
# claude-vm start the VM, run Claude Code in it, power it off on exit
# claude-vm resume the same, going on with an earlier conversation: Claude Code
# lists them (also: claude-vm --resume)
# claude-vm unattended the same, with Claude Code's own permission prompts off, for
# leaving it to work: a prompt nobody answers stops the work
# claude-vm shell a plain shell in the VM
# claude-vm status is the VM running, its size (CPUs, memory, disk) and account
# claude-vm stop power the VM off
# claude-vm pubkey the VM's ssh public key, if it is to push to a git host itself
# (claude-pull doesn't need it)
# claude-vm bundle NAME the VM's branch claude of ~/projects/NAME, as a git bundle
# on standard output (claude-pull uses it)
# claude-vm give PATH... files or folders for the agent to look at, copied into the
# VM's ~/given (it shows every name and asks first)
# claude-vm reset back to how it was built (faster than rebuild; asks first)
# claude-vm rebuild throw the VM away and build a fresh one (asks first)
# claude-vm --account NAME [COMMAND] another account's VM (its own Claude
# login and memories); set up with extras/claude-vm.sh --account NAME
# Started from a launcher (wmenu on Super+d), claude-vm and claude-vm shell
# open their own foot window, which stays open after an error so you can read it.
set -euo pipefail
AGENT=claude-vm
SESSION=/usr/local/libexec/claude-vm-session
# hold: in the window opened below, keep what went wrong on screen until Enter
hold() {
[ -n "${CLAUDE_VM_HOLD:-}" ] || return 0
printf '\nPress Enter to close this window.' >&2
read -r _ || true
}
die() { printf 'claude-vm: %s\n' "$*" >&2; hold; exit 1; }
[ "$(id -u)" -ne 0 ] || die "run it as your normal user, not root."
account=
case ${1:-} in
--account) [ $# -ge 2 ] || die "--account needs a name: claude-vm --account NAME"; account=$2; shift 2 ;;
--account=*) account=${1#--account=}; shift ;;
esac
if [ -n "$account" ]; then
[[ $account =~ ^[a-z][a-z0-9]{0,14}$ ]] || die "an account name is small letters and digits, a letter first."
AGENT=claude-vm-$account
fi
# give PATH...: copies of the files and folders named (regular files only: links and
# anything hidden, at any depth, left out; never all of your home), shown by name
# and asked about, then sent as one tar stream to the session, which unpacks it in
# a new folder of the VM's ~/given that it names itself
MAX_GIVE_MB=${WREN_GIVE_MAX_MB:-500}
give() {
local work path real f answer total located
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir "$work/give"
for path in "$@"; do
[ -e "$path" ] || die "there is no $path."
[ ! -L "$path" ] || die "$path is a link: name what it points to."
real=$(realpath -- "$path")
case $HOME/ in "$real"/*) die "$path holds your whole home folder: name the folder you mean." ;; esac
# ~/.ssh, ~/.gnupg...: where keys and passwords live
case $real in */.*) die "$path is hidden, or in a hidden folder: copy it elsewhere, if you do mean it." ;; esac
if [ -d "$path" ]; then
(cd "$path" && find . -type f -not -path '*/.*' -print0) |
while IFS= read -r -d '' f; do
mkdir -p "$work/give/${real##*/}/$(dirname "$f")"
cp -- "$path/$f" "$work/give/${real##*/}/$f"
done
else
[ -f "$path" ] || die "$path isn't a file or a folder."
cp -- "$path" "$work/give/"
fi
done
mapfile -d '' -t files < <(cd "$work/give" && find . -type f -print0 | sort -z)
[ ${#files[@]} -gt 0 ] || die "nothing to give there (hidden files and links are left out)."
total=$(du -sm "$work/give" | cut -f1)
[ "$total" -le "$MAX_GIVE_MB" ] || die "$total MB in all, over $MAX_GIVE_MB MB. Give fewer."
echo "This goes into the VM${account:+ $account}, which reaches the internet: what is there can leave it."
# (%q: a file name can't steer your terminal)
for f in "${files[@]}"; do printf ' %q\n' "${f#./}"; done
echo "${#files[@]} files, about $total MB. Leave out your own photos (they can say where they were taken) and anything private."
if command -v exiftool >/dev/null; then
# shellcheck disable=SC2016 # exiftool's own variables
located=$(cd "$work/give" && exiftool -q -r -if '$GPSLatitude' -p '$Directory/$FileName' . 2>/dev/null || true)
[ -z "$located" ] || { echo "THESE SAY WHERE THEY WERE TAKEN:"; printf '%s\n' "$located"; }
else
echo "(Hidden details in the files weren't checked: exiftool isn't installed.)"
fi
read -rp "Give them to the agent? [y/N] " answer || answer=
case $answer in [yY]*) ;; *) echo "Nothing was given."; return 0 ;; esac
tar -C "$work/give" --owner=0 --group=0 --numeric-owner -cf - . | sudo -n -u "$AGENT" "$SESSION" give
}
cmd=${1:-connect}
case "$cmd" in
--resume) cmd=resume ;;
connect | resume | unattended | shell | status | stop | pubkey | rebuild | reset) ;;
give) [ $# -ge 2 ] || die "usage: claude-vm give PATH... (files or folders for the agent)" ;;
bundle)
[ $# -eq 2 ] || die "usage: claude-vm bundle NAME (a folder in the VM's ~/projects)"
[ ! -t 1 ] || die "that writes a git bundle, not text: send it to a file (claude-pull does)." ;;
-h | --help)
sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) die "unknown command '$1'. Try: claude-vm --help" ;;
esac
# Started from wmenu or another launcher there is no terminal to talk in, and
# ssh would quietly give up: open one in sway and run again inside it. (No
# controlling terminal, /dev/tty: what sway starts gets tty1 as stdin, out of sight)
if [ "$cmd" = connect ] || [ "$cmd" = resume ] || [ "$cmd" = unattended ] || [ "$cmd" = shell ]; then
if ! { : </dev/tty; } 2>/dev/null && [ -n "${WAYLAND_DISPLAY:-}" ] && command -v foot >/dev/null; then
exec foot --title="Claude (VM${account:+ $account})" env CLAUDE_VM_HOLD=1 "$0" ${account:+--account "$account"} "$cmd"
fi
fi
if [ -n "$account" ]; then
sudo -n -u "$AGENT" -l "$SESSION" >/dev/null 2>&1 ||
die "the account $account isn't set up. Run: wrenctl install claude-vm --account $account"
fi
sudo -n -u "$AGENT" -l "$SESSION" >/dev/null 2>&1 ||
die "the VM sandbox isn't set up. Run: wrenctl install claude-vm"
if [ "$cmd" = unattended ]; then
echo "Unattended: the agent does what it decides without asking, what a web page talks it into too. The VM, its firewall and your review of what it pushes still hold."
fi
if [ "$cmd" = reset ]; then
printf "This deletes everything in the VM since it was built, work claude-pull hasn't brought in and your Claude login too. Go on? [y/N] "
IFS= read -r a || a=
[[ $a =~ ^[Yy] ]] || { echo "Nothing was changed."; exit 0; }
fi
if [ "$cmd" = rebuild ]; then
printf "This deletes the VM and everything in it, work claude-pull hasn't brought in and your Claude login too, then builds a new one. Go on? [y/N] "
IFS= read -r a || a=
[[ $a =~ ^[Yy] ]] || { echo "Nothing was changed."; exit 0; }
exec sudo -n -u "$AGENT" "$SESSION" rebuild
fi
if [ -n "${CLAUDE_VM_HOLD:-}" ]; then
rc=0
sudo -n -u "$AGENT" "$SESSION" "$cmd" || rc=$?
[ "$rc" -eq 0 ] || hold
exit "$rc"
fi
if [ "$cmd" = bundle ]; then exec sudo -n -u "$AGENT" "$SESSION" bundle "$2"; fi
if [ "$cmd" = give ]; then shift; give "$@"; exit; fi
exec sudo -n -u "$AGENT" "$SESSION" "$cmd"