fix(starlark): stop the root display service locking the web UI out of starlark-apps (#604)

* fix(starlark): stop the root display service locking the web UI out

Reported after a fresh install: installing an app from the Starlark tab
failed with "install failed: Failed to install from repository", and so did
uploading a .star file and installing from a GitHub directory. The reporter
found the cause only by reading service logs, and fixed it with

    sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/starlark-apps

starlark-apps is gitignored, so it is never checked out -- it is created
lazily by whichever process reaches it first. Those processes run as
different users. systemd/ledmatrix.service is User=root and constructs this
plugin at startup, which is where _get_apps_directory() is called from;
systemd/ledmatrix-web.service runs as the login user and is what actually
installs apps.

The documented first step is to install pixlet and reboot, so on a fresh
machine the display service usually wins that race and mkdir() leaves the
directory root-owned. The web process then fails in _install_star_file() on
app_dir.mkdir(), which catches nothing, so PermissionError reaches the
route's outer `except Exception` and becomes the generic message the user
saw. All three install paths write to the same directory, which is why all
three failed.

The web user cannot repair this -- chown needs root. So root does it, on
every startup, which also heals machines already broken by this without the
owner having to find the chown themselves. It is a no-op when not root, when
the platform has no POSIX ownership, and when the checkout genuinely belongs
to root; a chown that fails warns rather than killing startup.

Also made the failure legible if the handover is ever prevented: a
PermissionError now names the directory, the automatic repair, and the
manual chown, instead of a message that names neither path nor cause.

Verified by mutation: dropping the handover call, chowning a genuinely
root-owned checkout, and letting a non-root process chown each fail their
own test. 121 starlark tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(starlark): address the review on the ownership repair

Findings from the automated review of #604.

Symlinks (CWE-59, the serious one). A root chown that follows links is a
privilege-escalation primitive: anyone able to write in starlark-apps could
point a link at a root-owned file and have the repair hand it over. Entries
are now read with os.lstat, symlinks are skipped outright, and the chown
passes follow_symlinks=False. Descendants are processed before the directory
itself, so the container does not change hands while its contents are still
being walked.

install_app() caught PermissionError in its broad handler and returned
False, which both routes report as a generic install failure -- the exact
shape of the bug this PR exists to fix, since the caller could not tell
"this app is broken" from "this process cannot write here". PermissionError
is now re-raised; every other failure still returns False.

The test fixtures skipped on bare Exception, which would have turned a
syntax error or NameError in the plugin into a green run. They now skip only
for a named absent dependency and re-raise anything else.

Also fixed the _Stat stub that failed in CI but passed locally: it carried
only st_uid/st_gid, and pathlib reads st_mode while walking. It now wraps
the real stat result and overrides ownership alone.

NOT taken: the CodeQL "information exposure through an exception" finding on
the hint response. Dropping `details` would contradict this package's
documented rule -- "if it returns 5xx, it says why" -- which
test_no_api_v3_handler_discards_its_exception enforces with an allowance
that may shrink and never grow. The Starlark routes are the ones that policy
was written for: they answered 500 with no detail for three releases.
describe_exception already redacts credentials and truncates. Keeping the
detail is the deliberate trade-off, so the finding is declined rather than
silently worked around.

Verified on hdpi with the updated code: a symlink to /etc/shadow planted in
starlark-apps was skipped while the directory was handed back, and
/etc/shadow stayed root:shadow.

Mutation-checked all three behaviours. The symlink test was vacuous on the
first attempt -- the link already had the target owner, so it was skipped
for the wrong reason and the mutation passed. It now forces the link to look
like it needs handing over, and fails when the check is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 16:33:12 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 4a1fd7464a
commit 4fe3cdd906
3 changed files with 482 additions and 2 deletions
+42 -1
View File
@@ -26,6 +26,36 @@ import web_interface.blueprints.api_v3 as _pkg
# package is the only patch point that covers every caller.
def _ownership_hint(err: BaseException):
"""An actionable message when the apps directory is not writable.
The display service runs as root and the web interface as the login user
(see systemd/ledmatrix.service and systemd/ledmatrix-web.service). The
starlark-apps directory is not in the repository, so whichever service
reaches it first creates it -- and when that is the display service, the
web user cannot write into it and every install fails.
The plugin now hands the directory back on startup, so this should not be
reachable. It is kept because the failure is otherwise invisible: the
generic message names no path and no cause, and the one user who hit it
had to read the service logs to find it. If the handover is ever prevented
-- an exotic mount, a directory root-owned for another reason -- this says
what to do instead of costing somebody an evening.
Returns None when `err` is not a permission problem.
"""
if not isinstance(err, PermissionError):
return None
return (
f"Cannot write to {_STARLARK_APPS_DIR}. It is owned by another user "
f"-- usually because the display service, which runs as root, created "
f"it before the web interface did. Restarting the display service "
f"(sudo systemctl restart ledmatrix) repairs the ownership "
f"automatically. To fix it by hand: "
f"sudo chown -R $USER:$USER {_STARLARK_APPS_DIR}"
)
@api_v3.route('/starlark/status', methods=['GET'])
def get_starlark_status():
"""Get Starlark plugin status and Pixlet availability."""
@@ -278,7 +308,8 @@ def upload_starlark_app():
# without it, though, and describe_exception redacts credentials and
# truncates -- the same trade-off every other handler here makes.
logger.exception("[Starlark] File error uploading starlark app: %s", err)
return jsonify({'status': 'error', 'message': 'File error during upload',
return jsonify({'status': 'error',
'message': _ownership_hint(err) or 'File error during upload',
'details': describe_exception(err)}), 500
except ImportError as err:
logger.exception("[Starlark] Module load error uploading starlark app: %s", err)
@@ -702,6 +733,16 @@ def install_from_tronbyte_repository():
except Exception as e:
logger.exception("[Starlark] install_from_tronbyte_repository failed")
hint = _ownership_hint(e)
if hint:
# `details` is kept deliberately. CodeQL flags it as information
# exposure, but this package's rule is "if it returns 5xx, it says
# why" -- enforced by test_no_api_v3_handler_discards_its_exception,
# whose PRE_EXISTING allowance may shrink and never grow. The
# Starlark routes are exactly the ones that policy was written for:
# they answered 500 with no detail for three releases. It stays.
return jsonify({'status': 'error', 'message': hint,
'details': describe_exception(e)}), 500
return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/repository/categories', methods=['GET'])
def get_tronbyte_categories():