Files
esh-pfi-infrastructure/stacks/blender/conf/scripts/startup/fleet_extensions.py
T
vh 83dc497b40 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).
2026-09-28 12:50:57 -07:00

145 lines
6.1 KiB
Python

"""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)}")