mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
* fix: install plugin/base requirements as root so ledmatrix.service can see them
ledmatrix-web.service runs as a non-root user, so "Reinstall plugin
requirements" installed packages into that user's ~/.local site-packages.
ledmatrix.service (the actual display, which loads and runs plugin code)
runs as root and can't see another user's user-site packages, so plugins
with dependencies not already present system-wide would silently fail at
runtime with ModuleNotFoundError even after a "successful" reinstall.
Reproduced and fixed live against a real device (weather plugin's astral
dependency, used for moon-phase data): confirmed the exact failure
("No module named 'astral'" on every almanac cycle) and confirmed it's
gone after this fix.
Adds scripts/fix_perms/safe_pip_install.sh, a root-owned wrapper (mirroring
the existing safe_plugin_rm.sh pattern) that validates the target is
requirements.txt at the project root or under plugin-repos/ or plugins/
before running pip install as root. configure_web_sudo.sh provisions a
narrowly-scoped sudoers rule for it. api_v3.py's install_base_requirements
and install_plugin_requirements actions now use it via `sudo -n`, falling
back to today's current-user-only install (with an explanatory note) if
the wrapper isn't set up yet, so existing installs don't regress.
Also uses --ignore-installed in the wrapper: root's site-packages often has
apt/dpkg-managed copies of common libraries (requests, etc.) with no pip
RECORD file, which pip refuses to upgrade in place and aborts the *entire*
requirements.txt install over — discovered this while testing the fix live,
since a plugin's other already-satisfied-for-the-web-user dependencies had
never actually been attempted as root before.
Also fixes a pre-existing bug in configure_web_sudo.sh where the
display_controller.py/start_display.sh/stop_display.sh sudoers entries used
PROJECT_DIR (scripts/install/, where this script lives) instead of
PROJECT_ROOT (where those files actually live) — visible as the script's
own "File access test" self-check failing. Verified fixed live.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* fix: invoke safe_pip_install.sh via explicit bash, matching sudoers rule
CodeRabbit caught this on review: the sudoers rule configure_web_sudo.sh
provisions is scoped to "$BASH_PATH $SAFE_PIP_INSTALL_PATH *" (matching
the existing safe_plugin_rm.sh precedent in
src/common/permission_utils.py), but _pip_install_requirements() called
`sudo -n <wrapper> <req_file>` directly, relying on the script's shebang
instead of an explicit bash prefix. sudo matches the literal command line,
so this never matched the allowlisted rule on an install with only the
specific sudoers entries this script provisions — it silently fell back
to the non-root install path every time, which is the exact bug this PR
set out to fix.
This wasn't caught by live testing on ledpi.local because that device
also has a broader, non-standard "NOPASSWD: ALL" grant which masked the
mismatch. Confirmed the fix is correct by reading sudo's documented
command-matching semantics and mirroring the already-proven-working
bash-prefix pattern from permission_utils.py's safe_plugin_rm.sh call
exactly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* fix: harden safe_pip_install.sh invocation against bash-path drift + address CodeRabbit nitpicks
CodeRabbit follow-up findings on the bash-prefix fix (7558aaab):
1. (Actionable) shutil.which('bash') at runtime could in principle resolve
to a different absolute path than configure_web_sudo.sh's `command -v
bash`, which is resolved once at setup time and baked into the static
sudoers file as a literal string — sudo requires an exact match. Now
tries /usr/bin/bash and /bin/bash (the standard Debian/Raspberry Pi OS
locations, matching what the setup script virtually always produces)
before falling back to this process's own PATH resolution, so a
divergence in just one of them doesn't break the install.
2. (Nitpick) Any nonzero returncode was treated as "sudo denied", so a
real pip failure (bad package, build error) would trigger a pointless
duplicate non-root install attempt and a misleading error message.
Now distinguishes "sudo -n rejected this exact command line" from
"sudo ran it but the command itself failed" via sudo's own diagnostic
text, and surfaces genuine failures immediately without retrying other
bash candidates or falling back.
3. (Nitpick) Added structured logging for every fallback/failure path
(wrapper missing, sudo denied, real install failure), previously only
visible via the returned stdout note — needed for remote debugging on
a headless Pi.
Verified: function-level smoke test confirms a real failure (this sandbox's
system python3 lacking pip) is now correctly classified as non-denial and
returned immediately without retrying candidates or double-installing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
75 lines
2.7 KiB
Bash
Executable File
75 lines
2.7 KiB
Bash
Executable File
#!/bin/bash
|
|
# safe_pip_install.sh — Install a requirements.txt as root after validating
|
|
# that the resolved path is the project's own requirements.txt or a plugin's
|
|
# requirements.txt under plugin-repos/ or plugins/.
|
|
#
|
|
# This script is intended to be called via sudo from the web interface, so
|
|
# that packages a plugin declares end up visible to ledmatrix.service (which
|
|
# runs as root) rather than only to whichever non-root user runs the web
|
|
# interface. Plugin code already runs as root once loaded, so installing its
|
|
# declared dependencies as root is not a new trust boundary.
|
|
#
|
|
# Usage: safe_pip_install.sh <requirements_txt_path>
|
|
|
|
set -euo pipefail
|
|
|
|
if [ $# -ne 1 ]; then
|
|
echo "Usage: $0 <requirements_txt_path>" >&2
|
|
exit 1
|
|
fi
|
|
|
|
TARGET="$1"
|
|
|
|
# Determine the project root (parent of scripts/fix_perms/)
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
|
|
# Allowed locations (resolved, no trailing slash):
|
|
# - the project's own requirements.txt
|
|
# - any requirements.txt under plugin-repos/ or plugins/
|
|
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
|
|
ALLOWED_BASES=(
|
|
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
|
|
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
|
|
)
|
|
|
|
# Resolve the target path (follow symlinks); works even if it doesn't exist.
|
|
RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")"
|
|
|
|
# Must be named requirements.txt — never install from an arbitrary file.
|
|
if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
|
|
echo "DENIED: $RESOLVED_TARGET is not a requirements.txt file" >&2
|
|
exit 2
|
|
fi
|
|
|
|
ALLOWED=false
|
|
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
|
|
ALLOWED=true
|
|
else
|
|
for BASE in "${ALLOWED_BASES[@]}"; do
|
|
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
|
|
ALLOWED=true
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if [ "$ALLOWED" = false ]; then
|
|
echo "DENIED: $RESOLVED_TARGET is not an allowed requirements.txt location" >&2
|
|
echo "Allowed: $ALLOWED_EXACT, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
|
|
exit 2
|
|
fi
|
|
|
|
if [ ! -f "$RESOLVED_TARGET" ]; then
|
|
echo "ERROR: $RESOLVED_TARGET does not exist" >&2
|
|
exit 3
|
|
fi
|
|
|
|
PYTHON_PATH="$(command -v python3)"
|
|
# --ignore-installed: root's site-packages often has apt/dpkg-managed copies
|
|
# of common libraries (requests, urllib3, ...) with no pip RECORD file, which
|
|
# pip refuses to uninstall in place ("Cannot uninstall: no RECORD file was
|
|
# found"). This tells pip to install the newer version alongside rather than
|
|
# aborting the whole requirements.txt install over one such conflict.
|
|
exec "$PYTHON_PATH" -m pip install --break-system-packages --ignore-installed -r "$RESOLVED_TARGET"
|