feat(blender): pinned extension set in a read-only System repo, for the GUI and blender-run --extensions
Blender is now a mandatory stage in draupnir's pipeline (Prime, 2026-09-28), and draupnir asked for eight add-ons from extensions.blender.org: SurfacePsycho 0.10.4, CAD Sketcher 0.32.1, 3D-Print Toolbox 1.4.1, STEP Importer 1.2.1, Bool Tool 2.1.0, LoopTools 4.7.7, MeasureIt 1.8.4, 3MF Import/Export 2.7.7. - stacks/blender/extensions.lock pins each by version and archive sha256. - scripts/blender-extensions sync builds fv-ml1:/tank/blender-extensions/5.2/system with Blender's own install-file, pre-warms and byte-compiles it, checks a read-only enable, then swaps it in. It refuses while the GUI or a blender-run job holds the old directory. - conf/scripts/startup/fleet_extensions.py enables every package in the System repo: in a timer in the GUI (after the prefs load), and as --python ahead of the caller's args in blender-run --extensions (a failed enable exits 1 before the caller's script). - It also patches SurfacePsycho's sp_overwrite_segment_selection from eval() to literal_eval(): the eval walked past MCP safe mode (control: unpatched ran code, patched refuses). - blender-run: --extensions (bind mounts via --mount so a missing source fails instead of being created); USER/LOGNAME set, which CAD Sketcher's getpass needs. - compose.yaml mounts the repo read-only and the hook into the GUI container. NOT yet deployed. - scripts/blender-probes/extensions_acceptance.py: one operator run per add-on, safe-mode compliant. Headless 8/9 online and with --network none; CAD Sketcher sketching is GUI-only. A Python audit hook saw no network/process events (positive control fired).
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""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)}")
|
||||
Reference in New Issue
Block a user