#!/usr/bin/env bash # # create-agent-user.sh # ----------------------------------------------------------------------------- # Provision a local `agent-user` account on a Linux host so an AI agent can be # handed full (sudo) access to the machine over SSH. # # What it does, in order: # 1. Refuses to run unless invoked as root and an SSH server is running. # 2. Detects the distro family (Debian/Ubuntu, Fedora/RHEL, Arch/CachyOS). # 3. Creates `agent-user` as a normal user for that distro, with a fixed UID # and the SAME supplementary groups as the default uid=1000 user. # 4. Grants passwordless sudo (default) via /etc/sudoers.d. # 5. Sets a long word!word!word passphrase (from the system dict) or, if no # dict is available, a 13-char unambiguous random password. # 6. Hides the account from every installed graphical greeter (GDM/SDDM/ # LightDM) while keeping it fully usable for VNC / X2Go / forwarded-X # desktop sessions. # 7. Installs lightweight agent + hardware-inspection tooling (best effort). # 8. Writes a system-info file into the account's home dir. # 9. Prints a short block of credentials + instructions to paste into an # AI agent prompt. # # Run it AS ROOT, directly on the target host: # sudo ./create-agent-user.sh [OPTIONS] # # It is NOT meant to be piped from curl. Download it, read it, then run it. # ----------------------------------------------------------------------------- set -uo pipefail # deliberately NOT -e: many steps are best-effort and checked # explicitly with die() where failure must actually stop us. SCRIPT_VERSION="1.2.0" # ============================================================================= # CONFIG / DEFAULTS -- everything below is overridable via CLI flags. # Edit these to change the baked-in defaults for your fleet. # ============================================================================= AGENT_USER="agent-user" # account name to create AGENT_UID="60123" # fixed UID. Sits in the reserved 60001-61183 # gap: above the human range (<=60000) and # below the systemd DynamicUser floor (61184). # Well clear of SSSD AD idmap (200000+). # Validated as free at runtime regardless. AGENT_GECOS="AI agent access account" REF_UID="1000" # the "default user" whose groups we clone PASSPHRASE_WORDS=6 # words in the generated passphrase PASSPHRASE_SEP='!' # separator between words RANDOM_PW_LEN=13 # length of the fallback random password MIN_WORD_LEN=4 # dict-word length filter (inclusive) MAX_WORD_LEN=8 SUDO_MODE="nopasswd" # "nopasswd" | "password" DO_INSTALL=1 # 1 = install tooling, 0 = skip (--no-install) DICT_PROMPT=1 # 1 = offer to install a dict if missing SSHD_CHECK=1 # 1 = require a running sshd before proceeding ROTATE=0 # 1 = rotate creds/groups if user exists NONINTERACTIVE=0 # 1 = never prompt; take safe fallbacks PW_MODE="auto" # "auto" | "passphrase" | "random" UNIQUE_KEYPAIR=0 # 1 = tell the agent to mint a per-host keypair # (--unique-keypair); default just says "use key # auth", since most agents already have one. AGENT_TARGET_HOST="" # override reachable address in the output SSH_PORT="" # override advertised SSH port (else detected) INFO_FILE_NAME="AGENT_INFO.md" # written into the account's home dir PROMPT_FILE_NAME="AGENT_PROMPT.txt" # copy of the final agent-prompt block, # also written into the account's home dir # Dictionaries we look for, in order of preference: DICT_CANDIDATES=( /usr/share/dict/words /usr/share/dict/american-english /usr/share/dict/british-english /usr/dict/words ) # ============================================================================= # Logging helpers # ============================================================================= if [ -t 2 ]; then C_RED=$'\033[31m'; C_YLW=$'\033[33m'; C_GRN=$'\033[32m' C_BLU=$'\033[34m'; C_RST=$'\033[0m' else C_RED=""; C_YLW=""; C_GRN=""; C_BLU=""; C_RST="" fi info() { printf '%s[*]%s %s\n' "$C_BLU" "$C_RST" "$*" >&2; } ok() { printf '%s[+]%s %s\n' "$C_GRN" "$C_RST" "$*" >&2; } warn() { printf '%s[!]%s %s\n' "$C_YLW" "$C_RST" "$*" >&2; } die() { printf '%s[x]%s %s\n' "$C_RED" "$C_RST" "$*" >&2; exit 1; } usage() { cat >&2 < we cannot prompt. [ -t 0 ] || NONINTERACTIVE=1 ask_yes_no() { # $1 = prompt, $2 = default (y/n). Honors --non-interactive. local prompt="$1" def="${2:-n}" reply if [ "$NONINTERACTIVE" -eq 1 ]; then [ "$def" = "y" ] && return 0 || return 1 fi local hint="[y/N]"; [ "$def" = "y" ] && hint="[Y/n]" read -r -p "$prompt $hint " reply || reply="" reply="${reply:-$def}" case "$reply" in [Yy]*) return 0 ;; *) return 1 ;; esac } # ============================================================================= # Preconditions # ============================================================================= [ "$(id -u)" -eq 0 ] || die "Must run as root (use sudo)." for bin in getent useradd usermod chpasswd visudo shuf install; do command -v "$bin" >/dev/null 2>&1 || die "Required tool missing: $bin" done ssh_running() { pgrep -x sshd >/dev/null 2>&1 && return 0 systemctl is-active --quiet sshd 2>/dev/null && return 0 systemctl is-active --quiet ssh 2>/dev/null && return 0 command -v ss >/dev/null 2>&1 && ss -tlnH 2>/dev/null | grep -qE ':22\b' && return 0 return 1 } if [ "$SSHD_CHECK" -eq 1 ]; then ssh_running || die "No running SSH server detected. Start sshd, or pass --no-sshd-check." ok "SSH server detected." fi # ============================================================================= # Distro detection -> PKG_FAMILY + package name arrays # ============================================================================= PKG_FAMILY="" DNF="dnf" detect_distro() { local id="" like="" if [ -r /etc/os-release ]; then # Source in a SUBSHELL so os-release vars (VERSION, ID, NAME, ...) never # leak into and clobber this script's own globals. # shellcheck disable=SC1091 id="$( . /etc/os-release; printf '%s' "${ID:-}" )" like="$( . /etc/os-release; printf '%s' "${ID_LIKE:-}" )" fi case " $id $like " in *" debian "*|*" ubuntu "*) PKG_FAMILY="debian" ;; *" fedora "*|*" rhel "*|*" centos "*) PKG_FAMILY="rhel" ;; *" arch "*) PKG_FAMILY="arch" ;; *) # Fall back on whichever package manager exists. if command -v apt-get >/dev/null; then PKG_FAMILY="debian" elif command -v dnf >/dev/null; then PKG_FAMILY="rhel" elif command -v yum >/dev/null; then PKG_FAMILY="rhel"; DNF="yum" elif command -v pacman >/dev/null; then PKG_FAMILY="arch" else die "Unsupported distro: could not find apt/dnf/pacman." fi ;; esac command -v dnf >/dev/null 2>&1 || DNF="yum" case "$PKG_FAMILY" in debian) SUDO_GROUP="sudo" PKGS_AGENT=(git jq curl rsync tmux ca-certificates python3 python3-venv python3-pip pipx) PKGS_HW=(lshw lsscsi hdparm inxi pciutils usbutils dmidecode smartmontools) PKG_DICT="wamerican" ;; rhel) SUDO_GROUP="wheel" PKGS_AGENT=(git jq curl rsync tmux ca-certificates python3 python3-pip pipx) PKGS_HW=(lshw lsscsi hdparm inxi pciutils usbutils dmidecode smartmontools) PKG_DICT="words" ;; arch) SUDO_GROUP="wheel" PKGS_AGENT=(git jq curl rsync tmux ca-certificates python python-pip python-pipx) PKGS_HW=(lshw lsscsi hdparm inxi pciutils usbutils dmidecode smartmontools) PKG_DICT="words" ;; esac } detect_distro ok "Distro family: $PKG_FAMILY (sudo group: $SUDO_GROUP)" # ============================================================================= # Package installation helpers (best-effort) # ============================================================================= PKG_REFRESHED=0 pkg_refresh() { [ "$PKG_REFRESHED" -eq 1 ] && return 0 PKG_REFRESHED=1 case "$PKG_FAMILY" in debian) DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true ;; arch) pacman -Sy --noconfirm >/dev/null 2>&1 || true ;; rhel) : ;; # dnf resolves metadata on demand esac } pkg_install_one() { local p="$1" case "$PKG_FAMILY" in debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$p" >/dev/null 2>&1 ;; rhel) $DNF install -y "$p" >/dev/null 2>&1 ;; arch) pacman -S --noconfirm --needed "$p" >/dev/null 2>&1 ;; esac } INSTALLED_OK=() INSTALLED_FAIL=() pkg_install_list() { # Best-effort: never aborts the script. Records per-package outcome. pkg_refresh local p for p in "$@"; do if pkg_install_one "$p"; then INSTALLED_OK+=("$p") else INSTALLED_FAIL+=("$p") fi done } # ============================================================================= # Reference user (uid=1000): clone its supplementary groups + login shell # ============================================================================= REF_USER="$(getent passwd "$REF_UID" | cut -d: -f1)" SUPP_GROUPS=() REF_SHELL="/bin/bash" if [ -n "$REF_USER" ]; then info "Cloning groups from reference user: $REF_USER (uid $REF_UID)" ref_primary="$(id -gn "$REF_USER" 2>/dev/null || echo)" ref_shell="$(getent passwd "$REF_USER" | cut -d: -f7)" [ -n "$ref_shell" ] && [ -x "$ref_shell" ] && REF_SHELL="$ref_shell" for g in $(id -nG "$REF_USER" 2>/dev/null); do [ "$g" = "$ref_primary" ] && continue [ "$g" = "$REF_USER" ] && continue SUPP_GROUPS+=("$g") done else warn "No uid=$REF_UID user found; falling back to '$SUDO_GROUP' group only." getent group "$SUDO_GROUP" >/dev/null && SUPP_GROUPS+=("$SUDO_GROUP") fi SUPP_CSV="$(IFS=,; echo "${SUPP_GROUPS[*]:-}")" info "Supplementary groups: ${SUPP_CSV:-}" info "Login shell: $REF_SHELL" # ============================================================================= # Password generation # ============================================================================= find_dict() { local d for d in "${DICT_CANDIDATES[@]}"; do if [ -r "$d" ] && [ "$(grep -cE "^[a-z]{${MIN_WORD_LEN},${MAX_WORD_LEN}}\$" "$d" 2>/dev/null)" -ge 100 ]; then echo "$d"; return 0 fi done return 1 } gen_passphrase() { local dict="$1" picked out="" w mapfile -t picked < <(grep -E "^[a-z]{${MIN_WORD_LEN},${MAX_WORD_LEN}}\$" "$dict" | shuf -n "$PASSPHRASE_WORDS") [ "${#picked[@]}" -lt "$PASSPHRASE_WORDS" ] && return 1 for w in "${picked[@]}"; do out+="${w^}${PASSPHRASE_SEP}" # Capitalize each word done out+="$(( (RANDOM % 90) + 10 ))" # trailing 2-digit for complexity printf '%s' "$out" } gen_random_pw() { # Unambiguous set: no I l O 0 1 o. local U='ABCDEFGHJKMNPQRSTUVWXYZ' L='abcdefghijkmnpqrstuvwxyz' \ D='23456789' S='!@#%^*-_=+' all pw="" i all="$U$L$D$S" pw+="${U:RANDOM%${#U}:1}" pw+="${L:RANDOM%${#L}:1}" pw+="${D:RANDOM%${#D}:1}" pw+="${S:RANDOM%${#S}:1}" for ((i=${#pw}; i/dev/null && USER_EXISTS=1 if [ "$USER_EXISTS" -eq 1 ] && [ "$ROTATE" -eq 0 ]; then die "Account '$AGENT_USER' already exists. Re-run with --rotate to rotate its credentials." fi if [ "$USER_EXISTS" -eq 0 ]; then # UID collision check (real users, winbind mappings, anything). uid_owner="$(getent passwd "$AGENT_UID" | cut -d: -f1)" if [ -n "$uid_owner" ]; then die "UID $AGENT_UID already in use by '$uid_owner'. Re-run with --uid ." fi info "Creating account '$AGENT_USER' (uid $AGENT_UID)" useradd --create-home --uid "$AGENT_UID" --shell "$REF_SHELL" \ --comment "$AGENT_GECOS" "$AGENT_USER" \ || die "useradd failed." ok "Account created." else existing_uid="$(id -u "$AGENT_USER")" warn "Rotating existing account '$AGENT_USER' (uid $existing_uid); UID left unchanged." AGENT_UID="$existing_uid" fi # Apply supplementary groups (idempotent). if [ -n "$SUPP_CSV" ]; then usermod -aG "$SUPP_CSV" "$AGENT_USER" || warn "usermod -aG partially failed." fi # Set the password. make_password printf '%s:%s\n' "$AGENT_USER" "$AGENT_PASSWORD" | chpasswd \ || die "Failed to set password." ok "Password set ($PW_KIND)." AGENT_HOME="$(getent passwd "$AGENT_USER" | cut -d: -f6)" [ -n "$AGENT_HOME" ] || die "Could not determine home directory." # Pre-create ~/.ssh so the agent can drop in a key cleanly. install -d -m 700 -o "$AGENT_USER" -g "$AGENT_USER" "$AGENT_HOME/.ssh" # ============================================================================= # sudo configuration # ============================================================================= SUDOERS_FILE="/etc/sudoers.d/$AGENT_USER" if [ "$SUDO_MODE" = "nopasswd" ]; then SUDO_RULE="$AGENT_USER ALL=(ALL:ALL) NOPASSWD:ALL" else SUDO_RULE="$AGENT_USER ALL=(ALL:ALL) ALL" fi printf '# Managed by create-agent-user.sh\n%s\n' "$SUDO_RULE" > "$SUDOERS_FILE" chmod 0440 "$SUDOERS_FILE" if ! visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1; then rm -f "$SUDOERS_FILE" die "Generated sudoers file failed validation; removed it." fi ok "sudo configured ($SUDO_MODE)." # ============================================================================= # Hide from graphical greeters (GDM / SDDM / LightDM) via AccountsService # plus greeter-specific excludes for whichever are installed. # ============================================================================= greeter_present() { case "$1" in gdm) command -v gdm >/dev/null || command -v gdm3 >/dev/null || [ -d /etc/gdm ] || [ -d /etc/gdm3 ] ;; sddm) command -v sddm >/dev/null || [ -f /etc/sddm.conf ] || [ -d /etc/sddm.conf.d ] ;; lightdm) command -v lightdm >/dev/null || [ -d /etc/lightdm ] ;; esac } # AccountsService: honored by GDM and, in modern setups, by SDDM/LightDM too. install -d -m 775 /var/lib/AccountsService/users 2>/dev/null || true cat > "/var/lib/AccountsService/users/$AGENT_USER" < "/etc/sddm.conf.d/10-hide-$AGENT_USER.conf" <> "$LDM" fi info "LightDM hide rule written." fi ok "Account hidden from installed greeters." # ============================================================================= # Install tooling (best-effort) # ============================================================================= if [ "$DO_INSTALL" -eq 1 ]; then info "Installing agent tooling (best-effort): ${PKGS_AGENT[*]}" info "Installing hardware-inspection tooling (best-effort): ${PKGS_HW[*]}" pkg_install_list "${PKGS_AGENT[@]}" "${PKGS_HW[@]}" ok "Installed (${#INSTALLED_OK[@]}): ${INSTALLED_OK[*]:-}" [ "${#INSTALLED_FAIL[@]}" -gt 0 ] && warn "Not installed (${#INSTALLED_FAIL[@]}): ${INSTALLED_FAIL[*]}" else info "Skipping package installation (--no-install)." fi # ============================================================================= # Determine reachable host + SSH port for the output block # # Preference order: FQDN, then a static IP, then the short hostname (works via # mDNS/local resolution on many LANs), then a plain DHCP-assigned IP as last # resort. ADDR_KIND records which one was picked so the output can flag the # weaker choices (short hostname / DHCP IP) instead of presenting them as if # they were as durable as an FQDN or static IP. # ============================================================================= PRIMARY_IFACE="$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" is_static_ip() { # Best-effort: returns 0=static, 1=dhcp, 2=unknown. Checked in order of # how common each network stack is; the first one that gives a definite # answer wins. local iface="$1" conn method ifidx [ -n "$iface" ] || return 2 if command -v nmcli >/dev/null 2>&1; then conn="$(nmcli -t -f GENERAL.CONNECTION device show "$iface" 2>/dev/null | cut -d: -f2-)" if [ -n "$conn" ] && [ "$conn" != "--" ]; then method="$(nmcli -t -f ipv4.method connection show "$conn" 2>/dev/null | cut -d: -f2-)" case "$method" in manual) return 0 ;; auto) return 1 ;; esac fi fi ifidx="$(cat "/sys/class/net/$iface/ifindex" 2>/dev/null)" [ -n "$ifidx" ] && [ -f "/run/systemd/netif/leases/$ifidx" ] && return 1 ls /var/lib/dhcp/dhclient*"$iface"*.lease* >/dev/null 2>&1 && return 1 ls /var/lib/dhcpcd*/*.lease >/dev/null 2>&1 && return 1 if [ -f /etc/network/interfaces ] \ && grep -qE "iface[[:space:]]+${iface}[[:space:]]+inet[[:space:]]+static" /etc/network/interfaces 2>/dev/null; then return 0 fi return 2 } # NOTE: detect_host sets OUT_HOST/ADDR_KIND directly (globals) rather than # echoing a return value, because it must run in THIS shell, not a # command-substitution subshell — otherwise ADDR_KIND would be lost the # moment the function returns. ADDR_KIND="" # override | fqdn | static-ip | short-hostname | dhcp-ip OUT_HOST="" detect_host() { if [ -n "$AGENT_TARGET_HOST" ]; then ADDR_KIND="override"; OUT_HOST="$AGENT_TARGET_HOST"; return fi local ip fqdn short ip="$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')" [ -z "$ip" ] && ip="$(hostname -I 2>/dev/null | awk '{print $1}')" fqdn="$(hostname -A 2>/dev/null | awk '{print $1}')" if [ -n "$fqdn" ] && [[ "$fqdn" == *.* ]]; then ADDR_KIND="fqdn"; OUT_HOST="$fqdn"; return fi if [ -n "$ip" ]; then is_static_ip "$PRIMARY_IFACE" if [ "$?" -eq 0 ]; then ADDR_KIND="static-ip"; OUT_HOST="$ip"; return fi fi short="$(hostname 2>/dev/null)" if [ -n "$short" ] && [ "$short" != "localhost" ]; then ADDR_KIND="short-hostname"; OUT_HOST="$short"; return fi ADDR_KIND="dhcp-ip"; OUT_HOST="$ip" } detect_port() { [ -n "$SSH_PORT" ] && { echo "$SSH_PORT"; return; } local p p="$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}')" [ -z "$p" ] && p=22 echo "$p" } detect_host OUT_PORT="$(detect_port)" HOSTNAME_FQDN="$(hostname -f 2>/dev/null || hostname)" ADDR_WARNING="" case "$ADDR_KIND" in short-hostname) ADDR_WARNING="No FQDN or static IP was found; advertising the short hostname '$OUT_HOST'. This may only resolve on the local network/mDNS domain — confirm it resolves before relying on it, or re-run with --agent-target-host." ;; dhcp-ip) ADDR_WARNING="No FQDN was found and the IP looks DHCP-assigned; advertising $OUT_HOST as-is may go stale if the lease changes. Prefer an FQDN if one exists, or re-run with --agent-target-host." ;; esac # Representative package-manager invocation + human-readable distro name, so # the agent knows immediately how to install things without waiting to read # AGENT_INFO.md. case "$PKG_FAMILY" in debian) PKG_MGR_CMD="apt-get install -y " ;; rhel) PKG_MGR_CMD="$DNF install -y " ;; arch) PKG_MGR_CMD="pacman -S --noconfirm " ;; esac DISTRO_PRETTY="$( [ -r /etc/os-release ] && . /etc/os-release; printf '%s' "${PRETTY_NAME:-$PKG_FAMILY}" )" # ============================================================================= # x86-64 microarchitecture level (nice to know; explains SIGILL-on-old-CPU) # ============================================================================= x86_64_level() { [ "$(uname -m)" = "x86_64" ] || { echo "n/a ($(uname -m))"; return; } local f lvl=1 f=" $(grep -m1 '^flags' /proc/cpuinfo | cut -d: -f2) " has() { [[ $f == *" $1 "* ]]; } if has cx16 && has lahf_lm && has popcnt && has sse4_1 && has sse4_2 && has ssse3; then lvl=2; fi if [ "$lvl" -ge 2 ] && has avx && has avx2 && has bmi1 && has bmi2 && has f16c && has fma && has movbe && has abm; then lvl=3; fi if [ "$lvl" -ge 3 ] && has avx512f && has avx512bw && has avx512cd && has avx512dq && has avx512vl; then lvl=4; fi echo "x86-64-v$lvl" } # ============================================================================= # Write the system-info file into the account's home directory # ============================================================================= INFO_PATH="$AGENT_HOME/$INFO_FILE_NAME" try() { "$@" 2>/dev/null || true; } { echo "# Agent host reference — $HOSTNAME_FQDN" echo echo "> Written by create-agent-user.sh v$SCRIPT_VERSION. This file is here for the" echo "> agent to read after logging in. It contains no credentials." echo echo "## Identity" echo '```' echo "hostname : $HOSTNAME_FQDN" echo "account : $AGENT_USER (uid $AGENT_UID)" echo "sudo : $SUDO_MODE" echo "shell : $REF_SHELL" echo "groups : ${SUPP_CSV:-}" echo '```' echo echo "## OS & kernel" echo '```' try grep -E '^(PRETTY_NAME|VERSION|ID)=' /etc/os-release echo "kernel : $(uname -r)" echo "arch : $(uname -m)" echo '```' echo echo "## CPU" echo '```' try grep -m1 'model name' /proc/cpuinfo echo "cores : $(nproc 2>/dev/null || echo '?')" echo "x86 level: $(x86_64_level)" echo '```' echo echo "## Memory" echo '```' try free -h echo '```' echo echo "## Block devices" echo '```' if command -v lsblk >/dev/null; then try lsblk -o NAME,SIZE,TYPE,MODEL,MOUNTPOINT; fi if command -v lsscsi >/dev/null; then echo; try lsscsi; fi echo '```' echo echo "## GPU / display" echo '```' if command -v lspci >/dev/null; then try lspci | grep -iE 'vga|3d|display'; fi echo '```' echo echo "## Network" echo '```' if command -v ip >/dev/null; then try ip -brief addr; else try hostname -I; fi echo '```' echo echo "## Installed agent tooling" echo '```' for t in git python3 python pip pip3 pipx jq curl rsync tmux; do if command -v "$t" >/dev/null 2>&1; then printf '%-8s %s\n' "$t" "$("$t" --version 2>&1 | head -n1)" fi done echo '```' echo echo "## Notes" echo "- Provisioned for AI agent access. Add host-specific notes below." echo } > "$INFO_PATH" chown "$AGENT_USER:$AGENT_USER" "$INFO_PATH" chmod 640 "$INFO_PATH" ok "System info written to $INFO_PATH" # ============================================================================= # Final output: short block to paste into an AI agent prompt # ============================================================================= SUDO_NOTE="nopasswd"; [ "$SUDO_MODE" = "password" ] && SUDO_NOTE="password" TODAY="$(date +%Y-%m-%d 2>/dev/null || echo unknown)" PROMPT_BLOCK="$(cat <" \\ --note "host=$OUT_HOST user=$AGENT_USER uid=$AGENT_UID sudo=$SUDO_NOTE" and record the returned secret id in your pointer. Fetch later with \`bws secret get | jq -r .value\`. - FALLBACK, otherwise: write it to ~/.config/agent-hosts/${HOSTNAME_FQDN}.yaml and \`chmod 600\` it (install a YAML parser — yq, or python3 + PyYAML — if none is present). Judge which fits this machine. 2. SSH in and read ~/$INFO_FILE_NAME on the host — it has the machine's OS / CPU / memory / disk / GPU inventory and notes. Keep the useful parts alongside your stored credential / pointer. (This exact prompt is also saved at ~/$PROMPT_FILE_NAME on the host, in case it's needed again later.) 3. Verify access end-to-end and report results: - SSH login succeeds - \`sudo -n true\` succeeds (sudo works) - an SFTP/SCP round-trip of a small test file succeeds 4. Harden:$( [ "$UNIQUE_KEYPAIR" -eq 1 ] && cat <' ~/.ssh/id_ed25519_${AGENT_USER}-${HOSTNAME_FQDN%%.*}{,.pub}\`) so it never becomes an implicit default. Then prefer this key over the password. KEYEOF )$( [ "$UNIQUE_KEYPAIR" -eq 0 ] && cat < "$PROMPT_PATH" chown "$AGENT_USER:$AGENT_USER" "$PROMPT_PATH" chmod 600 "$PROMPT_PATH" ok "Agent prompt saved to $PROMPT_PATH" ok "Provisioning complete."