feat: Python Plugins

This commit is contained in:
Ian Chua
2026-07-02 17:49:36 +08:00
parent 395e070a0e
commit ecddf3d18f
183 changed files with 49955 additions and 2120 deletions

View File

@@ -0,0 +1,475 @@
# /// script
# requires-python = ">=3.12"
# dependencies = ["numpy"]
#
# [tool.orcaslicer.plugin]
# name = "Host Inspector Panel"
# description = "A non-modal interactive panel that browses the whole orca.host read-only API."
# author = "OrcaSlicer"
# version = "1.0.0"
# ///
"""Host Inspector — a worked sample for the interactive `orca.host.ui` API that
also walks the entire `orca.host` read-only surface.
Run it from the Plugins dialog. It opens a NON-MODAL window (so OrcaSlicer stays
usable) with three expandable rows — Plater, Presets, Model. Each row shows a one
line summary; click its triangle to reveal the full detail for that section. The
page talks to the plugin through the injected `window.orca` bridge:
page --orca.postMessage({command:'refresh'})--> plugin.on_message()
page --orca.postMessage({command:'detail',section})--> plugin.on_message()
plugin --win.post({command:'summary'|'detail', ...})--> page (orca.onMessage)
Detail is fetched lazily — the heavy Model walk (mesh geometry + numpy arrays)
runs only when you expand the Model row. Click "Refresh" after changing the plate
and the summaries update; any section left open re-fetches its detail to stay live.
numpy is declared as a dependency so the zero-copy mesh arrays and 4x4 matrices
are available; the code still degrades gracefully if it is missing.
"""
import orca
try:
import numpy as np
except Exception:
np = None
# Soft caps so a heavy scene cannot produce an unusably long detail block. When a
# cap trims output we say so explicitly rather than truncating silently.
MAX_OBJECTS = 25
MAX_VOLUMES = 10
MAX_INSTANCES = 10
# --------------------------------------------------------------------------- #
# small formatting helpers
# --------------------------------------------------------------------------- #
def fmt_vec(v):
"""Format an (x, y, z) tuple / 3-element sequence with mm precision."""
return "(" + ", ".join(f"{float(c):.3f}" for c in v) + ")"
def fmt_bbox(bb):
"""Format a host.BoundingBox as min / max / size (mm)."""
if not bb.defined:
return "<undefined>"
return f"min{fmt_vec(bb.min)} max{fmt_vec(bb.max)} size{fmt_vec(bb.size)}"
def vol_type_name(volume):
"""Readable name of a ModelVolumeType enum value."""
t = volume.type()
return getattr(t, "name", str(t))
class Report:
"""Accumulates indented key/value lines into one block of text."""
def __init__(self):
self._lines = []
def line(self, text=""):
self._lines.append(text)
def kv(self, indent, key, value, width=15):
pad = " " * indent
self._lines.append(f"{pad}{(key + ':'):<{width}} {value}")
def text(self):
return "\n".join(self._lines)
# --------------------------------------------------------------------------- #
# section builders — each guards itself so one failure doesn't sink the report
# --------------------------------------------------------------------------- #
def report_plater(r, plater):
r.line("[Plater]")
try:
r.kv(2, "project dirty", plater.is_project_dirty())
r.kv(2, "presets dirty", plater.is_presets_dirty())
r.kv(2, "in snapshot", plater.inside_snapshot_capture())
except Exception as exc:
r.kv(2, "error", exc)
r.line()
def report_presets(r, bundle):
r.line("[Presets]")
try:
pp = bundle.current_print_preset()
r.kv(2, "process", f"{pp.name} (system={pp.is_system} dirty={pp.is_dirty})")
printer = bundle.current_printer_preset()
r.kv(2, "printer", f"{printer.name} (system={printer.is_system})")
r.kv(2, "filaments", bundle.current_filament_preset_names())
# PresetCollection access: sizes + currently selected names.
r.kv(2, "collections",
f"prints={bundle.prints.size()} "
f"printers={bundle.printers.size()} "
f"filaments={bundle.filaments.size()}")
# full_config_value(): a few representative keys, only if present.
keys = ("layer_height", "nozzle_diameter", "filament_type",
"printer_model", "sparse_infill_density")
samples = []
for key in keys:
value = bundle.full_config_value(key)
if value is not None:
samples.append(f"{key}={value}")
if samples:
r.kv(2, "config sample", " | ".join(samples))
r.kv(2, "config keys", f"{len(bundle.full_config_keys())} total")
except Exception as exc:
r.kv(2, "error", exc)
r.line()
def report_mesh(r, indent, mesh, instance, volume):
"""Detail a host.TriangleMesh: counts, samples, and (numpy) arrays."""
r.kv(indent, "vertices", mesh.vertex_count())
r.kv(indent, "triangles", mesh.triangle_count())
r.kv(indent, "empty", mesh.is_empty())
r.kv(indent, "manifold", mesh.is_manifold())
r.kv(indent, "volume", f"{mesh.volume():.3f} mm^3")
r.kv(indent, "bbox", fmt_bbox(mesh.bounding_box()))
if not mesh.is_empty():
# numpy-free element access (always available, bounds-checked).
r.kv(indent, "vertex[0]", fmt_vec(mesh.vertex(0)))
r.kv(indent, "triangle[0]", tuple(mesh.triangle(0)))
if np is None:
r.kv(indent, "numpy", 'not installed (add dependencies=["numpy"])')
return
try:
V = np.asarray(mesh.vertices()) # (N, 3) float32, read-only, zero-copy
T = np.asarray(mesh.triangles()) # (M, 3) int32, read-only, zero-copy
N = np.asarray(mesh.face_normals()) # (M, 3) float32, computed copy
r.kv(indent, "np vertices",
f"shape={V.shape} dtype={V.dtype} "
f"writeable={V.flags.writeable} zero_copy={V.base is not None}")
r.kv(indent, "np triangles", f"shape={T.shape} dtype={T.dtype}")
r.kv(indent, "np normals", f"shape={N.shape} dtype={N.dtype}")
# World-space bounding box for this volume under `instance`, using the
# row-vector convention world = [V 1] @ (instance @ volume).T
if instance is not None and V.size:
M = instance.matrix() @ volume.matrix() # 4x4 float64
homog = np.c_[V.astype(np.float64), np.ones(len(V))]
world = (homog @ M.T)[:, :3]
r.kv(indent, "world bbox",
f"min{fmt_vec(world.min(0))} max{fmt_vec(world.max(0))}")
except Exception as exc:
r.kv(indent, "numpy", f"<error: {exc}>")
def report_volume(r, index, volume, instance):
r.line(f" Volume[{index}] '{volume.name}' type={vol_type_name(volume)}")
try:
r.kv(6, "roles",
f"part={volume.is_model_part()} modifier={volume.is_modifier()} "
f"negative={volume.is_negative_volume()} "
f"support_enf={volume.is_support_enforcer()} "
f"support_blk={volume.is_support_blocker()}")
r.kv(6, "extruder_id", volume.extruder_id())
r.kv(6, "offset", fmt_vec(volume.offset()))
r.kv(6, "rotation", fmt_vec(volume.rotation()))
r.kv(6, "scale", fmt_vec(volume.scaling_factor()))
r.kv(6, "mirror", fmt_vec(volume.mirror()))
r.kv(6, "facets", volume.facets_count())
r.kv(6, "manifold", volume.is_manifold())
r.kv(6, "mesh errors", volume.mesh_errors_count())
r.kv(6, "painted",
f"support={volume.is_fdm_support_painted()} "
f"seam={volume.is_seam_painted()} "
f"mm={volume.is_mm_painted()} "
f"fuzzy={volume.is_fuzzy_skin_painted()}")
r.kv(6, "config keys", len(volume.config_keys()))
r.line(" mesh:")
report_mesh(r, 8, volume.mesh(), instance, volume)
except Exception as exc:
r.kv(6, "error", exc)
def report_instance(r, index, instance):
r.line(f" Instance[{index}]")
try:
r.kv(6, "printable", instance.printable)
r.kv(6, "is_printable", instance.is_printable())
r.kv(6, "offset", fmt_vec(instance.offset()))
r.kv(6, "rotation", fmt_vec(instance.rotation()))
r.kv(6, "scale", fmt_vec(instance.scaling_factor()))
r.kv(6, "mirror", fmt_vec(instance.mirror()))
r.kv(6, "left_handed", instance.is_left_handed())
r.kv(6, "world bbox", fmt_bbox(instance.bounding_box()))
except Exception as exc:
r.kv(6, "error", exc)
def report_object(r, index, obj):
r.line(f" Object[{index}] '{obj.name}'")
try:
r.kv(4, "input_file", obj.input_file or "<none>")
r.kv(4, "module_name", obj.module_name or "<none>")
r.kv(4, "printable", obj.printable)
r.kv(4, "volumes", obj.volume_count())
r.kv(4, "instances", obj.instance_count())
r.kv(4, "facets", obj.facets_count())
r.kv(4, "parts", obj.parts_count())
r.kv(4, "materials", obj.materials_count())
r.kv(4, "mesh errors", obj.mesh_errors_count())
r.kv(4, "flags",
f"multiparts={obj.is_multiparts()} cut={obj.is_cut()} "
f"custom_layering={obj.has_custom_layering()}")
r.kv(4, "painted",
f"support={obj.is_fdm_support_painted()} "
f"seam={obj.is_seam_painted()} "
f"mm={obj.is_mm_painted()} "
f"fuzzy={obj.is_fuzzy_skin_painted()}")
r.kv(4, "z range", f"[{obj.min_z():.3f}, {obj.max_z():.3f}]")
r.kv(4, "bbox", fmt_bbox(obj.bounding_box()))
r.kv(4, "raw bbox", fmt_bbox(obj.raw_mesh_bounding_box()))
# The first instance is used as the frame for world-space mesh maths.
instance0 = obj.instance(0) if obj.instance_count() else None
shown = min(obj.volume_count(), MAX_VOLUMES)
for vi in range(shown):
report_volume(r, vi, obj.volume(vi), instance0)
if obj.volume_count() > shown:
r.line(f" ... and {obj.volume_count() - shown} more volume(s)")
shown = min(obj.instance_count(), MAX_INSTANCES)
for ii in range(shown):
report_instance(r, ii, obj.instance(ii))
if obj.instance_count() > shown:
r.line(f" ... and {obj.instance_count() - shown} more instance(s)")
except Exception as exc:
r.kv(4, "error", exc)
r.line()
def report_model(r, model):
r.line(f"[Model] id={model.id()}")
try:
r.kv(2, "objects", model.object_count())
r.kv(2, "materials", model.material_count())
r.kv(2, "current plate", model.current_plate_index())
r.kv(2, "max_z", f"{model.max_z():.3f} mm")
r.kv(2, "painted",
f"support={model.is_fdm_support_painted()} "
f"seam={model.is_seam_painted()} "
f"mm={model.is_mm_painted()} "
f"fuzzy={model.is_fuzzy_skin_painted()}")
r.kv(2, "designer", repr(model.designer()))
r.kv(2, "design_id", repr(model.design_id()))
r.kv(2, "bbox exact", fmt_bbox(model.bounding_box()))
r.kv(2, "bbox approx", fmt_bbox(model.bounding_box_approx()))
except Exception as exc:
r.kv(2, "error", exc)
r.line()
count = model.object_count()
if count == 0:
r.line(" (no objects on the plate)")
r.line()
return
shown = min(count, MAX_OBJECTS)
for oi in range(shown):
report_object(r, oi, model.object(oi))
if count > shown:
r.line(f" ... and {count - shown} more object(s)")
r.line()
# --------------------------------------------------------------------------- #
# one-line summaries (collapsed row text) — pure formatters
# --------------------------------------------------------------------------- #
def summary_plater(plater):
return f"project dirty: {'yes' if plater.is_project_dirty() else 'no'}"
def summary_presets(bundle):
return f"printer: {bundle.current_printer_preset().name}"
def summary_model(model):
return f"{model.object_count()} object(s) · max_z {model.max_z():.1f} mm"
# --------------------------------------------------------------------------- #
# the page — three expandable section rows, themed, self-contained
# --------------------------------------------------------------------------- #
#
# No hardcoded colors: the host injects a theme matching OrcaSlicer's current
# light/dark mode and exposes it as CSS variables (--orca-bg, --orca-fg,
# --orca-muted, --orca-accent, --orca-border). The page only adds layout and
# reuses those variables, so it follows the active theme automatically.
PAGE = r"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
header { display:flex; gap:8px; align-items:center; padding:10px 14px;
border-bottom:1px solid var(--orca-border); }
header h1 { font-size:14px; margin:0; flex:1; }
button.secondary { background:transparent; color:var(--orca-fg);
border-color:var(--orca-border); }
.section { border-bottom:1px solid var(--orca-border); }
.row { display:flex; align-items:center; gap:8px; padding:8px 14px;
cursor:pointer; user-select:none; }
.row:hover { background:var(--orca-border); }
.tri { width:1em; color:var(--orca-muted); }
.label { font-weight:600; }
.sum { color:var(--orca-muted); }
pre.detail { margin:0; padding:8px 14px 14px 32px;
font:12px ui-monospace,SFMono-Regular,Menlo,monospace;
white-space:pre-wrap; word-break:break-word; }
</style>
</head>
<body>
<header>
<h1>Host Inspector</h1>
<button onclick="refresh()">Refresh</button>
<button class="secondary" onclick="orca.close()">Close</button>
</header>
<div class="section">
<div class="row" onclick="toggle('plater')">
<span class="tri" id="tri-plater">&#9654;</span><span class="label">Plater</span>
<span class="sum" id="sum-plater">&#8230;</span>
</div>
<pre class="detail" id="det-plater" style="display:none"></pre>
</div>
<div class="section">
<div class="row" onclick="toggle('presets')">
<span class="tri" id="tri-presets">&#9654;</span><span class="label">Presets</span>
<span class="sum" id="sum-presets">&#8230;</span>
</div>
<pre class="detail" id="det-presets" style="display:none"></pre>
</div>
<div class="section">
<div class="row" onclick="toggle('model')">
<span class="tri" id="tri-model">&#9654;</span><span class="label">Model</span>
<span class="sum" id="sum-model">&#8230;</span>
</div>
<pre class="detail" id="det-model" style="display:none"></pre>
</div>
<script>
var KEYS = ['plater', 'presets', 'model'];
var expanded = {}, loaded = {};
function toggle(key) {
expanded[key] = !expanded[key];
document.getElementById('tri-' + key).innerHTML = expanded[key] ? '&#9660;' : '&#9654;';
var det = document.getElementById('det-' + key);
det.style.display = expanded[key] ? 'block' : 'none';
if (expanded[key] && !loaded[key]) requestDetail(key);
}
function requestDetail(key) {
document.getElementById('det-' + key).textContent = 'Loading…';
orca.postMessage({ command: 'detail', section: key });
}
function refresh() { orca.postMessage({ command: 'refresh' }); }
orca.onMessage(function (msg) {
if (!msg) return;
if (msg.command === 'summary') {
var d = msg.data || {};
KEYS.forEach(function (key) {
document.getElementById('sum-' + key).textContent = d[key] || '';
if (expanded[key]) { loaded[key] = false; requestDetail(key); } // keep open rows live
});
} else if (msg.command === 'detail') {
var det = document.getElementById('det-' + msg.section);
if (det) det.textContent = msg.text;
loaded[msg.section] = true;
}
});
refresh(); // initial summaries; rows start collapsed
</script>
</body>
</html>
"""
# --------------------------------------------------------------------------- #
# the plugin
# --------------------------------------------------------------------------- #
class HostInspectorPanel(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "Host Inspector"
def execute(self):
# Non-modal: returns immediately. The window is host-owned and lives on
# after execute() returns; on_message keeps firing when the page posts.
self.win = orca.host.ui.create_window(
title="Host Inspector",
html=PAGE,
width=760,
height=560,
on_message=self.on_message,
on_close=self.on_close,
)
return orca.ExecutionResult.success("Host Inspector opened.")
# Called on the UI thread when the page posts a message.
def on_message(self, msg):
msg = msg or {}
command = msg.get("command")
if command == "refresh":
self.win.post({"command": "summary", "data": self.summaries()})
elif command == "detail":
section = msg.get("section", "")
self.win.post({"command": "detail", "section": section,
"text": self.detail(section)})
def on_close(self):
print("Host Inspector closed")
@staticmethod
def summaries():
"""One-line summary per section; each guarded independently so one
failure (or a not-ready host) shows only that row's error."""
def safe(fn):
try:
return fn()
except Exception as exc:
return f"<error: {exc}>"
return {
"plater": safe(lambda: summary_plater(orca.host.plater())),
"presets": safe(lambda: summary_presets(orca.host.preset_bundle())),
"model": safe(lambda: summary_model(orca.host.model())),
}
@staticmethod
def detail(section):
"""Full monospace report for one section, built on demand."""
try:
r = Report()
if section == "plater":
report_plater(r, orca.host.plater())
elif section == "presets":
report_presets(r, orca.host.preset_bundle())
elif section == "model":
report_model(r, orca.host.model())
else:
return f"<unknown section: {section}>"
return r.text()
except Exception as exc:
return f"<error: {exc}>"
@orca.plugin
class HostInspectorPlugin(orca.base):
def register_capabilities(self):
orca.register_capability(HostInspectorPanel)

View File

@@ -0,0 +1,141 @@
# /// script
# requires-python = ">=3.12"
# dependencies = []
#
# [tool.orcaslicer.plugin]
# name = "Capability Skeleton"
# description = "Starter template: one plugin package that registers several different capabilities."
# author = "Your Name"
# version = "1.0.0"
# ///
"""Multi-capability plugin skeleton.
Copy this file into its own folder under ``data_dir()/orca_plugins/<your-plugin>/``
and adapt it. It shows the full shape of a plugin that offers more than one
capability:
* ExampleScript - a ``script`` capability (runs from the Plugins dialog)
* ExamplePostProcess - a ``post-processing`` capability (edits exported G-code)
* ExamplePrinterAgent - a ``printer-connection`` capability (a network printer agent)
A plugin is a *package* (the ``@orca.plugin`` class at the bottom) that registers one
or more *capabilities*. Each capability is an independent class with its own name and
type. To make your own plugin: delete the capabilities you do not need, fill in the
ones you keep, and register only those in ``register_capabilities``.
See ``plugin_development.md`` for the full reference, and ``host_ui_panel.py`` for a
richer worked example built on the ``orca.host`` read-only API.
"""
import json
import orca
# --------------------------------------------------------------------------- #
# Capability 1 - a script capability
# Runs on the main/UI thread via the Plugins dialog "Run" action. Keep
# execute() fast: a slow call freezes the UI. Offload heavy work to your own
# threading.Thread (which must not touch the model) and surface results through
# an orca.host.ui window (see host_ui_panel.py).
# --------------------------------------------------------------------------- #
class ExampleScript(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
# Display name; unique within this plugin; must not contain ';'.
return "Example Script"
def on_load(self):
# Optional. Runs once when the capability is loaded. Default: no-op.
pass
def on_unload(self):
# Optional. Runs once when the capability is unloaded. Default: no-op.
pass
def execute(self):
# TODO: your logic here.
return orca.ExecutionResult.success("Example Script ran")
# --------------------------------------------------------------------------- #
# Capability 2 - a post-processing (G-code) capability
# Runs on a background slicing thread during G-code export. Receives a context
# pointing at the temporary G-code file, which you may rewrite in place.
# --------------------------------------------------------------------------- #
class ExamplePostProcess(orca.gcode.GCodePluginCapabilityBase):
def get_name(self):
return "Example Post-process"
def execute(self, ctx):
# ctx.gcode_path - absolute path to the temp G-code being post-processed
# ctx.output_name - the output file name
# ctx.host - target host when exporting to a network printer
# ctx.orca_version - OrcaSlicer version string
# Writing into the folder of ctx.gcode_path is permitted by the audit hook;
# writing elsewhere outside data_dir() is blocked.
try:
with open(ctx.gcode_path, "a", encoding="utf-8") as f:
f.write(f"\n; processed by Example Post-process for {ctx.output_name}\n")
except Exception as exc:
return orca.ExecutionResult.failure(
orca.PluginResult.RecoverableError,
f"post-process failed: {exc}")
return orca.ExecutionResult.success("G-code annotated")
# --------------------------------------------------------------------------- #
# Capability 3 - a printer-connection (agent) capability
# Registers a network printer agent on load. The host serialises each native
# agent call into a single JSON request envelope
# ({command, request_id, dev_id, payload}); you dispatch on request["command"]
# and return a JSON response envelope string. Delete this whole class if your
# plugin is not a printer agent.
# --------------------------------------------------------------------------- #
class ExamplePrinterAgent(orca.printer_agent.PrinterAgentBase):
def get_name(self):
return "Example Printer Agent"
def get_agent_info(self):
return orca.printer_agent.AgentInfo(
id="example-agent",
name="Example Printer Agent",
version="1.0.0",
description="Skeleton printer agent.",
)
def send_command(self, request_json):
# Parse the request envelope and dispatch on its "command".
try:
request = json.loads(request_json or "{}")
except json.JSONDecodeError:
request = {}
command = request.get("command", "")
request_id = request.get("request_id", "")
# TODO: handle the commands your device supports and build a real response.
return json.dumps({
"request_id": request_id,
"status": "error",
"message": f"unhandled command: {command}",
})
def send_command_with_progress(self, request_json, update_fn, cancel_fn):
# Optional. Override only for long-running commands (uploads, prints).
# update_fn(stage, percent, message) - push progress to the host UI.
# cancel_fn() -> bool - poll it; abort if it returns True.
# Default behaviour is to run as a plain send_command.
return self.send_command(request_json)
# --------------------------------------------------------------------------- #
# The package - exactly one @orca.plugin class per file. register_capabilities()
# declares which capabilities this plugin exposes. A capability you do not pass to
# register_capability() is invisible to OrcaSlicer, even if its class is defined
# above.
# --------------------------------------------------------------------------- #
@orca.plugin
class CapabilitySkeleton(orca.base):
def register_capabilities(self):
orca.register_capability(ExampleScript)
orca.register_capability(ExamplePostProcess)
orca.register_capability(ExamplePrinterAgent)

View File

@@ -0,0 +1,385 @@
# Plugin Audit Hook
OrcaSlicer's plugin system runs Python, which is extremely capable — it can read and
write files, spawn processes, open sockets, and load native code. To keep plugins from
reaching outside what they legitimately need, we install a **CPython audit hook** that
inspects sensitive runtime operations performed by plugin code and blocks the ones that
fall outside an allowlist.
> **Scope of this version.** This is intentionally a *narrow, lowrisk first version* —
> groundwork, not a complete sandbox. Today it enforces one thing: **file writes are
> restricted to an allowlist of directories** while a plugin is executing. Reads are
> left permissive so Python can still import modules. Process/network/nativecode events
> are *not* yet enforced. See [Limitations](#limitations) before relying on it as a
> security boundary.
---
## What is a plugin audit hook?
CPython exposes an auditing API (PEP 578). Any interpreterwide hook registered with
`PySys_AddAuditHook` is called *before* the runtime performs a sensitive operation — for
example opening a file (`open`), spawning a subprocess (`subprocess.Popen`), or connecting
a socket (`socket.connect`). The hook receives the event name and its arguments and may
abort the operation by setting a Python exception and returning a nonzero value.
We register exactly **one** such hook, once, from `PythonInterpreter::initialize()` via
`PluginAuditManager::instance().install_hook()`. Everything else — *which* plugin is
running, *what* mode it runs under, and *which* directories it may touch — is tracked by
`PluginAuditManager`.
The hook itself is global to the interpreter, but it only enforces anything when a plugin
**audit context** is active (see below). Nonplugin Python code, and plugin loading before
the context is set, pass through untouched.
---
## How it works
There are three moving parts. Keep them distinct — conflating them is the usual source of
confusion.
### 1. Audit identity — *who* is running (set once, per instance)
Every plugin instance carries a C++only identity string, never exposed to Python:
```cpp
// PythonPluginInterface.hpp
class PluginCapabilityInterface {
public:
void set_audit_plugin_key(std::string key);
const std::string& audit_plugin_key() const;
private:
std::string m_audit_plugin_key; // == PluginDescriptor::plugin_key
};
```
This is the canonical runtime ID, `PluginDescriptor::plugin_key`. It is stamped onto the
instance by the loader **after** the plugin is captured and **before** `on_load()` runs:
- `PluginLoader::load_plugin_impl()``set_audit_plugin_key(descriptor.plugin_key)`
- `PluginLoader::update_loaded_plugin_key()` → restamps it if a key is migrated
Stamping the identity does **not** turn on enforcement — it only labels the object so that
later calls know which plugin they belong to. This matters because printeragent plugins
are later invoked through `IPrinterAgent` / `NetworkAgent`, where the original `plugin_key`
is no longer available at the call site; the instance carries it instead.
### 2. Audit context — *how strict*, for the duration of one call (set per call)
The active plugin, mode, and scoped roots live in threadlocal state on
`PluginAuditManager`. They are set and restored by an RAII guard,
`ScopedPluginAuditContext`:
```cpp
// constructor: remember previous state, then apply the new plugin/mode and clear scoped roots
ScopedPluginAuditContext(const std::string& plugin_key,
AuditMode mode = AuditMode::Loading);
// destructor: restore the previous plugin/mode/scoped-roots
```
A context is constructed at the **start of every C++ → Python trampoline call** and
destroyed when that call returns or throws. So enforcement is *per call*: outside any
trampoline call the mode is just its default and `current_plugin()` is empty, so the hook
allows everything.
### 3. Audit modes — what "strict" means
```cpp
enum class AuditMode {
// Permissive reads, restricted writes. Python must be able to read stdlib
// modules and the plugin file during import/on-load, so reads are allowed;
// only writes outside the allowed roots are blocked.
Loading,
// Restricted reads AND writes: every file path must resolve inside an
// allowed root, or it is blocked.
Enforcing,
};
```
The check that implements this is `PluginAuditManager::check_open(path, mode)`:
1. Empty path → allow.
2. No active plugin (`current_plugin()` empty) → allow.
3. `Loading` **and** the open is a read (`mode` has no `w`/`a`/`+`) → allow (earlyout).
4. Otherwise the path must resolve inside a **scoped allowed root** or the **global
allowed root**, else it is blocked.
So the only difference between the two modes is step 3: `Loading` lets reads through before
the allowlist check; `Enforcing` does not, so reads are subject to the same allowlist as
writes.
### Allowed roots
There are two tiers, checked in this order:
| Tier | Stored in | Lifetime | Set by |
|---|---|---|---|
| **Scoped** | threadlocal, cleared on every new context | one call | `add_scoped_allowed_root()` inside an `audit_setup` callback |
| **Global** | shared, mutexguarded | process | `add_global_allowed_root()` in `install_hook()` |
In this version the global allowlist contains **only `data_dir()`**. The executable
directory and resources directory are deliberately *not* allowed — plugins must not write
there. Gcode plugins additionally get the temp Gcode folder as a *scoped* root for the
duration of their `execute()` call.
Path matching (`is_inside_allowed_root`) canonicalizes both paths with
`weakly_canonical` (resolving symlinks without requiring existence) and does a
componentwise prefix match that rejects any `..` traversal.
### Putting it together — the flow of one call
```
PluginLoader (once) set_audit_plugin_key(plugin_key) // identity stamped
C++ calls plugin->execute() ─► trampoline method
│ ├─ ScopedPluginAuditContext ctor // mode + plugin set
│ ├─ audit_setup() // e.g. add scoped roots
│ └─ PYBIND11_OVERRIDE(_PURE) ──► Python runs
│ │
│ Python does open("/x", "w") ─┤
│ ▼
│ CPython raises "open" audit event
│ │
│ PluginAuditManager::audit_hook
│ │
│ check_open("/x","w") → blocked?
│ └─ PyErr_SetString + return -1 ► PermissionError
trampoline returns ─► ScopedPluginAuditContext dtor // previous state restored
```
---
## Audit hook development
The point of interest is **`PluginAuditManager.hpp` / `.cpp`** (the modes, the events, and
the policy) and the trampoline macros in **`PyPluginTrampoline.hpp`** (how each plugin
function opts into a mode).
### Handling events
Events are dispatched by name in `PluginAuditManager::audit_hook`. Return `0` to allow;
set a Python exception and return nonzero (we use `-1`) to block:
```cpp
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
{
auto* mgr = static_cast<PluginAuditManager*>(user_data);
std::string event_name(event ? event : "");
if (event_name == "open") {
// CPython passes ("open", path, mode, flags)
const char* path = nullptr; const char* mode = nullptr; int flags = 0;
if (!PyArg_ParseTuple(args, "s|si", &path, &mode, &flags)) {
PyErr_Clear();
return 0; // couldn't parse — allow
}
if (!mgr->check_open(path ? path : "", mode ? mode : "r").allowed) {
PyErr_SetString(PyExc_PermissionError,
"Plugin attempted to access a blocked file path");
return -1; // block
}
return 0;
}
// else if (event_name == "os.rename") { ... } // see below
return 0; // unhandled event — allow
}
```
To audit a new operation, add another `else if` branch. **Each event has its own argument
tuple** — you cannot assume `(path, mode, flags)`. Look the event up in the official table
and parse accordingly:
- `os.rename``(src, dst, src_dir_fd, dst_dir_fd)`
- `os.remove``(path, dir_fd)`
- `os.mkdir``(path, mode, dir_fd)`
- `subprocess.Popen``(executable, args, cwd, env)`
The complete, versionspecific list of audit events and their arguments:
**https://docs.python.org/3/library/audit_events.html**
For filesystem mutations you'll usually want to route the extracted path(s) through
`check_open(path, "w")` (or a dedicated checker) so they share the same allowlist logic.
### Defining the audit mode of a function
Every C++ → Python plugin call crosses a trampoline method, and those methods wrap the
pybind11 override in the `ORCA_PY_OVERRIDE_AUDITED` macro
(`PyPluginTrampoline.hpp`). The macro both (a) logs and rethrows Python exceptions at the
single boundary and (b) opens the audit context. Its signature:
```cpp
ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, /* args... */)
```
| Param | Meaning |
|---|---|
| `mode` | `AuditMode::Loading` or `AuditMode::Enforcing` for this call |
| `audit_setup` | a callable (often `[] {}`) run *after* the context is constructed — use it to register scoped roots |
| `override_macro` | `PYBIND11_OVERRIDE` or `PYBIND11_OVERRIDE_PURE` |
| `ret, base, name, …` | the usual pybind11 override arguments |
When you add a new method to any trampoline, **you must choose its mode** based on what the
function legitimately needs:
```cpp
void on_load() override
{
ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, // imports during load → reads allowed
[] {}, // no extra setup
PYBIND11_OVERRIDE,
void, Base, on_load);
}
```
Rule of thumb:
- Use **`Loading`** for lifecycle/setup calls that may import modules (`on_load`,
`on_unload`, `get_type`) or any call where you only care about restricting writes.
- Use **`Enforcing`** for calls that should also be prevented from *reading* outside the
allowlist. Be aware this will block lazilyimported stdlib/3rdparty modules read from
disk during the call, so only use it where the plugin is not expected to import at call
time.
### Adding percall allowed roots (the `audit_setup` callback)
`ScopedPluginAuditContext`'s constructor **clears** the scoped roots, so any scoped root
must be added *after* construction — which is exactly what `audit_setup` is for. The Gcode
trampoline uses it to grant write access to the folder holding the current temp Gcode
file:
```cpp
ExecutionResult execute(const GCodePluginContext& ctx) override
{
ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[&] { // runs only when a context is active
if (!ctx.gcode_path.empty())
::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root(
std::filesystem::path(ctx.gcode_path).parent_path());
},
PYBIND11_OVERRIDE_PURE,
ExecutionResult, GCodePlugin, execute, ctx);
}
```
The callback runs only when the instance has a nonempty audit key (i.e. a context was
actually opened), so it's safe to assume enforcement is live inside it.
### Adding a global allowed root
If *every* plugin should be allowed a directory, add it in `install_hook()`:
```cpp
void PluginAuditManager::install_hook()
{
PySys_AddAuditHook(audit_hook, this);
add_global_allowed_root(data_dir()); // the only global root today
// add_global_allowed_root(std::filesystem::temp_directory_path()); // e.g. to allow /tmp
}
```
Prefer scoped roots over global ones — a global root widens the boundary for *all* plugins
and is processlifetime. Only add a global root when the access is genuinely universal.
### Identity wiring (rarely touched)
If you add a new way to load or rekey plugin instances, make sure the new path also calls
`set_audit_plugin_key()` — otherwise the instance has an empty key and **no context is ever
opened**, so its calls run completely unaudited. The existing call sites are
`PluginLoader::load_plugin_impl()` and `PluginLoader::update_loaded_plugin_key()`.
---
## Current policy at a glance
| Plugin call | Mode | Effective access |
|---|---|---|
| `on_load` / `on_unload` / `get_type` | `Loading` | read anywhere; write only under `data_dir()` |
| Gcode `execute()` | `Loading` | + write under the current temp Gcode folder |
| Script `execute()` | `Loading` | read anywhere; write only under `data_dir()` |
| Printeragent methods | `Loading` | read anywhere; write only under `data_dir()` |
> Modes are chosen at each trampoline call site, so this table reflects the current source —
> always check the actual `ORCA_PY_OVERRIDE_AUDITED(...)` call when in doubt.
---
## Limitations
This version is deliberately minimal. Do **not** treat it as a hardened sandbox. Known gaps:
- **Only the `open` event is enforced.** `subprocess.Popen`, `os.system`, `socket.*`,
`ctypes.*` and friends are *not* blocked. (The `Enforcing` enum comment describes an
aspiration, not current behavior.)
- **`os.open` slips through.** It raises the `open` event with `mode = None`, so the
`"s|si"` parse fails and the call is allowed. Lowlevel opens are currently unaudited.
- **`open(path, "x")`** (exclusive create — a write) contains no `w`/`a`/`+`, so it is
classified as a read and allowed under `Loading`.
- **Non`open` filesystem mutations are unaudited.** `os.remove`, `os.rename`, `os.mkdir`,
`shutil.*` raise their own events, which we don't yet handle — a plugin can delete or
rename files outside `data_dir()` without tripping anything.
- Enforcement is **per process / per thread** via threadlocals; code that hops threads
without reestablishing a context runs unaudited.
Closing these gaps (especially the filesystemmutation events and `os.open` flags) is the
natural next step for anyone hardening this into a real writesandbox.
---
## Debugging
Enforcement only fires while a context is active, and the read/write distinction trips
people up, so when something is unexpectedly blocked (or unexpectedly allowed), get the
facts first.
**Temporary block log.** `check_open` logs each block just before returning, including the
mode that was actually live:
```
[AUDIT] block path=/tmp open_mode=w audit_mode=Loading plugin=local:.../Environment_Report_Script_
```
Read it field by field:
- `open_mode=w` → it's a **write**. Under `Loading`, writes outside the allowlist are
*supposed* to be blocked. A blocked `open_mode=r` under `audit_mode=Loading` is
impossible from current source — if you see it, your binary is stale (see below).
- `audit_mode=` → tells you whether the live call site is `Loading` or `Enforcing`, which
is the quickest way to confirm a trampoline change actually took effect.
- `path=` → the resolved path that failed the allowlist. Compare against `data_dir()`.
The permanent `report_violation` log (`[AUDIT BLOCKED] …`) fires on the same blocks and
includes the plugin key, event name, path, and reason.
**Common pitfalls**
- **Read vs write.** `Loading` never blocks a read. If a "read" is blocked, it's actually a
write (check `open_mode`), or the mode is `Enforcing`.
- **Stale / incremental builds.** `PyPluginTrampoline.hpp` and `PluginAuditManager.hpp` are
included by many translation units. A headeronly change (e.g. flipping a trampoline's
mode) may not propagate with an incremental build. If runtime behavior contradicts the
source, do a clean rebuild of the affected targets. `PluginAuditManager.cpp` changes are
a singleTU recompile + relink.
- **No context = no enforcement.** If a plugin's calls are never audited, check that its
instance got `set_audit_plugin_key()` (nonempty key) and that the method actually wraps
through `ORCA_PY_OVERRIDE_AUDITED`.
---
## Key files
| File | Responsibility |
|---|---|
| `src/slic3r/plugin/PluginAuditManager.{hpp,cpp}` | modes, allowed roots, `audit_hook`, `check_open`, `ScopedPluginAuditContext` |
| `src/slic3r/plugin/PyPluginTrampoline.hpp` | the `ORCA_PY_*` macros (logging + audit context) |
| `src/slic3r/plugin/PythonPluginInterface.hpp` | the perinstance audit identity |
| `src/slic3r/plugin/PluginLoader.cpp` | stamps the audit key at load / key migration |
| `src/slic3r/plugin/pluginTypes/*/*Trampoline.hpp` | perplugintype methods and their chosen modes |
| `src/slic3r/plugin/PythonInterpreter.cpp` | installs the hook once at interpreter init |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
# Plugin System Overview
OrcaSlicer can be extended at runtime with **Python plugins** that execute inside an
embedded CPython interpreter — no recompilation, no patching the C++ core. This document is
the **architectural overview**: what the pieces are, how they fit together, and the
lifecycle of a plugin from discovery to teardown.
It is the map; the other two plugin docs are the detail:
- [`plugin_development.md`](plugin_development.md) — how to *write* a Python plugin and how to
*add a new plugin type* in C++ (the authoring/extension guide).
- [`plugin_audit_hook.md`](plugin_audit_hook.md) — the CPython audit hook that constrains
what plugin code may do (the security deepdive).
> **All paths below are under `src/slic3r/plugin/`** unless stated otherwise.
---
## What the system provides
- **Extensibility without rebuilding** — users drop a plugin into a folder (or subscribe to
one from the cloud) and OrcaSlicer loads it.
- **Capabilities, not singlepurpose plugins** — one plugin is a *package* that registers one
or more **capabilities**, each a typed unit of functionality (e.g. `post-processing`,
`script`, `printer-connection`). Each capability type has a fixed C++ entry point and is
invoked at a specific place in the app; a plugin's "types" are simply the set of capability
types it registers.
- **Presets remember the plugins they use** — when a preset references a plugin capability,
the full reference is stored in the preset and can be restored from OrcaCloud on another
machine (see [Plugin references in presets](#plugin-references-in-presets)).
- **A single, narrow API surface** — plugins see only the embedded `orca` module, not the
slicer internals.
- **A security boundary** — file access by plugin code is filtered by an audit hook with a
write allowlist (groundwork; see the audit doc for current scope).
- **Isolation of failure** — a misbehaving plugin reports an error and is unloaded rather
than taking down the app; tracebacks are persisted to a log file.
---
## Architecture at a glance
```
┌──────────────────────────────────────────────┐
app startup ───► │ PluginManager (singleton orchestrator) │
(GUI_App::OnInit) │ owns: CloudPluginService, PluginCatalog, │
│ PluginLoader │
└───────┬───────────────┬───────────────┬───────┘
│ │ │
discover (scan) │ install/ │ load/unload │
▼ download ▼ ▼
┌──────────────────┐ ┌───────────────┐ ┌────────────────────┐
│ PluginCatalog │ │ CloudPlugin │ │ PluginLoader │
│ manifest-only │ │ Service │ │ threaded loads, │
│ inventory of │ │ (cloud fetch/ │ │ deps (uv), audit │
│ PluginDescriptor│ │ download) │ │ key, capabilities │
└──────────────────┘ └───────────────┘ └─────────┬──────────┘
│ instantiates via
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Embedded CPython (PythonInterpreter, singleton) │
│ PythonPluginBridge → `orca` module + @orca.plugin/register_capability + capture │
│ PyPluginTrampoline → C++↔Python call boundary (traceback logging + audit scope) │
│ PluginAuditManager → CPython audit hook (filesystem policy) │
│ pluginTypes/* (gcode, script, printerAgent) → typed capability bases + tramps │
└─────────────────────────────────────────────────────────────────────────────────┘
│ get_plugin_capability_* + dynamic_pointer_cast
workflow call sites: PostProcessor (G-code post-processing) ·
PluginsDialog "Run" (script) · NetworkAgentFactory (printer agent)
```
Two broad layers:
- **Orchestration (C++, no Python):** `PluginManager`, `PluginCatalog`, `CloudPluginService`,
`PluginLoader`, `PluginDescriptor`. These discover, install, and manage plugins as data.
- **Execution (the C++↔Python bridge):** `PythonInterpreter`, `PythonPluginBridge`,
`PyPluginTrampoline`, `PluginAuditManager`, and the pertype bases under `pluginTypes/`.
These turn a discovered plugin into a live object the app can call.
---
## Core components
| Component | Responsibility |
|---|---|
| `PluginManager` | Toplevel **singleton orchestrator**. Owns the catalog, loader, and cloud service; exposes `initialize()`, `discover_plugins()`, install/update/delete, and `shutdown()`. |
| `PluginCatalog` | **Manifestonly inventory.** Scans the plugin directories, parses each plugin's metadata into a `PluginDescriptor`, and splits results into valid vs. invalid. Loads no Python. |
| `CloudPluginService` | Thin wrapper over the cloud agent: fetch subscribed/owned plugin manifests, download a plugin payload, unsubscribe/delete. |
| `PluginLoader` | **Load/unload lifecycle.** Installs dependencies (bundled `uv`), imports the module, instantiates the package and its capabilities, stamps their audit identity, runs `on_load()`, and keeps the live capability instances keyed by a `PluginCapabilityIdentifier`. Provides `get_plugin_capabilities_by_type()` / `get_plugin_capability_by_name()` and onload/unload + oncapabilityload/unload callbacks. |
| `PluginDescriptor` | The canonical record for one plugin: key, paths, capability/display types, version, changelog, dependencies, cloud overlay, and any error/validity state. |
| `PythonInterpreter` | **Singleton RAII wrapper around embedded CPython.** Init/finalize, GIL handoff, `sys.path`, module loading, and installing the audit hook + stderrtolog redirect. |
| `PythonPluginBridge` | Defines the embedded **`orca` module**, the `@orca.plugin` decorator + `orca.base` package class + `register_capability` entry, and captures/instantiates the package and the capability classes it registers. |
| `PyPluginTrampoline` | The pybind11 override base at the **C++↔Python boundary**: logs Python tracebacks and opens the percall audit scope. |
| `pluginTypes/*` | Pertype C++ capability bases + trampolines (`GCodePluginCapability`, `ScriptPluginCapability`, `PrinterAgentPluginCapability`) that define each type's entry method and dispatch. |
| `PluginAuditManager` | **Singleton CPython audit hook**: filesystem policy (write allowlist), scoped roots, `Loading`/`Enforcing` modes. See the audit doc. |
---
## Plugin packaging and discovery
A plugin is a folder under one of two roots, containing a single **entry file**:
| Root | Source |
|---|---|
| `data_dir()/orca_plugins/` | locally installed / sideloaded |
| `data_dir()/orca_plugins/_subscribed/<user_id>/` | cloudsubscribed (per loggedin user) |
The entry file is either a single **`.py`** (metadata in a PEP 723 comment block) or a
**`.whl`** wheel (metadata from the wheel's `METADATA`). The **capabilities** the plugin
registers determine which workflows can run it — there is no separate `type` declaration in the
metadata. Metadata and packaging details are in
[`plugin_development.md`](plugin_development.md).
**Discovery vs. loading are separate stages.** `PluginCatalog` *scans* directories and
produces `PluginDescriptor`s — it parses manifests only and never executes plugin code. A
*catalog entry* is just data; a *loaded plugin* is a live Python instance created later by
`PluginLoader`. Cloud manifests are merged into the catalog as an overlay once a user is
logged in.
---
## The plugin lifecycle
```
1. App startup (GUI_App::OnInit, after network init)
2. PluginManager::initialize()
│ └─ PythonInterpreter::initialize() (MAIN THREAD ONLY)
│ ├─ start embedded CPython, set sys.path / python home
│ ├─ install the audit hook (global allowed root = data_dir())
│ ├─ tee sys.stderr → data_dir()/log/python_*.log
│ └─ release the GIL (PyEval_SaveThread)
3. discover_plugins() ─► PluginCatalog scans local + cloud roots
│ → PluginDescriptor list (valid / invalid)
│ (cloud login later: fetch_plugins_from_cloud → catalog overlay)
4. PluginLoader::load_plugin() (worker thread, serialized)
│ ├─ install dependencies via bundled `uv`; extract bundled .whl deps onto sys.path
│ ├─ begin capture → import module (runs @orca.plugin, marking the package class)
│ ├─ finalize capture → instantiate package, call register_capabilities(),
│ │ then instantiate each registered capability and cache its get_name()
│ ├─ set_audit_plugin_key(descriptor.plugin_key) // audit identity
│ ├─ on_load() (under the GIL)
│ └─ store the capabilities; fire on-load + on-capability-load callbacks
5. Use: a workflow call site resolves a capability (get_plugin_capability_by_name /
│ get_plugin_capabilities_by_type) + dynamic_pointer_cast<TypeCapability>,
│ builds the type's context, and calls the entry method (under the GIL).
│ Each call crosses a trampoline that opens a ScopedPluginAuditContext.
6. Unload / shutdown: set_shutting_down → unload_plugin / unload_all_plugins
(the instance's destructor runs on_unload() + Py_DECREF under the GIL)
→ PythonInterpreter::shutdown()
```
A few loadtime invariants worth knowing:
- **`set_audit_plugin_key()` is what arms enforcement.** Without it the instance has an empty
key and its calls run unaudited. It is stamped at load and restamped on key migration
(`update_loaded_plugin_key`). See the audit doc.
- A module must mark exactly one package class with `@orca.plugin` (a subclass of
`orca.base`), and that class's `register_capabilities()` must register at least one valid
capability via `orca.register_capability(...)`, or the load fails. Each capability must
resolve `get_name()`, and `(type, name)` must be unique within the plugin.
---
## Execution model: how the app calls a plugin
Capabilities are reached **by type, not by name**. There is no pertype instantiation
registry: a capability's Python class subclasses a typed C++ base, the package registers it
via `register_capability`, and each workflow call site narrows the stored capability instance
(`PluginCapabilityInterface`) with `std::dynamic_pointer_cast<ConcreteType>`. If the cast
succeeds, the capability is present and is invoked; if not (no such capability installed or
enabled), the path is a noop — which is how the system guarantees that absent/disabled
capabilities never change existing behavior.
| Capability type | Entry method | Invoked by |
|---|---|---|
| `post-processing` (Gcode) | `execute(ctx)` | `PostProcessor` during Gcode export, resolving the preset's plugin refs |
| `script` | `execute()` | the **Plugins dialog → Run** action |
| `printer-connection` | agent methods | `NetworkAgentFactory`, registered through a loader oncapabilityload callback wired in `GUI_App` |
The onload / onunload **callbacks** (`PluginLoader::subscribe_on_load_callback` /
`subscribe_on_unload_callback`) and the percapability variants
(`subscribe_on_capability_load_callback` / `subscribe_on_capability_unload_callback`) are how
subsystems react to plugins and capabilities appearing or disappearing — e.g. the
printeragent layer registers/deregisters an agent for each `PrinterConnection` capability,
and the Plugins dialog refreshes. Adding a new type and wiring a call site is covered in
[`plugin_development.md`](plugin_development.md).
---
## Threading and the GIL
- **The interpreter is initialized on the main thread.** CPython is started once via
`PythonInterpreter` (singleton). Initializing it off the main thread risks heap
corruption, so `PluginManager::initialize()` does it eagerly and synchronously.
- **After init the GIL is released** (`PyEval_SaveThread`) and reacquired at shutdown, so
other threads may take it.
- **Plugin loads run on worker threads**, serialized by a static mutex so module imports
don't race. Discovery can also run on a background thread (`discover_plugins(async=true)`),
though startup discovery is synchronous.
- **Every touch of Python from a nonmain thread acquires the GIL** through the
`PythonGILState` RAII guard (`PyGILState_Ensure` / `Release`) — load, execute, and the
instance destructor (`on_unload` + `Py_DECREF`) all wrap in it.
---
## Cloud subscriptions
`CloudPluginService` wraps the cloud agent (`OrcaCloudServiceAgent`) and is gated on login.
It fetches the manifests of subscribed/owned plugins, merges them into the catalog as an
overlay, and downloads a plugin's payload (sniffing the file to tell a `.whl` from a `.py`)
to a temporary file. `PluginManager` sets the loader's cloud user id, and `PluginLoader`
installs the downloaded payload under `orca_plugins/_subscribed/<user_id>/`. Logging out
unloads cloud plugins. The cloud auth token (`orca_refresh_token.sec`) is owned by the cloud
agent, not by the plugin layer.
---
## Plugin references in presets
When a setting points at a plugin capability (for example `post_process_plugin`), the value
the setting stores is just the capability's **name**. So that the reference survives being
copied to another machine — where the plugin might not be installed — each preset also carries
a `plugins` array that records the **full reference** for every capability it uses.
Each entry is a single string with three `;`separated fields:
```
<plugin_name>;<cloud_uuid>;<capability_name>
```
```json
{
"plugins": [
"Sample Plugin;1f998ea9-0183-4cc5-957f-4eef659ba4e6;G-code Benchmark (.py)",
"master_plugin;;header-stamp"
],
"post_process_plugin": ["G-code Benchmark (.py)", "header-stamp"]
}
```
- The **`cloud_uuid`** is present for plugins subscribed from OrcaCloud and **empty** for
localonly plugins (note the adjacent `;;`). It is what lets OrcaSlicer offer to restore a
missing plugin automatically.
- Because `;` is the field separator, a **capability name may not contain `;`** (the loader
rejects such a plugin), and plugin display names have any `;` replaced with `_`
(`sanitize_plugin_name`).
- The `plugins` array is an internal manifest (`coStrings`, `comDevelop` mode — not a
useredited field). Fields that hold a capability name are flagged `support_plugin`; on
save the array is **pruned** to only the references still used by such a field, so stale
entries drop out.
- Parsing/serialization lives in `Config.cpp` (`parse_capability_ref`
`PluginCapabilityRef{ name, capability_name, uuid }`); the `plugins` option is defined in
`PrintConfig.cpp` and is a **process/print** preset setting. See
`tests/libslic3r/test_config.cpp` and
`tests/slic3rutils/test_plugin_capability_identifier.cpp`.
## Restoring missing plugins
When a slice is started (`Plater::reslice`), OrcaSlicer resolves the active preset's `plugins`
array against the loaded catalog. Any reference that is not installed is **missing**, and a
dialog appears before slicing continues. Missing references are split by whether they carry a
cloud UUID:
- **Missing OrcaCloud plugins** (have a UUID) — the dialog offers **Install plugins**, which
subscribes to, installs, loads, and enables each one so it is usable immediately, or
**Continue without plugins**.
- **Missing local plugins** (no UUID) — these cannot be fetched automatically, so the dialog
offers **Open OrcaCloud** (a browser search for similarly named plugins on the OrcaCloud
plugins explore page) or **Continue without plugins**.
Choosing *Continue without plugins* proceeds with the slice; the functionality those plugins
would have provided is simply skipped.
## The Plugins dialog
The Plugins dialog (`PluginsDialog.cpp` + `resources/web/dialog/PluginsDialog/`) presents each
installed plugin as an expandable row (Activate · Name · Version · Status). Expanding a plugin
shows a **capability tree** — one row per registered capability with its own enable checkbox,
type label, and (for runnable script capabilities) a **Run** button. The details pane is
tabbed:
| Tab | Shows |
|---|---|
| **Plugin Info** | thumbnail, source, types, author, version (with an update badge) |
| **Description** | the plugin's own description, taken from its Python/wheel metadata |
| **Changelog** | version / date / changes table |
| **Diagnostics** | load status and any error state |
Installing is done from a **Browse plugins** split dropdown that opens the OrcaCloud plugins
hub, with an **Install local plugin** option for sideloading a `.py` or `.whl` directly.
Perplugin and percapability enablement is persisted in a perplugin `.install_state.json`
sidecar (written by `PluginManager`).
---
## Security and observability
- **Security** — all C++→Python calls cross a trampoline that opens a percall audit context;
the `PluginAuditManager` audit hook then filters sensitive operations (today: a filesystem
write allowlist rooted at `data_dir()`, plus scoped roots such as the current Gcode
folder). This is groundwork, not a hardened sandbox — read
[`plugin_audit_hook.md`](plugin_audit_hook.md) for exactly what is and isn't enforced.
- **Observability** — Python `sys.stderr` (plugin tracebacks, including from
pluginspawned threads) is teed to `data_dir()/log/python_*.log`; C++side
load/discovery messages go to the main session log. How errors surface in the UI (message
box vs. the plugin details area) is described in
[`plugin_development.md`](plugin_development.md#how-errors-are-surfaced).
---
## Related documents
- [`plugin_development.md`](plugin_development.md) — authoring Python plugins; adding a new
C++ plugin type; testing and debugging.
- [`plugin_audit_hook.md`](plugin_audit_hook.md) — the audit hook: modes, allowlist,
extending the policy.
## Key files
| File | Role |
|---|---|
| `src/slic3r/plugin/PluginManager.{hpp,cpp}` | toplevel orchestrator; startup `initialize()` / `discover_plugins()` / `shutdown()` |
| `src/slic3r/plugin/PluginCatalog.{hpp,cpp}` | directory scan → `PluginDescriptor` inventory |
| `src/slic3r/plugin/PluginLoader.{hpp,cpp}` | threaded load/unload, dependency install, capability registry, auditkey stamping |
| `src/slic3r/plugin/PluginDescriptor.hpp` | the perplugin record (types, changelog, `sanitize_plugin_name`) |
| `src/slic3r/plugin/CloudPluginService.{hpp,cpp}` | cloud fetch / download / subscribe / unsubscribe |
| `src/slic3r/plugin/PythonInterpreter.{hpp,cpp}` | embedded CPython, GIL handoff, audithook + log install |
| `src/slic3r/plugin/PythonPluginBridge.{hpp,cpp}` | the `orca` module, `@orca.plugin` / `register_capability`, package + capability capture |
| `src/slic3r/plugin/PyPluginPackage.hpp` | the package base (`orca.base`) + `register_capabilities` |
| `src/slic3r/plugin/PyPluginTrampoline.hpp` | C++↔Python boundary macros (traceback logging + audit scope) |
| `src/slic3r/plugin/pluginTypes/*` | pertype capability bases + trampolines |
| `src/slic3r/plugin/PluginAuditManager.{hpp,cpp}` | the CPython audit hook and policy |
| `src/libslic3r/Config.cpp` | `parse_capability_ref`, the `plugins` array (de)serialization |
| `src/libslic3r/PrintConfig.cpp` | the `plugins` / `post_process_plugin` option definitions |
| `src/slic3r/GUI/PostProcessor.cpp` | resolves preset plugin refs and runs Gcode capabilities |
| `src/slic3r/GUI/PluginPickerDialog.{hpp,cpp}` | pick a capability as a setting value |
| `src/slic3r/GUI/Plater.cpp` | the missingplugins resolution dialog on slice (`reslice`) |
| `src/slic3r/GUI/GUI_App.cpp` | startup wiring (init, discovery, onload / oncapabilityload callbacks) and shutdown |
| `src/slic3r/GUI/PluginsDialog.cpp` | the Plugins dialog (capability tree, tabs, Run, Browse plugins) |