#!/usr/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# /usr/local/libexec/ai-tools/ai-tools-launcher-symlink
# Atomically repoints an agent's stable launcher symlink -- /opt/ai-tools/bin/<launcher> -- at a versioned binary
# under the sandbox account's nvm. Idempotent: it skips the repoint (and its log line) when the link already points
# at the target and that target's entrypoint is already labelled, so the daily same-version updater run is a quiet
# no-op.
#
# It is agent-agnostic: the launcher is the TARGET's own basename, accepted only when an ENABLED agent manifest claims
# it -- the same allowlist ai-tools-run builds -- so the link it writes is always <bin>/<launcher> for a declared
# launcher, and never differs from the binary it points at. The path's shape says where the link sits; what a session
# executes is what it RESOLVES to, so the target is resolved once and required to be a regular executable inside
# the version directory the path names, at a path that agent's entrypoint_fcontext covers -- the predicate
# ai_tools_relink_launcher applies to the same chain (providers.lib.sh).
#
# `--remove <stable-launcher-path>` is the second form: it removes /opt/ai-tools/bin/<launcher>, and only for a launcher
# an INSTALLED manifest claims whose agent is NOT enabled -- the link of a package the updater has just removed
# as residue (toolchain.lib.sh), and not while the enabled set cannot be read. The enabled set cannot be narrowed
# from the sandbox side by this route, and an unknown name is refused, so the links it can remove are exactly those
# the launch already refuses on.
#
# /opt/ai-tools/bin is locked (0551 root:ai-tools), so this root helper is the ONLY way the sandbox updater can move
# or remove a launcher symlink; it validates its argument strictly, because the caller is the agent-reachable handback
# socket (SYMLINK and SYMLINK_REMOVE verbs) or install.sh, never sudo.
#
# Deploy:
#   ```bash
#   sudo install -o root -g root -m 750 \
#       src/usr/local/libexec/ai-tools/ai-tools-launcher-symlink.sh \
#       /usr/local/libexec/ai-tools/ai-tools-launcher-symlink
#   ```

set -euo pipefail

readonly BIN_DIR="/opt/ai-tools/bin"
MODE=repoint
if [[ "${1:-}" == --remove ]]; then MODE=remove; shift; fi
readonly MODE
readonly TARGET="${1:?usage: ai-tools-launcher-symlink <versioned-launcher-path> | --remove <stable-launcher-path>}"

# Shared leveled logger: journald (always) + the root-only file /var/log/ai-tools/symlink.log. Best-effort -- a no-op
# fallback keeps the helper working if the lib is missing.
AI_TOOLS_LOG_TAG="ai-tools-launcher-symlink"
AI_TOOLS_LOG_FILE="symlink.log"
readonly LOG_LIB="/usr/local/lib/ai-tools/log.lib.sh"
# shellcheck source=SCRIPTDIR/../../lib/ai-tools/log.lib.sh
if ! source "${LOG_LIB}" 2>/dev/null; then
    ai_tools_log() { :; }; ai_tools_log_debug() { :; }; ai_tools_log_info() { :; }
    ai_tools_log_warn() { :; }; ai_tools_log_error() { :; }
fi

# 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, and carried into the log line. Matched inline: this helper does not load
# the library.
err() {
    local code=""
    if [[ "${1-}" =~ ^MSG-[A-Z][0-9][A-Z][0-9]$ ]]; then code="$1"; shift; fi
    ai_tools_log_error "${code:+${code} }$*"
    [[ -z "${code}" ]] || printf '%s\n' "${code}" >&2
    printf 'ai-tools-launcher-symlink: %s\n' "$*" >&2; exit 1
}

# Authoritative validation of the caller-supplied path. A repoint takes EXACTLY the shape a wrapper resolves -- a single
# vMAJOR.MINOR.PATCH component under the sandbox toolchain, then bin/, then ONE path component, the launcher name.
# A removal takes the stable link's own path: the locked directory, then ONE path component. Each anchored regex admits
# no '..' and no extra slashes.
readonly RE='^/opt/ai-tools/\.nvm/versions/node/v[0-9]+\.[0-9]+\.[0-9]+/bin/([A-Za-z0-9._-]+)$'
readonly REMOVE_RE='^/opt/ai-tools/bin/([A-Za-z0-9._-]+)$'
if [[ "${MODE}" == remove ]]; then
    [[ "${TARGET}" =~ $REMOVE_RE ]] \
        || err MSG-D9K2 "not a stable launcher path under ${BIN_DIR}: ${TARGET}"
else
    [[ "${TARGET}" =~ $RE ]] \
        || err MSG-W5K8 "target is not a versioned launcher path: ${TARGET}"
fi
readonly LAUNCHER="${BASH_REMATCH[1]}"
# The link is NAMED from the target's own basename, so the two can never diverge: this helper cannot be made to point
# one agent's stable link at another binary.
readonly LINK="${BIN_DIR}/${LAUNCHER}"

# ...and that name must belong to an ENABLED agent: without this, any binary sitting in a versioned bin/ could be given
# a stable link in the locked control-plane dir. An unresolvable allowlist REFUSES rather than degrading to "allow
# anything", so probe the resolver rather than assume the source succeeded (providers.lib.sh returns non-zero without
# defining a resolver when its dependency is missing).
readonly PROVIDERS_LIB="/usr/local/lib/ai-tools/providers.lib.sh"
# 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 \
        || ! declare -F ai_tools_installed_agents >/dev/null 2>&1 \
        || ! declare -F ai_tools_agents_empty_verdict >/dev/null 2>&1; then
    err MSG-R6K3 "cannot resolve the enabled agents (${PROVIDERS_LIB}) -- refusing to change ${LINK}"
fi
agent_name=""
enabled_count=0
while IFS=$'\t' read -r manifest_agent _ manifest_launcher; do
    [[ -n "${manifest_agent}" ]] && enabled_count=$(( enabled_count + 1 ))
    [[ "${manifest_launcher}" == "${LAUNCHER}" ]] && agent_name="${manifest_agent}"
done < <(ai_tools_enabled_agents 2>/dev/null)

# The removal form. The link may go only for a launcher an installed manifest claims whose agent the enabled set does
# not carry: an enabled agent's link is what its every launch resolves through, and a name no manifest claims is not
# this helper's to reason about. An empty enabled set counts only where the configuration asks for no agent (the `none`
# verdict): under a fault -- an invalid AI_TOOLS_AGENTS, an untrusted operator.conf -- the declared set is unknown,
# and reading it as empty would make every installed agent's link removable. The removal is what makes the operator's
# read (the link) agree with the sandbox's (the tree) once the updater has removed the package; a link already absent is
# the wanted state, and a path that is not a symlink is not something this helper wrote.
if [[ "${MODE}" == remove ]]; then
    installed_agent=""
    while IFS=$'\t' read -r manifest_agent _ manifest_launcher; do
        [[ "${manifest_launcher}" == "${LAUNCHER}" ]] && installed_agent="${manifest_agent}"
    done < <(ai_tools_installed_agents 2>/dev/null)
    empty_verdict=none; empty_reason=""
    if (( enabled_count == 0 )); then
        IFS=$'\t' read -r empty_verdict empty_reason < <(ai_tools_agents_empty_verdict 2>/dev/null) || true
    fi
    remove_refusal=""
    if [[ -n "${agent_name}" ]]; then
        remove_refusal="${agent_name} is enabled, so its launcher link stays"
    elif [[ "${empty_verdict}" != none ]]; then
        remove_refusal="the enabled agent set cannot be read (${empty_reason:-its classification printed nothing})"
    elif [[ -z "${installed_agent}" ]]; then
        remove_refusal="no installed agent manifest claims the launcher \"${LAUNCHER}\""
    elif [[ -e "${LINK}" && ! -L "${LINK}" ]]; then
        remove_refusal="it is not a symlink"
    fi
    [[ -z "${remove_refusal}" ]] \
        || err MSG-U2A7 "refusing to remove ${LINK}: ${remove_refusal}"
    if [[ ! -L "${LINK}" ]]; then
        ai_tools_log_debug "already absent: ${LINK} (${installed_agent} is installed and not enabled)"
        printf 'ai-tools-launcher-symlink: already absent: %s\n' "${LINK}"
        exit 0
    fi
    rm -f -- "${LINK}"
    ai_tools_log_info "removed ${LINK} (${installed_agent} is installed and not enabled)"
    printf 'ai-tools-launcher-symlink: removed %s\n' "${LINK}"
    # The unlink lands as a change in the watched bin directory like a repoint does, so the relabel watcher's reconcile
    # runs and reports that agent's entrypoint as none -- the state it is in.
    exit 0
fi

[[ -n "${agent_name}" ]] \
    || err MSG-G4F4 "no enabled agent provides the launcher \"${LAUNCHER}\" -- refusing to repoint ${LINK}"

# ...and the file it points at must be the one that agent's manifest declares as its entrypoint. The path's SHAPE says
# only where the link sits; what the session executes is what the link RESOLVES to, and that is the file root pins
# and labels. Without the three checks that follow, a well-shaped target naming any file the sandbox account can create
# -- inside a version directory the toolchain did not install, or reached by a symlink out of it -- takes a stable link
# in the locked control-plane directory. Each is the predicate the toolchain's own re-link already applies
# (ai_tools_relink_launcher), the pattern's containment included, so the two writers of this chain accept the same set
# of targets.
#
# It does not close the observed tier's limit on its own -- the same account owns the current version directory
# (updater.rule.md) -- and it is not what confines the session either: an enforcing host labels the resolved file alone,
# and ai-tools-run re-validates the whole chain at launch. It keeps this helper from being the step that widens it.
#
# Every read here is a getattr, which is all ai_tools_handback_t holds on the entrypoint (ai_tools.te): realpath
# and `-f` are stat(2), and the execute bit is read off the mode with stat as well. `-x` is access(2), which the kernel
# answers with a `file execute` check this domain does not hold, so under enforcing it read false for a 0755 entrypoint
# and refused every repoint the updater asked for. Any execute bit is what `-x` means to root, so the set accepted is
# unchanged.
executable_bits() {
    local mode
    mode="$(stat -c '%a' -- "$1" 2>/dev/null)" || return 1
    [[ "${mode}" =~ ^[0-7]+$ ]] && (( 8#${mode} & 8#111 ))
}
version_dir="${TARGET%/bin/*}"
resolved="$(realpath -e -- "${TARGET}" 2>/dev/null || true)"
real_version_dir="$(realpath -e -- "${version_dir}" 2>/dev/null || true)"
[[ -n "${resolved}" && -n "${real_version_dir}" ]] \
    || err MSG-T8B9 "target does not exist: ${TARGET}"
if [[ "${resolved}" != "${real_version_dir}/"* || ! -f "${resolved}" ]] || ! executable_bits "${resolved}"; then
    err MSG-P2R8 "target does not resolve to an executable file inside ${version_dir} (it resolves to ${resolved}) -- refusing to repoint ${LINK}"
fi

# The manifest's own pattern, held to the containment the relabel holds it to -- a plain path pattern anchored
# under the directory the resolved version directory sits in (ai_tools_entrypoint_fcontext_valid) -- and then matched
# whole against the resolved path, exactly as the re-link checks and matches it. A file no entrypoint rule covers does
# not take ai_tools_exec_t, so a link written to it fails every launch closed at the label preflight; an invalid regex
# that passes the containment's charset makes `=~` return 2, which `!` reads as no match, and no match refuses.
entrypoint_fcontext="$(ai_tools_agent_manifest_field "${agent_name}" entrypoint_fcontext 2>/dev/null || true)"
fcontext_pattern="^${entrypoint_fcontext}\$"
containment_root="${real_version_dir%/*}"
fcontext_refusal=""
if [[ -z "${entrypoint_fcontext}" ]]; then
    fcontext_refusal="declares no entrypoint_fcontext"
elif ! ai_tools_entrypoint_fcontext_valid "${entrypoint_fcontext}" "${containment_root}"; then
    fcontext_refusal="declares an entrypoint_fcontext that is not a plain path pattern under ${containment_root}"
elif ! [[ "${resolved}" =~ ${fcontext_pattern} ]]; then
    fcontext_refusal="declares an entrypoint_fcontext that does not cover it"
fi
[[ -z "${fcontext_refusal}" ]] \
    || err MSG-D4X6 "the ${agent_name} manifest ${fcontext_refusal}, so ${resolved} would carry no entrypoint label -- refusing to repoint ${LINK}"

# Operate only inside the expected locked dir, never an attacker-substituted one.
[[ -d "${BIN_DIR}" ]] || err MSG-Q9M3 "the launcher directory is missing: ${BIN_DIR}"

# Idempotency guard. The repoint is also the sole trigger for the ai-tools-relabel.path watcher (the rename changes
# an entry in the watched bin directory), so skipping it when no change changed must not skip a pending relabel:
# entrypoint_relabel_pending reports whether the binary the link resolves to still needs its ai_tools_exec_t label --
# true for a freshly (re)minted entrypoint, including a same-version reinstall. Any uncertainty answers "pending",
# so the guard falls through to a repoint.
entrypoint_relabel_pending() {
    # 0 = relabel pending (or unknowable) -> must repoint to trip the watcher. 1 = entrypoint already correctly
    # labelled, or SELinux/the module inactive -> may skip.
    command -v selinuxenabled >/dev/null 2>&1 || return 1
    selinuxenabled 2>/dev/null || return 1
    command -v matchpathcon >/dev/null 2>&1 || return 1
    # The path the containment check resolved: one resolution per run, so the file this guard reads the label of is
    # the file that was checked.
    local real="${resolved}" want have
    want="$(matchpathcon -n "${real}" 2>/dev/null | awk -F: '{print $3}' || true)"
    [[ "${want}" == "ai_tools_exec_t" ]] || return 1            # no rule governs this path
    have="$(stat -c '%C' -- "${real}" 2>/dev/null | awk -F: '{print $3}' || true)"
    [[ "${have}" == "ai_tools_exec_t" ]] && return 1            # already labelled -> skip
    return 0                                                    # mislabelled -> repoint to relabel
}

# Skip the repoint only when the stable link already points at TARGET AND no relabel is pending: no work to do,
# so the daily no-op timer run stops churning the symlink and the log. Otherwise fall through to the atomic repoint.
if [[ "$(readlink -- "${LINK}" 2>/dev/null || true)" == "${TARGET}" ]] \
   && ! entrypoint_relabel_pending; then
    ai_tools_log_debug "already current: ${LINK} -> ${TARGET} (entrypoint labelled; no repoint)"
    printf 'ai-tools-launcher-symlink: already current: %s -> %s\n' "${LINK}" "${TARGET}"
    exit 0
fi

# Atomic repoint: build the new symlink under a temp name in the same dir, then rename(2) it over the old one -- no
# window in which the stable link is missing. (The sandbox account cannot race us here: it has no write access to this
# 0551 dir; only root can write it.)
tmp="$(mktemp -u "${BIN_DIR}/.${LAUNCHER}.XXXXXX")"
ln -s "${TARGET}" "${tmp}"
mv -Tf "${tmp}" "${LINK}"
ai_tools_log_info "repointed ${LINK} -> ${TARGET}"
printf 'ai-tools-launcher-symlink: %s -> %s\n' "${LINK}" "${TARGET}"

# This helper does NOT relabel the new entrypoint: it runs in ai_tools_handback_t, which is granted no relabel rights
# by design (ai_tools.te), so the privilege stays off the agent-reachable domain. The rename instead trips the root-side
# ai-tools-relabel.path watcher, which watches the bin DIRECTORY and so fires for whichever agent's link moved;
# `ai-tools-admin system entrypoints relabel` is the on-demand path. A label still wrong at launch makes ai-tools-run
# fail closed. See .claude/rules/updater.rule.md.
