#!/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 # /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