Files
esh-pfi-infrastructure/stacks/blender/README.md
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

15 KiB
Raw Blame History

blender

Blender 5.2.2 LTS on fv-ml1 GPU 3, on demand. Agents drive it; there is also a browser desktop to watch it or take over. Prime, 2026-09-27: "go ahead with gpu 3, both". He does not use Blender himself, so the agent side (MCP) is the primary interface.

Desktop https://10.251.50.54:3001 (self-signed cert). Basic auth: user blender, password secret get fv-ml1/blender-web-password.
Image lscr.io/linuxserver/blender:5.2.2-ls241@sha256:9216c77a… (Selkies 2.0 Wayland desktop, NVENC stream).
GPU GPU 3 only (NVIDIA_VISIBLE_DEVICES=3). About 270 MiB is held while the desktop runs.
Files /work → /tank/blender (projects, assets, renders; not backed up). /config → /opt/docker/data/blender (prefs, add-ons; restic). Files are owned by infra-ops (uid 1002), so agents can scp in and out.

⚠ On demand: GPU 3 is borrowed

GPU 3 is the fleet's reserve card for a full-size vLLM seat (servers/fv-ml1/README.md). Blender uses it only while in use:

ssh infra-ops@10.251.50.54 'cd /opt/docker/compose/blender && docker compose up -d'   # start
ssh infra-ops@10.251.50.54 'cd /opt/docker/compose/blender && docker compose down'    # stop, card back to 0

restart: "no", so a reboot never brings it back. When a big seat moves onto GPU 3, Blender stays down.

Headless rendering: scripts/blender-run (for scripts and CLI callers)

A one-shot docker run --rm of this image with Blender as the entrypoint. It needs no desktop and does not collide with the GUI container's up/down. --job DIR stages a local dir to /tank/blender/jobs/<name>/ and copies results back. Engines tested headless on 2026-09-28: Cycles GPU and CPU, EEVEE (EGL), Workbench. STL import is built in. --extensions also enables the pinned add-on set (STEP import and export among them; see "Extensions" below). Foot-guns and budget are in docs/fleettools/blender.md. First consumer: draupnir.

Headless rendering inside the running GUI container

ssh infra-ops@10.251.50.54 'docker exec -u abc blender blender -b /work/<file>.blend -E CYCLES -o /work/out/frame_#### -a -- --cycles-device OPTIX'

Run as -u abc, the image's user, mapped to uid 1002, so outputs land owned by infra-ops.

Acceptance (2026-09-27, 1356)

  • Cycles sees the card on both OptiX and CUDA: "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition". The build ships kernel_sm_120.cubin plus OptiX PTX.
  • Self-test (/tank/blender/render_test.py): a subdivided glass monkey, 1920×1080, 1024 samples, 32 bounces, no denoise. OptiX 3.54 s against CPU 22.71 s (96 threads). That is n=1 per device: a functional check that the GPU is really used, not a benchmark. A trivial default-cube scene could not separate them (0.59 s against 0.65 s), which is why the self-test scene is heavy.
  • Web auth: no credentials gives 401, a wrong password 401, the right one 200.
  • Selkies: "Render node 1 encodes H264, AV1, H265 on nvenc"; the Wayland renderer runs GL on GPU 3.

Agent control (MCP): scripts/blender-mcp

The chosen server is mcp-for-blender (MIT, one maintainer, ~29k stars; researched by dvalin-smithy-dev 2026-09-27, thread 01M3JA61FTFW2ZPSD20MHF7RPJ, full note in dvalin-smithy research/blender-agent-drive-2026-09-27.md). Two halves:

  • The add-on is inside the running GUI Blender and serves a socket that executes arbitrary Python with no authentication. It is vendored at upstream commit 41a18432 (conf/scripts/addons/blender_mcp.py, MIT licence alongside) and started by conf/scripts/startup/fleet_mcp.py. The add-on only serves from a GUI Blender, never from blender -b, which is one reason the desktop exists.
  • The MCP server: mcp-for-blender==2.1.1, frozen in conf/mcp-requirements.txt and installed in /work/.mcp-venv inside the container (scripts/blender-mcp setup). Agents reach it as stdio over ssh infra-ops@fv-ml1 docker exec -i. It runs in the container for two reasons:
    1. No port is published. The socket stays on the container's localhost, so access means ssh + docker on fv-ml1.
    2. Viewport screenshots need a shared filesystem. Blender writes the image and the server reads it back. With the server on nh3-dev it failed ("Screenshot file was not created").

Settings: DISABLE_TELEMETRY=true and BLENDER_MCP_SAFE_MODE=1. Safe mode puts upstream's AST allowlist in front of execute_blender_code: bpy, bmesh, mathutils and pure stdlib only, and no os, open, eval or network. It guards against prompt injection from third-party asset text; it is not a sandbox.

For an agent

scripts/blender-mcp up          # GPU 3 is borrowed: start only when needed
scripts/blender-mcp status      # wait for "mcp add-on: answering" (~10-40 s)
claude mcp add blender -- /home/lkraven/development/eshpfi-management/scripts/blender-mcp   # PER TASK (Prime 2026-09-27): never user/project-wide
scripts/blender-mcp down        # when finished: the card goes back to 0
  • Always pass user_prompt. The tools require it; it is a short statement of the user's request.
  • Render on the GPU: set scene.cycles.device = 'GPU' on any scene you create. Safe mode forbids touching bpy.context.preferences, so the startup hook has already pointed Cycles at OptiX on GPU 3. The startup scene is already set to GPU.
  • Save everything under /work/… (= fv-ml1:/tank/blender), then scp it out.
  • Look before you report: get_viewport_screenshot returns an image, and a still render to /work is the real check.
  • The asset tools (Poly Haven etc.) reach the internet from Blender. The paid ones (Hyper3D, Hunyuan, Tripo, Sketchfab) need keys we do not have; leave them off.

Acceptance (2026-09-27, 1433)

An MCP client on nh3-dev → scripts/blender-mcp → the in-container server → the add-on:

  • initialize OK; 36 tools listed.
  • execute_blender_code built a gold metallic torus and rendered it with Cycles on the GPU to /work/_selftest/mcp-torus.png. The file landed, and I checked it by eye.
  • get_viewport_screenshot returned an image of the scene.
  • Negative control: import os was rejected by safe mode.
  • status pings the add-on itself. A TCP probe was useless because the port accepted while Blender was still loading.

⚠ Two traps found on the way, both fixed and commented where they live:

  • Enabling the add-on from a startup script gets undone when user prefs load. The hook enables it in a timer instead.
  • A pre-flight ssh without -n swallowed the MCP client's initialize, and the session hung at init.

Extensions: the pinned add-on set (2026-09-28)

Why: Prime's ruling of 2026-09-28 makes Blender a mandatory stage in draupnir's pipeline (requirements → functional shape in build123d → industrial design in Blender → print). draupnir asked for these add-ons (thread 01M3MQEGGR0N981645WNZ9DMF3). All come from extensions.blender.org, all are GPL, and all are pinned by version and archive sha256 in extensions.lock.

Add-on Version For Wheels
SurfacePsycho 0.10.4 NURBS/Bezier patch surfacing; STEP/IGES export (the route back to build123d). Alpha. cadquery-ocp-novtk 7.9.3.1 (cp313)
CAD Sketcher 0.32.1 Constraint-based precise profiles slvs 3.2 (cp313)
3D-Print Toolbox 1.4.1 Mesh cleanup (clean non-manifold) and checks before a mesh leaves Blender none
STEP Importer (Clonephaze) 1.2.1 STEP in, from vendor parts and functional shapes cascadio 0.0.18rc8 (abi3)
Bool Tool 2.1.0 Hard-surface booleans none
LoopTools 4.7.7 Mesh helpers none
MeasureIt 1.8.4 Dimensions drawn in the viewport (they show in screenshots) none
3MF Import/Export (Clonephaze) 2.7.7 3MF, which carries units (STL does not) none

Deliberately skipped (draupnir): ND, HardOps, BoxCutter (modal only, an agent cannot drive them), Quad Remesher (paid; the built-in QuadriFlow covers it), QRemeshify (not in the 5.2 catalogue).

How it is wired

stacks/blender/extensions.lock ──scripts/blender-extensions sync──▶ fv-ml1:/tank/blender-extensions/5.2/system
                                                                          │ mounted READ-ONLY as Blender's
                                                                          │ System repo (/blender/5.2/extensions/system)
                              ┌───────────────────────────────────────────┴──────────────────────────┐
                    GUI container (compose.yaml)                                  blender-run --extensions
     conf/scripts/startup/fleet_extensions.py enables all            the same file runs as --python ahead of the
     of it in a timer after the prefs load                           caller's args; any failure exits 1 first
  • Nobody installs from inside Blender. MCP safe mode blocks bpy.ops.extensions.*, addon_enable and register_class, and the repository is mounted read-only, so an agent can neither add an add-on nor alter one. Everything in the System repo is enabled; that directory holds exactly the lock.
  • Wheels (OCP, slvs, cascadio) are unpacked by Blender into the USER extensions dir, about 60 MB. In the GUI that is /config (= /opt/docker/data/blender, restic). In blender-run it is the container's /tmp, rebuilt every run, so concurrent runs share nothing.
  • scripts/blender-extensions sync downloads each archive (cached in /tank/blender-extensions/zips/), checks its sha256, installs it with Blender's own --command extension install-file into a staging dir, pre-warms and byte-compiles it, checks it enables read-only, and only then swaps it in. It refuses while the GUI container or a blender-run job is running, because they hold the old directory through the bind mount. scripts/blender-extensions status compares what is installed with the lock.
  • Bumping a version: edit the lock row, re-run the audit below on the new archive, stop the GUI (scripts/blender-mcp down), scripts/blender-extensions sync, run the acceptance probe.

Fleet-local fixes (in fleet_extensions.py, both paths)

  • SurfacePsycho 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 that operator, so the hook swaps eval for ast.literal_eval, which keeps its documented use (a literal set of segment ids) and refuses code. Control (2026-09-28): unpatched, the payload [__import__('os').getpid()] ran and returned [1]; patched, it raised ValueError: malformed node; the literal {3, 5} worked both ways. The hook logs a warning if the upstream code changes.
  • 3MF's "please rate us" popup (after five exports) is switched off.

Phone-home audit (2026-09-28)

  • Manifests: none of the eight declares the network permission (five declare files only).
  • Source: no add-on imports socket, urllib.request, requests, http, subprocess or webbrowser (3MF uses urllib.parse for path joining only). wm.url_open appears only behind buttons a person clicks (CAD Sketcher's help links, 3MF's rating dialog), and safe mode blocks wm.url_open in agent code anyway. The GUI hook logs bpy.app.online_access.
  • Runtime: the full acceptance probe ran with docker run --network none under a Python audit hook watching socket.*, urllib.Request, http.client.*, subprocess.Popen, os.system/exec/posix_spawn and webbrowser.open. Zero events, and every operator passed offline. Positive control: one deliberate socket.getaddrinfo in the same setup showed up as an event. Sensitivity floor: the hook sees Python-level calls only. The native wheels (OCCT, SolveSpace) could open a socket without Python seeing it, but nothing needed the network to work, and none of them is a networking library.

Foot-guns (all measured 2026-09-28)

  • blender --addons x,y is not the same as enabling. It leaves the add-on out of preferences.addons, and Bool Tool and LoopTools read their own prefs in register(), so they failed with a KeyError. The hook enables them the way the Preferences button does.
  • 3D-Print Toolbox writes a cache into its own package dir on first import. On the read-only mount that fails, which is why sync pre-warms the staging copy while it is still writable.
  • CAD Sketcher calls getpass.getuser(), which fails for a uid with no passwd entry. blender-run sets USER; the GUI's abc user exists.
  • CAD Sketcher's sketch operators need the GUI. Creating a sketch activates a workspace tool, and in blender -b there is none: 'NoneType' object has no attribute 'widget'. The add-on loads headless and its solver (slvs) imports; sketch authoring is a GUI/MCP job.
  • Object.dimensions is in local axes and ignores rotation, so it cannot show which way an import faces. The probe uses world-space bounds.
  • STEP Importer orientation: a SurfacePsycho STEP (Z-up, mm) comes back the right way up with the importer's default up_axis="Y". up_axis="Z" stands it on its edge (this is the probe's control for its own orientation check).
  • SurfacePsycho's default patch is 2 x 2 m (Blender units), and STEP export scales ×1000 into millimetres by default. Scale the object to part size first.
  • MeasureIt and LoopTools want a 3D View area. In the GUI, override with the window's VIEW_3D area; headless, a screen datablock's VIEW_3D area works (view3d_override() in the probe).

Acceptance

The probe is scripts/blender-probes/extensions_acceptance.py: one real operator run per add-on, written to pass MCP safe mode so the same code runs on both paths. Files land in fv-ml1:/tank/blender/acceptance/extensions/.

Headless, 2026-09-28: 8 of 9 checks PASS, both online and with --network none: print3d clean non-manifold (4 → 0 non-manifold edges), Bool Tool auto difference, LoopTools circle (radius spread 4.14 mm → 0), MeasureIt segment, SurfacePsycho patch → STEP (sp-patch-headless.step, 20 × 20 mm), STEP Importer read-back (20 × 20 × 0 mm world, plus the wrong-axis control), 3MF out and back (20 mm cube kept its size). The one FAIL is CAD Sketcher, for the GUI-only reason above. These runs used the same docker invocation blender-run makes.

MCP (GUI) path: pending the stack deploy that mounts the repo and hook into the GUI container.