The shipped unity plugin scripts capture child output with text=True behind guards that name only TimeoutExpired/OSError — the same narrow-net defect #380 and #383 closed in orchestrator core, still open in plugin data.
The defect genus
subprocess.run(..., text=True) decodes with the locale codec at errors="strict". The fault that raises is UnicodeDecodeError — a UnicodeError, so a ValueError. It is neither an OSError nor a SubprocessError, so a guard naming only those does not catch it, and the decode happens after the child has exited, so a child that succeeded takes its exit code down with the raise.
That is the same shape as #383 (plugins/bus.py, probe.py) and the decode half of #380 (adapters/tmux_base.py). Those were fixed by adding errors="replace" — stop raising, rather than catch more.
Sites
Under src/bmad_loop/data/plugins/unity/:
unity_ready.py:113 (wait-for-ready) and unity_ready.py:129 (run-tool) — both guarded by except subprocess.TimeoutExpired only. Output is sliced ([-500:]) into hook advisory text.
unity_quiesce.py:111
unity_plugin.py:408
unity_setup.py:182, :324, and :402 — :402 (setup-mcp) has no try at all; it writes proc.stdout + proc.stderr straight to stderr.
The decoded bytes are output from the Unity CLI and the Editor bridge — an operator's own toolchain, not a path, so nothing constrains them to the run's encoding. A project path, asset name or C# diagnostic carrying a non-UTF-8 byte under a non-UTF-8 locale is enough.
Why it was not folded into the #380/#383 fix
These are shipped plugin data, not orchestrator core, and neither issue names them. Folding them in would have widened a bundled fix past the two issues it closes. Filed separately so the decision is recorded rather than implied.
Asked for
The shipped unity plugin scripts capture child output with
text=Truebehind guards that name onlyTimeoutExpired/OSError— the same narrow-net defect #380 and #383 closed in orchestrator core, still open in plugin data.The defect genus
subprocess.run(..., text=True)decodes with the locale codec aterrors="strict". The fault that raises isUnicodeDecodeError— aUnicodeError, so aValueError. It is neither anOSErrornor aSubprocessError, so a guard naming only those does not catch it, and the decode happens after the child has exited, so a child that succeeded takes its exit code down with the raise.That is the same shape as #383 (
plugins/bus.py,probe.py) and the decode half of #380 (adapters/tmux_base.py). Those were fixed by addingerrors="replace"— stop raising, rather than catch more.Sites
Under
src/bmad_loop/data/plugins/unity/:unity_ready.py:113(wait-for-ready) andunity_ready.py:129(run-tool) — both guarded byexcept subprocess.TimeoutExpiredonly. Output is sliced ([-500:]) into hook advisory text.unity_quiesce.py:111unity_plugin.py:408unity_setup.py:182,:324, and:402—:402(setup-mcp) has notryat all; it writesproc.stdout + proc.stderrstraight to stderr.The decoded bytes are output from the Unity CLI and the Editor bridge — an operator's own toolchain, not a path, so nothing constrains them to the run's encoding. A project path, asset name or C# diagnostic carrying a non-UTF-8 byte under a non-UTF-8 locale is enough.
Why it was not folded into the #380/#383 fix
These are shipped plugin data, not orchestrator core, and neither issue names them. Folding them in would have widened a bundled fix past the two issues it closes. Filed separately so the decision is recorded rather than implied.
Asked for
errors="replace"to each capture, matching the fix used inbus.py/probe.py."replace"rather thansurrogateescapefor the same reason as there: this text feeds forward into advisory strings and journals, never back to a filesystem API, andsurrogateescapewould seed it with lone surrogates that cannot be encoded to UTF-8 — converting a decode crash into the encode crash Codec except-tuple asymmetry in _append_diag_jsonl (and POSIX-only strictness in tmux_base) #380 fixed.subprocess.runnever executes the stdlib decode, so such a test passes identically with the bug restored — the fake-green this repo already hit in run_verify_commands decodes operator command output strictly; a non-UTF-8 byte crashes the run #378. Note unity_cleanup.py ships with zero behavioral tests, and the plugin-side dispatch guards are equally unpinned #546 records that the unity scripts are thinly tested generally.