#!/usr/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# /usr/local/libexec/ai-tools/ai-tools-bootstrap
# Provision the sandbox account's Node toolchain: create the ai-tools system account and its /opt/ai-tools home
# (if absent), then install nvm, Node, and the enabled agents' npm packages AS ai-tools, and point
# /opt/ai-tools/bin/<launcher> at each freshly installed binary. This is the one step that reaches the network (nvm
# from GitHub, packages from npm), so it is a command run once by the operator -- never an RPM scriptlet, which must
# succeed offline and inside build chroots. The scheduled nvm-update timer maintains the tree afterwards.
#
# Agent-agnostic: it does not install a hardcoded agent. Which agents to provision -- their npm package and launcher
# name -- comes from the per-package manifests under /usr/local/lib/ai-tools/agents.d, gated by operator.conf
# AI_TOOLS_AGENTS (providers.lib.sh). No agent ships enabled: every agent manifest is default_enable=no,
# so with that key absent this command ASKS which one installed agent to enable (choose_agents), writes the line,
# and provisions what it wrote -- ahead of the first network step, so an unattended run that chose none installs Node
# alone. `--agents NAME[,NAME...]` is the unattended form of the same choice, checked against the installed manifests
# before anything is written. A present key is the operator's declaration and is not asked about again; one naming more
# than one agent is answered with a notice, since every agent named shares one sandbox account. With no manifests
# deployed yet it provisions Node alone; a re-run after an ai-tools-agents-* package is installed asks. The package
# of an agent that is installed and NOT in that set is residue: it is removed next, still ahead of the network step,
# with its launcher link, since every launch refuses while it is in the toolchain (remove_residue).
#
# Idempotent: an existing account, nvm install, or Node version is reused, not rebuilt.
#
# Run as root (it creates a user and execs npm as ai-tools) through the command that reaches
# it, which is what an administrator types:
#       `sudo ai-tools-admin system bootstrap [--agents NAME[,NAME...]]`
# ai-tools-admin execs it at its installed path; it does not have a name on PATH of its own.
# nvm defaults to its latest GitHub release (resolved at run time, so it does not rot); set
# AI_TOOLS_NVM_VERSION=vX.Y.Z to pin it, or AI_TOOLS_NODE_MAJOR to choose the Node line.
#
# Deploy:
#   ```bash
#   sudo install -o root -g root -m 750 \
#       src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh /usr/local/libexec/ai-tools/ai-tools-bootstrap
#   ```

set -euo pipefail

readonly SANDBOX_USER="ai-tools"
readonly SANDBOX_GROUP="ai-tools"
readonly SANDBOX_HOME="/opt/ai-tools"
readonly NVM_DIR="${SANDBOX_HOME}/.nvm"
# nvm version is resolved at run time (latest release, unless pinned); see resolve_nvm_version. The fallback is used
# only when the GitHub API cannot be reached and no pin is set.
readonly NVM_FALLBACK_VERSION="v0.40.3"
readonly NODE_MAJOR="${AI_TOOLS_NODE_MAJOR:-22}"

# A leading message code (msg.lib.sh states the form) is printed on its own line ahead of the message, the shape
# tests/lib/harness.sh's assert_msg reads. Matched inline: this helper reports before the control plane,
# and so the library, exists.
die() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    printf 'ai-tools-bootstrap: error: %s\n' "$*" >&2; exit 1
}
log() { printf 'ai-tools-bootstrap: %s\n' "$*"; }
# warn carries the severity itself, so no message text spells one out, and it writes to stderr like every other helper's
# -- a provisioning step that did not complete is not part of the progress narrative log() prints, and nothing reads
# this helper's stdout.
warn() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    printf 'ai-tools-bootstrap: warn: %s\n' "$*" >&2
}
# notice states a consequence of the configuration this run read or wrote, for the operator at the terminal, at a lower
# severity than warn: the run is complete and the host is as asked for.
notice() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    printf 'ai-tools-bootstrap: notice: %s\n' "$*" >&2
}
# err reports a fault in the HOST that this command found and does not own: the provisioning it was asked for completed,
# so it says so at the severity the state deserves and leaves the exit status to the steps that provision. die is
# the other direction -- a fault that ends this run.
err() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; printf '%s\n' "${code}" >&2; fi
    printf 'ai-tools-bootstrap: error: %s\n' "$*" >&2
}

# resolve_nvm_version: echo the nvm release tag to install. An explicit AI_TOOLS_NVM_VERSION pin wins; otherwise query
# the GitHub API for the latest release tag, falling back to the pinned default on any failure (offline, rate-limited,
# unparseable) so bootstrap stays robust without carrying a version that rots. The caller validates the result before it
# is used.
resolve_nvm_version() {
    if [[ -n "${AI_TOOLS_NVM_VERSION:-}" ]]; then
        printf '%s' "${AI_TOOLS_NVM_VERSION}"
        return
    fi
    local tag
    tag="$(curl -fsSL --max-time 10 \
            https://api.github.com/repos/nvm-sh/nvm/releases/latest 2>/dev/null \
          | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" || true
    if [[ "${tag}" == v[0-9]* ]]; then
        printf '%s' "${tag}"
    else
        printf '%s' "${NVM_FALLBACK_VERSION}"
    fi
}

# require_msg_lib: source the shared message library, which every prompting step here needs once the control plane it
# ships with is present. Required rather than optional past that gate: a missing lib is a broken install, and a prompt
# answered through a private fallback would decide differently from every other consumer.
require_msg_lib() {
    local msglib=/usr/local/lib/ai-tools/msg.lib.sh
    [[ -r "${msglib}" ]] || die MSG-H3H3 "control plane present but ${msglib} missing -- reinstall ai-tools"
    # shellcheck source=/dev/null
    source "${msglib}"
}

# configure_git_identity: offer to set the sandbox git identity -- the name/email the agent authors commits with --
# in the shared control-plane gitconfig. install.sh / the RPM %post seed a safe default (ai-tools@<domain-or-hostname>);
# this is the one interactive point both install flows share (an RPM %post cannot prompt), so the operator can adopt
# their own git identity, keep the default, or edit the file by hand. Runs only when the control plane is present (the
# gitconfig exists) -- a bootstrap that precedes install.sh has no gitconfig to configure and skips. Past that gate
# msg.lib is deployed, so it is REQUIRED like every other prompting consumer (a missing lib is a broken install
# and dies, not a silent skip); an unattended run keeps the default via msg.lib's no-tty path.
configure_git_identity() {
    local gc="${SANDBOX_HOME}/.gitconfig"
    command -v git >/dev/null 2>&1 \
        || { log "git not found -- set the sandbox commit identity in ${gc} by hand"; return 0; }
    [[ -f "${gc}" ]] \
        || { log "git identity: ${gc} not present yet -- install the control plane, then re-run to set it"; return 0; }

    # The control plane is present (the gitconfig check), so its msg.lib is deployed too; require it like every other
    # prompting consumer -- a missing lib is a broken install, not a skip.
    require_msg_lib

    local cur_name cur_email
    cur_name="$(git config --file "${gc}" user.name  2>/dev/null || true)"
    cur_email="$(git config --file "${gc}" user.email 2>/dev/null || true)"

    # The operator who invoked sudo; their personal git identity is the adopt-able option.
    local op="${SUDO_USER:-}" op_home op_name="" op_email=""
    if [[ -n "${op}" ]]; then
        op_home="$(getent passwd "${op}" | cut -d: -f6 || true)"
        if [[ -n "${op_home}" && -r "${op_home}/.gitconfig" ]]; then
            op_name="$(git config --file "${op_home}/.gitconfig" user.name  2>/dev/null || true)"
            op_email="$(git config --file "${op_home}/.gitconfig" user.email 2>/dev/null || true)"
        fi
    fi

    ai_tools_msg_block "Sandbox git identity" \
        "The sandbox account authors git commits in your projects with this identity." \
        "" \
        "  current: ${cur_name:-?} <${cur_email:-?}>"

    # Default is always Keep, so an unattended/piped run (no tty) leaves the seeded identity.
    local sel adopt=""
    [[ -n "${op_email}" ]] && adopt="Use your identity: ${op_name:-${op}} <${op_email}>"
    if [[ -n "${adopt}" ]]; then
        sel="$(ai_tools_msg_pick 2 "${adopt}" "Keep the current identity" "Edit ${gc} by hand")"
    else
        # No operator identity to adopt: keep-or-edit only; option 1 is the default.
        sel="$(ai_tools_msg_pick 1 "Keep the current identity" "Edit ${gc} by hand")"
        # Shift so the case arms read the same in both shapes (1=adopt, 2=keep, 3=edit).
        (( sel += 1 ))
    fi

    case "${sel}" in
        1)  git config --file "${gc}" user.name  "${op_name:-${op}}"
            git config --file "${gc}" user.email "${op_email}"
            chown "root:${SANDBOX_GROUP}" "${gc}"; chmod 0644 "${gc}"
            log "sandbox git identity set to ${op_name:-${op}} <${op_email}>" ;;
        3)  log "left ${gc} unchanged -- edit it to set the agent's commit identity" ;;
        *)  log "kept the current sandbox git identity: ${cur_name:-?} <${cur_email:-?}>" ;;
    esac
    log "verify the result in ${gc}"
}

# write_agents <name>... : set AI_TOOLS_AGENTS in operator.conf to the names given, through the shared writer,
# so the line replaced is the one every reader of the file matches. The file this writes is the one the resolver reads
# (AI_TOOLS_OPERATOR_CONF), so what the rest of this run provisions is what was just written. A write that does not read
# back ends the run: the provision that followed would install the agents of a line the operator did not get.
write_agents() {
    install -d -o root -g root -m 755 "${AI_TOOLS_OPERATOR_CONF%/*}"
    ai_tools_conf_set_key "${AI_TOOLS_OPERATOR_CONF}" AI_TOOLS_AGENTS "$*" \
        || die MSG-J3E6 "could not write AI_TOOLS_AGENTS=\"$*\" into ${AI_TOOLS_OPERATOR_CONF} -- set the line by hand, then re-run: sudo ai-tools-admin system bootstrap"
}

# shared_account_notice <name>... : say, once per run, what naming more than one agent shares. Every agent runs
# as the one sandbox account, so the notice is the trail that the operator who wrote the line was told; no confirm is
# drawn, since the line is already theirs.
shared_account_notice() {
    notice MSG-C8W2 "AI_TOOLS_AGENTS names more than one agent ($*): every agent named runs as the one sandbox account, so a login or token one agent stores and the session history it keeps are readable by every session of every agent named, and a session of one can start another's binary inside itself -- see AI_TOOLS_AGENTS in operator.conf(5)"
}

# choose_agents [names] -- decide which agents this run provisions, ahead of the first network step, and write
# the decision into operator.conf. Four inputs, in this order:
#   * <names> (from `--agents`, the unattended form): each checked against the installed manifests
#     BEFORE the line is written, since a name with no manifest is the updater's `fault` verdict
#     on every later run; an unknown name refuses the run with no line written. Then the line is
#     written and the run does not ask.
#   * a present AI_TOOLS_AGENTS: the operator's declaration, not asked about again; one naming
#     more than one agent gets the shared-account notice.
#   * an untrusted operator.conf: not written to -- a root-owned rewrite would bless a file whose
#     state is a tamper signal -- and the resolver reports the refusal when it reads the set.
#   * the key absent (the baseline, which is no agent) with at least one installed manifest: one
#     menu, ai_tools_msg_pick with no default, one option per installed agent and one for none.
#     The library declines to answer for the user, so this caller decides every no-answer outcome
#     -- no terminal, closed input, three unanswered attempts, or "none" chosen -- as NO AGENT:
#     Node is provisioned bare, a coded warning names the line to set and the re-run, and the
#     run exits 0, since a provision completed with a step outstanding is what the no-agent path
#     already reports. The menu is single-select: a second agent is a manual edit of the line
#     and a further run, which is where shared_account_notice meets the operator.
# Gated on the resolver having loaded (the caller's guarded source) and on a manifest being installed; a host with
# neither has no agent to choose and the Node-only path says so. The menu draws through msg.lib, required past that
# gate like every other prompting step.
choose_agents() {
    local requested="${1-}" name npm_package display gate sel none_index
    local -a installed_names=() installed_labels=() requested_names=() unknown=()
    if declare -F ai_tools_installed_agents >/dev/null 2>&1; then
        while IFS=$'\t' read -r name npm_package _; do
            [[ -n "${name}" ]] || continue
            installed_names+=("${name}")
            display="$(ai_tools_agent_manifest_field "${name}" display_name 2>/dev/null || true)"
            installed_labels+=("${display:-${name}}"$'\t'"installs ${npm_package} into the sandbox toolchain")
        done < <(ai_tools_installed_agents)
    fi

    if [[ -n "${requested}" ]]; then
        # The shared list grammar (commas and whitespace); split inline where the resolver, and so conf.lib.sh, did not
        # load, since every name is then unknown and the refusal that follows has to name them.
        if declare -F ai_tools_conf_split >/dev/null 2>&1; then
            ai_tools_conf_split requested_names "${requested}"
        else
            read -ra requested_names <<< "${requested//,/ }"
        fi
        if (( ${#requested_names[@]} == 0 )); then
            # The dispatcher's refusal for a valueless `--agents`, met here again for a value that does not name
            # an agent.
            printf '%s\n' MSG-X8K6 >&2
            die "--agents takes a value (NAME[,NAME...]); nothing was written"
        fi
        local installed_name found
        for name in "${requested_names[@]}"; do
            found=0
            for installed_name in "${installed_names[@]+"${installed_names[@]}"}"; do
                [[ "${installed_name}" == "${name}" ]] && { found=1; break; }
            done
            (( found )) || unknown+=("${name}")
        done
        if (( ${#unknown[@]} > 0 )); then
            die MSG-M2N6 "--agents names an agent with no installed manifest: ${unknown[*]} (installed: ${installed_names[*]:-none}) -- install its ai-tools-agents-* package, then re-run; nothing was written"
        fi
        write_agents "${requested_names[@]}"
        log "enabled ${requested_names[*]} in ${AI_TOOLS_OPERATOR_CONF} (--agents)"
        (( ${#requested_names[@]} > 1 )) && shared_account_notice "${requested_names[@]}"
        return 0
    fi

    declare -F ai_tools_provider_gate >/dev/null 2>&1 || return 0
    gate="$(ai_tools_provider_gate AI_TOOLS_AGENTS)"
    case "${gate}" in
        allowlist)
            ai_tools_conf_split requested_names "$(ai_tools_conf_get "${AI_TOOLS_OPERATOR_CONF}" AI_TOOLS_AGENTS || true)"
            (( ${#requested_names[@]} > 1 )) && shared_account_notice "${requested_names[@]}"
            return 0 ;;
        untrusted)
            log "agent choice: ${AI_TOOLS_OPERATOR_CONF} is not trusted, so no agent is enabled and none is asked about (the refusal is reported below)"
            return 0 ;;
    esac
    if (( ${#installed_names[@]} == 0 )); then
        log "agent choice: no agent manifest is installed under ${AI_TOOLS_AGENTS_DIR} -- provisioning Node only; install an ai-tools-agents-* package, then re-run to choose one"
        return 0
    fi

    require_msg_lib
    ai_tools_msg_block "Choose the agent this host runs" \
        "No agent runs until AI_TOOLS_AGENTS in ${AI_TOOLS_OPERATOR_CONF} names one. This run writes that line for the agent you pick and installs its package into the sandbox toolchain." \
        "" \
        "Every agent named there runs as the one sandbox account and reads what the others store, so a second agent is a deliberate step: add its name to that line by hand and re-run this command. operator.conf(5) states what the account shares."
    none_index=$(( ${#installed_names[@]} + 1 ))
    sel="$(ai_tools_msg_pick none "${installed_labels[@]}" \
            "None now"$'\t'"provision Node alone; set AI_TOOLS_AGENTS in ${AI_TOOLS_OPERATOR_CONF} later")" || sel=""
    if [[ -z "${sel}" || "${sel}" == "${none_index}" ]]; then
        warn MSG-X3M9 "no agent chosen -- provisioning Node alone; set AI_TOOLS_AGENTS in ${AI_TOOLS_OPERATOR_CONF}, or pass --agents NAME, then re-run: sudo ai-tools-admin system bootstrap"
        return 0
    fi
    name="${installed_names[$(( sel - 1 ))]}"
    write_agents "${name}"
    log "enabled ${name} in ${AI_TOOLS_OPERATOR_CONF}"
}

# remove_residue -- remove every installed, not enabled agent's package from the sandbox toolchain, and its stable
# launcher link with it, ahead of the first network step: a package of an agent the operator did not name keeps
# an entrypoint a session can exec, so every launch refuses while it is there, and this command is the remedy those
# refusals name -- so an offline host that cannot reach the registry still cleans up before its npm step fails.
# The routine is toolchain.lib.sh's (the updater, the agent packages' %preun and `install.sh uninstall` run the same
# one); the package removal runs AS the sandbox account, the owner of the tree, and the link's removal as root,
# which owns the locked bin directory. The link goes only for an agent none of whose version directories still holds
# the package: a removal deferred under a live session keeps the link, which is what keeps the wrapper refusing. Gated
# on a toolchain being present at all -- a first run has no tree to hold residue -- and on the library loading;
# a library that will not load warns and leaves the package, since every launch then keeps refusing and says why.
remove_residue() {
    local toolchain_lib=/usr/local/lib/ai-tools/toolchain.lib.sh outcomes agent version_dir outcome launcher
    local -A still_present=()
    (( _providers_loaded )) || return 0
    [[ -d "${NVM_DIR}/versions/node" ]] && id "${SANDBOX_USER}" &>/dev/null || return 0
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/toolchain.lib.sh
    if ! source "${toolchain_lib}" 2>/dev/null || ! declare -F ai_tools_agent_residue_links >/dev/null 2>&1; then
        warn "toolchain library unavailable (${toolchain_lib}) -- a disabled agent's package left in the toolchain is not removed this run, and every launch refuses until it is"
        return 0
    fi
    # One line per residue package, "agent<TAB>version-dir<TAB>outcome", from the account that owns the tree.
    # The heredoc is single-quoted, so the inner shell expands the variables from the env passed in.
    outcomes="$(sudo -u "${SANDBOX_USER}" env HOME="${SANDBOX_HOME}" NVM_DIR="${NVM_DIR}" TOOLCHAIN_LIB="${toolchain_lib}" \
        bash -s <<'EOSU'
set -euo pipefail
. "${TOOLCHAIN_LIB}"
while IFS=$'\t' read -r agent package version_dir; do
    [[ -n "${agent}" ]] || continue
    outcome="$(ai_tools_agent_package_remove "${version_dir}" "${package}")" || outcome="${outcome:-failed}"
    printf '%s\t%s\t%s\n' "${agent}" "${version_dir}" "${outcome}"
done < <(ai_tools_agent_residue "${NVM_DIR}")
EOSU
    )" || warn "the residue removal step did not complete as ${SANDBOX_USER} -- a disabled agent's package may still be in the toolchain (see above)"
    while IFS=$'\t' read -r agent version_dir outcome; do
        [[ -n "${agent}" ]] || continue
        log "${agent}: package in ${version_dir##*/} -- ${outcome}"
        [[ "${outcome}" == removed || "${outcome}" == absent ]] || still_present["${agent}"]=1
    done <<<"${outcomes}"
    while IFS=$'\t' read -r agent launcher; do
        [[ -n "${agent}" && -z "${still_present[${agent}]:-}" ]] || continue
        rm -f -- "${SANDBOX_HOME}/bin/${launcher}"
        log "${agent}: removed the stable launcher link ${SANDBOX_HOME}/bin/${launcher}"
    done < <(ai_tools_agent_residue_links "${SANDBOX_HOME}/bin")
    return 0
}

# seed_managed_assets_step: (re)seed the ai-tools-managed shared assets from the pristine datadir copies into the config
# directory of each agent that uses that asset format. The directories come from the manifests (control-plane.lib.sh),
# so this helper does not hardcode a path itself. Runs only when the control plane is present (a config dir
# and the /usr/share/ai-tools pristine copies exist) and the seeder lib is deployed; a bootstrap that precedes
# install.sh has no source to seed and skips. Past that gate msg.lib is deployed, so the update confirm requires it like
# every other prompting consumer. Same non-overwrite and version rules as install.sh -- only ai-tools-* assets carrying
# x-ai-tools-managed are touched, and an existing one updates only on confirm (default keep). See
# managed-assets.lib.sh.
seed_managed_assets_step() {
    local pristine=/usr/share/ai-tools
    local lib=/usr/local/lib/ai-tools/managed-assets.lib.sh msglib=/usr/local/lib/ai-tools/msg.lib.sh
    local cplib=/usr/local/lib/ai-tools/control-plane.lib.sh
    # conf.lib.sh owns the dated-sidecar stamp the seeder uses to preserve a replaced or withdrawn asset, so it is
    # required here rather than optional: without it those steps decline to act.
    local conflib=/usr/local/lib/ai-tools/conf.lib.sh
    [[ -d "${pristine}/agents" && -r "${cplib}" ]] \
        || { log "managed assets: control plane not present yet -- install it, then re-run to seed agents/skills"; return 0; }
    [[ -r "${lib}" && -r "${msglib}" && -r "${conflib}" ]] \
        || die MSG-D9D3 "control plane present but the managed-asset libs are missing -- reinstall ai-tools"
    # shellcheck source=/dev/null
    source "${msglib}"
    # shellcheck source=/dev/null
    source "${conflib}"
    # shellcheck source=/dev/null
    source "${lib}"
    # shellcheck source=/dev/null
    source "${cplib}"
    declare -F ai_tools_agent_config_dirs >/dev/null 2>&1 \
        || die MSG-H9S6 "control plane present but ${cplib} does not resolve the agents' config dirs"
    # The SHARED kinds first, into their own roots: skills and subagent definitions are agent-agnostic, so they live
    # in one place and each agent gets symlinks to them. The pairs are <shared kind>:<the manifest field naming
    # where that agent keeps it>.
    local spec kind shared asset_dir seeded=0
    for spec in skills:skills_dir subagents:subagents_dir; do
        kind="${spec%%:*}"; shared="${CP_HOME}/${kind}"
        install -d -o root -g "${SANDBOX_GROUP}" -m "${CP_DIR_MODES[${kind}]}" "${shared}"
        log "seeding ai-tools-managed ${kind} into ${shared}"
        ai_tools_seed_managed_assets "${pristine}" "${CP_HOME}" "${SANDBOX_GROUP}" "${kind}"
        ai_tools_remove_retired_assets "${CP_HOME}" "${kind}"
        ai_tools_link_asset_readme "${pristine}/${kind}/README.md" "${shared}" "${SANDBOX_GROUP}"
        while IFS=$'\t' read -r _ asset_dir; do
            log "linking the shared ${kind} into ${asset_dir}"
            ai_tools_link_shared_assets "${shared}" "${asset_dir}" \
                "${SANDBOX_GROUP}" "${pristine}/${kind}/README.md"
            seeded=1
        done < <(ai_tools_agent_asset_dirs "${spec#*:}")
    done
    # The orientation text is a single file rather than a directory of assets, and each agent reads it under its own
    # filename (the manifest's memory_file), so it is seeded like the other kinds and linked by name instead
    # of by iterating the shared root.
    local memory_target
    shared="${CP_SHARED_ORIENTATION}"
    install -d -o root -g "${SANDBOX_GROUP}" -m "${CP_DIR_MODES[orientation]}" "${shared}"
    log "seeding the ai-tools-managed orientation into ${shared}"
    ai_tools_seed_managed_assets "${pristine}" "${CP_HOME}" "${SANDBOX_GROUP}" orientation
    while IFS=$'\t' read -r _ memory_target; do
        log "linking the shared orientation into ${memory_target}"
        ai_tools_link_agent_memory "${shared}/AGENTS.md" \
            "${memory_target%/*}" "${memory_target##*/}" "${SANDBOX_GROUP}"
        seeded=1
    done < <(ai_tools_agent_memory_targets)
    (( seeded )) || log "managed assets: no agent config directory to seed yet"
}

# report_shadowed_operators -- name each enrolled operator whose shell reaches an agent outside /usr/local/bin,
# so a host is not called ready while typing the launcher name starts an UNCONFINED session as that operator
# (path-order.lib.sh).
#
# It belongs to this command because the reading is taken from a login shell of that account, which needs the root this
# command already holds; `ai-tools status` re-reads it afterwards from the operator's own shell. It reads those init
# files and does not rewrite any of them: `ai-tools-admin operators add` is this project's one writer of that line.
report_shadowed_operators() {
    local polib=/usr/local/lib/ai-tools/path-order.lib.sh
    local oplib=/usr/local/lib/ai-tools/operator.lib.sh
    [[ -r "${polib}" && -r "${oplib}" ]] || return 0
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/path-order.lib.sh
    source "${polib}" 2>/dev/null || return 0
    # shellcheck source=SCRIPTDIR/../../lib/ai-tools/operator.lib.sh
    source "${oplib}" 2>/dev/null || return 0
    declare -F ai_tools_path_order_shadowed_operators >/dev/null 2>&1 || return 0
    declare -F ai_tools_load_operators >/dev/null 2>&1 || return 0
    ai_tools_load_operators || return 0

    local user launcher winner
    while IFS=$'\t' read -r user launcher winner; do
        [[ -n "${user}" ]] || continue
        err MSG-K2D4 "operator ${user} who types ${launcher} would run ${winner}, which is an agent outside the sandbox"
        err "    rank the wrapper ahead of it:  sudo ai-tools-admin operators add ${user}"
        err "    or remove that install:        ${winner}"
    done < <(ai_tools_path_order_shadowed_operators \
        "${AI_TOOLS_OPERATORS[@]+"${AI_TOOLS_OPERATORS[@]}"}")
}

# Executed, this provisions a host and needs root. Sourced -- by tests/unit/bootstrap.sh, which drives
# report_shadowed_operators with its readings stubbed and choose_agents over fixture manifests -- it defines its
# functions and stops here: every statement from the argument parse on provisions.
if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then
    return 0
fi

# The one option, the unattended agent choice; ai-tools-admin parses the same spelling ahead of the exec and refuses
# the same two shapes under the same codes, so a direct invocation meets the tokens the dispatcher's page documents.
REQUESTED_AGENTS=""
while [[ $# -gt 0 ]]; do
    case "$1" in
        --agents)
            if [[ $# -lt 2 || -z "$2" ]]; then
                printf '%s\n' MSG-X8K6 >&2
                die "--agents takes a value (NAME[,NAME...])"
            fi
            REQUESTED_AGENTS="$2"; shift 2 ;;
        *)  printf '%s\n' MSG-H5Z4 >&2
            die "unknown argument '$1' (--agents NAME[,NAME...])" ;;
    esac
done
readonly REQUESTED_AGENTS

[[ "${EUID}" -eq 0 ]] || die MSG-X7Z2 "run as root (sudo)"
command -v curl >/dev/null 2>&1 || die MSG-T7H8 "curl is required to fetch nvm"

# Run from a neutral, world-traversable directory. The `sudo -u ${SANDBOX_USER}` steps inherit this process's CWD;
# invoked from an operator's private dir (e.g. ~/Downloads, mode 0700) the sandbox account cannot traverse back into it,
# so nvm/npm's internal `find` warns "Failed to restore initial working directory". No step here depends on CWD (every
# path is absolute), and / is always reachable, so move off the caller's directory up front.
cd /

# The provider resolver -- providers.lib.sh, the seam that keeps this toolchain step agent-agnostic: which agents are
# installed, which are enabled, and what each one's npm package and launcher are, read from the manifests (agents.d)
# gated by operator.conf AI_TOOLS_AGENTS. The lib is control-plane, so a bootstrap that PRECEDES control-plane install
# has none yet: Node is provisioned bare and a re-run picks up the agents. Guarded load: providers.lib.sh returns
# non-zero without defining a resolver when its own dependency (conf.lib.sh, the shared KEY=value grammar) is missing,
# so probe the resolver rather than assume the source succeeded -- a bare `source` under `set -e` would abort
# the provision instead of falling back to Node-only.
_providers_lib=/usr/local/lib/ai-tools/providers.lib.sh
_providers_loaded=0
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/providers.lib.sh
if source "${_providers_lib}" 2>/dev/null \
        && declare -F ai_tools_enabled_agents >/dev/null 2>&1; then
    _providers_loaded=1
else
    log "provider resolver unavailable -- provisioning Node only; re-run after the control plane and an ai-tools-agents-* package are installed to provision agents"
fi

# Which agents this run provisions, decided and written before the first network step: a name given on the command line,
# the line already in operator.conf, or the operator's answer to the menu. An unknown `--agents` name ends the run here,
# with no package installed and no line written.
choose_agents "${REQUESTED_AGENTS}"

# What the toolchain holds for an agent that is installed and not in the set just decided is residue, removed here --
# ahead of the network step, so an offline host still cleans up -- and every launch refuses until it is gone.
remove_residue

# Concrete tag (latest, pinned, or fallback). Constrained to v + digits/dots before it reaches the download URL piped
# to bash, so a resolved value can never inject shell or URL.
NVM_VERSION="$(resolve_nvm_version)"
[[ "${NVM_VERSION}" =~ ^v[0-9][0-9.]*$ ]] \
    || die MSG-W8X8 "invalid nvm version '${NVM_VERSION}' (expected vMAJOR.MINOR.PATCH)"
readonly NVM_VERSION

# 1. Sandbox account + home. `--system`: no aging, low uid; /sbin/nologin + locked password:
#    the agent account has no interactive login. /opt (not /home) because /home is nosuid,
#    which would defeat the sudo UID-switch the launch path relies on.
if ! id "${SANDBOX_USER}" &>/dev/null; then
    log "creating system user ${SANDBOX_USER} (home ${SANDBOX_HOME})"
    useradd --system --shell /sbin/nologin --home-dir "${SANDBOX_HOME}" \
        --no-create-home --comment "AI tools sandbox user" "${SANDBOX_USER}"
    passwd -l "${SANDBOX_USER}" >/dev/null 2>&1 || true
fi
# Home root owned root:ai-tools, mode 2751: root owns the control plane and the agent reaches it through group ai-tools;
# the o+x search bit lets an operator readlink the launcher. The agent cannot create entries in this dir,
# so the agent-owned subtrees it must write are pre-created here, as root, and chowned to the account: .nvm holds
# the toolchain, .cache the NODE_COMPILE_CACHE, .npm the npm cache, .local XDG state. nvm/npm then write only within
# these, never the home root.
install -d "${SANDBOX_HOME}"
chown "root:${SANDBOX_GROUP}" "${SANDBOX_HOME}"
chmod 2751 "${SANDBOX_HOME}"
for _sub in .nvm .cache .npm .local; do
    install -d -o "${SANDBOX_USER}" -g "${SANDBOX_GROUP}" -m 0750 "${SANDBOX_HOME}/${_sub}"
done

# Resolve the enabled agents -- read AFTER choose_agents, so the set is the one this run just wrote. Each enabled line
# is "name<TAB>npm_package<TAB>launcher"; collect the packages (installed in step 2) and launchers (symlinked in step
# 3). The resolver's stderr warns of an enabled-but-uninstalled agent.
_agent_packages=(); _agent_launchers=()
if (( _providers_loaded )); then
    while IFS=$'\t' read -r _ manifest_package manifest_launcher; do
        [[ -n "${manifest_package}" ]]  && _agent_packages+=("${manifest_package}")
        [[ -n "${manifest_launcher}" ]] && _agent_launchers+=("${manifest_launcher}")
    done < <(ai_tools_enabled_agents)
fi

# 2. nvm + Node + the enabled agents' npm packages, installed AS the sandbox account (network).
#    The heredoc is single-quoted, so the variables are expanded by the inner shell from the env
#    passed via `env`, never by this script. PROFILE=/dev/null directs nvm's installer to append
#    its init lines to a discard sink instead of the root-owned home profile. Existing nvm/Node
#    are reused (idempotent); all writes land within the pre-created .nvm/.npm subtrees.
if [[ ${#_agent_packages[@]} -gt 0 ]]; then
    log "installing nvm ${NVM_VERSION} + Node ${NODE_MAJOR} + ${_agent_packages[*]} as ${SANDBOX_USER} (network)"
else
    log "installing nvm ${NVM_VERSION} + Node ${NODE_MAJOR} (no agents enabled) as ${SANDBOX_USER} (network)"
fi
sudo -u "${SANDBOX_USER}" env \
    NVM_DIR="${NVM_DIR}" HOME="${SANDBOX_HOME}" PROFILE=/dev/null \
    NVM_VERSION="${NVM_VERSION}" NODE_MAJOR="${NODE_MAJOR}" \
    AGENT_PACKAGES="${_agent_packages[*]}" \
    bash -s <<'EOSU'
set -euo pipefail
if [ ! -s "${NVM_DIR}/nvm.sh" ]; then
    curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" | bash
fi
# shellcheck source=/dev/null
. "${NVM_DIR}/nvm.sh"
nvm install "${NODE_MAJOR}"
nvm alias default "${NODE_MAJOR}"
# Install each enabled agent's npm package. npm 11.5+ gates preinstall/install/postinstall
# behind an allowScripts allowlist, so a bare `npm install -g` BLOCKS the postinstall --
# @anthropic-ai/claude-code fetches and wires its platform-native binary there (node
# install.cjs); blocked, the JS launcher installs but exits "native binary not installed" at
# every launch. npm re-scans the WHOLE global tree on each install, so the allowlist must cover
# the full set on every call (mirrors nvm-update.sh's install_packages) -- scoped to the named
# agents, never `--dangerously-allow-all-scripts`. With no agents enabled, Node is provisioned bare.
read -ra agent_packages <<< "${AGENT_PACKAGES}"
if [ "${#agent_packages[@]}" -gt 0 ]; then
    allow_scripts="$(IFS=,; printf '%s' "${agent_packages[*]}")"
    for agent_package in "${agent_packages[@]}"; do
        npm install -g --allow-scripts="${allow_scripts}" "${agent_package}"
    done
fi
EOSU

# 2a. Point each enabled agent's versioned launcher at the executable its manifest declares
#     (launcher_target, ai-tools-providers(5)): npm links bin/<launcher> at the package's entry
#     file, which for a shim-started agent is not the binary the session runs. Runs as
#     ${SANDBOX_USER}, the owner of that tree, through the library the resolver came from, and
#     before the verification (2b) and the stable symlink (3), so each reads the chain in its
#     final shape. The library reports a refusal and leaves npm's link in place: that agent's
#     launch then fails closed at the label preflight until its manifest and its package agree.
#     The active Node version is read once here and reused by step 3.
_node_version="$(sudo -u "${SANDBOX_USER}" env NVM_DIR="${NVM_DIR}" HOME="${SANDBOX_HOME}" \
        bash -c '. "${NVM_DIR}/nvm.sh"; nvm version default' 2>/dev/null || true)"

# What the install did NOT complete (toolchain.lib.sh), reported here -- before the re-link and the relabel, each
# of which fails because of it and the second of which names this very command as its remedy. An install npm reports
# as successful can leave this state, and why this run says so rather than the relabel, are in updater.rule.md. Read
# as root, which traverses the 0750 toolchain, through the library the residue step sourced: a run that did not reach
# that step says nothing here.
if [[ -n "${_node_version}" ]] && declare -F ai_tools_agent_incomplete >/dev/null 2>&1; then
    _version_dir="${NVM_DIR}/versions/node/${_node_version}"
    while IFS=$'\t' read -r _agent _package; do
        [[ -n "${_package}" ]] || continue
        warn MSG-T9P2 "incomplete package for ${_agent}: ${_package} is installed and ${_version_dir} does not hold the entrypoint its manifest declares -- no session of ${_agent} starts until that executable is installed; this run's npm output carries the reason"
    done < <(ai_tools_agent_incomplete "${_version_dir}")
fi

if [[ -n "${_node_version}" ]] && declare -F ai_tools_relink_launcher >/dev/null 2>&1; then
    while IFS=$'\t' read -r _agent _ _launcher; do
        [[ -n "${_agent}" && -n "${_launcher}" ]] || continue
        _launcher_target="$(ai_tools_agent_manifest_field "${_agent}" launcher_target || true)"
        [[ -n "${_launcher_target}" ]] || continue
        _fcontext="$(ai_tools_agent_manifest_field "${_agent}" entrypoint_fcontext || true)"
        if _verdict="$(sudo -u "${SANDBOX_USER}" env HOME="${SANDBOX_HOME}" PROVIDERS_LIB="${_providers_lib}" \
                bash -c 'set -euo pipefail; . "${PROVIDERS_LIB}"; ai_tools_relink_launcher "$@"' _ \
                "${NVM_DIR}/versions/node/${_node_version}" "${_launcher}" "${_launcher_target}" "${_fcontext}")"; then
            log "${_agent}: ${_launcher} ${_verdict/linked/re-linked} at ${_launcher_target}"
        else
            warn "${_agent}: ${_launcher} was not re-linked at its declared target (see above) -- npm's own link stays, and a launch fails closed at the label preflight until the manifest and the installed package agree"
        fi
    done < <(ai_tools_enabled_agents 2>/dev/null)
fi

# 2b. Verify the installed toolchain's npm registry signatures BEFORE wiring the launcher, so a
#     compromised registry serving a tampered package is caught before the first launch can use
#     it. Runs as ${SANDBOX_USER} (the verifier refuses root: it audits the sandbox-owned global
#     tree and as root would resolve the wrong prefix). nvm is re-sourced so npm/node are on
#     PATH. Gated on the lib being deployed: a bootstrap that precedes the control plane has no
#     lib yet -- the nvm-update timer verifies on its first run instead. A tamper aborts the
#     bootstrap before the symlink/relabel, leaving the tampered tree unwired; an inability to
#     verify (offline/unsupported) warns and proceeds, matching the check's best-effort posture.
_verify_lib=/usr/local/lib/ai-tools/npm-verify.lib.sh
if [[ -r "${_verify_lib}" ]]; then
    _vrc=0
    sudo -u "${SANDBOX_USER}" env \
        NVM_DIR="${NVM_DIR}" HOME="${SANDBOX_HOME}" VERIFY_LIB="${_verify_lib}" \
        bash -c '
            set -euo pipefail
            . "${NVM_DIR}/nvm.sh" >/dev/null 2>&1
            nvm use default >/dev/null 2>&1 || true
            . "${VERIFY_LIB}"
            ai_tools_verify_npm_signatures
        ' || _vrc=$?
    case "${_vrc}" in
        0) log "npm registry signatures verified for the installed toolchain" ;;
        1) die MSG-F3Y2 "npm signature verification FAILED (possible registry tampering) -- aborting before wiring the launcher; the installed package is left unactivated" ;;
        *) warn MSG-H6A8 "could not verify npm signatures (offline or unsupported) -- proceeding; the toolchain is installed but unverified" ;;
    esac
else
    warn MSG-P9Q6 "signature-verification library not deployed yet -- skipping the check; the nvm-update timer verifies on its first run"
fi

# 3. Point /opt/ai-tools/bin/<launcher> at the versioned binary, once per enabled agent whose
#    launcher is present. Runs as root: the agent cannot create top-level entries in the home
#    root. bin is the locked control-plane dir (0551 root:ai-tools); root writes the symlinks
#    here, and install.sh / the RPM repoint them through the root symlink helper afterwards.
#    Agent runtime state is not seeded here: ai-tools-run pins CLAUDE_CONFIG_DIR to the
#    group-writable .claude dir, where claude creates its own state files (.claude.json
#    included).
if [[ ${#_agent_launchers[@]} -gt 0 ]]; then
    if [[ -n "${_node_version}" ]]; then
        install -d -o root -g "${SANDBOX_GROUP}" -m 0551 "${SANDBOX_HOME}/bin"
        for _launcher in "${_agent_launchers[@]}"; do
            _launcher_bin="${NVM_DIR}/versions/node/${_node_version}/bin/${_launcher}"
            [[ -x "${_launcher_bin}" ]] && ln -sfn "${_launcher_bin}" "${SANDBOX_HOME}/bin/${_launcher}"
        done
    fi
fi

# 3b. Relabel the freshly installed entrypoint for the SELinux domain transition. A fresh
#     claude.exe is born bin_t/lib_t, so the -> ai_tools_t transition does not fire and
#     ai-tools-run refuses to launch (it would run UNCONFINED) until the entrypoint carries
#     ai_tools_exec_t. Bootstrap runs as root (a domain that holds relabel) and has just minted
#     the entrypoint, so it relabels here rather than leaving the first launch to fail with a
#     manual `ai-tools-admin system entrypoints relabel`. Gated on the helper being deployed: a bootstrap that precedes
#     the control plane has no helper yet (install.sh / the RPM relabel then). The helper is
#     idempotent and no-ops when SELinux or the ai_tools module is inactive, so this is safe on a
#     DAC-only host; best-effort -- a relabel gap degrades to ai-tools-run's refusal, not a failed
#     bootstrap. See .claude/rules/updater.rule.md.
_relabel_helper=/usr/local/libexec/ai-tools/ai-tools-relabel-agent
if [[ -x "${_relabel_helper}" ]]; then
    "${_relabel_helper}" \
        || warn MSG-C7C8 "entrypoint relabel did not complete -- run 'sudo ai-tools-admin system entrypoints relabel' before launching claude"
fi

# 4. Capture the control plane's initial state in a root-private git repo so drift is reviewable.
#    The control plane is root:ai-tools, so the repo is root-owned: run AS root, and lock .git
#    root:root 0700 so committed blobs are unreadable to the agent (group ai-tools) and the
#    operators. The shipped .gitignore makes the repo default-deny, so auth tokens, conversation
#    logs, and nvm/npm churn are never staged -- so the capture runs ONLY when that denylist is
#    present (a populated control plane, e.g. after package install). Idempotent (skipped when
#    .git exists); non-fatal: a missing git or a commit failure warns and leaves the tree intact.
if [[ ! -e "${SANDBOX_HOME}/.git" && -e "${SANDBOX_HOME}/.gitignore" ]] && command -v git >/dev/null 2>&1; then
    log "capturing initial control-plane state in ${SANDBOX_HOME}/.git"
    _gitrun=(git -C "${SANDBOX_HOME}" -c user.name=ai-tools -c user.email="ai-tools@localhost")
    if "${_gitrun[@]}" init -q -b main \
       && "${_gitrun[@]}" add -A \
       && "${_gitrun[@]}" commit -q -m "Initial control-plane state (ai-tools-bootstrap)"; then
        log "captured initial control-plane commit"
    else
        warn MSG-U6F2 "control-plane git capture incomplete"
    fi
    if [[ -d "${SANDBOX_HOME}/.git" ]]; then
        chown -R root:root "${SANDBOX_HOME}/.git"
        chmod 0700 "${SANDBOX_HOME}/.git"
    fi
fi

# 5. Enable the maintenance timer in the sandbox account's own `systemd --user instance`, which
#    keeps Node and the agent package current. The home is root-owned (2751), so the account
#    cannot write ~/.config; root provisions the XDG config tree (root:group 2750 -- the account
#    reads its units via the group) and the timers.target.wants symlink that enables the timer.
#    Order matters: the symlink is laid down before linger brings the manager up, so the manager
#    reaches timers.target with the enablement already in place and starts the timer itself. The
#    explicit start that follows covers a manager that was already running. Best-effort: a tree
#    that ships the unit later (the dev install.sh flow) warns rather than failing bring-up.
_uid="$(id -u "${SANDBOX_USER}")"
install -d -o root -g "${SANDBOX_GROUP}" -m 2750 \
    "${SANDBOX_HOME}/.config" \
    "${SANDBOX_HOME}/.config/systemd" \
    "${SANDBOX_HOME}/.config/systemd/user" \
    "${SANDBOX_HOME}/.config/systemd/user/timers.target.wants"
ln -sfn /usr/lib/systemd/user/nvm-update.timer \
    "${SANDBOX_HOME}/.config/systemd/user/timers.target.wants/nvm-update.timer"

# Pre-seed the timer's Persistent run-stamp so starting it begins on the next scheduled window rather than an immediate
# catch-up run. nvm-update.timer is Persistent=true with a daily OnCalendar; started (or reached via timers.target)
# after that time has passed with no prior stamp, systemd runs nvm-update.service at once. That run reinstalls the agent
# package -- reminting claude.exe at lib_t (a freshly written entrypoint is born the default type; only restorecon
# applies ai_tools_exec_t) -- and its async repoint -> relabel chain races the operator's first launch, so the first
# `claude` refuses on a mislabelled entrypoint. Bootstrap has just installed the latest toolchain, so "last run = now"
# is truthful: record it (mtime is all systemd reads), and the next run is the next scheduled window. Written
# AS the sandbox account into its XDG_DATA_HOME, the path the `--user manager` reads and later updates itself. See
# .claude/rules/updater.rule.md.
_stampdir="${SANDBOX_HOME}/.local/share/systemd/timers"
sudo -u "${SANDBOX_USER}" mkdir -p "${_stampdir}"
sudo -u "${SANDBOX_USER}" touch "${_stampdir}/stamp-nvm-update.timer"

# Linger keeps the `--user manager` running without an interactive login, so the timer it holds stays active. Surface
# a failure so an instance that does not engage linger is visible.
if command -v loginctl >/dev/null 2>&1; then
    _linger_out="$(loginctl enable-linger "${SANDBOX_USER}" 2>&1)" \
        || warn MSG-V4D9 "could not enable linger for ${SANDBOX_USER} (${_linger_out:-no output})"
fi
# Wait for the manager to come up before driving it; XDG_RUNTIME_DIR alone lets `systemctl --user` reach the user
# manager over its bus, so DBUS_SESSION_BUS_ADDRESS need not be pinned.
for _i in $(seq 1 30); do
    systemctl is-active "user@${_uid}.service" >/dev/null 2>&1 && break
    sleep 0.5
done
# Start the timer now to cover a manager that was already running: the wants symlink alone starts it when the manager
# next reaches timers.target. Capture the output so the warn on a failed start carries systemctl's own error text.
if _start_out="$(sudo -u "${SANDBOX_USER}" \
        XDG_RUNTIME_DIR="/run/user/${_uid}" \
        bash -c 'systemctl --user daemon-reload && systemctl --user start nvm-update.timer' 2>&1)"; then
    log "started nvm-update.timer in ${SANDBOX_USER}'s --user instance"
else
    warn MSG-C8M9 "could not start nvm-update.timer (${_start_out:-no output}) -- start it after the control plane is installed"
fi

log "toolchain ready under ${SANDBOX_HOME}"

# Managed agents/skills (control-plane .claude). Seeded/updated here from the pristine datadir copies; skipped cleanly
# when the control plane is not yet in place.
seed_managed_assets_step

# Sandbox git commit identity (control-plane gitconfig). Offered here as the shared interactive step; skipped cleanly
# when the control plane is not yet in place.
configure_git_identity

# 6. Say which enrolled operators a `claude` typed in their shell does not reach the sandbox
#    through. Last, so the reading covers the wrapper and the agents this run has just installed.
report_shadowed_operators

# Bootstrap runs in either order relative to the control plane: after a package/install.sh deploy (the common flow --
# the CLI is already present), or before it on a from-source host. Name the step that is actually still outstanding
# rather than assuming one order. The CLI is the sentinel because base ships it whichever agents a host installs;
# an agent's wrapper would read a host that enabled another agent as undeployed.
if [[ -x /usr/local/bin/ai-tools ]]; then
    log "next: enrol an operator -- sudo ai-tools-admin operators add <user>"
else
    log "next: deploy the control plane -- sudo ./install.sh install   (or install the RPM)"
fi
