From 76c5e367efd66d7d8ec352d97f2167f600e9e1fa Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 7 Sep 2026 13:47:14 -0400 Subject: [PATCH 1/7] fix(starlark): restore the Pixlet install and status routes "Pixlet install failed: Resource not found" is Flask's 404 handler. The install button posts to /api/v3/starlark/install-pixlet, that route does not exist, and app.py's generic 404 answers with {"status": "error", "message": "Resource not found"} which the button prints verbatim -- naming neither the resource nor the cause. The routes were added by #253 and removed by #330, which rewrote api_v3.py (3272 lines changed) and dropped all thirteen Starlark routes with it. Nothing else moved: the frontend still calls them and plugin-repos/starlark-apps still implements the work behind them, so every Starlark call in the UI has been landing on the 404 handler since. Restores the two the Pixlet flow needs -- install-pixlet, and the status call the button reloads afterwards -- verbatim from 1c4d5c52^, plus the three helpers they use (_get_starlark_plugin, _find_pixlet_binary, _read_starlark_manifest) and _STARLARK_APPS_DIR. The installer itself is fine; I checked rather than assumed. Against the live release, the naming the script builds matches what tronbyt/pixlet publishes, and the URL resolves: latest tag v0.53.1 asset published pixlet_v0.53.1_linux-arm64.tar.gz script would request pixlet_v0.53.1_linux-arm64.tar.gz HEAD 200 So the missing route was the whole fault. Deliberately scoped to the reported bug. The other eleven routes are still missing -- apps CRUD, config, toggle, render, repository browse and install, repository categories -- and the rest of the Starlark page is still dead. #463 covers those alongside behavioural changes; this stands alone so the install can be fixed without them. test_starlark_pixlet_routes.py asserts both routes are registered and answer in the shape the JS reads, including the not-yet-loaded-plugin case a first-time user is in when they press Install. 8 of its 9 tests fail against main. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .../test_starlark_pixlet_routes.py | 109 +++++++++++++ web_interface/blueprints/api_v3.py | 143 +++++++++++++++++- 2 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 test/web_interface/test_starlark_pixlet_routes.py diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py new file mode 100644 index 00000000..8a82d7e7 --- /dev/null +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -0,0 +1,109 @@ +"""The Starlark routes the frontend calls must exist. + +`plugins_manager.js` posts to /api/v3/starlark/install-pixlet and then reloads +/api/v3/starlark/status. Neither route existed: #330 rewrote api_v3.py and +dropped all thirteen Starlark routes that #253 had added, so both calls fell +through to Flask's 404 handler, which answers + + {"status": "error", "message": "Resource not found"} + +and the button reported "Pixlet install failed: Resource not found" -- a +message that names neither the resource nor the cause. + +These assert the routes are registered and answer in the shape the frontend +reads, so a future rewrite of this file cannot silently drop them again. +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def client(): + from web_interface.app import app + app.config['TESTING'] = True + with app.test_client() as c: + yield c + + +class TestRoutesAreRegistered: + """The failure was a missing route, so check the URL map directly.""" + + @pytest.mark.parametrize("rule,method", [ + ("/api/v3/starlark/install-pixlet", "POST"), + ("/api/v3/starlark/status", "GET"), + ]) + def test_route_exists(self, rule, method): + from web_interface.app import app + matches = [r for r in app.url_map.iter_rules() + if r.rule == rule and method in r.methods] + assert matches, ( + f"{method} {rule} is not registered; the frontend calls it and " + "would get Flask's generic 'Resource not found'") + + +class TestInstallPixlet: + def test_it_does_not_404(self, client): + with patch('web_interface.blueprints.api_v3.subprocess.run') as run: + run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + resp = client.post('/api/v3/starlark/install-pixlet') + assert resp.status_code != 404, "the route is still missing" + assert resp.get_json().get('message') != 'Resource not found' + + def test_success_is_reported_in_the_shape_the_button_reads(self, client): + with patch('web_interface.blueprints.api_v3.subprocess.run') as run: + run.return_value = MagicMock(returncode=0, stdout="done", stderr="") + resp = client.post('/api/v3/starlark/install-pixlet') + body = resp.get_json() + assert body['status'] == 'success', body + assert 'message' in body, "the JS shows data.message on success" + + def test_a_failed_download_says_why(self, client): + with patch('web_interface.blueprints.api_v3.subprocess.run') as run: + run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release") + resp = client.post('/api/v3/starlark/install-pixlet') + body = resp.get_json() + assert body['status'] == 'error' + assert 'no such release' in body['message'], \ + "the installer's own stderr is what tells the user what went wrong" + + def test_a_timeout_is_reported_rather_than_hanging(self, client): + import subprocess as sp + with patch('web_interface.blueprints.api_v3.subprocess.run', + side_effect=sp.TimeoutExpired(cmd='x', timeout=300)): + resp = client.post('/api/v3/starlark/install-pixlet') + assert resp.get_json()['status'] == 'error' + assert 'timed out' in resp.get_json()['message'].lower() + + +class TestStarlarkStatus: + def test_it_does_not_404(self, client): + resp = client.get('/api/v3/starlark/status') + assert resp.status_code != 404, "the route is still missing" + assert resp.get_json().get('message') != 'Resource not found' + + def test_it_reports_pixlet_availability_without_the_plugin_loaded(self, client): + # The status call runs before install too -- it must answer even when + # starlark-apps is not loaded, which is the state a user is in when + # they press the install button for the first time. + with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None): + resp = client.get('/api/v3/starlark/status') + body = resp.get_json() + assert body['status'] == 'success', body + assert 'pixlet_available' in body + assert body['plugin_loaded'] is False + + +class TestTheInstallerScriptIsActuallyThere: + def test_download_pixlet_script_exists_and_is_executable(self): + # install_pixlet chmods and runs this; a missing file is the one error + # it reports as a 404 of its own, which would look identical to the + # bug being fixed here. + import os + from pathlib import Path + from web_interface.blueprints.api_v3 import PROJECT_ROOT + script = Path(PROJECT_ROOT) / 'scripts' / 'download_pixlet.sh' + assert script.is_file(), f"{script} is missing; install_pixlet would 404" + assert os.access(script, os.R_OK) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 35dfd885..74e940e3 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -8654,4 +8654,145 @@ def backup_delete(filename): except OSError as e: logger.error("backup_delete failed: %s", e, exc_info=True) return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500 - return jsonify({'status': 'error', 'message': 'Backup not found'}), 404 \ No newline at end of file + return jsonify({'status': 'error', 'message': 'Backup not found'}), 404 + + +# ── Starlark / Pixlet ──────────────────────────────────────────────────────── +# These routes were added by #253 and removed by #330, which rewrote this file +# and dropped all thirteen of them. Nothing else changed: the frontend still +# calls them and plugin-repos/starlark-apps still implements the work behind +# them, so every call has been landing on Flask's 404 handler and coming back +# as the generic "Resource not found" -- which is what the Pixlet install +# button reports. +# +# Restored verbatim from 1c4d5c52^ (the commit before the removal). The other +# eleven are still missing; see the PR description. + +_STARLARK_APPS_DIR = PROJECT_ROOT / 'starlark-apps' +_STARLARK_MANIFEST_FILE = _STARLARK_APPS_DIR / 'manifest.json' + +def _get_starlark_plugin() -> Optional[Any]: + """Get the starlark-apps plugin instance, or None.""" + if not api_v3.plugin_manager: + return None + return api_v3.plugin_manager.get_plugin('starlark-apps') + +def _find_pixlet_binary(explicit_path: Optional[str] = None) -> Optional[str]: + """Find pixlet binary: explicit path → bundled binary → system PATH.""" + import platform + if explicit_path and os.path.isfile(explicit_path) and os.access(explicit_path, os.X_OK): + return explicit_path + bin_dir = PROJECT_ROOT / "bin" / "pixlet" + system = platform.system().lower() + machine = platform.machine().lower() + if system == "linux": + if "aarch64" in machine or "arm64" in machine: + name = "pixlet-linux-arm64" + elif "x86_64" in machine or "amd64" in machine: + name = "pixlet-linux-amd64" + else: + name = None + elif system == "darwin": + name = "pixlet-darwin-arm64" if "arm64" in machine else "pixlet-darwin-amd64" + else: + name = None + if name: + bundled = bin_dir / name + if bundled.is_file(): + if os.access(str(bundled), os.X_OK): + return str(bundled) + try: + bundled.chmod(0o755) + except OSError: + logger.warning("Could not make pixlet bundled binary executable (%s); falling back to PATH", bundled) + else: + if os.access(str(bundled), os.X_OK): + return str(bundled) + logger.warning("Pixlet bundled binary still not executable after chmod (%s); falling back to PATH", bundled) + return shutil.which("pixlet") + +def _read_starlark_manifest() -> Dict[str, Any]: + """Read the starlark-apps manifest.json directly from disk.""" + try: + if _STARLARK_MANIFEST_FILE.exists(): + with open(_STARLARK_MANIFEST_FILE, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.error(f"Error reading starlark manifest: {e}") + return {'apps': {}} + +@api_v3.route('/starlark/status', methods=['GET']) +def get_starlark_status(): + """Get Starlark plugin status and Pixlet availability.""" + try: + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + info = starlark_plugin.get_info() + magnify_info = starlark_plugin.get_magnify_recommendation() + return jsonify({ + 'status': 'success', + 'pixlet_available': info.get('pixlet_available', False), + 'pixlet_version': info.get('pixlet_version'), + 'installed_apps': info.get('installed_apps', 0), + 'enabled_apps': info.get('enabled_apps', 0), + 'current_app': info.get('current_app'), + 'plugin_enabled': starlark_plugin.enabled, + 'display_info': magnify_info + }) + + # Plugin not loaded - check Pixlet availability via shared resolver + # (respects user-configured pixlet_path, bundled binary, and system PATH) + full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + pixlet_path = _find_pixlet_binary(full_config.get('starlark-apps', {}).get('pixlet_path')) + pixlet_available = pixlet_path is not None + + # Read app counts from manifest + manifest = _read_starlark_manifest() + apps = manifest.get('apps', {}) + installed_count = len(apps) + enabled_count = sum(1 for a in apps.values() if a.get('enabled', True)) + + return jsonify({ + 'status': 'success', + 'pixlet_available': pixlet_available, + 'pixlet_version': None, + 'installed_apps': installed_count, + 'enabled_apps': enabled_count, + 'plugin_enabled': True, + 'plugin_loaded': False, + 'display_info': {} + }) + + except Exception as e: + logger.exception("[Starlark] get_starlark_status failed") + return jsonify({'status': 'error', 'message': 'Failed to get Starlark status'}), 500 + +@api_v3.route('/starlark/install-pixlet', methods=['POST']) +def install_pixlet(): + """Download and install Pixlet binary.""" + try: + script_path = PROJECT_ROOT / 'scripts' / 'download_pixlet.sh' + if not script_path.exists(): + return jsonify({'status': 'error', 'message': 'Installation script not found'}), 404 + + os.chmod(script_path, 0o755) + + result = subprocess.run( + [str(script_path)], + cwd=str(PROJECT_ROOT), + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode == 0: + logger.info("Pixlet downloaded successfully") + return jsonify({'status': 'success', 'message': 'Pixlet installed successfully!', 'output': result.stdout}) + else: + return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500 + + except subprocess.TimeoutExpired: + return jsonify({'status': 'error', 'message': 'Download timed out'}), 500 + except Exception as e: + logger.exception("[Starlark] install_pixlet failed") + return jsonify({'status': 'error', 'message': 'Failed to install Pixlet'}), 500 From 253869043584e8bc44beaa9f593044d5528930f5 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 7 Sep 2026 14:14:23 -0400 Subject: [PATCH 2/7] fix(starlark): restore the remaining eleven routes, so the app store works The Pixlet button was the reported symptom; the app store is dead the same way. #330 dropped all thirteen Starlark routes, and browse, categories and install are what the store page is built on -- each answering the generic 404, which from the UI is indistinguishable from an empty store. Restores the other eleven from 1c4d5c52^: apps list and detail, delete, per-app config get/put, toggle, render, manual .star upload, and the Tronbyte repository browse/categories/install. Their dependency closure came with them (8 helpers, resolved by walking the handlers' references rather than by eye), plus the Tuple and Type imports the old file had. Two changes rather than a straight revert. The restored handlers used `request.get_json()` where the file has since standardised on `silent=True`: without it Werkzeug raises on a bodyless POST before the handler's own `if not data` guard runs, so the caller gets a framework error instead of the declared 400. test_api_v3_optional_body.py already checks for exactly that and caught all three. test_every_endpoint_the_frontend_calls_is_registered reads the URLs out of plugins_manager.js and matches each through the URL map, so a rewrite of this file cannot quietly drop the set again -- one assertion over the frontend's own list is what would have caught #330. 26 tests, 17 of which fail against main. Full core suite: 3973 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .../test_starlark_pixlet_routes.py | 94 +- web_interface/blueprints/api_v3.py | 873 +++++++++++++++++- 2 files changed, 965 insertions(+), 2 deletions(-) diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py index 8a82d7e7..fa2d6878 100644 --- a/test/web_interface/test_starlark_pixlet_routes.py +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -29,11 +29,27 @@ def client(): class TestRoutesAreRegistered: - """The failure was a missing route, so check the URL map directly.""" + """The failure was a missing route, so check the URL map directly. + + All thirteen, not just the two the Pixlet button needs: #330 dropped the + lot, and the app store page is built on repository/browse, + repository/categories and repository/install, which 404 the same way. + """ @pytest.mark.parametrize("rule,method", [ ("/api/v3/starlark/install-pixlet", "POST"), ("/api/v3/starlark/status", "GET"), + ("/api/v3/starlark/apps", "GET"), + ("/api/v3/starlark/upload", "POST"), + ("/api/v3/starlark/repository/browse", "GET"), + ("/api/v3/starlark/repository/categories", "GET"), + ("/api/v3/starlark/repository/install", "POST"), + ("/api/v3/starlark/apps/", "GET"), + ("/api/v3/starlark/apps/", "DELETE"), + ("/api/v3/starlark/apps//config", "GET"), + ("/api/v3/starlark/apps//config", "PUT"), + ("/api/v3/starlark/apps//toggle", "POST"), + ("/api/v3/starlark/apps//render", "POST"), ]) def test_route_exists(self, rule, method): from web_interface.app import app @@ -107,3 +123,79 @@ def test_download_pixlet_script_exists_and_is_executable(self): script = Path(PROJECT_ROOT) / 'scripts' / 'download_pixlet.sh' assert script.is_file(), f"{script} is missing; install_pixlet would 404" assert os.access(script, os.R_OK) + + +class TestTheAppStoreFlow: + """Browsing and installing from the Tronbyte repository. + + These are the calls the app store page makes. Each returned the generic + "Resource not found" before this change, which is indistinguishable from + an empty store. + """ + + def test_browse_does_not_404(self, client): + resp = client.get('/api/v3/starlark/repository/browse') + assert resp.status_code != 404, "the store cannot list anything" + assert resp.get_json().get('message') != 'Resource not found' + + def test_categories_does_not_404(self, client): + resp = client.get('/api/v3/starlark/repository/categories') + assert resp.status_code != 404 + assert resp.get_json().get('message') != 'Resource not found' + + def test_installed_apps_list_does_not_404(self, client): + resp = client.get('/api/v3/starlark/apps') + assert resp.status_code != 404 + assert resp.get_json().get('message') != 'Resource not found' + + def test_repository_install_rejects_a_missing_body_rather_than_404ing(self, client): + # A 400/422 here is the route working: it received the call and said + # what was wrong. A 404 means it was never reached at all. + resp = client.post('/api/v3/starlark/repository/install', + json={}, content_type='application/json') + assert resp.status_code != 404, "the install route is still missing" + assert resp.get_json().get('message') != 'Resource not found' + + def test_upload_rejects_an_empty_post_rather_than_404ing(self, client): + resp = client.post('/api/v3/starlark/upload') + assert resp.status_code != 404 + assert resp.get_json().get('message') != 'Resource not found' + + +class TestNoStarlarkRouteIsMissing: + """A single check that the whole set is present. + + #330 removed all thirteen at once by rewriting this file. One assertion + over the frontend's own list is what would have caught that. + """ + + def test_every_endpoint_the_frontend_calls_is_registered(self): + import re + from pathlib import Path + from werkzeug.exceptions import MethodNotAllowed, NotFound + from web_interface.app import app + + root = Path(__file__).resolve().parent.parent.parent + js = (root / 'web_interface' / 'static' / 'v3' / 'plugins_manager.js').read_text() + + # The frontend builds some of these with template literals, e.g. + # `/api/v3/starlark/apps/${appId}/toggle`. Substitute a placeholder so + # the URL is concrete, then let Werkzeug match it the way a request + # would -- string comparison cannot see rules. + raw = set(re.findall(r"[\'\"`](/api/v3/starlark/[^\'\"`\s]*)", js)) + urls = set() + for u in raw: + u = re.sub(r"\$\{[^}]*\}", "probe", u) + urls.add(u.rstrip('/') or u) + assert urls, "found no starlark calls in the frontend -- did the file move?" + + adapter = app.url_map.bind('localhost') + missing = [] + for u in sorted(urls): + try: + adapter.match(u, method='GET') + except MethodNotAllowed: + pass # route exists, just not for GET -- fine + except NotFound: + missing.append(u) + assert not missing, f"the frontend calls these and they are not registered: {missing}" diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 74e940e3..9dfd7f8e 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -13,7 +13,7 @@ import logging from datetime import datetime from pathlib import Path -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Tuple, Type from urllib.parse import urlparse, urlunparse logger = logging.getLogger(__name__) @@ -8796,3 +8796,874 @@ def install_pixlet(): except Exception as e: logger.exception("[Starlark] install_pixlet failed") return jsonify({'status': 'error', 'message': 'Failed to install Pixlet'}), 500 + + +# The remaining eleven routes #330 dropped, restored the same way: apps CRUD, +# per-app config and toggle, render, manual .star upload, and the Tronbyte +# repository browse/categories/install the app store page is built on. Without +# these the store lists nothing and installing anything answers with the same +# generic 404 the Pixlet button did. + +def _get_tronbyte_repository_class() -> Type[Any]: + """Import TronbyteRepository from plugin-repos directory.""" + import importlib.util + import importlib + + module_path = PROJECT_ROOT / 'plugin-repos' / 'starlark-apps' / 'tronbyte_repository.py' + if not module_path.exists(): + raise ImportError(f"TronbyteRepository module not found at {module_path}") + + # If already imported, return cached class + if "tronbyte_repository" in sys.modules: + return sys.modules["tronbyte_repository"].TronbyteRepository + + spec = importlib.util.spec_from_file_location("tronbyte_repository", str(module_path)) + if spec is None: + raise ImportError(f"Failed to create module spec for tronbyte_repository at {module_path}") + + module = importlib.util.module_from_spec(spec) + if module is None: + raise ImportError("Failed to create module from spec for tronbyte_repository") + + sys.modules["tronbyte_repository"] = module + spec.loader.exec_module(module) + return module.TronbyteRepository + +def _get_pixlet_renderer_class() -> Type[Any]: + """Import PixletRenderer from plugin-repos directory.""" + import importlib.util + import importlib + + module_path = PROJECT_ROOT / 'plugin-repos' / 'starlark-apps' / 'pixlet_renderer.py' + if not module_path.exists(): + raise ImportError(f"PixletRenderer module not found at {module_path}") + + # If already imported, return cached class + if "pixlet_renderer" in sys.modules: + return sys.modules["pixlet_renderer"].PixletRenderer + + spec = importlib.util.spec_from_file_location("pixlet_renderer", str(module_path)) + if spec is None: + raise ImportError(f"Failed to create module spec for pixlet_renderer at {module_path}") + + module = importlib.util.module_from_spec(spec) + if module is None: + raise ImportError("Failed to create module from spec for pixlet_renderer") + + sys.modules["pixlet_renderer"] = module + spec.loader.exec_module(module) + return module.PixletRenderer + +def _validate_and_sanitize_app_id(app_id: Optional[str], fallback_source: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]: + """Validate and sanitize app_id to a safe slug.""" + if not app_id and fallback_source: + app_id = fallback_source + if not app_id: + return None, "app_id is required" + if '..' in app_id or '/' in app_id or '\\' in app_id: + return None, "app_id contains invalid characters" + + sanitized = re.sub(r'[^a-z0-9_]', '_', app_id.lower()).strip('_') + if not sanitized: + sanitized = f"app_{hashlib.sha256(app_id.encode()).hexdigest()[:12]}" + if sanitized[0].isdigit(): + sanitized = f"app_{sanitized}" + return sanitized, None + +def _validate_timing_value(value: Any, field_name: str, min_val: int = 1, max_val: int = 86400) -> Tuple[Optional[int], Optional[str]]: + """Validate and coerce timing values.""" + if value is None: + return None, None + try: + int_value = int(value) + except (ValueError, TypeError): + return None, f"{field_name} must be an integer" + if int_value < min_val: + return None, f"{field_name} must be at least {min_val}" + if int_value > max_val: + return None, f"{field_name} must be at most {max_val}" + return int_value, None + +def _validate_starlark_app_path(app_id: str) -> Tuple[bool, Optional[str]]: + """ + Validate app_id for path traversal attacks before filesystem access. + + Args: + app_id: App identifier from user input + + Returns: + Tuple of (is_valid, error_message) + """ + # Check for path traversal characters + if '..' in app_id or '/' in app_id or '\\' in app_id: + return False, f"Invalid app_id: contains path traversal characters" + + # Construct and resolve the path + try: + app_path = (_STARLARK_APPS_DIR / app_id).resolve() + base_path = _STARLARK_APPS_DIR.resolve() + + # Verify the resolved path is within the base directory + try: + app_path.relative_to(base_path) + return True, None + except ValueError: + return False, f"Invalid app_id: path traversal attempt" + except Exception as e: + logger.warning(f"Path validation error for app_id '{app_id}': {e}") + return False, f"Invalid app_id" + +def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[str]]: + """Render a Starlark app via pixlet directly (no plugin required). + + Reads the .star file and config from starlark-apps/{app_id}/, runs pixlet, + and saves the output to cached_render.webp in the same directory. + This is the web-service fallback when starlark-apps plugin is not loaded. + + Returns (success, http_status_code, error_message). + """ + manifest = _read_starlark_manifest() + if not isinstance(manifest, dict): + return False, 400, "Invalid manifest shape: expected object with 'apps' mapping" + apps = manifest.get('apps', {}) + if not isinstance(apps, dict): + return False, 400, "Invalid manifest shape: expected object with 'apps' mapping" + app_data = apps.get(app_id) + if not app_data: + return False, 404, f"App not found: {app_id}" + + app_dir = _STARLARK_APPS_DIR / app_id + star_file = app_dir / app_data.get('star_file', f'{app_id}.star') + if not star_file.exists(): + return False, 404, f"Star file not found: {star_file}" + + full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + plugin_config = full_config.get('starlark-apps', {}) + + pixlet_path = _find_pixlet_binary(plugin_config.get('pixlet_path')) + if not pixlet_path: + return False, 503, "Pixlet binary not found — install pixlet first" + + magnify = plugin_config.get('magnify') + if magnify is None: + hw = full_config.get('display', {}).get('hardware', {}) + cols = hw.get('cols', 64) + chain = hw.get('chain_length', 1) + rows = hw.get('rows', 32) + magnify = max(1, min(8, int(min((cols * chain) / 64, rows / 32)))) + else: + try: + magnify = max(1, min(8, int(magnify))) + except (ValueError, TypeError): + magnify = 1 + + config_file = app_dir / 'config.json' + app_config: Dict[str, Any] = {} + if config_file.exists(): + try: + with open(config_file) as f: + app_config = json.load(f) + except json.JSONDecodeError as e: + return False, 400, f"Invalid config.json for {app_id} ({config_file}): {e}" + except OSError as e: + return False, 400, f"Cannot read config.json for {app_id} ({config_file}): {e}" + if not isinstance(app_config, dict): + return False, 400, ( + f"config.json for {app_id} must be a JSON object, " + f"got {type(app_config).__name__}" + ) + + INTERNAL_KEYS = {'render_interval', 'display_duration'} + pixlet_config = {k: v for k, v in app_config.items() if k not in INTERNAL_KEYS} + + output_path = str(app_dir / 'cached_render.webp') + cmd = [pixlet_path, 'render', str(star_file)] + for key, value in pixlet_config.items(): + if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key): + continue + value_str = 'true' if value is True else 'false' if value is False else str(value) + if re.search(r'[`$|<>&;\x00]|\$\(', value_str): + continue + cmd.append(f'{key}={value_str}') + cmd.extend(['-o', output_path, '-m', str(magnify)]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=str(app_dir)) + if result.returncode == 0 and os.path.isfile(output_path): + return True, 200, None + return False, 502, f"Pixlet failed (exit {result.returncode}): {result.stderr.strip()}" + except subprocess.TimeoutExpired: + return False, 504, "Render timed out after 30s" + except Exception as e: + return False, 500, f"Render error: {e}" + +def _write_starlark_manifest(manifest: Dict[str, Any]) -> bool: + """Write the starlark-apps manifest.json to disk with atomic write.""" + temp_file = None + try: + _STARLARK_APPS_DIR.mkdir(parents=True, exist_ok=True) + + # Atomic write pattern: write to temp file, then rename + temp_file = _STARLARK_MANIFEST_FILE.with_suffix('.tmp') + with open(temp_file, 'w') as f: + json.dump(manifest, f, indent=2) + f.flush() + os.fsync(f.fileno()) # Ensure data is written to disk + + # Atomic rename (overwrites destination) + temp_file.replace(_STARLARK_MANIFEST_FILE) + return True + except OSError as e: + logger.error(f"Error writing starlark manifest: {e}") + # Clean up temp file if it exists + if temp_file and temp_file.exists(): + try: + temp_file.unlink() + except Exception: + pass + return False + +def _install_star_file(app_id: str, star_file_path: str, metadata: Dict[str, Any], assets_dir: Optional[str] = None) -> bool: + """Install a .star file and update the manifest (standalone, no plugin needed).""" + import shutil + import json + app_dir = _STARLARK_APPS_DIR / app_id + app_dir.mkdir(parents=True, exist_ok=True) + dest = app_dir / f"{app_id}.star" + shutil.copy2(star_file_path, str(dest)) + + # Copy asset directories if provided (images/, sources/, etc.) + if assets_dir and Path(assets_dir).exists(): + assets_path = Path(assets_dir) + for item in assets_path.iterdir(): + if item.is_dir(): + # Copy entire directory (e.g., images/, sources/) + dest_dir = app_dir / item.name + if dest_dir.exists(): + shutil.rmtree(dest_dir) + shutil.copytree(item, dest_dir) + logger.debug(f"Copied assets directory: {item.name}") + logger.info(f"Installed assets for {app_id}") + + # Try to extract schema using PixletRenderer + schema = None + try: + PixletRenderer = _get_pixlet_renderer_class() + pixlet = PixletRenderer() + if pixlet.is_available(): + _, schema, _ = pixlet.extract_schema(str(dest)) + if schema: + schema_path = app_dir / "schema.json" + with open(schema_path, 'w') as f: + json.dump(schema, f, indent=2) + logger.info(f"Extracted schema for {app_id}") + except Exception as e: + logger.warning(f"Failed to extract schema for {app_id}: {e}") + + # Create default config — pre-populate with schema defaults + default_config = {} + if schema: + fields = schema.get('fields') or schema.get('schema') or [] + for field in fields: + if isinstance(field, dict) and 'id' in field and 'default' in field: + default_config[field['id']] = field['default'] + + # Create config.json file + config_path = app_dir / "config.json" + with open(config_path, 'w') as f: + json.dump(default_config, f, indent=2) + + manifest = _read_starlark_manifest() + manifest.setdefault('apps', {})[app_id] = { + 'name': metadata.get('name', app_id), + 'enabled': True, + 'render_interval': metadata.get('render_interval', 300), + 'display_duration': metadata.get('display_duration', 15), + 'config': metadata.get('config', {}), + 'star_file': str(dest), + } + return _write_starlark_manifest(manifest) + +@api_v3.route('/starlark/apps', methods=['GET']) +def get_starlark_apps(): + """List all installed Starlark apps.""" + try: + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + apps_list = [] + for app_id, app_instance in starlark_plugin.apps.items(): + apps_list.append({ + 'id': app_id, + 'name': app_instance.manifest.get('name', app_id), + 'enabled': app_instance.is_enabled(), + 'has_frames': app_instance.frames is not None, + 'render_interval': app_instance.get_render_interval(), + 'display_duration': app_instance.get_display_duration(), + 'config': app_instance.config, + 'has_schema': app_instance.schema is not None, + 'last_render_time': app_instance.last_render_time + }) + return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)}) + + # Standalone: read manifest from disk + manifest = _read_starlark_manifest() + apps_list = [] + for app_id, app_data in manifest.get('apps', {}).items(): + apps_list.append({ + 'id': app_id, + 'name': app_data.get('name', app_id), + 'enabled': app_data.get('enabled', True), + 'has_frames': False, + 'render_interval': app_data.get('render_interval', 300), + 'display_duration': app_data.get('display_duration', 15), + 'config': app_data.get('config', {}), + 'has_schema': False, + 'last_render_time': None + }) + return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)}) + + except Exception as e: + logger.exception("[Starlark] get_starlark_apps failed") + return jsonify({'status': 'error', 'message': 'Failed to get Starlark apps'}), 500 + +@api_v3.route('/starlark/apps/', methods=['GET']) +def get_starlark_app(app_id): + """Get details for a specific Starlark app.""" + try: + # Validate app_id before any filesystem access + is_valid, error_msg = _validate_starlark_app_path(app_id) + if not is_valid: + return jsonify({'status': 'error', 'message': error_msg}), 400 + + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + return jsonify({ + 'status': 'success', + 'app': { + 'id': app_id, + 'name': app.manifest.get('name', app_id), + 'enabled': app.is_enabled(), + 'config': app.config, + 'schema': app.schema, + 'render_interval': app.get_render_interval(), + 'display_duration': app.get_display_duration(), + 'has_frames': app.frames is not None, + 'frame_count': len(app.frames) if app.frames else 0, + 'last_render_time': app.last_render_time, + } + }) + + # Standalone: read from manifest + manifest = _read_starlark_manifest() + app_data = manifest.get('apps', {}).get(app_id) + if not app_data: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + # Load schema from schema.json if it exists (path already validated above) + schema = None + schema_file = _STARLARK_APPS_DIR / app_id / 'schema.json' + if schema_file.exists(): + try: + with open(schema_file, 'r') as f: + schema = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.warning(f"Failed to load schema for {app_id}: {e}") + + return jsonify({ + 'status': 'success', + 'app': { + 'id': app_id, + 'name': app_data.get('name', app_id), + 'enabled': app_data.get('enabled', True), + 'config': app_data.get('config', {}), + 'schema': schema, + 'render_interval': app_data.get('render_interval', 300), + 'display_duration': app_data.get('display_duration', 15), + 'has_frames': False, + 'frame_count': 0, + 'last_render_time': None, + } + }) + + except Exception as e: + logger.exception("[Starlark] get_starlark_app failed") + return jsonify({'status': 'error', 'message': 'Failed to get Starlark app'}), 500 + +@api_v3.route('/starlark/upload', methods=['POST']) +def upload_starlark_app(): + """Upload and install a new Starlark app.""" + try: + if 'file' not in request.files: + return jsonify({'status': 'error', 'message': 'No file uploaded'}), 400 + + file = request.files['file'] + if not file.filename or not file.filename.endswith('.star'): + return jsonify({'status': 'error', 'message': 'File must have .star extension'}), 400 + + # Check file size (limit to 5MB for .star files) + file.seek(0, 2) # Seek to end + file_size = file.tell() + file.seek(0) # Reset to beginning + MAX_STAR_SIZE = 5 * 1024 * 1024 # 5MB + if file_size > MAX_STAR_SIZE: + return jsonify({'status': 'error', 'message': f'File too large (max 5MB, got {file_size/1024/1024:.1f}MB)'}), 400 + + app_name = request.form.get('name') + app_id_input = request.form.get('app_id') + filename_base = file.filename.replace('.star', '') if file.filename else None + app_id, app_id_error = _validate_and_sanitize_app_id(app_id_input, fallback_source=filename_base) + if app_id_error: + return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400 + + render_interval_input = request.form.get('render_interval') + render_interval = 300 + if render_interval_input is not None: + render_interval, err = _validate_timing_value(render_interval_input, 'render_interval') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + render_interval = render_interval or 300 + + display_duration_input = request.form.get('display_duration') + display_duration = 15 + if display_duration_input is not None: + display_duration, err = _validate_timing_value(display_duration_input, 'display_duration') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + display_duration = display_duration or 15 + + import tempfile + with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp: + file.save(tmp.name) + temp_path = tmp.name + + try: + metadata = {'name': app_name or app_id, 'render_interval': render_interval, 'display_duration': display_duration} + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + success = starlark_plugin.install_app(app_id, temp_path, metadata) + else: + success = _install_star_file(app_id, temp_path, metadata) + if success: + return jsonify({'status': 'success', 'message': f'App installed: {app_id}', 'app_id': app_id}) + else: + return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500 + finally: + try: + os.unlink(temp_path) + except OSError: + pass + + except (OSError, IOError) as err: + logger.exception("[Starlark] File error uploading starlark app: %s", err) + return jsonify({'status': 'error', 'message': f'File error during upload: {err}'}), 500 + except ImportError as err: + logger.exception("[Starlark] Module load error uploading starlark app: %s", err) + return jsonify({'status': 'error', 'message': f'Failed to load app module: {err}'}), 500 + except Exception as err: + logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err) + return jsonify({'status': 'error', 'message': 'Failed to upload app'}), 500 + +@api_v3.route('/starlark/apps/', methods=['DELETE']) +def uninstall_starlark_app(app_id): + """Uninstall a Starlark app.""" + try: + # Validate app_id before any filesystem access + is_valid, error_msg = _validate_starlark_app_path(app_id) + if not is_valid: + return jsonify({'status': 'error', 'message': error_msg}), 400 + + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + success = starlark_plugin.uninstall_app(app_id) + else: + # Standalone: remove app dir and manifest entry (path already validated) + import shutil + app_dir = _STARLARK_APPS_DIR / app_id + + if app_dir.exists(): + shutil.rmtree(app_dir) + manifest = _read_starlark_manifest() + manifest.get('apps', {}).pop(app_id, None) + success = _write_starlark_manifest(manifest) + + if success: + return jsonify({'status': 'success', 'message': f'App uninstalled: {app_id}'}) + else: + return jsonify({'status': 'error', 'message': 'Failed to uninstall app'}), 500 + + except Exception as e: + logger.exception("[Starlark] uninstall_starlark_app failed") + return jsonify({'status': 'error', 'message': 'Failed to uninstall Starlark app'}), 500 + +@api_v3.route('/starlark/apps//config', methods=['GET']) +def get_starlark_app_config(app_id): + """Get configuration for a Starlark app.""" + try: + # Validate app_id before any filesystem access + is_valid, error_msg = _validate_starlark_app_path(app_id) + if not is_valid: + return jsonify({'status': 'error', 'message': error_msg}), 400 + + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + return jsonify({'status': 'success', 'config': app.config, 'schema': app.schema}) + + # Standalone: read from config.json file (path already validated) + app_dir = _STARLARK_APPS_DIR / app_id + config_file = app_dir / "config.json" + + if not app_dir.exists(): + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + config = {} + if config_file.exists(): + try: + with open(config_file, 'r') as f: + config = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.warning(f"Failed to load config for {app_id}: {e}") + + # Load schema from schema.json + schema = None + schema_file = app_dir / "schema.json" + if schema_file.exists(): + try: + with open(schema_file, 'r') as f: + schema = json.load(f) + except Exception as e: + logger.warning(f"Failed to load schema for {app_id}: {e}") + + return jsonify({'status': 'success', 'config': config, 'schema': schema}) + + except Exception as e: + logger.exception("[Starlark] get_starlark_app_config failed") + return jsonify({'status': 'error', 'message': 'Failed to get Starlark app config'}), 500 + +@api_v3.route('/starlark/apps//config', methods=['PUT']) +def update_starlark_app_config(app_id): + """Update configuration for a Starlark app.""" + try: + # Validate app_id before any filesystem access + is_valid, error_msg = _validate_starlark_app_path(app_id) + if not is_valid: + return jsonify({'status': 'error', 'message': error_msg}), 400 + + data = request.get_json(silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No configuration provided'}), 400 + + if 'render_interval' in data: + val, err = _validate_timing_value(data['render_interval'], 'render_interval') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + data['render_interval'] = val + + if 'display_duration' in data: + val, err = _validate_timing_value(data['display_duration'], 'display_duration') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + data['display_duration'] = val + + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + # Extract timing keys from data before updating config (they belong in manifest, not config) + render_interval = data.pop('render_interval', None) + display_duration = data.pop('display_duration', None) + + # Update config with non-timing fields only + app.config.update(data) + + # Update manifest with timing fields + timing_changed = False + if render_interval is not None: + app.manifest['render_interval'] = render_interval + timing_changed = True + if display_duration is not None: + app.manifest['display_duration'] = display_duration + timing_changed = True + if app.save_config(): + # Persist manifest if timing changed (same pattern as toggle endpoint) + if timing_changed: + try: + # Use safe manifest update to prevent race conditions + timing_updates = {} + if render_interval is not None: + timing_updates['render_interval'] = render_interval + if display_duration is not None: + timing_updates['display_duration'] = display_duration + + def update_fn(manifest): + manifest['apps'][app_id].update(timing_updates) + starlark_plugin._update_manifest_safe(update_fn) + except Exception as e: + logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}") + starlark_plugin._render_app(app, force=True) + return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config}) + else: + return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500 + + # Standalone: update both config.json and manifest + manifest = _read_starlark_manifest() + app_data = manifest.get('apps', {}).get(app_id) + if not app_data: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + # Extract timing keys (they go in manifest, not config.json) + render_interval = data.pop('render_interval', None) + display_duration = data.pop('display_duration', None) + + # Update manifest with timing values + if render_interval is not None: + app_data['render_interval'] = render_interval + if display_duration is not None: + app_data['display_duration'] = display_duration + + # Load current config from config.json + app_dir = _STARLARK_APPS_DIR / app_id + config_file = app_dir / "config.json" + current_config = {} + if config_file.exists(): + try: + with open(config_file, 'r') as f: + current_config = json.load(f) + except Exception as e: + logger.warning(f"Failed to load config for {app_id}: {e}") + + # Update config with new values (excluding timing keys) + current_config.update(data) + + # Write updated config to config.json + try: + with open(config_file, 'w') as f: + json.dump(current_config, f, indent=2) + except Exception as e: + logger.error(f"Failed to save config.json for {app_id}: {e}") + return jsonify({'status': 'error', 'message': f'Failed to save configuration: {e}'}), 500 + + # Also update manifest for backward compatibility + app_data.setdefault('config', {}).update(data) + + if _write_starlark_manifest(manifest): + return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config}) + else: + return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500 + + except Exception as e: + logger.exception("[Starlark] update_starlark_app_config failed") + return jsonify({'status': 'error', 'message': 'Failed to update Starlark app config'}), 500 + +@api_v3.route('/starlark/apps//toggle', methods=['POST']) +def toggle_starlark_app(app_id): + """Enable or disable a Starlark app.""" + try: + data = request.get_json(silent=True) or {} + + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + enabled = data.get('enabled') + if enabled is None: + enabled = not app.is_enabled() + app.manifest['enabled'] = enabled + # Use safe manifest update to prevent race conditions + def update_fn(manifest): + manifest['apps'][app_id]['enabled'] = enabled + starlark_plugin._update_manifest_safe(update_fn) + return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled}) + + # Standalone: update manifest directly + manifest = _read_starlark_manifest() + app_data = manifest.get('apps', {}).get(app_id) + if not app_data: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + enabled = data.get('enabled') + if enabled is None: + enabled = not app_data.get('enabled', True) + app_data['enabled'] = enabled + if _write_starlark_manifest(manifest): + return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled}) + else: + return jsonify({'status': 'error', 'message': 'Failed to save'}), 500 + + except Exception as e: + logger.exception("[Starlark] toggle_starlark_app failed") + return jsonify({'status': 'error', 'message': 'Failed to toggle Starlark app'}), 500 + +@api_v3.route('/starlark/apps//render', methods=['POST']) +def render_starlark_app(app_id): + """Force render a Starlark app.""" + try: + is_valid, err = _validate_starlark_app_path(app_id) + if not is_valid: + return jsonify({'status': 'error', 'message': err}), 400 + + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + success = starlark_plugin._render_app(app, force=True) + if success: + return jsonify({'status': 'success', 'message': 'App rendered', + 'frame_count': len(app.frames) if app.frames else 0}) + return jsonify({'status': 'error', 'message': 'Failed to render app'}), 500 + + # Web-service context: plugin not loaded, call pixlet directly + success, status_code, error = _standalone_render_starlark_app(app_id) + if success: + return jsonify({'status': 'success', 'message': 'App rendered successfully', 'frame_count': 0}), status_code + return jsonify({'status': 'error', 'message': error or 'Render failed', 'frame_count': 0}), status_code + + except Exception as e: + logger.exception("[Starlark] render_starlark_app failed") + return jsonify({'status': 'error', 'message': 'Failed to render Starlark app'}), 500 + +@api_v3.route('/starlark/repository/browse', methods=['GET']) +def browse_tronbyte_repository(): + """Browse all apps in the Tronbyte repository (bulk cached fetch). + + Returns ALL apps with metadata, categories, and authors. + Filtering/sorting/pagination is handled client-side. + Results are cached server-side for 2 hours. + """ + try: + TronbyteRepository = _get_tronbyte_repository_class() + + config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + github_token = config.get('github_token') + repo = TronbyteRepository(github_token=github_token) + + result = repo.list_all_apps_cached() + + rate_limit = repo.get_rate_limit_info() + + return jsonify({ + 'status': 'success', + 'apps': result['apps'], + 'categories': result['categories'], + 'authors': result['authors'], + 'count': result['count'], + 'cached': result['cached'], + 'rate_limit': rate_limit, + }) + + except Exception as e: + logger.exception("[Starlark] browse_tronbyte_repository failed") + return jsonify({'status': 'error', 'message': 'Failed to browse repository'}), 500 + +@api_v3.route('/starlark/repository/install', methods=['POST']) +def install_from_tronbyte_repository(): + """Install an app from the Tronbyte repository.""" + try: + data = request.get_json(silent=True) + if not data or 'app_id' not in data: + return jsonify({'status': 'error', 'message': 'app_id is required'}), 400 + + app_id, app_id_error = _validate_and_sanitize_app_id(data['app_id']) + if app_id_error: + return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400 + + TronbyteRepository = _get_tronbyte_repository_class() + import tempfile + + config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + github_token = config.get('github_token') + repo = TronbyteRepository(github_token=github_token) + + success, metadata, error = repo.get_app_metadata(data['app_id']) + if not success: + return jsonify({'status': 'error', 'message': f'Failed to fetch app metadata: {error}'}), 404 + + with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp: + temp_path = tmp.name + + try: + # Pass filename from metadata (e.g., "analog_clock.star" for analogclock app) + # Note: manifest uses 'fileName' (camelCase), not 'filename' + filename = metadata.get('fileName') if metadata else None + success, error = repo.download_star_file(data['app_id'], Path(temp_path), filename=filename) + if not success: + return jsonify({'status': 'error', 'message': f'Failed to download app: {error}'}), 500 + + # Download assets (images, sources, etc.) to a temp directory + import tempfile + temp_assets_dir = tempfile.mkdtemp() + try: + success_assets, error_assets = repo.download_app_assets(data['app_id'], Path(temp_assets_dir)) + # Asset download is non-critical - log warning but continue if it fails + if not success_assets: + logger.warning(f"Failed to download assets for {data['app_id']}: {error_assets}") + + render_interval = data.get('render_interval', 300) + ri, err = _validate_timing_value(render_interval, 'render_interval') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + render_interval = ri or 300 + + display_duration = data.get('display_duration', 15) + dd, err = _validate_timing_value(display_duration, 'display_duration') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + display_duration = dd or 15 + + install_metadata = { + 'name': metadata.get('name', app_id) if metadata else app_id, + 'render_interval': render_interval, + 'display_duration': display_duration + } + + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + success = starlark_plugin.install_app(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir) + else: + success = _install_star_file(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir) + finally: + # Clean up temp assets directory + import shutil + try: + shutil.rmtree(temp_assets_dir) + except OSError: + pass + + if success: + return jsonify({'status': 'success', 'message': f'App installed: {metadata.get("name", app_id) if metadata else app_id}', 'app_id': app_id}) + else: + return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500 + finally: + try: + os.unlink(temp_path) + except OSError: + pass + + except Exception as e: + logger.exception("[Starlark] install_from_tronbyte_repository failed") + return jsonify({'status': 'error', 'message': 'Failed to install from repository'}), 500 + +@api_v3.route('/starlark/repository/categories', methods=['GET']) +def get_tronbyte_categories(): + """Get list of available app categories (uses bulk cache).""" + try: + TronbyteRepository = _get_tronbyte_repository_class() + config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + repo = TronbyteRepository(github_token=config.get('github_token')) + + result = repo.list_all_apps_cached() + + return jsonify({'status': 'success', 'categories': result['categories']}) + + except Exception as e: + logger.exception("[Starlark] get_tronbyte_categories failed") + return jsonify({'status': 'error', 'message': 'Failed to fetch categories'}), 500 From 2a92f1ab3bad3bd597acc2bb6ae54dd6f499fc65 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 7 Sep 2026 14:31:40 -0400 Subject: [PATCH 3/7] fix(starlark): show installed apps with the other plugins, and let them toggle An app installed from the store appeared nowhere and could not be enabled or disabled. Same cause as the 404s: #253 surfaced installed apps in /plugins/installed as `starlark:` entries and routed `starlark:` toggles to the Starlark manifest, and #330 removed both along with the routes. Without the first, the app store installs successfully into a list nothing renders. Without the second, toggling one falls through to the plugin_manager lookup and answers "Plugin not found" -- a Starlark app is an entry in starlark-apps' own manifest, not a plugin in that sense. Restored as two named helpers rather than the original inline blocks: _starlark_virtual_plugins() reads the loaded plugin when there is one and the on-disk manifest otherwise, so the list is right before starlark-apps loads _toggle_starlark_app() updates through the plugin's own _update_manifest_safe when loaded, or the manifest directly when not Two changes on the original. The toggle now runs app_id through _validate_and_sanitize_app_id first -- it reaches a filesystem manifest and had no validation where the other Starlark routes all have it. And _starlark_virtual_plugins swallows its own failures: the entries are appended to the real plugin list, and a broken Starlark manifest should cost the Starlark rows, not empty the plugins page. 6 new tests covering listing, the fields the UI keys on, toggle persistence, the unknown-app 404, traversal rejection, and that a Starlark failure leaves the rest of the list intact. Full core suite: 3979 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .../test_starlark_pixlet_routes.py | 71 ++++++++++++++++ web_interface/blueprints/api_v3.py | 84 +++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py index fa2d6878..12697975 100644 --- a/test/web_interface/test_starlark_pixlet_routes.py +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -199,3 +199,74 @@ def test_every_endpoint_the_frontend_calls_is_registered(self): except NotFound: missing.append(u) assert not missing, f"the frontend calls these and they are not registered: {missing}" + + +class TestInstalledAppsAppearWithTheOtherPlugins: + """An installed .star app must be manageable like any other plugin. + + #253 surfaced installed apps in /plugins/installed as `starlark:` + entries, so they could be seen and enabled/disabled from the same list as + everything else, and routed `starlark:` toggles to the Starlark manifest. + #330 removed both. The result was an app that installs successfully, then + appears nowhere and cannot be turned on or off. + """ + + APPS = {'apps': {'quoteoftheday': {'name': 'A Quote A Day', 'enabled': True}}} + + def test_an_installed_app_is_listed(self, client): + with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \ + patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS): + resp = client.get('/api/v3/plugins/installed') + ids = [p['id'] for p in resp.get_json()['data']['plugins']] + assert 'starlark:quoteoftheday' in ids, \ + "an installed Starlark app does not appear among the plugins" + + def test_the_entry_carries_what_the_ui_needs(self, client): + with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \ + patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS): + resp = client.get('/api/v3/plugins/installed') + entry = next(p for p in resp.get_json()['data']['plugins'] + if p['id'] == 'starlark:quoteoftheday') + assert entry['name'] == 'A Quote A Day' + assert entry['enabled'] is True + assert entry['is_starlark_app'] is True, "the UI keys its Starlark handling off this" + assert entry['category'] == 'Starlark App' + + def test_a_starlark_failure_does_not_empty_the_plugin_list(self, client): + # The virtual entries are appended to the real ones; a broken manifest + # must cost the Starlark rows, not everybody else's. + with patch('web_interface.blueprints.api_v3._get_starlark_plugin', + side_effect=RuntimeError('boom')): + resp = client.get('/api/v3/plugins/installed') + assert resp.status_code == 200 + assert resp.get_json()['status'] == 'success' + + def test_toggling_an_app_does_not_report_plugin_not_found(self, client): + written = {} + with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \ + patch('web_interface.blueprints.api_v3._read_starlark_manifest', + return_value={'apps': {'quoteoftheday': {'enabled': True}}}), \ + patch('web_interface.blueprints.api_v3._write_starlark_manifest', + side_effect=lambda m: written.update(m) or True): + resp = client.post('/api/v3/plugins/toggle', + json={'plugin_id': 'starlark:quoteoftheday', 'enabled': False}) + body = resp.get_json() + assert body['status'] == 'success', body + assert body['enabled'] is False + assert written['apps']['quoteoftheday']['enabled'] is False, \ + "the manifest was not actually updated" + + def test_toggling_an_unknown_app_says_so(self, client): + with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \ + patch('web_interface.blueprints.api_v3._read_starlark_manifest', + return_value={'apps': {}}): + resp = client.post('/api/v3/plugins/toggle', + json={'plugin_id': 'starlark:nope', 'enabled': True}) + assert resp.status_code == 404 + assert 'nope' in resp.get_json()['message'] + + def test_a_traversal_app_id_is_rejected_before_touching_the_manifest(self, client): + resp = client.post('/api/v3/plugins/toggle', + json={'plugin_id': 'starlark:../../etc/passwd', 'enabled': True}) + assert resp.status_code == 400 + assert 'invalid characters' in resp.get_json()['message'] diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 9dfd7f8e..df7fe1a3 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -2813,6 +2813,7 @@ def _build_plugin_entry_inner(plugin_info, plugin_id): with ThreadPoolExecutor(max_workers=8) as executor: results = list(executor.map(_build_plugin_entry, all_plugin_info)) plugins = [r for r in results if r is not None] + plugins.extend(_starlark_virtual_plugins()) return jsonify({'status': 'success', 'data': {'plugins': plugins}}) except Exception as e: @@ -3119,6 +3120,13 @@ def toggle_plugin(): current_enabled = config.get(plugin_id, {}).get('enabled', False) enabled = not current_enabled + # A Starlark app is not a plugin in plugin_manager's sense -- it is an + # entry in starlark-apps' own manifest -- so its enable/disable is + # handled here rather than falling through to the check below, which + # would answer "Plugin not found". + if plugin_id.startswith('starlark:'): + return _toggle_starlark_app(plugin_id[len('starlark:'):], enabled) + # Check if plugin exists in manifests (discovered but may not be loaded) if plugin_id not in api_v3.plugin_manager.plugin_manifests: return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 @@ -9667,3 +9675,79 @@ def get_tronbyte_categories(): except Exception as e: logger.exception("[Starlark] get_tronbyte_categories failed") return jsonify({'status': 'error', 'message': 'Failed to fetch categories'}), 500 + + +def _starlark_virtual_plugins() -> list: + """Installed Starlark apps, shaped like plugin entries. + + #253 surfaced these alongside real plugins so an installed .star app can + be seen, enabled and disabled from the same list as everything else; #330 + dropped it with the rest of the Starlark code, which is why an app + installs successfully and then appears nowhere. + + Reads the loaded plugin when there is one and the on-disk manifest + otherwise, so the list is right before starlark-apps has been loaded too. + """ + entries = [] + base = { + 'version': 'starlark', 'category': 'Starlark App', 'tags': ['starlark'], + 'verified': False, 'last_updated': None, 'last_commit': None, + 'last_commit_message': None, 'branch': None, 'web_ui_actions': [], + 'vegas_mode': 'fixed', 'vegas_content_type': 'multi', + 'is_starlark_app': True, + } + try: + plugin = _get_starlark_plugin() + if plugin is not None and hasattr(plugin, 'apps'): + for app_id, app in plugin.apps.items(): + m = getattr(app, 'manifest', {}) or {} + entries.append({**base, + 'id': f'starlark:{app_id}', + 'name': m.get('name', app_id), + 'author': m.get('author', 'Tronbyte Community'), + 'description': m.get('summary', 'Starlark app'), + 'enabled': app.is_enabled(), + 'loaded': True}) + return entries + + for app_id, data in (_read_starlark_manifest().get('apps', {}) or {}).items(): + entries.append({**base, + 'id': f'starlark:{app_id}', + 'name': data.get('name', app_id), + 'author': data.get('author', 'Tronbyte Community'), + 'description': data.get('summary', 'Starlark app'), + 'enabled': data.get('enabled', True), + 'loaded': False}) + except Exception: + # Never let a Starlark problem empty the whole plugins list. + logger.exception('Could not build Starlark virtual plugin entries') + return entries + + +def _toggle_starlark_app(app_id: str, enabled: bool): + """Enable or disable one Starlark app, loaded or not.""" + safe_id, err = _validate_and_sanitize_app_id(app_id) + if err: + return jsonify({'status': 'error', 'message': f'Invalid app_id: {err}'}), 400 + + plugin = _get_starlark_plugin() + if plugin is not None and safe_id in getattr(plugin, 'apps', {}): + plugin.apps[safe_id].manifest['enabled'] = enabled + + def _update(manifest): + manifest['apps'][safe_id]['enabled'] = enabled + + plugin._update_manifest_safe(_update) + else: + manifest = _read_starlark_manifest() + app_data = manifest.get('apps', {}).get(safe_id) + if not app_data: + return jsonify({'status': 'error', + 'message': f'Starlark app not found: {safe_id}'}), 404 + app_data['enabled'] = enabled + if not _write_starlark_manifest(manifest): + return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500 + + return jsonify({'status': 'success', + 'message': f"Starlark app {'enabled' if enabled else 'disabled'}", + 'enabled': enabled}) From c7de8be6f5ecac31e4b78a8769dac7c4d6723a8d Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 7 Sep 2026 14:44:43 -0400 Subject: [PATCH 4/7] refactor(starlark): let the path check be a sanitiser, not a boolean CodeQL reported 24 path-injection alerts across these handlers. The guard was effective -- traversal is blocked at every entry, verified on hardware -- but the shape was wrong in a way worth fixing rather than dismissing: _validate_starlark_app_path returned a bool, and the caller then rebuilt the path with `_STARLARK_APPS_DIR / app_id` using the raw value. Validate in one place, join in another, and the two have to stay in step by hand. A boolean is also not something CodeQL can follow, so the value it saw reaching the filesystem was the untrusted one -- the alerts were reporting the pattern accurately. It now returns the resolved, checked Path, and every caller uses that instead of re-joining. Seven join sites became zero; the only remaining construction from app_id is inside the validator itself. _standalone_render_starlark_app and _install_star_file validate for themselves too. Both are reachable independently of the handlers, and a path built from app_id should not be assembled anywhere without the check. Also stops three error paths returning exception text to the caller (CodeQL's 4 information-exposure alerts): a bad config.json answered with its absolute path and the parser's message. Logged in full, answered generically -- the same split the composer blueprint already makes. The pixlet subprocess is left as it is. It is list-form with no shell, whitelists config keys against ^[a-zA-Z_][a-zA-Z0-9_]*$ and drops any value containing shell metacharacters; "x; id", "x$(id)" and "x`id`" are all treated as data on hardware. Nothing to fix there. Behaviour is unchanged: same error strings for callers, same status codes. 32 Starlark tests and the full core suite (3979) pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- web_interface/blueprints/api_v3.py | 97 +++++++++++++++++------------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index df7fe1a3..40f52234 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -8892,34 +8892,38 @@ def _validate_timing_value(value: Any, field_name: str, min_val: int = 1, max_va return None, f"{field_name} must be at most {max_val}" return int_value, None -def _validate_starlark_app_path(app_id: str) -> Tuple[bool, Optional[str]]: +def _validate_starlark_app_path(app_id: str) -> Tuple[Optional[Path], Optional[str]]: + """The app's directory, or an error if app_id could escape the base dir. + + Returns the *resolved* path rather than a boolean, and every caller uses + what it returns instead of re-joining ``_STARLARK_APPS_DIR / app_id`` + afterwards. The old shape validated in one place and rebuilt the path in + another, which is two things that have to stay in step -- and is why + CodeQL reported twenty-four path-injection alerts across these handlers + even though the guard was effective: a boolean is not a sanitiser it can + follow, and the value reaching the filesystem was the raw one. + + The name is unchanged so the call sites read the same. """ - Validate app_id for path traversal attacks before filesystem access. + if not isinstance(app_id, str) or not app_id: + return None, "Invalid app_id" - Args: - app_id: App identifier from user input - - Returns: - Tuple of (is_valid, error_message) - """ - # Check for path traversal characters + # Reject the traversal characters outright before touching the filesystem. if '..' in app_id or '/' in app_id or '\\' in app_id: - return False, f"Invalid app_id: contains path traversal characters" + return None, "Invalid app_id: contains path traversal characters" - # Construct and resolve the path try: - app_path = (_STARLARK_APPS_DIR / app_id).resolve() base_path = _STARLARK_APPS_DIR.resolve() - - # Verify the resolved path is within the base directory + app_path = (base_path / app_id).resolve() try: app_path.relative_to(base_path) - return True, None except ValueError: - return False, f"Invalid app_id: path traversal attempt" - except Exception as e: - logger.warning(f"Path validation error for app_id '{app_id}': {e}") - return False, f"Invalid app_id" + return None, "Invalid app_id: path traversal attempt" + return app_path, None + except OSError as e: + logger.warning("Path validation error for app_id %r: %s", app_id, e) + return None, "Invalid app_id" + def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[str]]: """Render a Starlark app via pixlet directly (no plugin required). @@ -8940,7 +8944,11 @@ def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[st if not app_data: return False, 404, f"App not found: {app_id}" - app_dir = _STARLARK_APPS_DIR / app_id + # Validated here as well as at the handler: this is reachable on its own, + # and a path built from app_id should never be assembled without it. + app_dir, path_error = _validate_starlark_app_path(app_id) + if path_error: + return False, 400, path_error star_file = app_dir / app_data.get('star_file', f'{app_id}.star') if not star_file.exists(): return False, 404, f"Star file not found: {star_file}" @@ -8972,9 +8980,11 @@ def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[st with open(config_file) as f: app_config = json.load(f) except json.JSONDecodeError as e: - return False, 400, f"Invalid config.json for {app_id} ({config_file}): {e}" + logger.warning("Invalid config.json for %r at %s: %s", app_id, config_file, e) + return False, 400, f"Invalid config.json for {app_id}" except OSError as e: - return False, 400, f"Cannot read config.json for {app_id} ({config_file}): {e}" + logger.warning("Cannot read config.json for %r at %s: %s", app_id, config_file, e) + return False, 400, f"Cannot read config.json for {app_id}" if not isinstance(app_config, dict): return False, 400, ( f"config.json for {app_id} must be a JSON object, " @@ -9003,7 +9013,8 @@ def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[st except subprocess.TimeoutExpired: return False, 504, "Render timed out after 30s" except Exception as e: - return False, 500, f"Render error: {e}" + logger.exception("Starlark render failed for %r", app_id) + return False, 500, "Render error" def _write_starlark_manifest(manifest: Dict[str, Any]) -> bool: """Write the starlark-apps manifest.json to disk with atomic write.""" @@ -9035,7 +9046,10 @@ def _install_star_file(app_id: str, star_file_path: str, metadata: Dict[str, Any """Install a .star file and update the manifest (standalone, no plugin needed).""" import shutil import json - app_dir = _STARLARK_APPS_DIR / app_id + app_dir, path_error = _validate_starlark_app_path(app_id) + if path_error: + logger.warning("Refusing to install %r: %s", app_id, path_error) + return False app_dir.mkdir(parents=True, exist_ok=True) dest = app_dir / f"{app_id}.star" shutil.copy2(star_file_path, str(dest)) @@ -9139,8 +9153,8 @@ def get_starlark_app(app_id): """Get details for a specific Starlark app.""" try: # Validate app_id before any filesystem access - is_valid, error_msg = _validate_starlark_app_path(app_id) - if not is_valid: + app_dir, error_msg = _validate_starlark_app_path(app_id) + if error_msg: return jsonify({'status': 'error', 'message': error_msg}), 400 starlark_plugin = _get_starlark_plugin() @@ -9172,7 +9186,7 @@ def get_starlark_app(app_id): # Load schema from schema.json if it exists (path already validated above) schema = None - schema_file = _STARLARK_APPS_DIR / app_id / 'schema.json' + schema_file = app_dir / 'schema.json' if schema_file.exists(): try: with open(schema_file, 'r') as f: @@ -9279,18 +9293,17 @@ def uninstall_starlark_app(app_id): """Uninstall a Starlark app.""" try: # Validate app_id before any filesystem access - is_valid, error_msg = _validate_starlark_app_path(app_id) - if not is_valid: + app_dir, error_msg = _validate_starlark_app_path(app_id) + if error_msg: return jsonify({'status': 'error', 'message': error_msg}), 400 starlark_plugin = _get_starlark_plugin() if starlark_plugin: success = starlark_plugin.uninstall_app(app_id) else: - # Standalone: remove app dir and manifest entry (path already validated) + # Standalone: remove app dir and manifest entry. app_dir is the + # path _validate_starlark_app_path checked, not a fresh join. import shutil - app_dir = _STARLARK_APPS_DIR / app_id - if app_dir.exists(): shutil.rmtree(app_dir) manifest = _read_starlark_manifest() @@ -9311,8 +9324,8 @@ def get_starlark_app_config(app_id): """Get configuration for a Starlark app.""" try: # Validate app_id before any filesystem access - is_valid, error_msg = _validate_starlark_app_path(app_id) - if not is_valid: + app_dir, error_msg = _validate_starlark_app_path(app_id) + if error_msg: return jsonify({'status': 'error', 'message': error_msg}), 400 starlark_plugin = _get_starlark_plugin() @@ -9322,8 +9335,8 @@ def get_starlark_app_config(app_id): return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 return jsonify({'status': 'success', 'config': app.config, 'schema': app.schema}) - # Standalone: read from config.json file (path already validated) - app_dir = _STARLARK_APPS_DIR / app_id + # Standalone: read from config.json. app_dir is the path + # _validate_starlark_app_path checked, not a fresh join. config_file = app_dir / "config.json" if not app_dir.exists(): @@ -9358,8 +9371,8 @@ def update_starlark_app_config(app_id): """Update configuration for a Starlark app.""" try: # Validate app_id before any filesystem access - is_valid, error_msg = _validate_starlark_app_path(app_id) - if not is_valid: + app_dir, error_msg = _validate_starlark_app_path(app_id) + if error_msg: return jsonify({'status': 'error', 'message': error_msg}), 400 data = request.get_json(silent=True) @@ -9436,8 +9449,8 @@ def update_fn(manifest): if display_duration is not None: app_data['display_duration'] = display_duration - # Load current config from config.json - app_dir = _STARLARK_APPS_DIR / app_id + # Load current config from config.json. app_dir is the path + # _validate_starlark_app_path checked, not a fresh join. config_file = app_dir / "config.json" current_config = {} if config_file.exists(): @@ -9514,8 +9527,8 @@ def update_fn(manifest): def render_starlark_app(app_id): """Force render a Starlark app.""" try: - is_valid, err = _validate_starlark_app_path(app_id) - if not is_valid: + app_dir, err = _validate_starlark_app_path(app_id) + if err: return jsonify({'status': 'error', 'message': err}), 400 starlark_plugin = _get_starlark_plugin() From 431daa0b59c6c7ce8e54a8080814684dbd2eaf79 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 7 Sep 2026 14:54:22 -0400 Subject: [PATCH 5/7] refactor(starlark): sanitise with basename, which CodeQL can follow Threading the checked Path through the callers was right on its own terms but moved CodeQL 29 -> 28: relative_to() is a check it cannot trace, so app_dir still resolved back to the URL parameter and all 24 path alerts stayed. os.path.basename is the sanitiser its path-injection query does follow. Applied with an equality check so this rejects rather than silently truncates -- identical behaviour to the character test above it, same error string, same status code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- web_interface/blueprints/api_v3.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 40f52234..15720ac3 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -8912,9 +8912,19 @@ def _validate_starlark_app_path(app_id: str) -> Tuple[Optional[Path], Optional[s if '..' in app_id or '/' in app_id or '\\' in app_id: return None, "Invalid app_id: contains path traversal characters" + # os.path.basename strips any directory component, so what is joined below + # cannot carry one. The equality check means this rejects rather than + # silently truncates -- behaviour is identical to the character test above, + # and it is the sanitiser CodeQL's path-injection query actually follows. + # relative_to() alone is a check it cannot trace, which is why twenty-four + # of these stayed flagged after the value was threaded through properly. + safe_name = os.path.basename(app_id) + if safe_name != app_id or safe_name in ('', '.', '..'): + return None, "Invalid app_id: contains path traversal characters" + try: base_path = _STARLARK_APPS_DIR.resolve() - app_path = (base_path / app_id).resolve() + app_path = (base_path / safe_name).resolve() try: app_path.relative_to(base_path) except ValueError: From a98bb4d8667b96f8ea67fe1d1a50ae9ccde2ff0f Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 7 Sep 2026 15:44:51 -0400 Subject: [PATCH 6/7] fix(starlark): stop three more error paths leaking exception text CodeQL's three remaining stack-trace-exposure alerts were real, not noise like the path ones -- these answered the caller with the exception: 'Failed to save configuration: {e}' 'File error during upload: {err}' 'Failed to load app module: {err}' An OSError there names absolute paths on the device. All three already logged the detail; the response now says what failed and nothing more, matching the generic Exception arm sitting directly beneath two of them. That leaves 25 CodeQL alerts on this PR, all of a kind: 24 path-injection where traversal is demonstrably blocked, and one command-line-injection on a list-form subprocess with no shell. Both classes already appear on main -- api_v3.py carries 11 path-injection and 69 stack-trace alerts before this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- web_interface/blueprints/api_v3.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 15720ac3..06340a0c 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -9289,11 +9289,14 @@ def upload_starlark_app(): pass except (OSError, IOError) as err: + # The detail goes to the log, not the response: it names absolute + # paths on the device, which the caller has no business seeing. The + # generic Exception arm below already did this; these two did not. logger.exception("[Starlark] File error uploading starlark app: %s", err) - return jsonify({'status': 'error', 'message': f'File error during upload: {err}'}), 500 + return jsonify({'status': 'error', 'message': 'File error during upload'}), 500 except ImportError as err: logger.exception("[Starlark] Module load error uploading starlark app: %s", err) - return jsonify({'status': 'error', 'message': f'Failed to load app module: {err}'}), 500 + return jsonify({'status': 'error', 'message': 'Failed to load app module'}), 500 except Exception as err: logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err) return jsonify({'status': 'error', 'message': 'Failed to upload app'}), 500 @@ -9479,7 +9482,8 @@ def update_fn(manifest): json.dump(current_config, f, indent=2) except Exception as e: logger.error(f"Failed to save config.json for {app_id}: {e}") - return jsonify({'status': 'error', 'message': f'Failed to save configuration: {e}'}), 500 + logger.exception("Failed to save Starlark configuration for %r", app_id) + return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500 # Also update manifest for backward compatibility app_data.setdefault('config', {}).update(data) From dfac0f892dd11f8023ab53dc78a3fa3b5a754390 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 7 Sep 2026 16:01:21 -0400 Subject: [PATCH 7/7] fix(starlark): five review findings, two of them mine Tests reached GitHub. browse and categories go through _get_tronbyte_repository_class() to list_all_apps_cached(), and with a cold server cache that is a live request -- slow, rate-limitable, and able to pass on a 500 because the assertions only checked for 404. Now patched, with an assertion that the patched class was actually used. The file went from 19.7s to 1.8s, which is the finding measured. _toggle_starlark_app ignored _update_manifest_safe's return and reported success over a failed write, and mutated the in-memory manifest first -- so a failed save left memory and disk disagreeing and told the caller it had worked. Checked now, and memory is updated only after persistence. _write_starlark_manifest used with_suffix('.tmp'), one fixed path shared by every caller. Flask serves concurrently and upload, uninstall, config, toggle and the plugin toggle all reach it: two writers opened the same file, interleaved their json.dump output, and both renamed it. The rename was atomic over a mix of two manifests. Now mkstemp in the target directory, chmod 0644 to match a normal write. Both dynamic importers cached the module in sys.modules before executing it, so a failed exec_module left a half-initialised object there and every later call took the cache branch and raised AttributeError instead of retrying. One transient failure disabled the repository and renderer for the process lifetime. Popped on failure. update_starlark_app_config mutated app.config and app.manifest before save_config(), and kept the new values when it returned False -- a later GET returned configuration that was never persisted. Snapshotted and rolled back on the failure branch. Full core suite: 3979 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .../test_starlark_pixlet_routes.py | 40 +++++++++++++- web_interface/blueprints/api_v3.py | 55 ++++++++++++++++--- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py index 12697975..f8692f49 100644 --- a/test/web_interface/test_starlark_pixlet_routes.py +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -133,16 +133,52 @@ class TestTheAppStoreFlow: an empty store. """ - def test_browse_does_not_404(self, client): + @pytest.fixture + def offline_repo(self): + """No live GitHub calls from the test suite. + + browse and categories reach _get_tronbyte_repository_class() and then + list_all_apps_cached(); with a cold server-side cache that is a real + network request, which makes the run slow, rate-limitable, and able to + pass on a 500 because these assertions only check for a 404. + """ + repo = MagicMock() + # Matches what the real list_all_apps_cached returns; the handler + # indexes every one of these keys. + repo.return_value.list_all_apps_cached.return_value = { + 'apps': [{'id': 'quoteoftheday', 'name': 'A Quote A Day', + 'category': 'text'}], + 'categories': ['text'], + 'authors': ['someone'], + 'count': 1, + 'cached': True, + } + repo.return_value.get_rate_limit_info.return_value = {'remaining': 5000} + with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class', + return_value=repo): + yield repo + + def test_browse_does_not_404(self, client, offline_repo): resp = client.get('/api/v3/starlark/repository/browse') assert resp.status_code != 404, "the store cannot list anything" assert resp.get_json().get('message') != 'Resource not found' - def test_categories_does_not_404(self, client): + def test_browse_returns_the_apps_the_store_lists(self, client, offline_repo): + resp = client.get('/api/v3/starlark/repository/browse') + body = resp.get_json() + assert body['status'] == 'success', body + assert any(a.get('id') == 'quoteoftheday' for a in body.get('apps', [])), body + + def test_categories_does_not_404(self, client, offline_repo): resp = client.get('/api/v3/starlark/repository/categories') assert resp.status_code != 404 assert resp.get_json().get('message') != 'Resource not found' + def test_no_live_network_call_is_made(self, client, offline_repo): + client.get('/api/v3/starlark/repository/browse') + assert offline_repo.called, \ + "the route did not go through the patched repository class" + def test_installed_apps_list_does_not_404(self, client): resp = client.get('/api/v3/starlark/apps') assert resp.status_code != 404 diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 06340a0c..f1066727 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -8834,7 +8834,15 @@ def _get_tronbyte_repository_class() -> Type[Any]: raise ImportError("Failed to create module from spec for tronbyte_repository") sys.modules["tronbyte_repository"] = module - spec.loader.exec_module(module) + try: + spec.loader.exec_module(module) + except BaseException: + # A module that failed to execute must not stay in sys.modules: the + # cache branch above would hand back the half-initialised object for + # the rest of the process, so one transient failure would disable + # this path permanently and surface as AttributeError, not ImportError. + sys.modules.pop("tronbyte_repository", None) + raise return module.TronbyteRepository def _get_pixlet_renderer_class() -> Type[Any]: @@ -8859,7 +8867,15 @@ def _get_pixlet_renderer_class() -> Type[Any]: raise ImportError("Failed to create module from spec for pixlet_renderer") sys.modules["pixlet_renderer"] = module - spec.loader.exec_module(module) + try: + spec.loader.exec_module(module) + except BaseException: + # A module that failed to execute must not stay in sys.modules: the + # cache branch above would hand back the half-initialised object for + # the rest of the process, so one transient failure would disable + # this path permanently and surface as AttributeError, not ImportError. + sys.modules.pop("pixlet_renderer", None) + raise return module.PixletRenderer def _validate_and_sanitize_app_id(app_id: Optional[str], fallback_source: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]: @@ -9032,12 +9048,20 @@ def _write_starlark_manifest(manifest: Dict[str, Any]) -> bool: try: _STARLARK_APPS_DIR.mkdir(parents=True, exist_ok=True) - # Atomic write pattern: write to temp file, then rename - temp_file = _STARLARK_MANIFEST_FILE.with_suffix('.tmp') - with open(temp_file, 'w') as f: + # Atomic write: unique temp file in the target directory, then rename. + # with_suffix('.tmp') gave every caller the same manifest.tmp, and + # Flask serves concurrently -- upload, uninstall, config, toggle and + # the plugin toggle all reach here. Two writers shared one file, + # interleaved their json.dump output, and both renamed it, so the + # rename was atomic over content that was a mix of two manifests. + fd, temp_name = tempfile.mkstemp( + dir=str(_STARLARK_APPS_DIR), prefix='.manifest.', suffix='.tmp') + temp_file = Path(temp_name) + with os.fdopen(fd, 'w') as f: json.dump(manifest, f, indent=2) f.flush() os.fsync(f.fileno()) # Ensure data is written to disk + os.chmod(temp_name, 0o644) # mkstemp creates 0600; match a normal write # Atomic rename (overwrites destination) temp_file.replace(_STARLARK_MANIFEST_FILE) @@ -9414,6 +9438,13 @@ def update_starlark_app_config(app_id): render_interval = data.pop('render_interval', None) display_duration = data.pop('display_duration', None) + # Snapshot before mutating. save_config() can fail, and the route + # answers 500 below when it does -- but the loaded app kept the new + # values anyway, so a later GET returned configuration that was + # never persisted and the plugin rendered with it. + prev_config = dict(app.config) + prev_manifest = dict(app.manifest) + # Update config with non-timing fields only app.config.update(data) @@ -9425,7 +9456,11 @@ def update_starlark_app_config(app_id): if display_duration is not None: app.manifest['display_duration'] = display_duration timing_changed = True - if app.save_config(): + saved = app.save_config() + if not saved: + app.config.clear(); app.config.update(prev_config) + app.manifest.clear(); app.manifest.update(prev_manifest) + if saved: # Persist manifest if timing changed (same pattern as toggle endpoint) if timing_changed: try: @@ -9759,12 +9794,14 @@ def _toggle_starlark_app(app_id: str, enabled: bool): plugin = _get_starlark_plugin() if plugin is not None and safe_id in getattr(plugin, 'apps', {}): - plugin.apps[safe_id].manifest['enabled'] = enabled - def _update(manifest): manifest['apps'][safe_id]['enabled'] = enabled - plugin._update_manifest_safe(_update) + if plugin._update_manifest_safe(_update) is False: + return jsonify({'status': 'error', + 'message': 'Failed to save app state'}), 500 + # Only now is the in-memory copy allowed to disagree with disk. + plugin.apps[safe_id].manifest['enabled'] = enabled else: manifest = _read_starlark_manifest() app_data = manifest.get('apps', {}).get(safe_id)