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