fix: dependency installation gaps in Plugin Store and first-time install (#385)

* fix: install plugin dependencies through root-visible installer in Plugin Store

install_plugin/update_plugin (store_manager.py) installed requirements.txt
with a bare `pip3` off PATH, bypassing the root-visible installer added in
#380 for the "Reinstall Plugin Deps" button. Two bugs stacked: (1) `pip3`
can resolve to a different Python install than the one that actually runs
ledmatrix.service, and (2) even when it resolves correctly, ledmatrix-web
runs as a non-root user so the package lands in that user's local
site-packages, invisible to root-run ledmatrix.service. Either way the
install reports success and writes the .dependencies_installed hash marker,
so plugin_loader's own (correct) install-on-load path skips reinstalling —
leaving the dependency permanently missing until a user finds and clicks
the separate "Reinstall Plugin Deps" tool. This is why users kept hitting
"No module named 'astral'" for the weather plugin even after installing it
from the Store.

Extracts the sudo-wrapper-then-fallback install logic from api_v3.py's
_pip_install_requirements into src/common/permission_utils.py as
install_requirements_file, and routes store_manager.py's dependency
installation through it so the automatic Store install/update path now
matches the manual "Reinstall Plugin Deps" path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* chore: suppress Codacy false-positive on subprocess.run in install_requirements_file

Codacy's generic subprocess-security rule (Bandit B603 equivalent) flagged
the pip/sudo subprocess.run calls in install_requirements_file for lacking a
"static string argument" — the standard pattern-based flag for any
subprocess.run() call with a variable in its argv list. Both calls use
list-form argv (no shell=True, so no shell-injection surface), and the only
dynamic value is req_file, a Path built internally by callers rather than
raw external input; safe_pip_install.sh independently re-validates it before
installing anything as root. Suppresses with inline `# nosec B603` comments
matching this codebase's existing convention (see permission_utils.py's own
PROTECTED_SYSTEM_DIRECTORIES, display_manager.py, sync_manager.py, etc.).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* fix: first-time install script fails on apt-managed requests package

web_interface/requirements.txt and requirements.txt both pin
requests>=2.33.0,<3.0.0, but Raspberry Pi OS ships an apt-managed
python3-requests with no pip RECORD file. Upgrading it via plain
`pip install` aborts with "uninstall-no-record-file" because pip refuses to
uninstall a package it has no record of, in place — which is exactly the
"Some web interface dependencies failed to install" warning first-time
install hits.

scripts/install_dependencies_apt.py and scripts/fix_perms/safe_pip_install.sh
already work around this with --ignore-installed (lets pip lay the new
version down in /usr/local, shadowing the apt copy, instead of trying to
remove it first). first_time_install.sh's own direct pip invocations —
the per-package requirements.txt loop, the web_interface/requirements.txt
install, and the requirements_web_v2.txt fallback — didn't have it. Adds
--ignore-installed to all three so first-time install no longer fails on
this well-known apt/pip conflict.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* chore: add nosemgrep to subprocess.run calls Codacy still flagged

The prior # nosec B603 comments suppressed Bandit's check but Codacy's
semgrep-based rule ("subprocess function 'run' without a static string")
kept flagging the same two lines as a critical security issue even after
that fix landed. install_dependencies_apt.py's _run() already needed both
tags together (# nosec B603 B607 ... # nosemgrep) for the identical
subprocess.run pattern, so apply the same double suppression here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* fix: add --ignore-installed to install_requirements_file fallback path

CodeRabbit review caught this (confirming a gap already flagged in
conversation): the non-sudo fallback pip install in install_requirements_file
was missing --ignore-installed, unlike the sudo-wrapper branch and
safe_pip_install.sh. Without it, the same apt/pip RECORD-file conflict this
PR fixes elsewhere (first_time_install.sh, install_dependencies_apt.py) could
still hit installs that fall back to this path (e.g. a plugin's
requirements.txt on a host where safe_pip_install.sh isn't set up yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-07-08 09:59:12 -04:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 7a9d01342a
commit 63a233f3ed
4 changed files with 138 additions and 94 deletions
+6 -76
View File
@@ -26,6 +26,7 @@ from src.web_interface.validators import (
validate_file_upload
)
from src.error_aggregator import get_error_aggregator
from src.common.permission_utils import install_requirements_file
_SUDO = shutil.which('sudo')
_JOURNALCTL = shutil.which('journalctl')
@@ -50,83 +51,12 @@ def _pip_install_requirements(req_file: Path, timeout: int) -> subprocess.Comple
for the current process only if the wrapper isn't set up yet (i.e. the
admin hasn't run scripts/install/configure_web_sudo.sh since upgrading),
so the button still does *something* useful rather than hard-failing.
Thin wrapper around the shared implementation in permission_utils so the
Plugin Store's own dependency installation (store_manager.py) follows the
exact same root-visible install path instead of a divergent one.
"""
wrapper = PROJECT_ROOT / 'scripts' / 'fix_perms' / 'safe_pip_install.sh'
if wrapper.exists():
# Must invoke via an explicit `bash <path>` — matching both the
# sudoers rule configure_web_sudo.sh provisions ($BASH_PATH
# $SAFE_PIP_INSTALL_PATH *) and the existing safe_plugin_rm.sh call
# in src/common/permission_utils.py. Calling the script path directly
# (relying on its shebang) makes sudo check a different command line
# than what's actually allowlisted, so `sudo -n` denies it on any
# install that only has the specific rules this script provisions —
# it only appeared to work in prior testing because that device also
# had a broader, non-standard NOPASSWD: ALL grant.
#
# $BASH_PATH is resolved once at setup time (configure_web_sudo.sh's
# `command -v bash`) and baked into the static sudoers file as a
# literal path; sudo requires an exact string match against that, so
# if this process's own PATH resolves bash somewhere else, the
# sudoers rule won't match here either. Try the standard Debian/
# Raspberry Pi OS locations first, then this process's own
# resolution, so a divergence in just one of them doesn't break this.
bash_candidates = []
for candidate in ('/usr/bin/bash', '/bin/bash', shutil.which('bash')):
if candidate and candidate not in bash_candidates:
bash_candidates.append(candidate)
result = None
for bash_path in bash_candidates:
result = subprocess.run(
['sudo', '-n', bash_path, str(wrapper), str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(PROJECT_ROOT)
)
if result.returncode == 0:
return result
# Best-effort distinction between "sudo rejected this exact
# command line" (no matching NOPASSWD rule for this bash path —
# worth trying the next candidate) and "sudo ran it but the
# wrapper/pip itself failed" (a real error — stop and surface it
# rather than uselessly retrying other bash paths or doubling up
# with a redundant non-root install attempt).
denied = any(
phrase in result.stderr
for phrase in ('a password is required', 'is not allowed to run', 'no tty present')
)
if not denied:
logger.warning(
"[Pip Install] Root install failed (rc=%s) for %s: %s",
result.returncode, req_file, result.stderr.strip()[:500],
)
return result
logger.warning(
"[Pip Install] Root wrapper denied via sudo for %s; falling back "
"to user-level install: %s",
req_file, result.stderr.strip()[:500] if result else 'no bash candidates found',
)
note = (
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
"installed for the web service's user only. Packages may not be "
"visible to ledmatrix.service if it runs as a different user — "
"run scripts/install/configure_web_sudo.sh to fix this.]\n"
)
else:
logger.warning(
"[Pip Install] safe_pip_install.sh not found; falling back to user-level install for %s",
req_file,
)
note = (
"[safe_pip_install.sh not found; installed for the web service's "
"user only. Run scripts/install/configure_web_sudo.sh to enable "
"root installs visible to ledmatrix.service.]\n"
)
result = subprocess.run(
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(PROJECT_ROOT)
)
result.stdout = note + (result.stdout or '')
return result
return install_requirements_file(req_file, timeout=timeout)
def _scrub_git_remote_url(url: str) -> str: