Wren Linux

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"