"""Fleet extensions: enable the pinned add-ons in Blender's System extension repository, in the GUI container and in `blender-run --extensions`. Part of eshpfi-management stacks/blender; see its README, section "Extensions". The add-ons (versions and sha256 pinned in conf/extensions.lock) are installed by scripts/blender-extensions into fv-ml1:/tank/blender-extensions/5.2/system. Both the GUI container and blender-run mount that directory READ-ONLY as the System repository (/blender/5.2/extensions/system). Nobody installs anything from inside Blender: MCP safe mode blocks bpy.ops.extensions.*, addon_enable and register_class, and the repository is read-only anyway. Every package in that directory is enabled, because the directory holds exactly the pinned set. This file runs in one of two ways: - GUI: from the user scripts/startup dir, at every launch. The add-ons are enabled in a timer, after the user preferences have loaded, because an earlier enable is undone by the prefs load (same trap as fleet_mcp.py). Progress goes to /config/.local/state/fleet_extensions.log. - Headless: blender-run --extensions passes this file as `--python` ahead of the caller's own arguments. If any add-on fails to enable it raises, and with --python-exit-code the run exits 1 before the caller's script starts, so a job never runs silently without an add-on it needs. Enabling follows the Preferences "enable" button: refresh the extension wheels with the pending modules listed, then addon_utils.enable(default_set=True). `blender --addons` is NOT equivalent: it leaves the add-on out of preferences.addons, and Bool Tool and LoopTools read their own prefs in register() and fail with a KeyError (found 2026-09-28). """ import ast import inspect import os import sys import time import traceback import addon_utils import bpy REPO = "system" LOG = "/config/.local/state/fleet_extensions.log" def log(msg): if bpy.app.background: print(f"fleet_extensions: {msg}", file=sys.stderr, flush=True) return os.makedirs(os.path.dirname(LOG), exist_ok=True) with open(LOG, "a") as f: f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n") def repo_dir(): for repo in bpy.context.preferences.extensions.repos: if repo.module == REPO: return repo.directory raise RuntimeError(f"no '{REPO}' extension repository in the preferences") def packages(): """Package ids in the System repository (one directory with a manifest per package).""" d = repo_dir() if not os.path.isdir(d): return [] return sorted(n for n in os.listdir(d) if os.path.isfile(os.path.join(d, n, "blender_manifest.toml"))) def enable_all(): """Enable every System-repository package. Returns (modules, failed, errors).""" modules = [f"bl_ext.{REPO}.{p}" for p in packages()] if not modules: raise RuntimeError(f"no extensions in {repo_dir()}: is /tank/blender-extensions mounted?") errors = [] addon_utils.extensions_refresh( ensure_wheels=True, addon_modules_pending=modules, handle_error=lambda ex: errors.append(f"wheels: {ex}"), ) for m in modules: if not addon_utils.check(m)[1]: addon_utils.enable(m, default_set=True, handle_error=lambda ex, m=m: errors.append(f"{m}: {ex!r}")) failed = [m for m in modules if not addon_utils.check(m)[1]] harden() return modules, failed, errors def harden(): """Fleet-local fixes applied on top of the pinned add-ons. Each one names what it guards.""" # SurfacePsycho 0.10.4: view3d.sp_overwrite_segment_selection runs eval() on its string # property. That walks straight past MCP safe mode: an agent's bpy.ops call passes the AST # check, and the string inside it is never parsed. Nothing in the add-on calls this operator # (audited 2026-09-28), so literal_eval keeps its documented use (a literal set/list of # segment ids) and refuses code. try: from bl_ext.system.surfacepsycho.tools import overlay_segment_selection as oss except ImportError: oss = None cls = getattr(oss, "SP_OT_overwrite_segment_selection", None) if cls is not None and not getattr(cls.execute, "fleet_hardened", False): if " eval(self.select_string)" in inspect.getsource(cls.execute): def execute(self, context): oss.SELECTED_SEGMENTS.clear() for s in ast.literal_eval(self.select_string): oss.SELECTED_SEGMENTS.append(s) return {"FINISHED"} execute.fleet_hardened = True cls.execute = execute log("hardened: surfacepsycho sp_overwrite_segment_selection eval -> literal_eval") else: log("WARNING: surfacepsycho sp_overwrite_segment_selection changed upstream; re-audit it") # 3MF Import/Export 2.7.7 opens a "please rate us" popup after five exports. Off: agents # export far more than five files and a popup is noise in the viewport screenshots. entry = bpy.context.preferences.addons.get(f"bl_ext.{REPO}.ThreeMF_io") if entry is not None and getattr(entry.preferences, "rating_prompt_after", -1) != -1: entry.preferences.rating_prompt_after = -1 def _gui_timer(): """GUI: runs once, after the user prefs have loaded.""" try: modules, failed, errors = enable_all() for e in errors: log(f"error: {e}") log(f"enabled {len(modules) - len(failed)}/{len(modules)}" + (f"; FAILED: {', '.join(failed)}" if failed else "")) log(f"online access: {bpy.app.online_access}") except Exception: log("enable_all failed:\n" + traceback.format_exc()) return None def register(): if bpy.app.background: return bpy.app.timers.register(_gui_timer, first_interval=2.0, persistent=True) def unregister(): pass if __name__ == "__main__": # Headless (blender-run --extensions): fail the run if anything did not enable. _modules, _failed, _errors = enable_all() for _e in _errors: log(f"error: {_e}") if _failed: raise RuntimeError(f"extensions failed to enable: {', '.join(_failed)}") log(f"enabled {len(_modules)}: {', '.join(m.rsplit('.', 1)[1] for m in _modules)}")