Files
nixconfig/agent-vm/launch.sh
T
OpenAI Coding Assistant 15878fd3b7 feat: include only dsh-context in agent VM setup
Install through the upstream CLI before Web startup, preserve existing profile state and skip completed installs. Keep pnpm's SQLite store on the guest cache disk and map the caller to namespace root for 9p atomic saves, without host-root privileges.

Cover context-only installation, retries, idempotence and preservation with offline checks. Validate real VM boot, ports, Firefox and a live DSH/context launch using a disposable profile.
2026-09-06 15:08:10 -05:00

145 lines
7.7 KiB
Bash

# Included by writeShellApplication: bash and PATH are supplied by Nix.
set -euo pipefail
if [[ ${1:-} == --help ]]; then
echo 'Usage: nix run .#agent -- [run | ssh [command ...] | url | stop]'
echo 'Workspace = cwd. RW config/credentials/skills = DSH_HOME (default ~/.dsh)'
echo 'Also shares DSH_AGENTS_HOME/skills (default ~/.agents/skills). RAM/CPU/network: flake.'
echo "Web UI tries $AGENT_WEB_PORT-$AGENT_WEB_PORT_END in order; url prints the selected port."
exit 0
fi
[[ $EUID != 0 ]] || { echo 'Run as your normal host user, not sudo/root.' >&2; exit 1; }
umask 077
project=$(pwd -P)
dsh=$(realpath -m "${DSH_HOME:-$HOME/.dsh}")
skills=$(realpath -m "${DSH_AGENTS_HOME:-$HOME/.agents}/skills")
state=$(realpath -m "${XDG_STATE_HOME:-$HOME/.local/state}/agent-vm/$(printf %s "$project" | sha256sum | cut -c1-16)")
for path in "$project" "$dsh" "$skills"; do
case "$path" in /|/home|/etc|/nix|/nix/*|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|"$HOME"|*$'\n'*) echo "Refusing broad/system share: $path" >&2; exit 1;; esac
[[ $state != "$path" && $state != "$path/"* ]] || { echo 'State must be outside shared directories.' >&2; exit 1; }
done
# Prevent a broad workspace/config mount from accidentally including other mounts.
disjoint() {
[[ $1 != "$2" && $1 != "$2/"* && $2 != "$1/"* ]] || { echo 'Writable shares must not overlap.' >&2; exit 1; }
}
disjoint "$project" "$dsh"; disjoint "$project" "$skills"; disjoint "$dsh" "$skills"
ssh_cmd=(ssh -F /dev/null -i "$state/client-key" -p "$AGENT_SSH_PORT"
-o IdentitiesOnly=yes -o IdentityAgent=none -o ForwardAgent=no -o BatchMode=yes
-o StrictHostKeyChecking=yes -o HostKeyAlias=agent-vm -o ConnectTimeout=3
-o "UserKnownHostsFile=$state/known_hosts" -o GlobalKnownHostsFile=/dev/null)
remote="root@$AGENT_SSH_HOST"
# Relative ControlPath avoids Unix-socket path limits with long state directories.
web_control() { (cd "$state" && "${ssh_cmd[@]}" -S web.sock "$@" "$remote"); }
url() {
local found port address=$AGENT_WEB_BIND
[[ -f $state/web-port ]] && read -r port < "$state/web-port" || return 1
[[ $port =~ ^[1-9][0-9]{0,4}$ ]] && (( port <= 65535 )) || return 1
web_control -O check >/dev/null 2>&1 || return 1
[[ $address != 0.0.0.0 ]] || address=127.0.0.1
found=$("${ssh_cmd[@]}" "$remote" 'journalctl -u agent -b -o cat --no-pager' |
grep -oE 'http://127\.0\.0\.1:3080/\?token=[a-zA-Z0-9_%.-]+' | tail -1) || return 1
[[ -n $found ]] || return 1
printf '%s\n' "${found/http:\/\/127.0.0.1:3080/http:\/\/$address:$port}"
}
# Expand cwd inside the guest, not on the host.
# shellcheck disable=SC2016
case ${1:-run} in
ssh) shift; if (( $# )); then exec "${ssh_cmd[@]}" "$remote" 'cd -- "$(cat /run/agent-vm/workdir)" || exit; '"$*"; else exec "${ssh_cmd[@]}" -t "$remote" 'cd -- "$(cat /run/agent-vm/workdir)" || exit; exec bash -l'; fi;;
url) url || { echo "DSH not ready; inspect: nix run .#agent -- ssh 'journalctl -u agent -b'" >&2; exit 1; }; exit;;
stop) cd "$state"; exec "$AGENT_RUNNER/microvm-shutdown";;
run) [[ $# -le 1 ]] || { echo 'Unexpected run arguments; use --help.' >&2; exit 1; };;
*) echo 'Unknown command; use --help.' >&2; exit 1;;
esac
[[ -r /dev/kvm && -w /dev/kvm ]] || { echo 'Need read/write access to /dev/kvm.' >&2; exit 1; }
mkdir -p "$state" "$dsh" "$skills"
for dir in "$state" "$dsh"; do
[[ $(stat -c %u "$dir") == "$(id -u)" && $(stat -c %a "$dir") == 700 ]] || {
echo "Make this directory private and user-owned first: $dir (chmod 700)" >&2; exit 1;
}
done
exec 9>"$state/run.lock"
flock -n 9 || { echo 'This project VM is already running.' >&2; exit 1; }
# Clean up only this project's stale forwarding state, after acquiring its lock.
web_control -O exit >/dev/null 2>&1 || true
rm -f "$state/web.sock" "$state/web-port"
for key in client-key ssh-host-key; do
[[ -f $state/$key ]] || ssh-keygen -q -t ed25519 -N '' -C agent-vm -f "$state/$key"
done
printf '%s\n' "$project" > "$state/workdir"
cp "$state/client-key.pub" "$state/ssh-authorized-key"
printf 'agent-vm %s\n' "$(cut -d' ' -f1,2 "$state/ssh-host-key.pub")" > "$state/known_hosts"
printf 'RW workspace: %s -> /workspace\nRW DSH home: %s\nRW shared skills: %s\nConsole log: %s/console.log\n' "$project" "$dsh" "$skills" "$state"
if [[ $AGENT_WEB_BIND != 127.0.0.1 ]]; then
echo 'WARNING: off-host Web access is plaintext HTTP. Use a VPN/TLS; never expose directly to the Internet.' >&2
fi
vm_pid=''
cleanup() {
trap - EXIT INT TERM
web_control -O exit >/dev/null 2>&1 || true
rm -f "$state/web-port" "$state/web.sock"
if [[ -n $vm_pid ]] && kill -0 "$vm_pid" 2>/dev/null; then
(cd "$state"; timeout 30 "$AGENT_RUNNER/microvm-shutdown") >/dev/null 2>&1 || true
kill "$vm_pid" 2>/dev/null || true
wait "$vm_pid" 2>/dev/null || true
fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# Host-side defense in depth around QEMU. No host home/session sockets or other
# processes; only the three explicit shares and private VM control state are RW.
# Network is intentionally inherited for API access (not an egress firewall).
devices=()
[[ $AGENT_NETWORK != tap ]] || devices=(--dev-bind /dev/net/tun /dev/net/tun)
# Map the caller to namespace uid/gid 0 so 9p ownership matches guest root.
# Host writes still belong to the caller; no host-root identity/capability is gained.
bwrap "${devices[@]}" --die-with-parent --new-session --unshare-user --uid 0 --gid 0 \
--unshare-pid --unshare-ipc --unshare-uts --unshare-cgroup-try --cap-drop ALL --clearenv \
--setenv HOME /tmp --setenv PATH /no-host-path --setenv LANG C.UTF-8 \
--ro-bind /nix/store /nix/store --proc /proc --dev /dev --dev-bind /dev/kvm /dev/kvm \
--tmpfs /tmp --bind "$state" /state --bind "$project" /workspace \
--bind "$dsh" /dsh-home --bind "$skills" /skills \
--ro-bind-try /etc/resolv.conf /etc/resolv.conf --ro-bind-try /etc/hosts /etc/hosts \
--chdir /state "$AGENT_RUNNER/microvm-run" >"$state/console.log" 2>&1 &
vm_pid=$!
ready=false
for ((i=0; i<90; i++)); do
kill -0 "$vm_pid" 2>/dev/null || { echo "VM exited; see $state/console.log" >&2; exit 1; }
if "${ssh_cmd[@]}" "$remote" true 2>/dev/null; then ready=true; break; fi
sleep 1
done
$ready || { echo "SSH boot timeout; see $state/console.log" >&2; exit 1; }
# DSH deliberately refuses --host 0.0.0.0. Keep its own authenticated browser
# endpoint on guest loopback and publish an SSH forward on the chosen host IP.
web_control -M -fN -g -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 \
-o ServerAliveCountMax=3 >>"$state/console.log" 2>&1 || {
echo "Cannot start Web SSH tunnel; see $state/console.log" >&2; exit 1;
}
# Ask SSH to actually bind each port: no probe-then-bind race or extra port helper.
for ((port=AGENT_WEB_PORT; port<=AGENT_WEB_PORT_END; port++)); do
if web_control -O forward -L "$AGENT_WEB_BIND:$port:127.0.0.1:3080" >>"$state/console.log" 2>&1; then
printf '%s\n' "$port" > "$state/web-port"
break
fi
done
[[ -f $state/web-port ]] || {
echo "No available Web UI port on $AGENT_WEB_BIND in $AGENT_WEB_PORT-$AGENT_WEB_PORT_END; see $state/console.log" >&2
exit 1
}
echo "Web UI selected host port $port."
echo "Booted. DSH resolves npm @latest on startup; first launch may take a few minutes."
echo 'Use another terminal: nix run .#agent -- url (or: ssh / stop)'
printed=false
while kill -0 "$vm_pid" 2>/dev/null; do
# Normal guest poweroff can close SSH slightly before QEMU exits.
if ! web_control -O check >/dev/null 2>&1; then
timeout 30 tail --pid="$vm_pid" -f /dev/null || true
if kill -0 "$vm_pid" 2>/dev/null; then echo "Web tunnel exited; see $state/console.log" >&2; exit 1; fi
break
fi
if ! $printed; then
if login_url=$(url 2>/dev/null); then printf 'Private login URL: %s\n' "$login_url"; printed=true; fi
fi
sleep 2
done
wait "$vm_pid"