From 34f1e3d56f841c994c208c60cec0cb5a8ddd452a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csheetung=E2=80=9D?= <755855262@qq.com> Date: Fri, 18 Sep 2026 16:03:42 +0800 Subject: [PATCH 1/2] feat(fnos): run entirely without root and harden process lifecycle - 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) --- packaging/fnos/README.md | 8 +- packaging/fnos/build.sh | 19 ++-- packaging/fnos/cmd/install_callback | 122 +++++++++++++++----------- packaging/fnos/cmd/install_init | 11 ++- packaging/fnos/cmd/main | 93 +++++++++++++++----- packaging/fnos/cmd/uninstall_callback | 3 +- packaging/fnos/cmd/upgrade_callback | 82 ++++++++++------- packaging/fnos/cmd/upgrade_init | 24 ++++- packaging/fnos/config/privilege | 2 +- packaging/fnos/manifest | 2 +- 10 files changed, 238 insertions(+), 128 deletions(-) diff --git a/packaging/fnos/README.md b/packaging/fnos/README.md index 1bd672b67..193a184f1 100644 --- a/packaging/fnos/README.md +++ b/packaging/fnos/README.md @@ -2,6 +2,10 @@ This directory packages LangBot as a `.fpk` app for the fnOS App Store. It is a native deployment: no Docker involved — uv creates a Python virtual environment directly on the NAS, and Node.js v22 from the fnOS App Store provides the Box sandbox and npx MCP capabilities. +## Privilege Model + +Per the [fnOS privilege docs](https://developer.fnnas.com/docs/core-concepts/privilege/), the whole app runs as the dedicated package user (`run-as: package`; username auto-generated by fnOS from `appname`) — no root anywhere. The official App Store apps `nodejs_v22` and `python312` (declared in `manifest` via `install_dep_apps`) are borrowed from `/var/apps/` cross-app. `HOME` is pointed at `/.home` inside the persistent share so tool caches (uv, npm/npx MCP) stay writable regardless of the generated user's system home. + ## Directory Structure ``` @@ -10,10 +14,10 @@ packaging/fnos/ ├── build.sh # One-shot build script (shared by local and CI) ├── LICENSE ├── config/ -│ ├── privilege # Privilege config (run-as: root) +│ ├── privilege # Privilege config (run-as: package, no root) │ └── resource # Persistent data share declaration (langbot/data) ├── cmd/ # Lifecycle scripts (fnOS invokes them with TRIM_* env vars) -│ ├── main # Service start/stop manager (start/stop/status, owns PID/log) +│ ├── main # Service start/stop manager (start/stop/status, owns PID/log); started via setsid, stop kills the whole process group │ ├── install_init # Pre-install hook │ ├── install_callback # Post-install hook: create venv, uv sync deps, seed config.yaml port │ ├── upgrade_init # Pre-upgrade hook diff --git a/packaging/fnos/build.sh b/packaging/fnos/build.sh index 74675e2a3..ef0f135fa 100644 --- a/packaging/fnos/build.sh +++ b/packaging/fnos/build.sh @@ -73,8 +73,11 @@ rsync -a \ [ -d "${FPK_DIR}/app/langbot/web/dist" ] || { echo "ERROR: web/dist missing after rsync!" >&2; exit 1; } echo " Source synced ($(du -sh "${FPK_DIR}/app/langbot" | cut -f1))" -# --- 2.5 Download bundled uv binaries (offline install on NAS) --- -echo "[2.5/5] Downloading bundled uv binaries..." +# --- 2.5 Download bundled uv binary (offline install on NAS) --- +# Python comes from the official python312 App Store app (see manifest +# install_dep_apps); only uv is carried in the package. The download is a +# hard requirement — the build fails without it (no fallback installs). +echo "[2.5/5] Downloading bundled uv binary..." UV_VERSION="0.12.9" mkdir -p "${FPK_DIR}/app/bin" for arch in x86_64 aarch64; do @@ -84,15 +87,13 @@ for arch in x86_64 aarch64; do continue fi tmp="$(mktemp -d)" - if curl -sSL -o "${tmp}/uv.tar.gz" \ + curl -fsSL -o "${tmp}/uv.tar.gz" \ "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${arch}-unknown-linux-gnu.tar.gz" \ && tar xzf "${tmp}/uv.tar.gz" -C "${tmp}" \ - && cp "${tmp}/uv-${arch}-unknown-linux-gnu/uv" "${out}"; then - chmod +x "${out}" - echo " uv-${arch} downloaded (${UV_VERSION})" - else - echo " WARNING: failed to download uv for ${arch}, install will fall back to online install" >&2 - fi + && cp "${tmp}/uv-${arch}-unknown-linux-gnu/uv" "${out}" \ + && chmod +x "${out}" \ + && echo " uv-${arch} downloaded (${UV_VERSION})" \ + || { echo "ERROR: failed to download uv for ${arch}" >&2; rm -rf "${tmp}"; exit 1; } rm -rf "${tmp}" done diff --git a/packaging/fnos/cmd/install_callback b/packaging/fnos/cmd/install_callback index 4eac06acc..96b7cff79 100755 --- a/packaging/fnos/cmd/install_callback +++ b/packaging/fnos/cmd/install_callback @@ -19,7 +19,30 @@ cd "${APP_DIR}" || { } # --- Ensure data directory exists --- -mkdir -p "${DATA_DIR}/plugins" "${DATA_DIR}/box" "${DATA_DIR}/logs" 2>/dev/null || true +mkdir -p "${DATA_DIR}/plugins" "${DATA_DIR}/box" "${DATA_DIR}/logs" || { + echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} + +# --- Writable HOME and tool caches --- +# Under run-as: package the generated user's system HOME and the app install +# dir (TRIM_APPDEST) can be read-only; uv aborts if it cannot initialise its +# cache. Point HOME / UV_CACHE_DIR at the persistent data share and keep the +# venv there too (UV_PROJECT_ENVIRONMENT), instead of inside APP_DIR. +VENV_DIR="${DATA_DIR}/.venv" +export HOME="${DATA_DIR}/.home" +export UV_CACHE_DIR="${DATA_DIR}/.cache/uv" +# Never download a managed CPython (the download host is unreachable on many +# NAS networks); use the distro Python only — fail loudly if it is missing. +export UV_PYTHON_DOWNLOADS=never +mkdir -p "${HOME}" "${UV_CACHE_DIR}" || { + echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} + +# Real uv/pip errors are captured here for diagnosis (the installer popup +# only shows the short message we write to TRIM_TEMP_LOGFILE). +DEBUG_LOG="${DATA_DIR}/logs/install-debug.log" # --- Pre-seed config.yaml with the user-selected web port --- # Data root points at the persistent share (LANGBOT_DATA_ROOT is exported by @@ -35,7 +58,10 @@ TEMPLATE_FILE="${APP_DIR}/src/langbot/templates/config.yaml" _patch_config() { local cfg_dir="$1" local cfg_file="${cfg_dir}/config.yaml" - mkdir -p "${cfg_dir}" 2>/dev/null || true + mkdir -p "${cfg_dir}" || { + echo "Cannot create config directory: ${cfg_dir}" > "${TRIM_TEMP_LOGFILE}" + exit 1 + } if [ ! -f "${cfg_file}" ] && [ -f "${TEMPLATE_FILE}" ]; then cp "${TEMPLATE_FILE}" "${cfg_file}" fi @@ -68,15 +94,27 @@ if [ ! -d "/var/apps/nodejs_v${NODE_VERSION}" ]; then exit 1 fi -# --- Python check --- -PYTHON_BIN="python3" -if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then - PYTHON_BIN="python" -fi -if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then - echo "Python not found on this system" > "${TRIM_TEMP_LOGFILE}" +# --- 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 + +# --- Python: official python312 App Store app (same borrow pattern as Node.js) --- +PYTHON_APP="python312" +PYTHON_BIN="/var/apps/${PYTHON_APP}/target/bin/python3" +if [ ! -d "/var/apps/${PYTHON_APP}" ]; then + echo "未找到官方 Python 环境:请先在应用中心安装 ${PYTHON_APP},再重新安装本应用。" > "${TRIM_TEMP_LOGFILE}" exit 1 fi +[ -x "${PYTHON_BIN}" ] || { + echo "Python 解释器缺失或不可执行:${PYTHON_BIN}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} PY_VER=$("${PYTHON_BIN}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null) if [ -z "${PY_VER}" ]; then @@ -90,55 +128,32 @@ if [ "${PY_MAJOR}" -lt 3 ] || { [ "${PY_MAJOR}" -eq 3 ] && [ "${PY_MINOR}" -lt 1 exit 1 fi -# --- Resolve uv: bundled binary first, then online fallbacks --- -UV_BIN="" -ARCH=$(uname -m) -case "${ARCH}" in - x86_64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-x86_64" ;; - aarch64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-aarch64" ;; - *) BUNDLED_UV="" ;; -esac - -if [ -n "${BUNDLED_UV}" ] && [ -x "${BUNDLED_UV}" ]; then - mkdir -p "${TRIM_PKGVAR}/bin" - cp "${BUNDLED_UV}" "${TRIM_PKGVAR}/bin/uv" && chmod +x "${TRIM_PKGVAR}/bin/uv" - UV_BIN="${TRIM_PKGVAR}/bin/uv" -fi - -if [ -z "${UV_BIN}" ] && command -v uv >/dev/null 2>&1; then - UV_BIN="uv" -fi - -if [ -z "${UV_BIN}" ]; then - "${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \ - "${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || \ - curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null || true - export PATH="${HOME}/.local/bin:${PATH}" - if command -v uv >/dev/null 2>&1; then - UV_BIN="uv" - elif [ -x "${HOME}/.local/bin/uv" ]; then - UV_BIN="${HOME}/.local/bin/uv" - fi -fi - -if [ -z "${UV_BIN}" ]; then - echo "无法获取 uv:内置二进制缺失且在线安装失败。请检查网络后重新安装。" > "${TRIM_TEMP_LOGFILE}" +# --- Resolve uv: run the bundled binary in place (single canonical path) --- +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 -fi +} -# --- Create venv via uv --- -if [ ! -d ".venv" ]; then - "${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || { - echo "Failed to create Python virtual environment via uv" > "${TRIM_TEMP_LOGFILE}" +# --- Create venv via uv (on the writable data share, not APP_DIR) --- +if [ ! -d "${VENV_DIR}" ]; then + { + echo "== whoami: $(id 2>&1)" + echo "== APP_DIR perms: $(ls -ld "${APP_DIR}" 2>&1)" + echo "== DATA_DIR perms: $(ls -ld "${DATA_DIR}" 2>&1)" + echo "== HOME=${HOME} UV_CACHE_DIR=${UV_CACHE_DIR}" + "${UV_BIN}" venv "${VENV_DIR}" --python "${PYTHON_BIN}" + } >> "${DEBUG_LOG}" 2>&1 || { + echo "Failed to create Python virtual environment via uv. See ${DEBUG_LOG}" > "${TRIM_TEMP_LOGFILE}" exit 1 } fi -# --- Sync dependencies --- -"${UV_BIN}" sync --extra seekdb || { - echo "Dependency sync failed. Check network connectivity." > "${TRIM_TEMP_LOGFILE}" +# --- Sync dependencies (install into the relocated venv) --- +if ! UV_PROJECT_ENVIRONMENT="${VENV_DIR}" "${UV_BIN}" sync --extra seekdb >> "${DEBUG_LOG}" 2>&1; then + echo "Dependency sync failed. Check network connectivity. See ${DEBUG_LOG}" > "${TRIM_TEMP_LOGFILE}" exit 1 -} +fi # --- Verify frontend dist --- if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then @@ -146,4 +161,9 @@ if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then exit 1 fi +# --- Prepare a writable HOME inside the data dir for tool caches (uv, +# npm/npx MCP). Everything already runs as the package user (run-as: +# package), so no chown is needed — files created here belong to it. +mkdir -p "${DATA_DIR}/.home" 2>/dev/null || true + exit 0 diff --git a/packaging/fnos/cmd/install_init b/packaging/fnos/cmd/install_init index 2e940ee03..22134488d 100755 --- a/packaging/fnos/cmd/install_init +++ b/packaging/fnos/cmd/install_init @@ -1,5 +1,12 @@ #!/bin/bash -# cmd/install_init - pre-install hook -# Nothing special to do before extraction. +# cmd/install_init - pre-install hook (runs before files are applied) +# Sweep stray processes from a previous failed/killed install: orphans whose +# parent was hard-killed keep running and hold the runtime/box ws ports, +# which breaks the new instance. No match is the normal case on a clean +# install — pkill exits 1. + +RUN_USER=$(id -un) +pkill -KILL -u "${RUN_USER}" -f "appcenter/langbot" 2>/dev/null || true +pkill -KILL -u "${RUN_USER}" -f "appshare/langbot" 2>/dev/null || true exit 0 diff --git a/packaging/fnos/cmd/main b/packaging/fnos/cmd/main index a3181c383..6dfdc6afd 100755 --- a/packaging/fnos/cmd/main +++ b/packaging/fnos/cmd/main @@ -27,27 +27,48 @@ if [ -z "${DATA_DIR}" ]; then DATA_DIR="${TRIM_PKGVAR}/data" fi export LANGBOT_DATA_ROOT="${DATA_DIR}" -mkdir -p "${DATA_DIR}" 2>/dev/null || true +mkdir -p "${DATA_DIR}" || { + echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} -# --- Locate Python --- -PYTHON_BIN="python3" -! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python" +# --- 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 +} -# --- Locate uv --- -# install_callback puts the bundled uv binary at ${TRIM_PKGVAR}/bin/uv -UV_BIN="${TRIM_PKGVAR}/bin/uv" -if [ ! -x "${UV_BIN}" ]; then - UV_BIN="uv" -fi -if ! command -v "${UV_BIN}" >/dev/null 2>&1; then - UV_BIN="${HOME}/.local/bin/uv" -fi -if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then - UV_BIN="${HOME}/.cargo/bin/uv" -fi -if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then - UV_BIN="${APP_DIR}/.venv/bin/uv" -fi +# --- 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) @@ -69,7 +90,7 @@ case $1 in exit 1 } - if [ ! -d ".venv" ]; then + if [ ! -d "${DATA_DIR}/.venv" ]; then echo "Python virtual environment not found. Please reinstall LangBot." > "${TRIM_TEMP_LOGFILE}" exit 1 fi @@ -79,6 +100,9 @@ case $1 in # 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 @@ -87,7 +111,7 @@ case $1 in # 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}/" 2>/dev/null || cp -a "${APP_DATA}/." "${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 @@ -146,7 +170,22 @@ except Exception: # (--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 - nohup "${UV_BIN}" run --no-sync main.py \ + # + # 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}" @@ -165,12 +204,18 @@ except Exception: if [ -f "${PID_FILE}" ]; then PID=$(cat "${PID_FILE}" | tr -d '[:space:]') if [ -n "${PID}" ]; then - kill "${PID}" 2>/dev/null + # 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 -9 "${PID}" 2>/dev/null + kill -KILL -- "-${PID}" 2>/dev/null + kill -KILL "${PID}" 2>/dev/null fi rm -f "${PID_FILE}" fi diff --git a/packaging/fnos/cmd/uninstall_callback b/packaging/fnos/cmd/uninstall_callback index 2baee90d9..2c982078c 100755 --- a/packaging/fnos/cmd/uninstall_callback +++ b/packaging/fnos/cmd/uninstall_callback @@ -7,8 +7,7 @@ if [ "${wizard_keep_data:-yes}" = "no" ]; then # 应用运行数据(pid、日志等) if [ -n "${TRIM_PKGVAR}" ]; then rm -rf "${TRIM_PKGVAR:?}"/langbot.pid \ - "${TRIM_PKGVAR:?}"/langbot.log \ - "${TRIM_PKGVAR:?}"/bin 2>/dev/null || true + "${TRIM_PKGVAR:?}"/langbot.log 2>/dev/null || true fi # 共享数据目录(langbot/data) diff --git a/packaging/fnos/cmd/upgrade_callback b/packaging/fnos/cmd/upgrade_callback index 9288c866c..f5b51c508 100755 --- a/packaging/fnos/cmd/upgrade_callback +++ b/packaging/fnos/cmd/upgrade_callback @@ -8,6 +8,20 @@ APP_DIR="${TRIM_APPDEST}/langbot" DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}" [ -z "${DATA_DIR}" ] && DATA_DIR="${TRIM_PKGVAR}/data" +# Writable HOME / caches / venv on the data share (must match +# cmd/install_callback — venv is relocated under DATA_DIR). +VENV_DIR="${DATA_DIR}/.venv" +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="${VENV_DIR}" +mkdir -p "${HOME}" "${UV_CACHE_DIR}" "${DATA_DIR}/logs" || { + echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} +DEBUG_LOG="${DATA_DIR}/logs/upgrade-debug.log" + # Apply port from upgrade wizard (config persists across upgrades; this # only rewrites it when the user changed the value in the upgrade wizard) CONFIG_FILE="${DATA_DIR}/config.yaml" @@ -26,52 +40,56 @@ cd "${APP_DIR}" || { exit 1 } -# Find uv (bundled first, then PATH / ~/.local/bin / ~/.cargo/bin) -UV_BIN="${TRIM_PKGVAR}/bin/uv" -if [ ! -x "${UV_BIN}" ]; then - UV_BIN="uv" -fi -if ! command -v "${UV_BIN}" >/dev/null 2>&1; then - UV_BIN="${HOME}/.local/bin/uv" -fi -if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then - UV_BIN="${HOME}/.cargo/bin/uv" -fi +# --- 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 -PYTHON_BIN="python3" -! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python" +# 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 +} -# Re-sync deps -if [ -d ".venv" ]; then - "${UV_BIN}" sync --extra seekdb 2>/dev/null || { - echo "Dependency sync failed after upgrade" > "${TRIM_TEMP_LOGFILE}" +# 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 +} + +# Re-sync deps (venv lives at ${DATA_DIR}/.venv, see install_callback) +if [ -d "${VENV_DIR}" ]; then + UV_PROJECT_ENVIRONMENT="${VENV_DIR}" "${UV_BIN}" sync --extra seekdb >> "${DEBUG_LOG}" 2>&1 || { + echo "Dependency sync failed after upgrade. See ${DEBUG_LOG}" > "${TRIM_TEMP_LOGFILE}" exit 1 } else - # Venv was lost, recreate via uv - if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then - "${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \ - "${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || { - echo "Failed to install uv" > "${TRIM_TEMP_LOGFILE}" - exit 1 - } - export PATH="${HOME}/.local/bin:${PATH}" - UV_BIN="uv" - fi - "${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || { - echo "Failed to recreate virtual environment" > "${TRIM_TEMP_LOGFILE}" + # Venv was lost, recreate it with the bundled uv on the data share + "${UV_BIN}" venv "${VENV_DIR}" --python "${PYTHON_BIN}" >> "${DEBUG_LOG}" 2>&1 || { + echo "Failed to recreate virtual environment. See ${DEBUG_LOG}" > "${TRIM_TEMP_LOGFILE}" exit 1 } - "${UV_BIN}" sync --extra seekdb || { - echo "Dependency sync failed" > "${TRIM_TEMP_LOGFILE}" + UV_PROJECT_ENVIRONMENT="${VENV_DIR}" "${UV_BIN}" sync --extra seekdb >> "${DEBUG_LOG}" 2>&1 || { + echo "Dependency sync failed. See ${DEBUG_LOG}" > "${TRIM_TEMP_LOGFILE}" exit 1 } fi # Verify frontend dist still present if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then - echo "Frontend dist missing after upgrade! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}" + echo "Frontend dist missing! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}" exit 1 fi +# Everything runs as the package user (run-as: package); the venv recreated +# above and the config rewritten via sed already belong to it. Cache HOME is +# prepared at the top of this script (see cmd/install_callback). + exit 0 diff --git a/packaging/fnos/cmd/upgrade_init b/packaging/fnos/cmd/upgrade_init index fdac71831..9fc46a9b9 100755 --- a/packaging/fnos/cmd/upgrade_init +++ b/packaging/fnos/cmd/upgrade_init @@ -1,20 +1,36 @@ #!/bin/bash -# cmd/upgrade_init - pre-upgrade hook -# Stop the running LangBot process before files are replaced. +# cmd/upgrade_init - pre-upgrade hook (runs before files are replaced) +# 1. Stop the running LangBot instance — whole process group, so stdio +# children (plugin runtime, Box) die with the parent. +# 2. Sweep stray processes from a previous crash/upgrade: orphans whose +# parent was hard-killed keep running and hold the runtime/box ws ports, +# which breaks the next start. PID_FILE="${TRIM_PKGVAR}/langbot.pid" if [ -f "${PID_FILE}" ]; then PID=$(cat "${PID_FILE}" | tr -d '[:space:]') if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then - kill "${PID}" 2>/dev/null + # Instances started with setsid have PID == PGID; the plain-PID kill + # covers instances started before that change. + 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 -9 "${PID}" 2>/dev/null + kill -KILL -- "-${PID}" 2>/dev/null + kill -KILL "${PID}" 2>/dev/null fi rm -f "${PID_FILE}" fi +# Stray sweep: match only our volume paths (venv/uv under the app dir on +# @appcenter, venv/HOME/caches under the data share on @appshare). This +# script itself runs from /var/apps//cmd/, so it never matches +# itself. No match is the normal case on a clean upgrade — pkill exits 1. +RUN_USER=$(id -un) +pkill -KILL -u "${RUN_USER}" -f "appcenter/langbot" 2>/dev/null || true +pkill -KILL -u "${RUN_USER}" -f "appshare/langbot" 2>/dev/null || true + exit 0 diff --git a/packaging/fnos/config/privilege b/packaging/fnos/config/privilege index e21db569b..2aafe2347 100644 --- a/packaging/fnos/config/privilege +++ b/packaging/fnos/config/privilege @@ -1,5 +1,5 @@ { "defaults": { - "run-as": "root" + "run-as": "package" } } diff --git a/packaging/fnos/manifest b/packaging/fnos/manifest index 98699b795..a05b5be1a 100644 --- a/packaging/fnos/manifest +++ b/packaging/fnos/manifest @@ -1,5 +1,5 @@ appname=langbot -version=4.10.10 +version=4.10.11 display_name=LangBot desc=基于 LLM 的多平台智能对话机器人,支持 QQ、微信、飞书、钉钉、Telegram 等十余种即时通讯平台,内置 Web 管理界面和 AI Agent 能力。 platform=all From d705861d259b831c76c53b0e2917a7020adf6259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csheetung=E2=80=9D?= <755855262@qq.com> Date: Fri, 18 Sep 2026 17:05:47 +0800 Subject: [PATCH 2/2] fix(fnos): open LangBot embedded inside fnOS desktop instead of external browser Change desktop entry type from 'url' (opens external browser) to 'iframe' (embeds the web UI inside the fnOS desktop window), per the official Application Entry documentation at developer.fnnas.com/docs/core-concepts/app-entry. --- packaging/fnos/app/desktop/langbot.main.url | 2 +- packaging/fnos/app/ui/config | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/fnos/app/desktop/langbot.main.url b/packaging/fnos/app/desktop/langbot.main.url index 513870fe8..1717216cc 100644 --- a/packaging/fnos/app/desktop/langbot.main.url +++ b/packaging/fnos/app/desktop/langbot.main.url @@ -1,7 +1,7 @@ { "title": "LangBot", "icon": "images/icon-256.png", - "type": "url", + "type": "iframe", "protocol": "http", "port": "${wizard_port}", "url": "/", diff --git a/packaging/fnos/app/ui/config b/packaging/fnos/app/ui/config index d45765dd4..2908f45de 100644 --- a/packaging/fnos/app/ui/config +++ b/packaging/fnos/app/ui/config @@ -3,7 +3,7 @@ "langbot.main": { "title": "LangBot", "icon": "images/icon-{0}.png", - "type": "url", + "type": "iframe", "protocol": "http", "port": "${wizard_port}", "url": "/",