mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-22 17:36:59 +08:00
34f1e3d56f
- Switch privilege model to run-as: package (zero root, per fnOS guide) - Borrow App Store python312 instead of bundling CPython; keep bundled uv - Move venv, HOME and caches onto the persistent data share - Start service via setsid; stop/upgrade kill the whole process group - Sweep stray runtime/box orphans in install/upgrade init hooks - Track LangBot 4.10.11 (manifest baseline + upstream merge)
240 lines
9.1 KiB
Bash
Executable File
240 lines
9.1 KiB
Bash
Executable File
#!/bin/bash
|
|
# cmd/main - LangBot lifecycle manager for fnOS
|
|
# Handles start / stop / status via standalone-runtime Python process.
|
|
# Node.js path is injected into PATH so Box sandbox npx MCP servers can run.
|
|
|
|
PID_FILE="${TRIM_PKGVAR}/langbot.pid"
|
|
APP_DIR="${TRIM_APPDEST}/langbot"
|
|
LOG_FILE="${TRIM_PKGVAR}/langbot.log"
|
|
|
|
# --- Locate fnOS Node.js bin path ---
|
|
# fnOS appname-based path: /var/apps/nodejs_vXX/target/bin
|
|
# This is a stable symlink regardless of which volume the app is on.
|
|
NODE_VERSION="${wizard_node_version:-22}"
|
|
NODE_BIN_DIR="/var/apps/nodejs_v${NODE_VERSION}/target/bin"
|
|
|
|
if [ -d "${NODE_BIN_DIR}" ]; then
|
|
export PATH="${NODE_BIN_DIR}:${PATH}"
|
|
fi
|
|
|
|
# --- Persistent data root ---
|
|
# LangBot loads data/config.yaml CWD-RELATIVE (see core/stages/load_config.py:
|
|
# load_yaml_config('data/config.yaml', ...)) and resolves its data root to
|
|
# <CWD>/data in source-install mode — it does NOT honour LANGBOT_DATA_ROOT for
|
|
# config.yaml. So the real fix is the symlink below: APP_DIR/data -> DATA_DIR.
|
|
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
|
|
if [ -z "${DATA_DIR}" ]; then
|
|
DATA_DIR="${TRIM_PKGVAR}/data"
|
|
fi
|
|
export LANGBOT_DATA_ROOT="${DATA_DIR}"
|
|
mkdir -p "${DATA_DIR}" || {
|
|
echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}"
|
|
exit 1
|
|
}
|
|
|
|
# --- Writable HOME / tool caches / venv on the data share ---
|
|
# Must match cmd/install_callback: under run-as: package neither the system
|
|
# HOME nor TRIM_APPDEST are guaranteed writable, and the venv lives at
|
|
# ${DATA_DIR}/.venv instead of inside the app dir.
|
|
export HOME="${DATA_DIR}/.home"
|
|
export UV_CACHE_DIR="${DATA_DIR}/.cache/uv"
|
|
# Never download a managed CPython — distro Python only (see install_callback)
|
|
export UV_PYTHON_DOWNLOADS=never
|
|
export UV_PROJECT_ENVIRONMENT="${DATA_DIR}/.venv"
|
|
mkdir -p "${HOME}" "${UV_CACHE_DIR}" || {
|
|
echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}"
|
|
exit 1
|
|
}
|
|
|
|
# --- CPU architecture (must be resolved before locating bundled binaries) ---
|
|
ARCH=$(uname -m)
|
|
case "${ARCH}" in
|
|
x86_64|aarch64) ;;
|
|
*)
|
|
echo "Unsupported CPU architecture: ${ARCH}" > "${TRIM_TEMP_LOGFILE}"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
# --- Locate Python: official python312 App Store app (same borrow pattern as Node.js) ---
|
|
PYTHON_BIN="/var/apps/python312/target/bin/python3"
|
|
[ -x "${PYTHON_BIN}" ] || {
|
|
echo "Python interpreter missing or not executable: ${PYTHON_BIN} (install the python312 app)" > "${TRIM_TEMP_LOGFILE}"
|
|
exit 1
|
|
}
|
|
|
|
# --- Locate uv: bundled binary at its single canonical path in the app dir ---
|
|
UV_BIN="${TRIM_APPDEST}/bin/uv-${ARCH}"
|
|
[ -x "${UV_BIN}" ] || {
|
|
echo "Bundled uv binary missing or not executable: ${UV_BIN}" > "${TRIM_TEMP_LOGFILE}"
|
|
exit 1
|
|
}
|
|
|
|
case $1 in
|
|
start)
|
|
if [ -f "${PID_FILE}" ]; then
|
|
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
|
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
|
exit 0
|
|
fi
|
|
rm -f "${PID_FILE}"
|
|
fi
|
|
|
|
if [ ! -d "${APP_DIR}" ]; then
|
|
echo "LangBot app directory missing: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}"
|
|
exit 1
|
|
fi
|
|
|
|
cd "${APP_DIR}" || {
|
|
echo "Cannot enter app directory: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}"
|
|
exit 1
|
|
}
|
|
|
|
if [ ! -d "${DATA_DIR}/.venv" ]; then
|
|
echo "Python virtual environment not found. Please reinstall LangBot." > "${TRIM_TEMP_LOGFILE}"
|
|
exit 1
|
|
fi
|
|
|
|
# --- Unify data location: APP_DIR/data -> symlink to persistent DATA_DIR ---
|
|
# LangBot reads config CWD-relative (data/config.yaml), so without this a
|
|
# fresh data/ with a default 5300 config gets recreated inside target/ on
|
|
# every install/upgrade. The symlink keeps everything on the persistent
|
|
# share; it is recreated here on each start (upgrades wipe target/).
|
|
# Requires the package user to have write permission on the app dir — if
|
|
# fnOS ever mounts it read-only, this fails loudly instead of silently
|
|
# running against an ephemeral data directory.
|
|
APP_DATA="${APP_DIR}/data"
|
|
if [ -L "${APP_DATA}" ]; then
|
|
# already a symlink; re-point if the persistent dir changed
|
|
[ "$(readlink "${APP_DATA}")" != "${DATA_DIR}" ] && ln -sfn "${DATA_DIR}" "${APP_DATA}"
|
|
elif [ -d "${APP_DATA}" ]; then
|
|
# legacy real dir (created by LangBot before this fix): merge into the
|
|
# persistent dir without overwriting newer files already there
|
|
mkdir -p "${DATA_DIR}"
|
|
cp -an "${APP_DATA}/." "${DATA_DIR}/." || cp -a "${APP_DATA}/." "${DATA_DIR}/"
|
|
rm -rf "${APP_DATA}"
|
|
ln -s "${DATA_DIR}" "${APP_DATA}"
|
|
else
|
|
ln -s "${DATA_DIR}" "${APP_DATA}"
|
|
fi
|
|
|
|
# Ensure LangBot's actual listen port always matches what fnOS shows in
|
|
# "应用设置 → 访问端口" (which is the single source of truth from the user's
|
|
# POV). Two sources, checked in priority order:
|
|
# 1. ${wizard_port} — only set during install/upgrade callbacks (not on
|
|
# normal `start`; kept for completeness).
|
|
# 2. target/ui/config — read the "port" field written by fnOS after
|
|
# ${wizard_port} substitution AND any later edit the user made via
|
|
# "应用设置 → 自定义 URL" pencil button.
|
|
# Without this: user picks 5303 in wizard, LangBot still listens on its
|
|
# default 5300, desktop shortcut hits 5303 → connection refused.
|
|
CONFIG_FILE="${DATA_DIR}/config.yaml"
|
|
_patch_port() {
|
|
local _port="$1"
|
|
case "${_port}" in
|
|
''|*[!0-9]*) return ;;
|
|
esac
|
|
if [ ! -f "${CONFIG_FILE}" ]; then
|
|
local _tmpl="${APP_DIR}/src/langbot/templates/config.yaml"
|
|
[ -f "${_tmpl}" ] && cp "${_tmpl}" "${CONFIG_FILE}"
|
|
fi
|
|
if [ -f "${CONFIG_FILE}" ]; then
|
|
sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${_port}/" "${CONFIG_FILE}"
|
|
sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${_port}'#" "${CONFIG_FILE}"
|
|
fi
|
|
}
|
|
if [ -n "${wizard_port:-}" ]; then
|
|
_patch_port "${wizard_port}"
|
|
fi
|
|
if [ -f "${TRIM_APPDEST}/ui/config" ]; then
|
|
_port_from_ui=$(python3 -c 'import json,sys
|
|
try:
|
|
d = json.load(open(sys.argv[1]))
|
|
for _name, _entry in (d.get(".url") or {}).items():
|
|
p = _entry.get("port")
|
|
if isinstance(p, (int, float)):
|
|
print(int(p))
|
|
elif isinstance(p, str) and p.isdigit():
|
|
print(int(p))
|
|
break
|
|
except Exception:
|
|
pass
|
|
' "${TRIM_APPDEST}/ui/config" 2>/dev/null)
|
|
if [ -n "${_port_from_ui}" ]; then
|
|
_patch_port "${_port_from_ui}"
|
|
fi
|
|
fi
|
|
|
|
# Native deployment: no --standalone-runtime flag, LangBot spawns the
|
|
# plugin runtime as a stdio subprocess (same as official `uv run main.py`).
|
|
# (--standalone-runtime would require an external runtime at
|
|
# ws://langbot_plugin_runtime:5400, which only exists in Docker Compose.)
|
|
# --standalone-box omitted: Box sandbox defaults off, users enable via Web UI
|
|
#
|
|
# Privilege model: the whole app runs as the generated package user
|
|
# (run-as: package, see config/privilege) — no root anywhere. HOME /
|
|
# UV_CACHE_DIR / UV_PROJECT_ENVIRONMENT are exported at the top and point
|
|
# at the persistent share so tool caches (uv, npm/npx) and the relocated
|
|
# venv stay writable regardless of the generated user's system home.
|
|
#
|
|
# Process-group lifecycle: setsid makes the main process a session/group
|
|
# leader, so PID == PGID. "stop" kills the whole group — stdio children
|
|
# (plugin runtime, Box) die with the parent and can never survive as
|
|
# orphans holding their ws ports after a crash, stop or upgrade.
|
|
command -v setsid >/dev/null 2>&1 || {
|
|
echo "setsid not found (util-linux required for process-group lifecycle)" > "${TRIM_TEMP_LOGFILE}"
|
|
exit 1
|
|
}
|
|
setsid nohup "${UV_BIN}" run --no-sync main.py \
|
|
> "${LOG_FILE}" 2>&1 &
|
|
echo $! > "${PID_FILE}"
|
|
|
|
sleep 3
|
|
if [ -f "${PID_FILE}" ]; then
|
|
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
|
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
|
exit 0
|
|
fi
|
|
fi
|
|
echo "LangBot failed to start. Check ${LOG_FILE}" > "${TRIM_TEMP_LOGFILE}"
|
|
exit 1
|
|
;;
|
|
|
|
stop)
|
|
if [ -f "${PID_FILE}" ]; then
|
|
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
|
if [ -n "${PID}" ]; then
|
|
# Started with setsid, so PID == PGID: kill the whole group so
|
|
# stdio children (plugin runtime, Box) die with the parent. The
|
|
# plain-PID kill covers instances started before the setsid
|
|
# change (group kill is a no-op for them).
|
|
kill -TERM -- "-${PID}" 2>/dev/null
|
|
kill -TERM "${PID}" 2>/dev/null
|
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
kill -0 "${PID}" 2>/dev/null || break
|
|
sleep 1
|
|
done
|
|
kill -KILL -- "-${PID}" 2>/dev/null
|
|
kill -KILL "${PID}" 2>/dev/null
|
|
fi
|
|
rm -f "${PID_FILE}"
|
|
fi
|
|
exit 0
|
|
;;
|
|
|
|
status)
|
|
if [ -f "${PID_FILE}" ]; then
|
|
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
|
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
|
exit 0
|
|
fi
|
|
rm -f "${PID_FILE}"
|
|
fi
|
|
exit 3
|
|
;;
|
|
|
|
*)
|
|
exit 1
|
|
;;
|
|
esac
|