#!/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.
#
# With --remove it does the reverse instead: ends the account's sessions,
# deletes the user (and home dir), and undoes the sudoers / greeter-hiding
# config written in steps 4 and 6. Installed packages are left in place.
#
# 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.3.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
REMOVE=0                         # 1 = delete the account + its config (--remove)
KEEP_HOME=0                      # 1 = with --remove, leave the home dir on disk
ASSUME_YES=0                     # 1 = with --remove, skip the confirmation prompt
NONINTERACTIVE=0                 # 1 = never prompt; take safe fallbacks
PW_MODE="auto"                   # "auto" | "passphrase" | "random"
UNIQUE_KEYPAIR=0                 # 1 = agent mints a keypair per host
                                 #   (~/.ssh/<user>/id_ed25519_<host>); default is one
                                 #   shared agent keypair (~/.ssh/<user>/id_ed25519),
                                 #   reused across hosts. Either way it is never the
                                 #   interactive user's own key.

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 <<EOF
create-agent-user.sh v$SCRIPT_VERSION

Usage: sudo ./create-agent-user.sh [OPTIONS]

  --rotate                 Rotate creds/groups if $AGENT_USER already exists
                           (default: refuse if the account exists).
  --remove                 Delete $AGENT_USER (sessions, account, home dir) and undo
                           the sudoers + greeter-hiding config. Installed packages
                           are left alone. Prompts for confirmation.
  --keep-home              With --remove: keep the home directory (the password
                           file $PROMPT_FILE_NAME is still deleted).
  --yes                    With --remove: skip the confirmation prompt (required
                           when non-interactive).
  --sudo-password          Require a password for sudo (default: NOPASSWD).
  --no-install             Skip all package installation.
  --agent-target-host H    Address to advertise in the output block.
  --uid N                  Override the fixed UID (default: $AGENT_UID).
  --username NAME          Override the account name (default: $AGENT_USER).
  --ssh-port N             Advertise this SSH port (default: auto-detect).
  --passphrase             Force a dictionary passphrase.
  --random-password        Force a random password.
  --words N                Passphrase word count (default: $PASSPHRASE_WORDS).
  --no-sshd-check          Do not require a running SSH server.
  --non-interactive        Never prompt; take safe fallbacks.
  --unique-keypair         Agent prompt tells the AI to mint a separate keypair for
                           THIS host (~/.ssh/$AGENT_USER/id_ed25519_<host>). Default:
                           one shared agent keypair (~/.ssh/$AGENT_USER/id_ed25519),
                           reused across hosts. Never the interactive user's key.
  -h, --help               Show this help.
EOF
    exit "${1:-0}"
}

# =============================================================================
# Argument parsing
# =============================================================================
while [ $# -gt 0 ]; do
    case "$1" in
        --rotate)            ROTATE=1 ;;
        --remove|--cleanup)  REMOVE=1 ;;
        --keep-home)         KEEP_HOME=1 ;;
        --yes|-y)            ASSUME_YES=1 ;;
        --sudo-password)     SUDO_MODE="password" ;;
        --no-install)        DO_INSTALL=0 ;;
        --agent-target-host) AGENT_TARGET_HOST="${2:?}"; shift ;;
        --uid)               AGENT_UID="${2:?}"; shift ;;
        --username)          AGENT_USER="${2:?}"; shift ;;
        --ssh-port)          SSH_PORT="${2:?}"; shift ;;
        --passphrase)        PW_MODE="passphrase" ;;
        --random-password)   PW_MODE="random" ;;
        --words)             PASSPHRASE_WORDS="${2:?}"; shift ;;
        --no-sshd-check)     SSHD_CHECK=0 ;;
        --non-interactive)   NONINTERACTIVE=1 ;;
        --unique-keypair)    UNIQUE_KEYPAIR=1 ;;
        -h|--help)           usage 0 ;;
        *) warn "Unknown option: $1"; usage 1 ;;
    esac
    shift
done

# No controlling terminal on stdin -> 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)."

# =============================================================================
# --remove: undo everything this script provisions, then exit.
# Runs before the sshd / distro / password steps: none of them apply here.
# =============================================================================
remove_agent() {
    [ "$ROTATE" -eq 0 ] || die "--remove and --rotate are mutually exclusive."
    command -v userdel >/dev/null 2>&1 || die "Required tool missing: userdel"
    getent passwd "$AGENT_USER" >/dev/null \
        || die "Account '$AGENT_USER' does not exist; nothing to remove."

    local entry uid home gecos sudoers marker
    entry="$(getent passwd "$AGENT_USER")"
    uid="$(cut -d: -f3 <<<"$entry")"
    gecos="$(cut -d: -f5 <<<"$entry")"
    home="$(cut -d: -f6 <<<"$entry")"
    sudoers="/etc/sudoers.d/$AGENT_USER"
    marker="# Managed by create-agent-user.sh"

    # Never delete a system account or a human's account by typo / wrong --username.
    [ "$uid" -ge 1000 ] || die "Refusing to remove '$AGENT_USER': uid $uid is a system account."
    if ! grep -qsF "$marker" "$sudoers" && [ "$gecos" != "$AGENT_GECOS" ]; then
        die "'$AGENT_USER' doesn't look like it was created by this script (no sudoers marker, different GECOS). Refusing."
    fi
    # Killing the account's sessions would kill us too.
    [ "${SUDO_USER:-}" != "$AGENT_USER" ] && [ "$(logname 2>/dev/null || true)" != "$AGENT_USER" ] \
        || die "Running as/through '$AGENT_USER' itself; run --remove from a different account."

    warn "About to remove '$AGENT_USER' (uid $uid):"
    warn "  - kill its sessions/processes and delete its crontab"
    if [ "$KEEP_HOME" -eq 1 ]; then
        warn "  - delete the account; KEEP $home (minus $PROMPT_FILE_NAME)"
    else
        warn "  - delete the account and $home"
    fi
    warn "  - remove $sudoers, its AccountsService entry, and greeter-hiding rules"
    if [ "$ASSUME_YES" -ne 1 ]; then
        [ "$NONINTERACTIVE" -eq 0 ] || die "Not interactive: pass --yes to confirm removal."
        local reply
        read -r -p "Type the username to confirm: " reply || reply=""
        [ "$reply" = "$AGENT_USER" ] || die "Aborted."
    fi

    # Sessions + processes + scheduled jobs, or userdel refuses / leaves debris.
    command -v loginctl >/dev/null 2>&1 && loginctl terminate-user "$AGENT_USER" >/dev/null 2>&1
    pkill -KILL -u "$AGENT_USER" >/dev/null 2>&1
    command -v crontab >/dev/null 2>&1 && crontab -r -u "$AGENT_USER" >/dev/null 2>&1
    sleep 1

    # The credential-bearing file goes even when the rest of the home is kept.
    [ -d "$home" ] && rm -f "$home/$PROMPT_FILE_NAME"

    if [ "$KEEP_HOME" -eq 1 ]; then
        userdel "$AGENT_USER" || die "userdel failed."
    else
        userdel -r "$AGENT_USER" || die "userdel -r failed."
    fi
    ok "Account '$AGENT_USER' deleted."

    # sudo
    if grep -qsF "$marker" "$sudoers"; then
        rm -f "$sudoers" && ok "Removed $sudoers"
    elif [ -e "$sudoers" ]; then
        warn "Leaving $sudoers in place: it isn't marked as managed by this script."
    fi

    # Greeter hiding: AccountsService entry, SDDM drop-in, LightDM token.
    rm -f "/var/lib/AccountsService/users/$AGENT_USER" \
          "/etc/sddm.conf.d/10-hide-$AGENT_USER.conf"
    local ldm=/etc/lightdm/users.conf esc
    if [ -f "$ldm" ] && grep -qE "^\s*hidden-users=.*(^|[ =])$AGENT_USER( |\$)" "$ldm"; then
        esc="$(printf '%s' "$AGENT_USER" | sed 's/[][\.*^$/]/\\&/g')"
        sed -i -E "/^\s*hidden-users=/{s/([ =])${esc}( |\$)/\1/;s/[[:space:]]+\$//}" "$ldm"
    fi
    ok "Greeter-hiding config removed."

    [ -n "$(getent passwd "$AGENT_USER")" ] && die "'$AGENT_USER' still resolves after removal (network/SSSD-backed?)."

    warn "Not undone (not tracked by this script):"
    warn "  - packages it installed (git, jq, tmux, lshw, ...)"
    warn "  - any off-host record: the stored credential (e.g. the bws secret ${AGENT_USER}-<host>) and"
    warn "    pointers in the agent's CLAUDE.md / ~/.config/agent-hosts/"
    ok "Removal complete."
    exit 0
}
[ "$REMOVE" -eq 1 ] && remove_agent

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)"

# Virtual machine or container? Physical-hardware tools are dead weight there:
# no real disk to SMART-query, no DMI table worth dumping. On a VM, pciutils,
# usbutils, lshw and lsscsi stay (they describe the virtual devices); inside a
# container there is no hardware of its own to enumerate, so they go too. inxi
# stays in both.
PKGS_HW_VM_SKIP=(smartmontools hdparm dmidecode)
PKGS_HW_CONTAINER_SKIP=("${PKGS_HW_VM_SKIP[@]}" lshw lsscsi pciutils usbutils)
VIRT_KIND="none"    # none | vm | container
VIRT_TYPE="none"    # kvm, vmware, lxc, docker, podman, ... ("vm" / "container" if unknown)
detect_virt() {
    local v=""
    if command -v systemd-detect-virt >/dev/null 2>&1; then
        v="$(systemd-detect-virt --container 2>/dev/null)"
        if [ -n "$v" ] && [ "$v" != "none" ]; then VIRT_KIND="container"; VIRT_TYPE="$v"; return; fi
        v="$(systemd-detect-virt --vm 2>/dev/null)"
        if [ -n "$v" ] && [ "$v" != "none" ]; then VIRT_KIND="vm"; VIRT_TYPE="$v"; fi
        return   # systemd knows and says bare metal
    fi
    # No systemd: container marker files, then the CPUID hypervisor cpuinfo flag.
    if [ -e /run/.containerenv ] || [ -e /.dockerenv ]; then
        VIRT_KIND="container"; VIRT_TYPE="container"; return
    fi
    if grep -qm1 -E '^flags.*[[:space:]]hypervisor([[:space:]]|$)' /proc/cpuinfo 2>/dev/null; then
        VIRT_KIND="vm"; VIRT_TYPE="vm"
    fi
}
detect_virt
if [ "$VIRT_KIND" != "none" ]; then
    if [ "$VIRT_KIND" = "container" ]; then _skip=("${PKGS_HW_CONTAINER_SKIP[@]}"); else _skip=("${PKGS_HW_VM_SKIP[@]}"); fi
    _hw=()
    for _p in "${PKGS_HW[@]}"; do
        case " ${_skip[*]} " in
            *" $_p "*) ;;
            *) _hw+=("$_p") ;;
        esac
    done
    PKGS_HW=("${_hw[@]}")
    ok "Running in a $VIRT_KIND ($VIRT_TYPE); skipping hardware tools: ${_skip[*]}"
fi

# =============================================================================
# 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:-<none>}"
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<RANDOM_PW_LEN; i++)); do pw+="${all:RANDOM%${#all}:1}"; done
    printf '%s' "$pw" | fold -w1 | shuf | tr -d '\n'
}

AGENT_PASSWORD=""
PW_KIND=""
make_password() {
    local dict=""
    case "$PW_MODE" in
        random)
            AGENT_PASSWORD="$(gen_random_pw)"; PW_KIND="random-${RANDOM_PW_LEN}ch" ;;
        passphrase)
            dict="$(find_dict)" || die "--passphrase requested but no usable dictionary found."
            AGENT_PASSWORD="$(gen_passphrase "$dict")" || die "Passphrase generation failed."
            PW_KIND="passphrase-${PASSPHRASE_WORDS}w" ;;
        auto)
            if dict="$(find_dict)"; then
                AGENT_PASSWORD="$(gen_passphrase "$dict")" && PW_KIND="passphrase-${PASSPHRASE_WORDS}w"
            fi
            if [ -z "$AGENT_PASSWORD" ]; then
                # No dict. Offer to install one, else random.
                if [ "$DO_INSTALL" -eq 1 ] && [ "$DICT_PROMPT" -eq 1 ] \
                   && ask_yes_no "No word list found. Install '$PKG_DICT' for a passphrase?" y; then
                    pkg_install_list "$PKG_DICT"
                    if dict="$(find_dict)"; then
                        AGENT_PASSWORD="$(gen_passphrase "$dict")" && PW_KIND="passphrase-${PASSPHRASE_WORDS}w"
                    fi
                fi
            fi
            if [ -z "$AGENT_PASSWORD" ]; then
                warn "Using random password (no dictionary available)."
                AGENT_PASSWORD="$(gen_random_pw)"; PW_KIND="random-${RANDOM_PW_LEN}ch"
            fi ;;
    esac
    [ -n "$AGENT_PASSWORD" ] || die "Failed to generate a password."
}

# =============================================================================
# Create (or rotate) the account
# =============================================================================
USER_EXISTS=0
getent passwd "$AGENT_USER" >/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 <N>."
    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" <<EOF
[User]
SystemAccount=true
EOF

# SDDM drop-in.
if greeter_present sddm; then
    install -d -m 755 /etc/sddm.conf.d
    cat > "/etc/sddm.conf.d/10-hide-$AGENT_USER.conf" <<EOF
[Users]
HideUsers=$AGENT_USER
EOF
    info "SDDM hide rule written."
fi

# LightDM users.conf.
if greeter_present lightdm; then
    LDM=/etc/lightdm/users.conf
    if [ -f "$LDM" ] && grep -qE '^\s*hidden-users=' "$LDM"; then
        grep -qE "hidden-users=.*(^|[ =])$AGENT_USER($|[ ])" "$LDM" \
            || sed -i "s/^\(\s*hidden-users=.*\)/\1 $AGENT_USER/" "$LDM"
    else
        install -d -m 755 /etc/lightdm
        printf '[UserList]\nhidden-users=nobody nobody4 noaccess %s\n' "$AGENT_USER" >> "$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[*]:-<none>}"
    [ "${#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 <pkg>" ;;
    rhel)   PKG_MGR_CMD="$DNF install -y <pkg>" ;;
    arch)   PKG_MGR_CMD="pacman -S --noconfirm <pkg>" ;;
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 "virt     : $VIRT_TYPE ($VIRT_KIND)"
    echo "sudo     : $SUDO_MODE"
    echo "shell    : $REF_SHELL"
    echo "groups   : ${SUPP_CSV:-<none>}"
    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
# =============================================================================
HOST_SHORT="${HOSTNAME_FQDN%%.*}"
# The literal ~ is intentional: these paths are prompt text for the agent, not expanded here.
# shellcheck disable=SC2088
if [ "$UNIQUE_KEYPAIR" -eq 1 ]; then
    KEY_PATH="~/.ssh/${AGENT_USER}/id_ed25519_${HOST_SHORT}"
    KEY_INTRO="a DEDICATED keypair for this host only"
    KEY_STEP="generate it"
    KEY_COMMENT="${AGENT_USER}@${HOST_SHORT}"
else
    KEY_PATH="~/.ssh/${AGENT_USER}/id_ed25519"
    KEY_INTRO="the shared agent keypair (one key reused across hosts)"
    KEY_STEP="if it doesn't exist yet, generate it (if it does, reuse it — don't overwrite)"
    KEY_COMMENT="${AGENT_USER}"
fi

SUDO_NOTE="nopasswd"; [ "$SUDO_MODE" = "password" ] && SUDO_NOTE="password"
TODAY="$(date +%Y-%m-%d 2>/dev/null || echo unknown)"

PROMPT_BLOCK="$(cat <<EOF

$(printf '%s' "$C_GRN")============================================================
 DONE — copy everything between the lines into your AI agent
============================================================$(printf '%s' "$C_RST")

You have been granted an account with full sudo on a Linux host. Here are the
credentials:

     host:        $OUT_HOST
     port:        $OUT_PORT
     username:    $AGENT_USER
     uid:         $AGENT_UID
     password:    "$AGENT_PASSWORD"
     sudo:        $SUDO_NOTE
     hostname:    $HOSTNAME_FQDN
     distro:      $DISTRO_PRETTY ($PKG_FAMILY; install with: $PKG_MGR_CMD)
     provisioned: $TODAY
$( [ -n "$ADDR_WARNING" ] && printf '\nNOTE: %s\n' "$ADDR_WARNING" )
Do this:

1. Ask the user where this machine's connection info should be recorded —
   project level or user level, the same choice as picking a scope when adding
   an MCP server — and wait for the answer rather than guessing:
     - user level: a pointer in your global CLAUDE.md / AGENTS.md, so every
       session on this machine can find this host.
     - project level: a pointer in this project's CLAUDE.md / AGENTS.md, so
       only work in this project sees it (use a local-only file such as
       CLAUDE.local.md if the repo is shared).
   Either way, store the credential itself where this machine keeps its
   secrets, and put only a *pointer* to it in that doc, so this access is
   discoverable later — not just for this one session. Never inline the
   password into a doc that gets loaded into context or committed to a repo.
     - PREFERRED, if this machine uses Bitwarden Secrets Manager (\`bws\` on
       PATH, token in the keyring): store it as a secret, e.g.
         BWS_ACCESS_TOKEN="\$(secret-tool lookup service bws account default)" \\
           bws secret create ${AGENT_USER}-${HOSTNAME_FQDN%%.*} "<password>" <project-id> \\
           --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 <id> | jq -r .value\`.
     - FALLBACK, otherwise: write it to ~/.config/agent-hosts/${HOSTNAME_FQDN}.yaml
       (outside any repo, whichever scope was chosen) 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: switch to key auth using $KEY_INTRO — do not
   reuse the interactive user's own key (~/.ssh/id_*). Keep it in its own
   subdirectory, where no tool treats it as a default identity. Key path:
   $KEY_PATH — $KEY_STEP:
     mkdir -p -m 700 ~/.ssh/${AGENT_USER}
     ssh-keygen -t ed25519 -N "" -C "$KEY_COMMENT" -f $KEY_PATH
   Append the .pub to ${AGENT_HOME}/.ssh/authorized_keys (name the key
   explicitly, e.g. \`ssh-copy-id -i <that .pub> ...\`, and do not load it into
   ssh-agent), then add a dedicated alias to ~/.ssh/config (chmod 600) — an
   alias, not a \`Host $OUT_HOST\` block, so it can't hijack the user's own
   ssh to this machine:
     Host ${AGENT_USER}-${HOST_SHORT}
       HostName $OUT_HOST
       Port $OUT_PORT
       User $AGENT_USER
       IdentityFile $KEY_PATH
       IdentitiesOnly yes
   Confirm \`ssh ${AGENT_USER}-${HOST_SHORT}\` logs in without a password,
   record the alias in your pointer, and prefer the key over the password from
   then on.

============================================================

EOF
)"

printf '%s\n' "$PROMPT_BLOCK"

# Also drop a plain-text (no ANSI) copy next to AGENT_INFO.md, so the prompt
# survives a lost terminal/scrollback — it's not otherwise persisted anywhere.
# Contains the password: keep it as locked-down as the account's own auth.
PROMPT_PATH="$AGENT_HOME/$PROMPT_FILE_NAME"
printf '%s\n' "$PROMPT_BLOCK" | sed -E 's/\x1b\[[0-9;]*m//g' > "$PROMPT_PATH"
chown "$AGENT_USER:$AGENT_USER" "$PROMPT_PATH"
chmod 600 "$PROMPT_PATH"
ok "Agent prompt saved to $PROMPT_PATH"

ok "Provisioning complete."
