#!/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 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(): if not KEY.fullmatch(key) or not ((isinstance(value, str) and len(value.encode()) <= 4096) or isinstance(value, (bool, int, float)) and not isinstance(value, bool) or isinstance(value, bool)): raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties must be typed scalar 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 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') allowed={{}} for prop in target.get_rna_type().properties: kind=str(getattr(prop, 'type', '')).upper() if prop.identifier == 'rna_type' or getattr(prop, 'array_length', 0) not in (0, 1) or kind not in ('BOOLEAN', 'INT', 'FLOAT', 'DOUBLE', 'ENUM', 'STRING'): continue allowed[prop.identifier]=kind for key, value in request['properties'].items(): kind=allowed.get(key) if kind is None: raise RuntimeError('SERVER_OPERATOR_PROPERTY_BLOCKED: ' + key) if kind == 'BOOLEAN' and not isinstance(value, bool): raise RuntimeError('SERVER_OPERATOR_PROPERTY_TYPE: ' + key) if kind in ('INT', 'FLOAT', 'DOUBLE') and (isinstance(value, bool) or not isinstance(value, (int, float))): raise RuntimeError('SERVER_OPERATOR_PROPERTY_TYPE: ' + key) if kind in ('ENUM', 'STRING') and not isinstance(value, str): raise RuntimeError('SERVER_OPERATOR_PROPERTY_TYPE: ' + key) operator_result=target(**request['properties']) 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 f"""import bpy, json, traceback, hashlib result={{'schemaVersion':1,'route':'SERVER_NATIVE','status':'FAILED'}} try: 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')) or not target.poll(): raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED') rna=target.get_rna_type() properties=[] for prop in rna.properties: kind=str(getattr(prop, 'type', '')).upper() if prop.identifier == 'rna_type' or getattr(prop, 'array_length', 0) not in (0, 1) or kind not in ('BOOLEAN', 'INT', 'FLOAT', 'DOUBLE', 'ENUM', 'STRING'): continue type_name='boolean' if kind == 'BOOLEAN' else 'number' if kind in ('INT', 'FLOAT', 'DOUBLE') else 'enum' if kind == 'ENUM' else 'string' spec={{'name':prop.identifier,'type':type_name,'required':bool(getattr(prop, 'is_required', False))}} label=getattr(prop, 'name', None) description=getattr(prop, 'description', None) if label: spec['label']=str(label) if description: spec['description']=str(description) try: default=getattr(prop, 'default', None) if isinstance(default, (bool, int, float, str)): spec['defaultValue']=default except Exception: pass for key, source in (('min','hard_min'),('max','hard_max')): try: value=getattr(prop, source, None) if isinstance(value, (int, float)) and not isinstance(value, bool): spec[key]=value except Exception: pass if kind == 'ENUM': items=[] try: for item in prop.enum_items: items.append({{'value':str(item.identifier),'label':str(getattr(item, 'name', item.identifier))}}) except Exception: pass if items: spec['enumItems']=items properties.append(spec) 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): 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") if operator.lower().startswith(BLOCKED_OPERATOR_PREFIXES): 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 == "describe": result = describe_server_operator(envelope["operator"], envelope.get("blenderBinary", "blender"), envelope.get("workspaceRoot")) 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)