#!/usr/bin/env python3 """Bounded SERVER_NATIVE Blender operator runner. The runner owns a temporary workspace, invokes Blender in background mode, and binds the result to the request/source/property hashes. UI, window and file-browser contexts are intentionally unavailable. """ import hashlib import json import math import os import re import resource import shutil import subprocess import sys import tempfile from pathlib import Path SCHEMA = 1 ROUTE = "SERVER_NATIVE" SHA256 = re.compile(r"^[a-f0-9]{64}$") JOB_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$") BLOCKED_OPERATOR_PREFIXES = ("wm.", "window.", "screen.", "file.", "outliner.", "view3d.") def canonical(value): return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) def digest(value): return hashlib.sha256(value.encode("utf-8")).hexdigest() def validate_request(request): if not isinstance(request, dict) or request.get("schemaVersion") != SCHEMA or request.get("route") != ROUTE: raise ValueError("SERVER_OPERATOR_JOB_INVALID: schema or route mismatch") required = {"schemaVersion", "route", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "operator", "properties", "propertiesSha256", "timeoutMs", "memoryBytes", "resultBinding", "requestSha256"} if set(request) - required - {"context"}: raise ValueError("SERVER_OPERATOR_JOB_INVALID: undeclared request field") if not JOB_ID.fullmatch(str(request.get("jobId", ""))): raise ValueError("SERVER_OPERATOR_JOB_INVALID: jobId is invalid") if not all(isinstance(request.get(name), str) and SHA256.fullmatch(request[name]) for name in ("sourceBlendSha256", "propertiesSha256", "requestSha256")): raise ValueError("SERVER_OPERATOR_JOB_INVALID: hash binding is invalid") if not isinstance(request.get("sourceBlendByteLength"), int) or not 1 <= request["sourceBlendByteLength"] <= 512 * 1024 * 1024: raise ValueError("SERVER_OPERATOR_JOB_INVALID: source blend exceeds the budget") if not isinstance(request.get("sourceRevision"), int) or request["sourceRevision"] < 0: raise ValueError("SERVER_OPERATOR_JOB_INVALID: sourceRevision is invalid") if not isinstance(request.get("operator"), str) or not 1 <= len(request["operator"].encode()) <= 128 or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", request["operator"]): raise ValueError("SERVER_OPERATOR_JOB_INVALID: operator is invalid") if request["operator"].lower().startswith(BLOCKED_OPERATOR_PREFIXES): raise ValueError("SERVER_OPERATOR_CONTEXT_BLOCKED: UI/window/file-browser operator") props = request.get("properties") if not isinstance(props, dict) or len(props) > 128: raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties exceed the budget") for key, value in props.items(): def scalar(item): return (isinstance(item, str) and len(item.encode()) <= 4096) or type(item) is bool or (type(item) in (int, float) and math.isfinite(item)) if not KEY.fullmatch(key): raise ValueError("SERVER_OPERATOR_JOB_INVALID: invalid property key") if isinstance(value, list): category = lambda item: 'number' if type(item) in (int, float) else type(item).__name__ if len(value) > 64 or any(not scalar(item) or category(item) != category(value[0]) for item in value): raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties must be bounded homogeneous arrays") elif not scalar(value): raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties must be typed scalar or array values") if len(canonical(props).encode()) > 64 * 1024: raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties exceed the byte budget") if not isinstance(request.get("blenderBuild"), dict) or not SHA256.fullmatch(str(request["blenderBuild"].get("buildSha256", ""))): raise ValueError("SERVER_OPERATOR_JOB_INVALID: Blender build binding is invalid") binding = request.get("resultBinding") if not isinstance(binding, dict) or binding.get("kind") not in ("json", "snapshot", "scene-delta") or not KEY.fullmatch(str(binding.get("key", ""))): raise ValueError("SERVER_OPERATOR_JOB_INVALID: result binding is invalid") if request.get("context", "BACKGROUND") != "BACKGROUND": raise ValueError("SERVER_OPERATOR_CONTEXT_BLOCKED: UI/window/file-browser contexts are unavailable") if not isinstance(request.get("timeoutMs"), int) or not 1000 <= request["timeoutMs"] <= 600000: raise ValueError("SERVER_OPERATOR_JOB_INVALID: timeout budget is invalid") if not isinstance(request.get("memoryBytes"), int) or not 64 * 1024 * 1024 <= request["memoryBytes"] <= 4 * 1024 * 1024 * 1024: raise ValueError("SERVER_OPERATOR_JOB_INVALID: memory budget is invalid") return request def _limit_memory(memory_bytes): try: resource.setrlimit(resource.RLIMIT_AS, (memory_bytes, memory_bytes)) except (OSError, ValueError): pass def _blender_script(request, source_path, result_path, result_blend_path): request_literal = json.dumps(str(request.get("_requestPath", ""))) source_literal = json.dumps(source_path or "") result_literal = json.dumps(str(result_path)) blend_literal = json.dumps(str(result_blend_path)) return Path(__file__).with_name("server_operator_properties.py").read_text() + "\n" + Path(__file__).with_name("server_operator_remesh.py").read_text() + "\n" + f"""import bpy, json, traceback, hashlib request=json.load(open({request_literal}, encoding='utf-8')) result={{'schemaVersion':1,'route':'SERVER_NATIVE','jobId':request['jobId'],'status':'FAILED','sourceBlendSha256':request['sourceBlendSha256'],'sourceRevision':request['sourceRevision'],'blenderBuild':request['blenderBuild'],'propertiesSha256':request['propertiesSha256'],'requestSha256':request['requestSha256']}} try: if not bpy.app.background or any(getattr(bpy.context, name, None) is not None for name in ('area', 'region')): raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED') source={source_literal} if source: bpy.ops.wm.open_mainfile(filepath=source, load_ui=False) target=bpy.ops for part in request['operator'].split('.'): target=getattr(target, part) if not target.poll(): raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED: operator poll failed in background context') operator_properties = (prepare_voxel_remesh(bpy, request['properties']) if request['operator'] == 'object.voxel_remesh' else request['properties']) operator_result=target(**validated_operator_properties(target, operator_properties)) if isinstance(operator_result, set) and 'FINISHED' not in operator_result: raise RuntimeError('SERVER_OPERATOR_CANCELLED: native operator did not finish') result['result']=sorted(str(item) for item in operator_result) if isinstance(operator_result, set) else operator_result bpy.ops.wm.save_as_mainfile(filepath={blend_literal}, copy=True) result['status']='SUCCEEDED' except Exception as error: result['errorMessage']=str(error) result['traceback']=traceback.format_exc(limit=3) result['resultSha256']=hashlib.sha256(json.dumps(result.get('result'), sort_keys=True, separators=(',',':')).encode()).hexdigest() json.dump(result, open({result_literal}, 'w', encoding='utf-8'), sort_keys=True, separators=(',',':')) """ def _blender_describe_script(operator, result_path): operator_literal = json.dumps(operator) result_literal = json.dumps(str(result_path)) return Path(__file__).with_name("server_operator_properties.py").read_text() + "\n" + Path(__file__).with_name("server_operator_remesh.py").read_text() + "\n" + f"""import bpy, json, traceback, hashlib result={{'schemaVersion':1,'route':'SERVER_NATIVE','status':'FAILED'}} try: bundled_exporters={{'export_scene.gltf':'io_scene_gltf2','export_scene.fbx':'io_scene_fbx'}} if {operator_literal} in bundled_exporters: import addon_utils addon_utils.enable(bundled_exporters[{operator_literal}], default_set=False, persistent=False) target=bpy.ops for part in {operator_literal}.split('.'): target=getattr(target, part) if not bpy.app.background or any(getattr(bpy.context, name, None) is not None for name in ('area', 'region')): raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED') rna=target.get_rna_type() properties=[] for prop in rna.properties: spec=operator_property_spec(prop) if spec is not None: properties.append(spec) if {operator_literal} == 'object.voxel_remesh': properties.extend(remesh_property_specs(bpy)) build_hash=str(getattr(bpy.app, 'build_hash', '') or getattr(bpy.app, 'version_string', 'unknown')) result={{'schemaVersion':1,'route':'SERVER_NATIVE','status':'SUCCEEDED','operator':{{'id':{operator_literal},'label':str(getattr(rna, 'name', {operator_literal})),'description':str(getattr(rna, 'description', '') or ''),'family':{operator_literal}.split('.', 1)[0],'context':'main','backgroundSafe':True,'properties':properties}},'blenderBuild':{{'version':str(getattr(bpy.app, 'version_string', 'unknown')),'buildSha256':hashlib.sha256(build_hash.encode()).hexdigest()}}}} except Exception as error: result['errorMessage']=str(error) result['traceback']=traceback.format_exc(limit=3) json.dump(result, open({result_literal}, 'w', encoding='utf-8'), sort_keys=True, separators=(',',':')) """ def describe_server_operator(operator, blender_binary="blender", workspace_root=None, allow_context=False): if not isinstance(operator, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", operator): raise ValueError("SERVER_OPERATOR_SCHEMA_OPERATOR_INVALID") blocked = ("wm.", "window.", "screen.", "file.") if allow_context else BLOCKED_OPERATOR_PREFIXES exporters = {'wm.obj_export', 'wm.stl_export', 'wm.ply_export', 'wm.usd_export', 'wm.alembic_export'} if operator.lower().startswith(blocked) and not (allow_context and operator in exporters): raise ValueError("SERVER_OPERATOR_CONTEXT_BLOCKED") owned_workspace = workspace_root is None temporary_workspace = tempfile.TemporaryDirectory(prefix="server-operator-describe-") if owned_workspace else None try: root = Path(temporary_workspace.name if temporary_workspace else workspace_root) root.mkdir(parents=True, exist_ok=True) result_path = root / "describe.json" script_path = root / "describe.py" script_path.write_text(_blender_describe_script(operator, result_path), encoding="utf-8") completed = subprocess.run([blender_binary, "--background", "--factory-startup", "--python", str(script_path)], cwd=str(root), timeout=120, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False) if completed.returncode != 0 and not result_path.exists(): raise ValueError(completed.stderr.decode(errors="replace")[-4096:] or "SERVER_OPERATOR_SCHEMA_FAILED") result = json.loads(result_path.read_text(encoding="utf-8")) if result.get("status") != "SUCCEEDED": raise ValueError(result.get("errorMessage", "SERVER_OPERATOR_SCHEMA_FAILED")) result.pop("status", None) return result finally: if temporary_workspace: temporary_workspace.cleanup() def run_server_operator_job(request, blender_binary="blender", source_blend_path=None, workspace_root=None): validate_request(request) owned_workspace = workspace_root is None temporary_workspace = tempfile.TemporaryDirectory(prefix=f"server-operator-{request['jobId']}-") if owned_workspace else None try: root = Path(temporary_workspace.name if temporary_workspace else workspace_root) root.mkdir(parents=True, exist_ok=True) request_path = root / "request.json" result_path = root / "result.json" result_blend_path = root / "result.blend" request_with_path = dict(request, _requestPath=str(request_path)) request_path.write_text(canonical(request_with_path), encoding="utf-8") script_path = root / "job.py" script_path.write_text(_blender_script(request_with_path, source_blend_path, result_path, result_blend_path), encoding="utf-8") completed = subprocess.run([blender_binary, "--background", "--factory-startup", "--python", str(script_path)], cwd=str(root), timeout=request["timeoutMs"] / 1000, preexec_fn=lambda: _limit_memory(request["memoryBytes"]), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False) if completed.returncode != 0 and not result_path.exists(): result = dict(request, status="FAILED", resultSha256=digest(""), errorMessage=completed.stderr.decode(errors="replace")[-4096:]) else: result = json.loads(result_path.read_text(encoding="utf-8")) result["resultSha256"] = digest(canonical(result.get("result"))) if result.get("status") == "SUCCEEDED" and result_blend_path.exists(): result["resultBlendByteLength"] = result_blend_path.stat().st_size result["resultBlendSha256"] = hashlib.sha256(result_blend_path.read_bytes()).hexdigest() (root / "result.json").write_text(canonical(result), encoding="utf-8") return result finally: if temporary_workspace: temporary_workspace.cleanup() def main(): envelope = json.load(sys.stdin) action = envelope.get("action", "submit") if action in ("describe", "describe-context"): result = describe_server_operator(envelope["operator"], envelope.get("blenderBinary", "blender"), envelope.get("workspaceRoot"), allow_context=action == "describe-context") elif action == "submit": result = run_server_operator_job(envelope["request"], envelope.get("blenderBinary", "blender"), envelope.get("sourceBlendPath"), envelope.get("workspaceRoot")) elif action == "cancel": result = {"schemaVersion": SCHEMA, "route": ROUTE, "jobId": envelope["jobId"], "status": "CANCELLED", "requestSha256": envelope["requestSha256"], "resultSha256": digest("CANCELLED")} elif action == "read": result = json.loads(Path(envelope["resultPath"]).read_text(encoding="utf-8")) else: raise ValueError("SERVER_OPERATOR_JOB_INVALID: unknown action") print(canonical(result)) if __name__ == "__main__": try: main() except Exception as error: print(json.dumps({"status": "FAILED", "errorMessage": str(error)}, separators=(",", ":"))) sys.exit(1)