#!/usr/bin/env bash # # Agent K bootstrap - get the code onto a machine that has nothing on it. # # curl -fsSL -A agent-k https://start.zobov.dev | bash # # Note the -A. InMotion's mod_security rejects the literal `curl/` User-Agent # with 406 on *every* path, including this static one, so the flag is not # optional - without it the one-liner fetches an error page and pipes it to a # shell. Every request this script makes sets its own User-Agent for the same # reason (D42, Phase 9). # # What this needs on the machine: curl and tar. Both are in the macOS base # system, so there is no git, no Homebrew and no Xcode Command Line Tools # prompt standing between a bare Mac and Agent K. # # What it does NOT do: hold a credential. The endpoint validates the OTP and # streams back a tarball it fetched itself, so no GitHub token ever reaches # this machine (D42). # # Bash 3.2, because that is what macOS ships and this runs before anything is # installed. set -euo pipefail ENDPOINT="${AGENTK_BOOTSTRAP_ENDPOINT:-https://start.zobov.dev/otp.php}" DEST="${AGENTK_BOOTSTRAP_DEST:-$HOME/Dev/kayzee-dotfiles}" UA="agent-k-bootstrap/1" CONNECT_TIMEOUT="${AGENTK_BOOTSTRAP_CONNECT_TIMEOUT:-10}" MAX_TIME="${AGENTK_BOOTSTRAP_MAX_TIME:-120}" # A tarball of this repo is tens of KB at least. Anything smaller is a # truncated transfer or an error page, and must not reach tar. MIN_TARBALL_BYTES=10240 say() { printf '==> %s\n' "$*"; } ok() { printf ' ok %s\n' "$*"; } die() { printf ' xx %s\n' "$*" >&2 exit 1 } WORK="" cleanup() { [[ -n "$WORK" && -d "$WORK" ]] && rm -rf "$WORK"; } trap cleanup EXIT INT TERM # ---- preflight --------------------------------------------------------------- for tool in curl tar; do command -v "$tool" >/dev/null 2>&1 || die "${tool} is missing, which should be impossible on macOS. Stopping rather than guessing." done if [[ -e "$DEST" ]]; then die "${DEST} already exists. Move it aside, or set AGENTK_BOOTSTRAP_DEST to somewhere else." fi # ---- the one-time code ------------------------------------------------------- # Read from the terminal, not from stdin. # # This script is normally run as `curl ... | bash`, which makes bash's stdin the # pipe from curl - already at EOF by the time we get here. A plain `read` would # see nothing and sail straight past the prompt with an empty OTP. Reading # /dev/tty explicitly is what makes the prompt work at all in the only form # anybody actually uses. if [[ ! -r /dev/tty ]]; then die "no terminal to read from. This needs an interactive shell; it cannot run unattended." fi say "Touch your YubiKey to continue (a short tap - the code is not shown)" otp="" IFS= read -rs otp "$WORK/curl.err")" curl_rc=$? set -e otp="" unset otp if [[ $curl_rc -ne 0 ]]; then printf ' xx could not reach %s\n' "$ENDPOINT" >&2 sed 's/^/ /' "$WORK/curl.err" >&2 || true die "check the network, then try again. Nothing has been changed on this machine." fi case "$http_code" in 200) ;; 401 | 403) die "that key is not on the allowlist. If it should be, add its public ID to the endpoint's config." ;; 409) die "that code has already been used. Touch the key again for a fresh one." ;; 429) die "too many attempts. Wait a few minutes and try again." ;; 5*) die "the endpoint failed (HTTP ${http_code}). That is a server-side problem, not this machine." ;; *) die "unexpected response from the endpoint (HTTP ${http_code})." ;; esac # ---- check what came back before trusting it --------------------------------- # # The failure this guards against is the nastiest one available here: a # truncated or error-page response piped straight into tar leaves a partial # tree, which the last line of this script would then happily execute. # So the download lands in a file, is checked, and only then extracted. size=$(wc -c <"$tarball" | tr -d ' ') [[ "$size" -ge "$MIN_TARBALL_BYTES" ]] || die "the download is only ${size} bytes, which is too small to be the repository." # gzip magic, rather than trusting the Content-Type. magic="$(od -An -tx1 -N2 "$tarball" | tr -d ' \n')" [[ "$magic" == "1f8b" ]] || die "the download is not a gzip archive. Refusing to extract it." tar -tzf "$tarball" >/dev/null 2>&1 || die "the archive does not list cleanly, so it is incomplete. Refusing to extract it." ok "got the repository ($((size / 1024)) KB)" # ---- unpack ------------------------------------------------------------------ # # Into a staging directory first, then moved into place, so an interrupted # extract cannot leave a half-populated $DEST that looks like a real checkout. stage="$WORK/stage" mkdir -p "$stage" # GitHub tarballs wrap everything in one --/ directory. tar -xzf "$tarball" -C "$stage" --strip-components=1 [[ -x "$stage/bin/agent-k" ]] || die "the archive does not contain bin/agent-k. Refusing to run anything from it." mkdir -p "$(dirname "$DEST")" mv "$stage" "$DEST" ok "unpacked into ${DEST}" # ---- hand over --------------------------------------------------------------- say "Starting Agent K" cd "$DEST" # stdin from the terminal, for the same reason the prompt reads /dev/tty. # # `curl ... | bash` leaves bash's stdin pointing at the pipe, already at EOF, so # the picker's keystroke read gets nothing and bin/agent-k correctly refuses # with "not an interactive terminal". Handing over without this makes the one # form anybody uses the one form that cannot reach the picker - which is the # whole point of the bootstrap. exec ./bin/agent-k "$@"