#!/usr/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# /opt/ai-tools/bin/ai-tools-run
# Confinement shim: runs one sandboxed agent session as ai-tools, inside a transient `systemd --user service`
# whose properties are the session's security boundary.
#
# Invoked only by an agent's launch wrapper, which resolves and validates the versioned executable and drops privilege:
#
#   ```bash
#   sudo -u ai-tools -g ai-tools -- /opt/ai-tools/bin/ai-tools-run [args...]
#   ```
#
# with AI_TOOLS_AGENT_EXEC (the versioned executable) and AI_TOOLS_PROJECT_DIR (the session's working directory) carried
# through sudo's env_keep. Both are re-validated here, so neither side is a single point of trust.
#
# What is CHECKED is what is EXEC'd. AI_TOOLS_AGENT_EXEC names the versioned launcher symlink; this shim resolves it
# once, contains the target to the same semver version directory, and uses that single path for the SELinux label
# preflight, the entrypoint pin, and the unit's ExecStart -- then re-resolves it immediately before the launch.
# What that window is, and why it is a DAC-only concern, are in launch.rule.md.
#
# It is agent-agnostic. Which executables may launch, what environment each session gets, and whether the session's
# ownership handback needs driving from here come from the root-owned provider manifests
# under /usr/local/lib/ai-tools/agents.d and the session-env fragments and per-agent pins
# under /usr/local/lib/ai-tools/session-env.d. The same manifests decide what the toolchain may hold: a package
# of an agent that is installed but not enabled refuses every launch until a provisioning run removes it
# (toolchain.lib.sh).
#
# Operating notes:
#   * The session appears as ai-tools-<agent>-<pid>.service in `systemctl --user`. Its
#     stdout/stderr go to the terminal (`--pty`), so the per-unit journal is empty on a clean run.
#   * Every launch logs its confinement inputs, the toolchain versions, and any refusal under the
#     `ai-tools-run` syslog tag -- where the useful records are:
#       ```bash
#       sudo journalctl -t ai-tools-run _UID=<sandbox uid> -n 50 --no-pager
#       ```
#   * A refusal names the fix. The common ones are a stale SELinux label after a Node upgrade
#     (`ai-tools-admin system entrypoints relabel`) and a stopped user manager (`loginctl enable-linger`).
#   * The ownership handback socket is checked before launch: if it is down the session still
#     starts (it is a data-ownership convenience, not a confinement boundary) but a NOTICE names
#     the fix, and the session-end sweep skips its walk rather than tallying failed hand-backs.
#
# Reference: launch.rule.md (launch mechanics, the wrapper contract, PATH and env pinning), confinement.rule.md
# (namespaces, SELinux transition, /tmp), providers.rule.md (manifests, enablement, the session-env seam).
#
# Ownership: 0550 root:ai-tools inside the 0551 /opt/ai-tools/bin, so ai-tools executes it but cannot
# modify, unlink, or replace it.

set -euo pipefail

readonly AI_TOOLS_LIB_DIR="/usr/local/lib/ai-tools"
readonly AI_TOOLS_NVM_DIR="/opt/ai-tools/.nvm"
readonly SESSION_ENV_DIR="${AI_TOOLS_LIB_DIR}/session-env.d"
readonly SANDBOX_HOME="/opt/ai-tools"

# Every library this script loads comes from AI_TOOLS_LIB_DIR while running as ai-tools, so that directory is
# the root of trust for this script. Verify it before sourcing anything out of it: root-owned, not a symlink, not
# group/other-writable. ai_tools_conf_is_trusted applies the same test to every later input, but it lives
# in the directory this gate protects.
lib_dir_metadata="$(stat -c '%u %a' "${AI_TOOLS_LIB_DIR}" 2>/dev/null || true)"
if [[ -L "${AI_TOOLS_LIB_DIR}" || "${lib_dir_metadata%% *}" != 0 \
      || ! "${lib_dir_metadata##* }" =~ ^[0-7]+$ ]] \
   || (( (0${lib_dir_metadata##* } & 022) != 0 )); then
    printf 'ai-tools-run: %s is not root-owned or is writable by group/other -- refusing to launch\n' \
        "${AI_TOOLS_LIB_DIR}" >&2
    exit 1
fi

# Five required libraries. Each is a gate, not an output path, so a bare source under `set -e` is the fail-closed load:
# a missing one is a broken install and refuses the launch rather than skipping a check (see shellcheck.rule.md).
#   msg          the framed refusals and the launch banner
#   conf         the KEY=value grammar and ai_tools_conf_is_trusted
#   providers    which agents may launch, which integrations contribute session env
#   toolchain    whether a disabled agent's package is still in the toolchain (the residue refusal)
#   confinement  the pure SELinux launch verdict
# shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/msg.lib.sh
source "${AI_TOOLS_LIB_DIR}/msg.lib.sh"
# shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/conf.lib.sh
source "${AI_TOOLS_LIB_DIR}/conf.lib.sh"
# shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/providers.lib.sh
source "${AI_TOOLS_LIB_DIR}/providers.lib.sh"
# shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/toolchain.lib.sh
source "${AI_TOOLS_LIB_DIR}/toolchain.lib.sh"
# shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/confinement.lib.sh
source "${AI_TOOLS_LIB_DIR}/confinement.lib.sh"

# refuse [code] <headline> [detail...] : frame the refusal and stop. Every call names the fix, so a refused launch is
# self-explaining at the terminal. The library's optional leading code is split off so the "ai-tools-run: " prefix lands
# on the headline rather than on the code.
refuse() {
    local code=""
    if ai_tools_msg_is_code "${1-}"; then code="$1"; shift; fi
    local headline="ai-tools-run: $1"; shift
    ai_tools_msg_error ${code:+"${code}"} "${headline}" "$@"
    exit 1
}
# audit <syslog-level> <message> : one journal line under the ai-tools-run tag, the durable record of what a session was
# launched with and why one was refused.
audit() {
    if command -v logger >/dev/null 2>&1; then
        logger -t ai-tools-run -p "authpriv.$1" "$2" 2>/dev/null || true
    fi
}

# ── Principal guards ─────────────────────────────────────────────────────────────────────────
# The session must run AS ai-tools: the transient unit, the SELinux transition, and the umask are all built
# around that account. Running as root or any other user would launch the agent unconfined with that user's privileges.
current_user_name="$(id -un 2>/dev/null || true)"
if [[ "${EUID}" -eq 0 || "${current_user_name}" != "ai-tools" ]]; then
    refuse "must run as ai-tools, not ${current_user_name:-?} -- launch through the agent's wrapper" \
           'the launch path runs:  sudo -u ai-tools -g ai-tools -- /opt/ai-tools/bin/ai-tools-run'
fi

# ai-ops membership carries the sudoers grant that drops into ai-tools and starts a session. The sandbox account
# holding it would let the agent drive a session as an operator. This runs as ai-tools, so it reads its own
# membership authoritatively.
if [[ " $(id -nG "ai-tools" 2>/dev/null) " == *" ai-ops "* ]]; then
    refuse 'ai-tools is a member of the ai-ops operators group -- refusing to launch' \
           'remove it:  sudo gpasswd -d ai-tools ai-ops'
fi

# ── Agent resolution and executable validation ───────────────────────────────────────────────
# The executable is accepted only when it is the launcher of an ENABLED, installed agent, at a semver-shaped version
# directory inside the sandbox's own Node toolchain. The launcher set is an allowlist built from root-owned manifests,
# so an executable no manifest claims cannot launch -- and the agent identity follows from the path rather than
# from a separate variable crossing sudo.
declare -A agent_name_by_launcher=()
declare -a enabled_agent_names=()   # manifest order; the session-env pins are sourced in it
while IFS=$'\t' read -r manifest_agent_name _ manifest_launcher; do
    [[ -n "${manifest_launcher}" ]] || continue
    agent_name_by_launcher["${manifest_launcher}"]="${manifest_agent_name}"
    enabled_agent_names+=( "${manifest_agent_name}" )
done < <(ai_tools_enabled_agents 2>/dev/null)
(( ${#agent_name_by_launcher[@]} > 0 )) \
    || refuse 'no agent is enabled on this host -- nothing can launch' \
              'enable one in /etc/ai-tools/operator.conf (AI_TOOLS_AGENTS), then provision it:' \
              '  sudo ai-tools-admin system bootstrap'

# The toolchain must hold the enabled agents' packages ALONE before any session starts: a package of an agent the host
# installed but did not enable keeps an entrypoint a session can exec at its real path, so its presence refuses every
# launch, this agent's included, until a provisioning run removes it. This shim runs as the account that owns the tree,
# so it reads the tree itself (toolchain.lib.sh); the wrapper read the launcher links as the operator and refused
# under the same code first, which makes this the boundary and the wrapper the diagnostician.
residue_agents=""
while IFS=$'\t' read -r residue_agent residue_package residue_version_dir; do
    [[ -n "${residue_agent}" ]] || continue
    residue_agents+="${residue_agents:+, }${residue_agent} (${residue_version_dir}/lib/node_modules/${residue_package})"
done < <(ai_tools_agent_residue "${AI_TOOLS_NVM_DIR}" 2>/dev/null)
if [[ -n "${residue_agents}" ]]; then
    audit warning "REFUSED: a disabled agent's package is still in the toolchain: ${residue_agents}"
    # The code is the wrapper's, cited here so both tiers of one situation carry one token (messaging.rule.md).
    printf '%s\n' MSG-H4E2 >&2
    refuse "no session starts while a disabled agent's package is still in the sandbox toolchain: ${residue_agents}" \
           'the provisioning run removes it before it installs anything:' \
           '  sudo ai-tools-admin system bootstrap'
fi

agent_executable_path="${AI_TOOLS_AGENT_EXEC:-}"
[[ "${agent_executable_path}" != *"/../"* ]] \
    || refuse MSG-N4P3 'AI_TOOLS_AGENT_EXEC contains parent-directory references'
# Anchored to the sandbox's own Node toolchain, an exact semver version directory, and a single path component
# for the launcher -- so the version component cannot be an arbitrary directory name and the launcher cannot carry
# a separator.
executable_suffix="${agent_executable_path#"${AI_TOOLS_NVM_DIR}/versions/node/"}"
[[ "${executable_suffix}" != "${agent_executable_path}" \
   && "${executable_suffix}" =~ ^(v?[0-9]+\.[0-9]+\.[0-9]+)/bin/([A-Za-z0-9._-]+)$ ]] \
    || refuse MSG-Z2J9 'invalid or absent AI_TOOLS_AGENT_EXEC -- cannot launch'
node_version="${BASH_REMATCH[1]}"
launcher_name="${BASH_REMATCH[2]}"

agent_name="${agent_name_by_launcher[${launcher_name}]:-}"
[[ -n "${agent_name}" ]] \
    || refuse MSG-A3H6 "no enabled agent provides the launcher \"${launcher_name}\" -- refusing to launch"
# The name becomes a systemd unit name; keep it to characters a unit name accepts.
[[ "${agent_name}" =~ ^[A-Za-z0-9._-]+$ ]] \
    || refuse "agent manifest name \"${agent_name}\" is not a valid unit-name component"

agent_display_name="$(ai_tools_agent_manifest_field "${agent_name}" display_name || true)"
[[ -n "${agent_display_name}" ]] || agent_display_name="${agent_name}"
# Which side converges ownership after the agent writes a file. An agent that declares
# handback=hooks drives it from its own tool/turn hooks; every other declaration gets the
# session-end sweep (see the sweep section).
agent_handback="$(ai_tools_agent_manifest_field "${agent_name}" handback || true)"

# ── Entrypoint resolution: verify and exec the same inode ────────────────────────────────────
# The path validated at the exec gate is the versioned launcher SYMLINK; the file execve actually transitions on is
# what it resolves to. Resolve it ONCE here and use that single path for the label preflight and for the unit's
# ExecStart, so the manager is never handed a link to re-resolve after the checks have run. The resolved target must
# stay inside the SAME semver version directory the launcher was accepted at. Why both properties are load-bearing:
# launch.rule.md.
#
# Frozen at the validated version: node_version is re-assigned to "n/a" further down when it fails the banner's display
# pattern, and the pre-launch re-check must resolve against the SAME root the first resolution used, not a display
# value.
readonly entrypoint_version_root="${AI_TOOLS_NVM_DIR}/versions/node/${node_version}/"

# resolve_entrypoint : print the launcher's resolved, contained, executable target; non-zero when
#   it does not resolve or leaves that root. Called twice -- once here, once immediately before the
#   launch -- so the check and the re-check cannot drift.
resolve_entrypoint() {
    local resolved
    resolved="$(realpath -e "${agent_executable_path}" 2>/dev/null)" || return 1
    [[ "${resolved}" == "${entrypoint_version_root}"* && "${resolved}" != *"/../"* ]] || return 1
    [[ -f "${resolved}" && -x "${resolved}" ]] || return 1
    printf '%s' "${resolved}"
}

# entrypoint_identity <path> : print a change-detecting identity for the file -- device, inode,
#   size, and ctime at nanosecond precision. Each of the three ways a same-uid process can swap an
#   entrypoint moves it: a symlink repoint and a rename-over both land a different inode, and an
#   in-place write bumps ctime (which no unprivileged caller can roll back -- utimes(2) sets atime
#   and mtime, never ctime). Prints an empty string when the path cannot be stat'd, which compares unequal.
entrypoint_identity() {
    stat -c '%d:%i:%s:%z' -- "$1" 2>/dev/null || true
}

session_exec_path="$(resolve_entrypoint)" \
    || refuse MSG-D7A7 "the launcher does not resolve to an executable inside ${entrypoint_version_root}" \
              "resolved from:  ${agent_executable_path}" \
              'reprovision the toolchain:' \
              '  sudo ai-tools-admin system bootstrap'
session_exec_identity="$(entrypoint_identity "${session_exec_path}")"

# ── Session working directory ────────────────────────────────────────────────────────────────
# A transient unit does not inherit the caller's cwd, so the wrapper's validated project directory is passed
# through and re-validated here before it becomes `--working-directory`. Absent (a direct diagnostic run outside
# a wrapper) leaves the systemd default.
session_working_directory=""
if [[ -n "${AI_TOOLS_PROJECT_DIR:-}" ]]; then
    [[ "${AI_TOOLS_PROJECT_DIR}" == /* ]] \
        || refuse MSG-D2A4 'AI_TOOLS_PROJECT_DIR must be an absolute path'
    [[ "${AI_TOOLS_PROJECT_DIR}" != *"/../"* && "${AI_TOOLS_PROJECT_DIR}" != *"/.." ]] \
        || refuse MSG-A4Q4 'AI_TOOLS_PROJECT_DIR contains parent-directory references'
    [[ -d "${AI_TOOLS_PROJECT_DIR}" ]] \
        || refuse MSG-F8V8 "AI_TOOLS_PROJECT_DIR is not an existing directory: ${AI_TOOLS_PROJECT_DIR}"
    session_working_directory="${AI_TOOLS_PROJECT_DIR}"
fi

# $UID is a bash built-in read from the real UID at startup -- no external command, no PATH dependency --
# so after the sudo drop this resolves to ai-tools's runtime directory.
export XDG_RUNTIME_DIR="/run/user/${UID}"
[[ -S "${XDG_RUNTIME_DIR}/bus" ]] \
    || refuse "ai-tools user instance not reachable (bus socket absent: ${XDG_RUNTIME_DIR}/bus)" \
              "ensure linger is enabled:  loginctl enable-linger ai-tools"

# ── Fail-closed SELinux preflight ────────────────────────────────────────────────────────────
# A session that does not transition into ai_tools_t runs UNCONFINED, and a wrapper cannot observe its successor's
# post-exec domain -- so the transition's inputs are verified here, before launch, and logged on every launch.
# The launch/refuse decision is the pure ai_tools_confinement_verdict; this block owns only the probing
# and the reporting.
if command -v getenforce >/dev/null 2>&1; then
    selinux_mode="$(getenforce 2>/dev/null || echo unknown)"
    # The already-resolved and contained entrypoint -- the same inode this shim hands systemd as ExecStart, so the label
    # checked here is the label the transitioning execve reads.
    entrypoint_path="${session_exec_path}"
    expected_label="" actual_label="" manager_domain="" module_present=no
    if command -v matchpathcon >/dev/null 2>&1; then
        expected_label="$(matchpathcon -n "${entrypoint_path}" 2>/dev/null | awk -F: '{print $3}' || true)"
        actual_label="$(stat -c '%C' -- "${entrypoint_path}" 2>/dev/null | awk -F: '{print $3}' || true)"
        # Module presence for the verdict, probed from a CORE-owned path rather than read from the root-only module
        # store, which this account cannot read. The classifier's contract (confinement.lib.sh) states what the probe
        # means; why the store read would fail OPEN here is in confinement.rule.md.
        module_present="$(ai_tools_confinement_module_present \
            "$(matchpathcon -n /opt/ai-tools/.config 2>/dev/null | awk -F: '{print $3}' || true)")"
    fi
    # The manager is the `systemd --user process` that execs the entrypoint; same uid, so its domain is readable.
    manager_pid="$(pgrep -u "${UID}" -f 'systemd --user' 2>/dev/null | head -n1 || true)"
    [[ -n "${manager_pid}" ]] && manager_domain="$(tr -d '\000' < "/proc/${manager_pid}/attr/current" 2>/dev/null | awk -F: '{print $3}' || true)"

    # AI_TOOLS_REQUIRE_SELINUX: the operator's declaration that confinement is mandatory here, read only while
    # ai_tools_conf_is_trusted holds for operator.conf. An untrusted or absent file yields "no", the default posture,
    # so the refusals it would otherwise produce -- require-not-enforcing and require-inactive -- stay DAC-only
    # launches; the package installs operator.conf 0644 root:root, so a file failing that check is a misconfigured host.
    # What the switch turns into a refusal: confinement.rule.md.
    require_selinux=no
    operator_conf="${AI_TOOLS_OPERATOR_CONF:-/etc/ai-tools/operator.conf}"
    if ai_tools_conf_is_trusted "${operator_conf}" 2>/dev/null \
            && ai_tools_conf_read "${operator_conf}" AI_TOOLS_REQUIRE_SELINUX 2>/dev/null; then
        case "${_ai_tools_conf_value,,}" in yes|true|1|on) require_selinux=yes ;; esac
    fi

    audit info "launch: agent=${agent_name} selinux=${selinux_mode} module=${module_present} exec_label=${actual_label:-none} expected=${expected_label:-none} manager_domain=${manager_domain:-unknown} require=${require_selinux}"

    case "$(ai_tools_confinement_verdict "${selinux_mode}" "${module_present}" \
                                         "${expected_label}" "${actual_label}" "${manager_domain}" \
                                         "${require_selinux}")" in
        mislabel)
            audit warning "REFUSED: entrypoint mislabelled (${actual_label:-none}, want ai_tools_exec_t)"
            refuse "refusing to launch -- ${entrypoint_path} is mislabelled \"${actual_label:-none}\"" \
                   "(expected ai_tools_exec_t), so no domain transition fires and the session would run UNCONFINED (relabel is required after upgrade)." \
                   "Fix:  sudo ai-tools-admin system entrypoints relabel" ;;
        manager-domain)
            audit warning "REFUSED: manager domain ${manager_domain} has no domtrans to ai_tools_t"
            refuse "refusing to launch -- the systemd --user manager runs in domain \"${manager_domain}\", which no domtrans_pattern in ai_tools.te covers, so the session would run UNCONFINED.  Add the source and rebuild:" \
                   "  domtrans_pattern(${manager_domain}, ai_tools_exec_t, ai_tools_t)   # in selinux/policy/ai_tools.te" \
                   "  sudo selinux/install-selinux.sh rebuild" ;;
        unverifiable)
            audit warning "REFUSED: ai_tools module present but file-contexts inactive (expected=${expected_label:-none})"
            refuse "refusing to launch -- the ai_tools SELinux module is installed but no file-context maps ${entrypoint_path} to ai_tools_exec_t, so the transition cannot be verified and the session would run UNCONFINED (fail closed on a half-installed host)." \
                   "The agent's entrypoint rule comes from its own manifest; register it:  sudo ai-tools-admin system entrypoints relabel" \
                   "Or bring the whole layer up:  sudo selinux/install-selinux.sh install" \
                   "Or make this a DAC-only host:  sudo semodule -r ai_tools   (or run SELinux permissive)" ;;
        require-not-enforcing)
            audit warning "REFUSED: AI_TOOLS_REQUIRE_SELINUX set but SELinux is ${selinux_mode}, not Enforcing"
            refuse "refusing to launch -- AI_TOOLS_REQUIRE_SELINUX is set in operator.conf, but SELinux is \"${selinux_mode}\", not Enforcing, so the session would run without the ai_tools_t confinement the operator requires." \
                   "Enforce it:  sudo setenforce 1   (and check /etc/selinux/config so it survives a reboot)" \
                   "Or drop the requirement:  unset AI_TOOLS_REQUIRE_SELINUX in /etc/ai-tools/operator.conf" ;;
        require-inactive)
            audit warning "REFUSED: AI_TOOLS_REQUIRE_SELINUX set but the ai_tools module is not active"
            refuse "refusing to launch -- AI_TOOLS_REQUIRE_SELINUX is set in operator.conf, but the ai_tools SELinux module is not active on this enforcing host, so the session would run without the ai_tools_t confinement the operator requires." \
                   "Install the confinement policy package (its scriptlet loads the module):  sudo dnf install ai-tools-selinux" \
                   "On a source checkout instead:  sudo selinux/install-selinux.sh install" \
                   "Or drop the requirement:  unset AI_TOOLS_REQUIRE_SELINUX in /etc/ai-tools/operator.conf" ;;
    esac
fi

# The podman policy group grants the SELinux access rootless containers need, but the session's
# RestrictNamespaces=yes still blocks the user namespace they create. Surface that pairing as an
# actionable notice rather than a cryptic EPERM inside a later build. Best-effort probe.
if command -v semodule >/dev/null 2>&1; then
    # The listing is captured, not piped into `grep -q`: an early-exiting reader makes semodule die of SIGPIPE,
    # which pipefail reports as a failed probe -- see the note on ai_tools_selinux_group_loaded (selinux-groups.lib.sh).
    loaded_modules="$(semodule -l 2>/dev/null || true)"
    if grep -qE '^ai_tools_podman([[:space:]]|$)' <<<"${loaded_modules}"; then
        ai_tools_msg_notice \
            "ai-tools-run: the \"podman\" SELinux group is enabled, but RestrictNamespaces=yes blocks the user namespace rootless podman/buildah require -- they will fail with EPERM on clone(CLONE_NEWUSER).  To allow containers, relax RestrictNamespaces in ${0} -- note that permitting the user namespace reopens ESC-001."
    fi
fi

# ── Handback socket preflight (warn, do not block) ───────────────────────────────────────────
# Every agent's ownership handback reaches ai-tools-chown as root over this socket. If it is down, every CHOWN fails
# and files this session writes stay ai-tools-owned, surfacing later as git "dubious ownership" -- an availability
# cost rather than a confinement one, so a down socket WARNS and proceeds (launch.rule.md carries that trade). Skipped
# for a diagnostic run with no project directory. The reconcile commands are printed plain, under the frame, so they
# stay
# paste-safe.
readonly HANDBACK_SOCKET="/run/ai-tools/handback.sock"
if [[ -n "${session_working_directory}" && ! -S "${HANDBACK_SOCKET}" ]]; then
    audit warning "handback socket ${HANDBACK_SOCKET} absent at launch -- ownership handback will not run this session"
    ai_tools_msg_notice \
        "ai-tools-run: the ownership handback socket is down (${HANDBACK_SOCKET}), so files this session writes stay ai-tools-owned until it is restored -- git may then report \"dubious ownership\".  Bring it up, then reclaim the tree:"
    printf '  sudo systemctl enable --now ai-tools-handback.socket\n' >&2
    printf '  ai-tools projects handback %s\n' "${session_working_directory}" >&2
fi

# ── Session environment ──────────────────────────────────────────────────────────────────────
# A service unit is spawned by the user manager with ITS OWN environment, so no variable crosses into the session unless
# named here. Only terminal-, locale-, and connectivity-shaping variables are forwarded by name; the operator's API
# keys, tokens, SSH_AUTH_SOCK, and cloud credentials stay out by construction, independent of sudo's env_reset/env_keep.
# `--setenv=NAME` imports NAME by name, so a value never reaches the command line.
readonly FORWARDED_ENVIRONMENT_VARIABLES=(
    TERM COLORTERM                                  # TUI rendering
    LANG LANGUAGE LC_ALL LC_CTYPE LC_MESSAGES       # locale / UTF-8 handling
    LC_COLLATE LC_NUMERIC LC_TIME LC_MONETARY
    XDG_RUNTIME_DIR                                 # user bus / runtime dir
    HTTP_PROXY HTTPS_PROXY NO_PROXY                 # outbound to the agent's API
    http_proxy https_proxy no_proxy
)
declare -a session_environment_options=()
for forwarded_variable_name in "${FORWARDED_ENVIRONMENT_VARIABLES[@]}"; do
    [[ -n "${!forwarded_variable_name:-}" ]] \
        && session_environment_options+=( "--setenv=${forwarded_variable_name}" )
done

# HOME and SHELL are pinned rather than inherited, so the session's identity and shell tooling are the sandbox's and not
# whatever the operator's login carries.
session_environment_options+=( "--setenv=HOME=${SANDBOX_HOME}" )
session_environment_options+=( "--setenv=SHELL=/usr/bin/bash" )

# PATH is assembled now and emitted after the session-env fragments run, so a fragment can extend its tail. The base
# tiers mirror path-order.sh's ordering -- root-owned, least-writable directories first so they win first-match --
# with the versioned Node bin LAST. That directory is dirname(AI_TOOLS_AGENT_EXEC), the same validated path the launcher
# symlink resolved to, which keeps node/npm on the same trusted resolution chain as the entrypoint and follows Node
# upgrades without routing through the agent-writable "default" symlink in the .nvm tree.
session_path="/usr/local/sbin:/usr/sbin:/usr/local/bin:/usr/bin:${agent_executable_path%/*}"
declare -a session_path_entries=()

# ── Session-env fragments ────────────────────────────────────────────────────────────────────
# Each enabled provider may ship /usr/local/lib/ai-tools/session-env.d/<name>.env.sh, appending
# to session_environment_options and session_path_entries, and each enabled agent may ship a second file beside it,
# <name>.pins.env.sh, holding the pins a session of ANY agent under this account needs -- its state directory, its
# updater switch, its cache -- and no variable routed to one agent's sessions alone (a credential, an endpoint).
# The order is: every enabled integration's fragment, then every enabled agent's pins in manifest order, then
# the launching agent's own fragment last, so an agent's pins are authoritative over an integration's and the launching
# agent's fragment over everything. Sourcing every agent's pins is what lets an agent entrypoint started from inside
# another agent's session find its own state directory (launch.rule.md); sourcing every agent's FRAGMENT would hand one
# agent's endpoint token to the other's sessions, which is why the pins are a file of their own.
#
# This runs as ai-tools and decides what the agent's own session gets, so every file -- and the directory holding
# it, since a group-writable directory lets a non-root writer replace a root-owned file inside it -- must pass
# ai_tools_conf_is_trusted; where a file fails it, the file is skipped and logged, never sourced. Fragments and pins
# are additive, so skipping one costs the session that provider's environment and leaves every other property intact.
source_session_env_fragment() {   # <provider> [pins]  -- <provider>.env.sh, or <provider>.pins.env.sh
    local provider_name="$1" fragment_path="${SESSION_ENV_DIR}/$1${2:+.$2}.env.sh"
    [[ -e "${fragment_path}" ]] || return 0
    if ! ai_tools_conf_is_trusted "${fragment_path}"; then
        ai_tools_msg_warn "ai-tools-run: skipping session env for ${provider_name} -- ${fragment_path} is not root-owned or is writable by group/other"
        audit warning "session-env fragment skipped: ${fragment_path}"
        return 0
    fi
    # shellcheck source=/dev/null
    source "${fragment_path}"
}
if ai_tools_conf_is_trusted "${SESSION_ENV_DIR}"; then
    while IFS= read -r enabled_integration_name; do
        [[ -n "${enabled_integration_name}" ]] && source_session_env_fragment "${enabled_integration_name}"
    done < <(ai_tools_enabled_integrations 2>/dev/null)
    for enabled_agent_name in "${enabled_agent_names[@]}"; do
        source_session_env_fragment "${enabled_agent_name}" pins
    done
    source_session_env_fragment "${agent_name}"
elif [[ -e "${SESSION_ENV_DIR}" ]]; then
    ai_tools_msg_warn "ai-tools-run: skipping all session env -- ${SESSION_ENV_DIR} is not root-owned or is writable by group/other"
    audit warning "session-env directory untrusted: ${SESSION_ENV_DIR}"
fi

(( ${#session_path_entries[@]} > 0 )) && session_path+=":$(IFS=:; printf '%s' "${session_path_entries[*]}")"
session_environment_options+=( "--setenv=PATH=${session_path}" )

# ── Session-end ownership sweep (agents that carry no handback hooks) ────────────────────────
# An agent whose manifest declares handback=hooks returns its writes to the operator per turn, and the shim stays
# out of it; an agent that declares anything else has no driver, so the shim sweeps once after the session ends --
# slower to converge than per-turn hooks, same end state.
#
# The walk only chooses which paths to OFFER: each one goes through the handback socket to ai-tools-chown,
# which re-validates the allowlist, the exclusions, and the born-owner guard as root, so this sweep cannot reach a path
# the hooks could not.
readonly HANDBACK_CLIENT="/usr/local/bin/ai-tools-handback-client"

# Directory-skip selector, shared with the hooks and the root helpers. Fail-SOFT by its own design -- a skip list is
# walk cost, not an access boundary -- so a missing lib leaves a stub that descends everywhere: a slower, more thorough
# sweep, never a narrower one.
# shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/skip-dirs.lib.sh
source "${AI_TOOLS_LIB_DIR}/skip-dirs.lib.sh" 2>/dev/null \
    || ai_tools_skip_find_expr() { AI_TOOLS_SKIP_FIND_EXPR=(); return 0; }

# sweep_project_ownership : hand every ai-tools-owned path under the session's project directory to ai-tools-chown
# through the handback socket. No project directory (a diagnostic run outside a wrapper), the sandbox home
# as the working directory (a print-and-exit run), or no client leaves it a no-op.
sweep_project_ownership() {
    [[ -n "${session_working_directory}" && -d "${session_working_directory}" ]] || return 0
    # A sole `--version`/`--help` runs with the sandbox home as its working directory. No session writes a project
    # there, and a walk of it would offer every toolchain file to the root helper for it to leave alone.
    [[ "${session_working_directory}" != "${SANDBOX_HOME}" ]] || return 0
    [[ -x "${HANDBACK_CLIENT}" ]] || return 0
    # A down socket fails every CHOWN, so skip the walk and record that once, rather than logging a reassuring count
    # of calls that changed no ownership (the failure mode this whole change fixes).
    if [[ ! -S "${HANDBACK_SOCKET}" ]]; then
        audit warning "session-end sweep skipped: handback socket ${HANDBACK_SOCKET} is down -- files under ${session_working_directory} stay ai-tools-owned (reclaim with: ai-tools projects handback ${session_working_directory})"
        return 0
    fi
    # The "reclaim" consumer omits the heavy dependency/build trees but WALKS .git -- the tree the per-turn hooks skip,
    # and which no other pass on this path would reach.
    ai_tools_skip_find_expr reclaim '' "${session_working_directory}"
    # Count OWNER CHANGES, not helper exits. `ai-tools-chown` exits 0 both for a path it handed back and for one it
    # deliberately LEFT ALONE -- a `!`-excluded path, a hardlinked file, a secret-named one it quarantined elsewhere --
    # so a tally of exits reports work that did not happen, which is the failure mode this line exists to rule out.
    # The walk selected ai-tools-owned paths, so a path no longer owned by that account is one this call changed;
    # an unreadable path (deleted mid-sweep) reads the same way and is not counted as left alone. A non-zero exit is
    # a refusal the helper reports or a mid-sweep socket loss, both surfaced as a failed tally.
    local confirmed=0 untouched=0 failed=0 path
    while IFS= read -r -d '' path; do
        if "${HANDBACK_CLIENT}" CHOWN "${path}" >/dev/null 2>&1; then
            if [[ "$(stat -c '%U' -- "${path}" 2>/dev/null || true)" == 'ai-tools' ]]; then
                untouched=$(( untouched + 1 ))
            else
                confirmed=$(( confirmed + 1 ))
            fi
        else
            failed=$(( failed + 1 ))
        fi
    done < <(find "${session_working_directory}" -xdev "${AI_TOOLS_SKIP_FIND_EXPR[@]}" \
                  '(' -user 'ai-tools' '(' -type f -o -type d ')' -print0 ')' 2>/dev/null)
    # Reported alongside the handbacks rather than folded into them: a sweep that left every path as it was is a project
    # whose paths the helper declines, which reads very differently from one it converged.
    local left_alone_note=""
    (( untouched > 0 )) && left_alone_note=", ${untouched} left as they were by the root helper"
    if (( failed > 0 )); then
        audit warning "session-end sweep: handed back ${confirmed} path(s)${left_alone_note}, ${failed} not handed back under ${session_working_directory} (agent=${agent_name}); reclaim with: ai-tools projects handback ${session_working_directory}"
    elif (( confirmed > 0 || untouched > 0 )); then
        audit info "session-end sweep: handed back ${confirmed} path(s)${left_alone_note} under ${session_working_directory} (agent=${agent_name})"
    fi
    return 0
}

# ── Launch ───────────────────────────────────────────────────────────────────────────────────
# Three versions are reported and logged: Node from the validated executable path, the agent from its npm package.json,
# and ai-tools from the value stamped at install (@*@ means an unsubstituted source tree). Both the node read here
# and the agent read below clamp what they admit, because each comes from a file the sandbox account OWNS and lands
# on the operator's terminal and in the journal, where a crafted value could otherwise inject terminal escapes.
readonly VERSION_PATTERN='^v?[0-9]+\.[0-9]+\.[0-9]+$'
ai_tools_version="0.19.1-1.el9"; [[ "${ai_tools_version}" == @*@ ]] && ai_tools_version="dev"
[[ "${node_version}" =~ ${VERSION_PATTERN} ]] || node_version="n/a"

# The reader, the walk and the clamp are entrypoint-verify.lib.sh's, which the pin is written through as well,
# so the banner and the pin cannot report different versions for one binary. The library is optional here -- a banner is
# display, not a gate -- so a load that does not happen costs the version line and nothing else.
agent_version="n/a"
# shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/entrypoint-verify.lib.sh
if source "${AI_TOOLS_LIB_DIR}/entrypoint-verify.lib.sh" 2>/dev/null \
        && declare -F ai_tools_entrypoint_installed_version >/dev/null 2>&1; then
    declared_version="$(ai_tools_entrypoint_installed_version "${session_exec_path}" || true)"
    [[ -n "${declared_version}" ]] && agent_version="${declared_version}"
fi
audit info "versions: ${agent_name}=${agent_version} node=${node_version} ai-tools=${ai_tools_version}"

show_banner=1
[[ $# -eq 1 ]] && case "$1" in --version|-v|--help|-h) show_banner=0 ;; esac
if (( show_banner )); then
    printf -v banner_agent_line '%-13s%s' "${agent_display_name}" "$(ai_tools_msg_version "${agent_version}")"
    printf -v banner_node_line  '%-13s%s' 'Node'                  "$(ai_tools_msg_version "${node_version}")"
    printf -v banner_tools_line '%-13s%s' 'ai-tools'              "$(ai_tools_msg_version "${ai_tools_version}")"
    ai_tools_msg_banner 'Agent Tools Restricted — Starting sandboxed session...' \
        "${banner_agent_line}" "${banner_node_line}" "${banner_tools_line}"
fi

# The unit name identifies the session in `systemctl --user list-units` and the journal; the pid suffix keeps concurrent
# sessions from colliding.
session_unit_name="ai-tools-${agent_name}-$$.service"
declare -a working_directory_option=()
[[ -n "${session_working_directory}" ]] \
    && working_directory_option=( "--working-directory=${session_working_directory}" )

# Point the operator at the `ai-tools-run` tag rather than at _SYSTEMD_USER_UNIT, which shows "No entries"
# for the reason the operating notes give. The read needs sudo because the sandbox account is deliberately not
# in systemd-journal. `-n 50 --no-pager` shows the recent records plainly -- `-e` (jump to end) leaves the pager padding
# the screen around short output with `~`, which reads as confusing blank lines.
if [[ -t 1 ]]; then
    printf 'Running as unit: %s\n' "${session_unit_name}"
    printf '%s  launch log: sudo journalctl -t ai-tools-run _UID=%s -n 50 --no-pager%s\n\n' \
        $'\033[2m' "${EUID}" $'\033[0m'
fi

# An EXIT trap rather than a call after the run, so an interrupted shim (Ctrl-C, SIGTERM) still converges the tree;
# a SIGKILL leaves it to the next session's sweep or `ai-tools projects handback`.
if ai_tools_agent_sweeps_at_exit "${agent_handback}"; then
    trap 'sweep_project_ownership || true' EXIT
fi

# ── Last-moment entrypoint re-validation ─────────────────────────────────────────────────────
# Everything between resolving the entrypoint and starting the unit -- the label probe, the version reads,
# the session-env fragments, the banner -- is time in which a concurrent process running as this same account could swap
# the file out from under the check. Re-resolve and re-stat here, at the last instruction before the launch,
# so the window such a process would have to win is the systemd-run round trip rather than the whole preflight. Both
# the path and the identity are compared, for the reasons entrypoint_identity states. This NARROWS the race rather than
# closing it, and the deployment it is for is the DAC-only one -- launch.rule.md carries why.
#
# The pin is checked in the same breath, this being the one place where hashing the file and starting it are adjacent.
# A MISMATCH means the binary changed after root verified it, and refuses; an UNPINNED entrypoint launches unless
# the operator required otherwise. Why those two outcomes differ, and what each costs, are in updater.rule.md.
entrypoint_pin_verdict=unchecked
# Guarded, not bare: the pin is a check the launch tightens with, and a missing library is a broken install rather than
# agent action -- it degrades to "unchecked", which the require switch turns into a refusal on a host that declared
# verification mandatory.
# shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/entrypoint-verify.lib.sh
if source "${AI_TOOLS_LIB_DIR}/entrypoint-verify.lib.sh" 2>/dev/null \
        && declare -F ai_tools_entrypoint_check >/dev/null 2>&1; then
    entrypoint_pin_verdict="$(ai_tools_entrypoint_check "${agent_name}" "${session_exec_path}")" || true
fi
# Through the library's own accessor, so this launch and the updater's activation gate cannot disagree about how strict
# the host is.
require_entrypoint_verify=no
declare -F ai_tools_entrypoint_verify_required >/dev/null 2>&1 \
    && ai_tools_entrypoint_verify_required && require_entrypoint_verify=yes
audit info "entrypoint: agent=${agent_name} pin=${entrypoint_pin_verdict} require=${require_entrypoint_verify}"

case "${entrypoint_pin_verdict}" in
    mismatch)
        audit warning "REFUSED: entrypoint does not match its pin (${session_exec_path})"
        # What the pin CLAIMS differs by tier, so the refusal names the tier this host holds: telling an operator
        # that a vendor signed a checksum, for an agent whose vendor publishes none, sends them looking for a signature
        # that does not exist. The reader defaults to the stronger claim, which is what a record with no KIND carries.
        entrypoint_pin_claim='the checksum its vendor signed for the installed version'
        if declare -F ai_tools_entrypoint_pin_kind >/dev/null 2>&1 \
                && [[ "$(ai_tools_entrypoint_pin_kind "${agent_name}" 2>/dev/null || true)" == observed ]]; then
            entrypoint_pin_claim='the checksum root recorded for the binary as installed'
        fi
        # The remedy is NOT the provisioning command on its own: its npm step is a no-op at an already-installed
        # version, so the modified binary would survive it and every launch would go on refusing. The package directory
        # goes first; the library composes it, and prints nothing where the entrypoint does not sit inside one.
        entrypoint_package_dir=""
        declare -F ai_tools_entrypoint_package_dir >/dev/null 2>&1 \
            && entrypoint_package_dir="$(ai_tools_entrypoint_package_dir "${session_exec_path}" \
                   "$(ai_tools_agent_manifest_field "${agent_name}" npm_package || true)" 2>/dev/null || true)"
        declare -a entrypoint_remedy=( '  sudo ai-tools-admin system bootstrap' )
        [[ -n "${entrypoint_package_dir}" ]] \
            && entrypoint_remedy=( "  sudo rm -rf ${entrypoint_package_dir}" "${entrypoint_remedy[@]}" )
        refuse MSG-H7S2 'the agent entrypoint does not match its recorded checksum -- refusing to start the session' \
               "entrypoint:  ${session_exec_path}" \
               "The pin holds ${entrypoint_pin_claim}, and the binary has changed since it was recorded." \
               'Treat this toolchain as tampered and replace the binary -- reprovisioning alone' \
               'reinstalls nothing at an unchanged version:' \
               "${entrypoint_remedy[@]}" ;;
    ok) # Either tier satisfies the switch: what it governs is an entrypoint carrying NO pin, and a pin recorded by
        # observation is one -- root hashed the installed binary, and this launch just matched it. Which tier a host
        # holds per agent is what the status reports name, so the operator sets the switch knowing that an agent
        # whose vendor publishes no signed manifest is covered against a change and not against its origin.
        ;;
    *)  if [[ "${require_entrypoint_verify}" == yes ]]; then
            audit warning "REFUSED: entrypoint unverified (${entrypoint_pin_verdict}) and AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY is set"
            # The reconcile reaches the network only for an agent whose manifest declares a release manifest; for one
            # that declares none it hashes what is installed, so naming an online host as a precondition would send
            # the operator hunting connectivity a local step never needed.
            if [[ -n "$(ai_tools_agent_manifest_field "${agent_name}" release_manifest_url 2>/dev/null || true)" ]]; then
                entrypoint_pin_step='Pin it (this fetches the vendor'"'"'s signed release manifest, so the host must be online):'
            else
                entrypoint_pin_step='Pin it (this agent publishes no signed manifest, so root records the binary as installed):'
            fi
            refuse 'refusing to launch -- AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY is set in operator.conf, but this entrypoint carries no pin' \
                   "${entrypoint_pin_step}" \
                   '  sudo ai-tools-admin system entrypoints relabel'
        fi ;;
esac

if [[ "$(resolve_entrypoint || true)" != "${session_exec_path}" \
      || "$(entrypoint_identity "${session_exec_path}")" != "${session_exec_identity}" ]]; then
    audit warning "REFUSED: entrypoint changed between preflight and launch (${session_exec_path})"
    refuse 'the agent entrypoint changed while this launch was being prepared -- refusing to start the session' \
           "entrypoint:  ${session_exec_path}" \
           'A toolchain update running at the same moment explains this: rerun the launch.' \
           'If it repeats with no update running, treat the toolchain as untrusted:' \
           'reprovision it:' \
           '  sudo ai-tools-admin system bootstrap'
fi

# ExecStart is the RESOLVED entrypoint, not the launcher symlink: the manager's execve performs the domain transition
# on the same inode this shim verified, with no link left for it to re-resolve. Run rather than exec: `--pty` implies
# `--wait` and returns the payload's status, which a fast failure turns into an actionable breadcrumb.
#
# SYSTEMD_TINT_BACKGROUND=0: systemd 256+ tints the terminal background for the life of a `--pty`
# run, picking the tint by querying the terminal for its background colour. The tint is decoration a full-screen TUI
# paints over anyway, so it is turned off here, on the command, since sudo started this process with a reset
# environment. A terminal reply printed over the agent's banner is the agent's own capability probe, not this query's;
# launch.rule.md has the mechanism.
session_start_seconds=${SECONDS}
session_exit_status=0
SYSTEMD_TINT_BACKGROUND=0 \
systemd-run --user --pty --quiet \
    --unit="${session_unit_name}" \
    --description="${agent_display_name} ai-tools session" \
    "${session_environment_options[@]}" \
    "${working_directory_option[@]}" \
    --property=RestrictNamespaces=yes \
    --property=NoNewPrivileges=yes \
    --property=UMask=0007 \
    -- "${session_exec_path}" "$@" || session_exit_status=$?

if (( session_exit_status != 0 && SECONDS - session_start_seconds < 5 )); then
    audit warning "session unit ${session_unit_name} exited with status ${session_exit_status} at startup"
    ai_tools_msg_warn \
        "ai-tools-run: the session exited with status ${session_exit_status} almost immediately." \
        "If it ended with no output, the sandbox toolchain may be incompletely" \
        "installed -- reprovision it as root, then relaunch:"
    printf '  sudo ai-tools-admin system bootstrap\n' >&2
fi
exit "${session_exit_status}"
