fix: UE 5.8 survival -- Capability Ladder + bAllowAnyRemoteFunctionCall (v0.3.1) - #20
Conversation
UE 5.8 added RemoteControlSettings.bAllowAnyRemoteFunctionCall (default False), which blocks EVERY /remote/object/call with HTTP 400 "not allowed by remote control settings". The bridge worked on 5.7 (remote function calls allowed) and silently broke on 5.8 -- every codegen tool (execute_python, actors, spatial, lighting, ...) fails. Mock-only tests never make the RC call, so CI stayed green while live was dead. Fix: bAllowAnyRemoteFunctionCall=True in Config/DefaultRemoteControl.ini (localhost-only, single-trusted-operator; the bridge already permits arbitrary Python exec over RC, so no real added exposure). Documented the gotcha in remote_control/execution.py. Root-caused live against UE 5.8 (exact error body + engine header RemoteControlSettings.h:351); the full live round trip was not re-verified in-session (needs the project's RC setting applied). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…to a named fix Connectivity, permission, and capability are three different properties; the bridge only measured connectivity (ue_status = a GET). So it could report "connected" and pass mock-only CI while every codegen tool 400s live -- which is exactly what UE 5.8's bAllowAnyRemoteFunctionCall=False default caused. - remote_control/preflight.py: preflight(rc) runs a ladder Reachable -> Permitted -> Capable -> RoundTrip, stops at the first red, and returns a named cause + one-line fix; a data-driven diagnose() map keyed on the RC error body (5.8 function block, python-exec disabled, passphrase, unreachable, ...); http_error_detail() preserves the RC response body the clients dropped. - async_client / sync_client: surface the RC error body instead of the bare "Client error '400 '"; add _probe_call for the ladder. - ue_preflight MCP tool + ue_health_check(deep=true) in mcp_server.py (inline, like ue_status/ue_health_check -- out of the tier table + exec-sim registry). - tests/test_preflight.py: 13 tests incl. the golden FAILURE test -- the 5.8 block must surface as rung Permitted naming bAllowAnyRemoteFunctionCall. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unctionCall) Bump ue_mcp/__version__.py to 0.3.1 (single version source; tag must match) and add the CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR fixes a UE 5.8 regression that silently blocks remote-control calls by setting ChangesCapability Ladder for UE 5.8 Remote Control
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant MCPTool as ue_preflight/ue_health_check
participant preflight
participant RemoteControlClient
participant diagnose
MCPTool->>preflight: preflight(rc)
preflight->>RemoteControlClient: GET /remote/info
alt unreachable
RemoteControlClient-->>preflight: exception
preflight->>diagnose: diagnose(status, body, exc)
diagnose-->>preflight: Diagnosis(cause, fix)
preflight-->>MCPTool: PreflightResult(rung=Reachable)
else reachable
preflight->>RemoteControlClient: PUT /remote/object/call
alt not permitted/capable
RemoteControlClient-->>preflight: error status/body
preflight->>diagnose: diagnose(status, body, exc)
diagnose-->>preflight: Diagnosis(cause, fix)
preflight-->>MCPTool: PreflightResult(rung=Permitted/Capable)
else capable
preflight->>RemoteControlClient: execute_python(sentinel)
RemoteControlClient-->>preflight: result
preflight-->>MCPTool: PreflightResult(ok=True, rung=RoundTrip)
end
end
MCPTool-->>MCPTool: return JSON (ok/rung/cause/fix/evidence)
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning Review ran into problems🔥 ProblemsGit: Failed to clone repository. Please run the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
remote_control/preflight.py (2)
86-100: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueThe
"python"body heuristic is fragile for distinguishing error subtypes.Line 87 uses
if "python" in bto distinguish "Remote Python execution disabled" from "function calls blocked by default." The actual UE 5.8 error body wording may not reliably contain "python" for the python-execution case, which could cause misdiagnosis. Consider matching against a more specific substring (e.g.,"remote python"or the setting name itself) or documenting the exact error body strings expected from each case.♻️ Suggested refinement
if status == 400 and "not allowed by remote control settings" in b: - if "python" in b: + if "bEnableRemotePythonExecution" in b or "remote python" in b: return Diagnosis( "Remote Python execution is disabled " "(RemoteControlSettings.bEnableRemotePythonExecution = False).",🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@remote_control/preflight.py` around lines 86 - 100, The error subtype check in preflight handling is too brittle because the Remote Python case is identified only by checking for a generic “python” substring in the response body. Update the Diagnosis selection logic in the preflight flow to use a more specific discriminator tied to the actual Remote Control error wording or setting name (for example, the remote Python execution message or RemoteControlSettings.bEnableRemotePythonExecution), so the branch between Remote Python disabled and generic remote function-call blocking is reliable even if the body text changes.
144-193: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win
preflight()RoundTrip rung lacks exception guard aroundexecute_python.Line 181 calls
rc.execute_python(...)without a try/except. Ifexecute_pythonraises (e.g., transient connection drop after the Permitted probe passed), the exception propagates unhandled and the caller gets a crash instead of aPreflightResultwith a named cause. The contract says it returns a dict, but a defensive guard would make the ladder robust against partial failures.🛡️ Suggested defensive guard
# Rung 3 -- RoundTrip (the full write -> exec -> poll -> result Python path) - result = await rc.execute_python('print("RESULT: PREFLIGHT_OK")') + try: + result = await rc.execute_python('print("RESULT: PREFLIGHT_OK")') + except Exception as exc: + return PreflightResult( + False, + "RoundTrip", + f"execute_python raised an exception ({type(exc).__name__}).", + "Check the Remote Control connection and Python Editor Script Plugin.", + _evidence(None, None, exc), + engine_version=version, + ) if result.get("result") != "PREFLIGHT_OK":🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@remote_control/preflight.py` around lines 144 - 193, The preflight ladder in preflight() should defensively handle failures from rc.execute_python during the RoundTrip rung instead of letting exceptions escape. Wrap the execute_python call in a try/except, and on any raised exception return a failed PreflightResult with rung "RoundTrip", a clear cause/fix, and evidence derived from the exception so the caller gets a structured result rather than a crash. Keep the change localized to preflight() and preserve the existing engine_version/version handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@remote_control/async_client.py`:
- Around line 97-108: The _probe_call helper still lets some transport failures
escape, so its “never raises” behavior is incomplete. Update the exception
handling in _probe_call to catch the full httpx.TransportError base class
instead of only specific subclasses, while keeping the existing return shape of
(status, body, exc). Make sure the change preserves the preflight ladder
behavior in AsyncClient and continues to return the captured exception for all
transport-related failures.
---
Nitpick comments:
In `@remote_control/preflight.py`:
- Around line 86-100: The error subtype check in preflight handling is too
brittle because the Remote Python case is identified only by checking for a
generic “python” substring in the response body. Update the Diagnosis selection
logic in the preflight flow to use a more specific discriminator tied to the
actual Remote Control error wording or setting name (for example, the remote
Python execution message or RemoteControlSettings.bEnableRemotePythonExecution),
so the branch between Remote Python disabled and generic remote function-call
blocking is reliable even if the body text changes.
- Around line 144-193: The preflight ladder in preflight() should defensively
handle failures from rc.execute_python during the RoundTrip rung instead of
letting exceptions escape. Wrap the execute_python call in a try/except, and on
any raised exception return a failed PreflightResult with rung "RoundTrip", a
clear cause/fix, and evidence derived from the exception so the caller gets a
structured result rather than a crash. Keep the change localized to preflight()
and preserve the existing engine_version/version handling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ee209462-e728-4da1-88f1-c910d4c762bf
📒 Files selected for processing (10)
CHANGELOG.mdConfig/DefaultRemoteControl.iniremote_control/__init__.pyremote_control/async_client.pyremote_control/execution.pyremote_control/preflight.pyremote_control/sync_client.pytests/test_preflight.pyue_mcp/__version__.pyue_mcp/mcp_server.py
| async def _probe_call(self, path: str, method: str = "GET", payload: dict | None = None): | ||
| """Low-level RC call for the capability preflight: returns | ||
| (status, body, exc) and never raises, so the ladder can inspect the | ||
| real status code and response body instead of a swallowed exception.""" | ||
| try: | ||
| if method == "GET": | ||
| r = await self._client.get(path) | ||
| else: | ||
| r = await self._client.put(path, json=payload) | ||
| return r.status_code, r.text, None | ||
| except (httpx.ConnectError, httpx.TimeoutException) as e: | ||
| return None, "", e |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== file outline ==\n'
ast-grep outline remote_control/async_client.py --view expanded || true
printf '\n== relevant lines ==\n'
sed -n '80,125p' remote_control/async_client.py | cat -n
printf '\n== httpx dependency references ==\n'
rg -n 'httpx|TransportError|ConnectError|TimeoutException|ReadError|WriteError|NetworkError|ProtocolError' -S pyproject.toml requirements*.txt setup*.py poetry.lock uv.lock Pipfile* . 2>/dev/null || true
printf '\n== installed httpx version / exception hierarchy probe ==\n'
python3 - <<'PY'
import sys
try:
import httpx
print("httpx_version:", getattr(httpx, "__version__", "unknown"))
names = ["TransportError", "ConnectError", "TimeoutException", "ReadError", "WriteError", "NetworkError", "ProtocolError"]
for name in names:
obj = getattr(httpx, name, None)
if obj is None:
print(name, ": missing")
continue
bases = [b.__name__ for b in obj.__mro__[1:4]]
print(f"{name}: bases={bases}")
except Exception as e:
print("probe_failed:", type(e).__name__, e)
sys.exit(0)
PYRepository: JosephOIbrahim/UnrealEngine_Bridge
Length of output: 2257
🌐 Web query:
HTTPX exception hierarchy TransportError ConnectError TimeoutException ReadError WriteError NetworkError ProtocolError documentation
💡 Result:
The HTTPX exception hierarchy is organized under a root base class called HTTPError [1][2]. Below this, RequestError serves as the primary base for exceptions occurring during the request process [3][1]. The specific exceptions you requested all fall under the TransportError branch, which itself is a subclass of RequestError [1][2]. The hierarchy for these components is structured as follows [1][2]: 1. RequestError (Base for request process errors) - TransportError (Base for transport layer exceptions) - TimeoutException (Base for all timeout-related errors) - ConnectTimeout - ReadTimeout - WriteTimeout - PoolTimeout - NetworkError (Base for network-related errors) - ConnectError - ReadError - WriteError - CloseError - ProtocolError (Base for protocol-related errors) - LocalProtocolError - RemoteProtocolError In summary, TransportError acts as the parent for TimeoutException, NetworkError, and ProtocolError [1][2]. NetworkError specifically branches into ConnectError, ReadError, and WriteError [1][2]. These exceptions are designed to allow developers to catch errors at varying levels of granularity—from catching a specific error (e.g., ConnectError) to catching an entire category (e.g., NetworkError or TransportError) [3].
Citations:
- 1: https://github.com/encode/httpx/blob/def4778d/httpx/_exceptions.py
- 2: https://github.com/encode/httpx/blob/37593c1952f4972040f6163da67e3777fd3d2e94/httpx/_exceptions.py
- 3: https://deepwiki.com/encode/httpx/7.1-exception-hierarchy
Catch the full transport-error base class in _probe_call. ReadError, WriteError, and ProtocolError still escape here because they all inherit from httpx.TransportError, so the “never raises” contract can still be broken on transport failures. Catching the base class matches the docstring and keeps the preflight ladder from crashing.
🛡️ Proposed fix
- except (httpx.ConnectError, httpx.TimeoutException) as e:
+ except httpx.TransportError as e:
return None, "", e📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async def _probe_call(self, path: str, method: str = "GET", payload: dict | None = None): | |
| """Low-level RC call for the capability preflight: returns | |
| (status, body, exc) and never raises, so the ladder can inspect the | |
| real status code and response body instead of a swallowed exception.""" | |
| try: | |
| if method == "GET": | |
| r = await self._client.get(path) | |
| else: | |
| r = await self._client.put(path, json=payload) | |
| return r.status_code, r.text, None | |
| except (httpx.ConnectError, httpx.TimeoutException) as e: | |
| return None, "", e | |
| async def _probe_call(self, path: str, method: str = "GET", payload: dict | None = None): | |
| """Low-level RC call for the capability preflight: returns | |
| (status, body, exc) and never raises, so the ladder can inspect the | |
| real status code and response body instead of a swallowed exception.""" | |
| try: | |
| if method == "GET": | |
| r = await self._client.get(path) | |
| else: | |
| r = await self._client.put(path, json=payload) | |
| return r.status_code, r.text, None | |
| except httpx.TransportError as e: | |
| return None, "", e |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@remote_control/async_client.py` around lines 97 - 108, The _probe_call helper
still lets some transport failures escape, so its “never raises” behavior is
incomplete. Update the exception handling in _probe_call to catch the full
httpx.TransportError base class instead of only specific subclasses, while
keeping the existing return shape of (status, body, exc). Make sure the change
preserves the preflight ladder behavior in AsyncClient and continues to return
the captured exception for all transport-related failures.
5.8 survival -- the Capability Ladder
The bridge was silently broken on UE 5.8. 5.8 defaults
RemoteControlSettings.bAllowAnyRemoteFunctionCallto False, which blocks every/remote/object/callwith HTTP 400 "not allowed by remote control settings". Soexecute_pythonand every codegen tool failed live -- whileue_statusand themock-only CI stayed green. Connectivity was measured; capability never was.
Fixed
bAllowAnyRemoteFunctionCall=TrueinConfig/DefaultRemoteControl.ini--the one line that unblocks every tool on 5.8 (localhost-only, single-trusted-
operator model; the bridge already permits arbitrary Python exec over RC).
returned only "Client error '400 '"; they now preserve the RC body that says
why.
Added -- the Capability Ladder (so this can't recur silently)
ue_preflight+ue_health_check(deep=true): a runtime probe thatseparates reachable (a GET) from permitted / capable (a real function
call) / round-trip (the full Python path), stops at the first failure, and
returns the named cause + one-line fix with the raw RC body as evidence.
failure becomes a rule.
rung
PermittednamingbAllowAnyRemoteFunctionCall.Why it matters: connectivity, permission, and capability are three different
signals. The bridge measured one. A health check can never again read green
while the bridge is dead -- and any failure now names its own fix.
Summary by CodeRabbit
New Features
Bug Fixes
Tests