#!/usr/bin/env bash # Sets up a Debian or Ubuntu machine as a didbot dev box: build packages, # Docker, Node, rustup with this checkout's pinned toolchain, sccache, Claude # Code, atgc and the didbot agent-host binaries. The AWS box runs it at first # boot (infra/devbox/templates/user_data.sh.tftpl); a home box runs it from a # clone. Safe to rerun, which is also how the tools are updated. # # It makes no login and holds no secret. docs/devbox.md has the human steps # that follow it. set -euo pipefail usage() { cat <<'EOF' sudo scripts/devbox-bootstrap.sh [--user NAME] [--pds HOST] [--tailscale] [--firewall] [--reboot-at HH:MM] [--node-major N] [--sccache-size-gb N] --user who the tools are for (default: the user running sudo) --pds DIDBOT_PDS for didbot-agentd (default: pds.did.bot) --tailscale install Tailscale and start tailscaled, without joining --firewall drop inbound traffic except on tailscale0 --reboot-at let unattended-upgrades reboot at this hour --node-major Node.js major version (default: 24) --sccache-size-gb sccache's local disk cache size (default: 50) EOF exit "${1:-2}" } REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TARGET_USER="${SUDO_USER:-}" DIDBOT_PDS=pds.did.bot TAILSCALE=0 FIREWALL=0 REBOOT_AT="" NODE_MAJOR=24 SCCACHE_SIZE_GB=50 while [ $# -gt 0 ]; do case "$1" in --user) TARGET_USER="$2"; shift 2 ;; --pds) DIDBOT_PDS="$2"; shift 2 ;; --tailscale) TAILSCALE=1; shift ;; --firewall) FIREWALL=1; shift ;; --reboot-at) REBOOT_AT="$2"; shift 2 ;; --node-major) NODE_MAJOR="$2"; shift 2 ;; --sccache-size-gb) SCCACHE_SIZE_GB="$2"; shift 2 ;; -h | --help) usage 0 ;; *) echo "unknown argument: $1" >&2; usage ;; esac done say() { printf '\n== %s\n' "$*"; } die() { echo "devbox-bootstrap: $*" >&2; exit 1; } [ "$(id -u)" -eq 0 ] || die "run it as root, with sudo" [ -n "$TARGET_USER" ] || die "name the user the tools are for with --user" TARGET_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)" [ -n "$TARGET_HOME" ] || die "no user named $TARGET_USER" [ -f "$REPO_DIR/rust-toolchain.toml" ] || die "run it from a didbot checkout" [[ "$REBOOT_AT" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ || -z "$REBOOT_AT" ]] \ || die "--reboot-at takes HH:MM" [[ "$SCCACHE_SIZE_GB" =~ ^[0-9]+$ ]] || die "--sccache-size-gb takes a whole number" # shellcheck source=/dev/null . /etc/os-release case "${ID:-}" in ubuntu | debian) ;; *) die "this script supports Ubuntu and Debian; /etc/os-release says ${ID:-nothing}" ;; esac ARCH="$(dpkg --print-architecture)" TOOLCHAIN="$(sed -n 's/^channel = "\(.*\)"/\1/p' "$REPO_DIR/rust-toolchain.toml")" [ -n "$TOOLCHAIN" ] || die "rust-toolchain.toml names no channel" export DEBIAN_FRONTEND=noninteractive as_user() { runuser -u "$TARGET_USER" -- env -C "$TARGET_HOME" HOME="$TARGET_HOME" \ PATH="$TARGET_HOME/.cargo/bin:$TARGET_HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" \ CARGO_BUILD_JOBS="$(nproc)" "$@" } # One apt source, written only when it differs, so a rerun does not rewrite # what an unchanged source already says. apt_source() { local name="$1" key_url="$2" key_file="$3" line="$4" install -d -m 755 /etc/apt/keyrings [ -s "$key_file" ] || curl -fsSL "$key_url" -o "$key_file" chmod 644 "$key_file" if [ "$(cat "/etc/apt/sources.list.d/$name.list" 2>/dev/null)" != "$line" ]; then echo "$line" > "/etc/apt/sources.list.d/$name.list" fi } say "packages" apt-get update apt-get install -y build-essential pkg-config cmake clang libssl-dev \ git tmux curl ca-certificates jq unzip python3 unattended-upgrades say "apt sources: Docker, Node $NODE_MAJOR" apt_source docker "https://download.docker.com/linux/$ID/gpg" /etc/apt/keyrings/docker.asc \ "deb [arch=$ARCH signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$ID $VERSION_CODENAME stable" apt_source nodesource https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key /etc/apt/keyrings/nodesource.asc \ "deb [arch=$ARCH signed-by=/etc/apt/keyrings/nodesource.asc] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" if [ "$TAILSCALE" = 1 ]; then apt_source tailscale "https://pkgs.tailscale.com/stable/$ID/$VERSION_CODENAME.noarmor.gpg" \ /usr/share/keyrings/tailscale-archive-keyring.gpg \ "deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/$ID $VERSION_CODENAME main" fi apt-get update apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin nodejs # The docker group can start a root container, so this user is as good as # root on this machine. A dev box has one user, and that user builds images. usermod -aG docker "$TARGET_USER" systemctl enable --now docker if [ "$TAILSCALE" = 1 ]; then # Installed and started, never joined: `tailscale up` is the owner's step. say "tailscale" apt-get install -y tailscale systemctl enable --now tailscaled fi if [ "$FIREWALL" = 1 ]; then # Drops everything inbound except over the tailnet. Run it after `tailscale # up` works, from a shell that is not on the LAN's ssh. say "firewall" ip link show tailscale0 >/dev/null 2>&1 || die "--firewall needs tailscale0; run tailscale up first" apt-get install -y ufw ufw default deny incoming ufw default allow outgoing ufw allow in on tailscale0 ufw --force enable fi if [ -n "$REBOOT_AT" ]; then say "unattended-upgrades reboots at $REBOOT_AT" cat > /etc/apt/apt.conf.d/52devbox-reboot </dev/null </dev/null <<'EOF' [build] rustc-wrapper = "sccache" EOF elif ! grep -q 'rustc-wrapper' "$CARGO_CONFIG"; then echo "left $CARGO_CONFIG alone; add rustc-wrapper = \"sccache\" under [build] to use the cache" >&2 fi # The checkout's .cargo/config.toml holds builds to two jobs for a laptop # shared with other work. A dev box gives every job to its builds. say "shell environment" ENV_FILE="$TARGET_HOME/.config/devbox/env.sh" as_user mkdir -p "$(dirname "$ENV_FILE")" as_user tee "$ENV_FILE" >/dev/null <<'EOF' export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" CARGO_BUILD_JOBS="$(nproc)" export CARGO_BUILD_JOBS EOF for rc in .profile .bashrc; do line=". \"\$HOME/.config/devbox/env.sh\"" as_user touch "$TARGET_HOME/$rc" grep -qxF "$line" "$TARGET_HOME/$rc" || echo "$line" >> "$TARGET_HOME/$rc" done say "Claude Code" if ! [ -x "$TARGET_HOME/.local/bin/claude" ]; then as_user bash -c 'curl -fsSL https://claude.ai/install.sh | bash' fi say "didbot-agentd user unit, not started" as_user mkdir -p "$TARGET_HOME/.config/didbot" "$TARGET_HOME/.config/systemd/user" if ! [ -e "$TARGET_HOME/.config/didbot/agentd.env" ]; then echo "DIDBOT_PDS=$DIDBOT_PDS" | as_user tee "$TARGET_HOME/.config/didbot/agentd.env" >/dev/null fi as_user cp "$REPO_DIR/crates/didbot-agentd/didbot-agentd.service" "$TARGET_HOME/.config/systemd/user/" # The user's systemd starts at boot rather than at first login, so the daemon # and agents in tmux outlive the ssh session that started them. loginctl enable-linger "$TARGET_USER" say "done" cat < --name didbot register host --server $DIDBOT_PDS claude plugin marketplace add https://tangled.org/permadeath.com/didbot-claude claude plugin install didbot@didbot systemctl --user enable --now didbot-agentd EOF if [ "$TAILSCALE" = 1 ]; then echo " sudo tailscale up --ssh --advertise-tags=tag:devbox" fi