Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@ All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.1] - 2026-07-08 — 5.8 survival: the Capability Ladder

The bridge was **silently broken on UE 5.8**: 5.8 defaults
`RemoteControlSettings.bAllowAnyRemoteFunctionCall` to False, which blocks every
`/remote/object/call` with HTTP 400 *"not allowed by remote control settings"*.
So `execute_python` and every codegen tool failed live while `ue_status` and the
mock-only CI stayed green — connectivity was measured, capability never was.

### Fixed
- **`bAllowAnyRemoteFunctionCall=True`** in `Config/DefaultRemoteControl.ini` —
the one line that unblocks every tool on 5.8 (localhost-only, single-trusted-
operator; the bridge already permits arbitrary Python exec over RC).
- The Remote Control error **body is no longer swallowed** — the clients returned
only `"Client error '400 '"`; they now preserve the RC response body that says
*why* (`remote_control/async_client.py`, `sync_client.py`).

### Added — the Capability Ladder (so this can't recur silently)
- **`ue_preflight`** MCP tool + `ue_health_check(deep=true)`: a runtime probe that
separates *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
(`remote_control/preflight.py`).
- A **diagnosis map** (error signature → cause → fix); every future live failure
becomes a rule.
- `tests/test_preflight.py` — 13 tests including the golden **failure** test: the
5.8 block must surface as rung `Permitted` naming `bAllowAnyRemoteFunctionCall`.

### Notes
- The mock suite proves the ladder's logic; a live 5.8 editor round-trip
(`ue_preflight`) is still the only proof of end-to-end capability. The config
fix is evidence-based (engine header `RemoteControlSettings.h:351` + the exact
error body).

## [0.3.0] - 2026-07-07 — UE ↔ X3D round-trip harness

A new, self-contained `x3d_bridge/` package: a lossless UE↔X3D serialization
Expand Down
8 changes: 8 additions & 0 deletions Config/DefaultRemoteControl.ini
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,11 @@ bAllowConsoleCommandRemoteExecution=False
; .planning/PRODUCTION_READINESS_REPORT.md. The server is already localhost-only
; by default, which is the primary network-exposure mitigation.
bRestrictServerAccess=False

; REQUIRED on UE 5.8+ -- 5.8 added bAllowAnyRemoteFunctionCall (default False),
; which blocks EVERY /remote/object/call. Without this, all codegen tools 400
; with "not allowed by remote control settings" -- the bridge worked on 5.7
; (where remote function calls were allowed) and silently broke on 5.8. Safe
; under the bridge's localhost-only, single-trusted-operator model, which
; already permits arbitrary Python execution over Remote Control.
bAllowAnyRemoteFunctionCall=True
9 changes: 9 additions & 0 deletions remote_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,19 @@
from .circuit_breaker import CircuitBreaker
from .codegen import _CodeGen
from .constants import BASE_URL, TIMEOUT
from .preflight import (
Diagnosis,
PreflightResult,
diagnose,
http_error_detail,
preflight,
)
from .sync_client import UnrealRemoteControl

__all__ = [
"BASE_URL", "TIMEOUT",
"CircuitBreaker", "_CodeGen",
"UnrealRemoteControl", "AsyncUnrealRemoteControl",
# capability preflight
"preflight", "diagnose", "http_error_detail", "PreflightResult", "Diagnosis",
]
23 changes: 21 additions & 2 deletions remote_control/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
_poll_result_async,
_prepare_execution,
)
from .preflight import http_error_detail


class AsyncUnrealRemoteControl:
Expand Down Expand Up @@ -93,6 +94,19 @@ async def call_function(self, object_path: str, function_name: str, params: dict
r.raise_for_status()
return r.json()

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
Comment on lines +97 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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)
PY

Repository: 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:


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.

Suggested change
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.


async def execute_python(self, code: str) -> dict:
metrics.inc("requests.total")
if not self._cb.allow_request():
Expand All @@ -112,8 +126,13 @@ async def execute_python(self, code: str) -> dict:
self._cb.record_failure()
metrics.inc("requests.error")
metrics.record_latency("execute_python", time.time() - t0)
logger.error("UE5 connection failed: %s", e)
return {"result": None, "output": "", "error": f"Connection failed: {e}"}
# Preserve the Remote Control response body -- the bare str() of an
# HTTPStatusError is "Client error '400 '" and drops the reason
# (e.g. "not allowed by remote control settings"). Run ue_preflight
# for a named cause + fix.
detail = http_error_detail(e)
logger.error("UE5 remote call failed: %s", detail)
return {"result": None, "output": "", "error": detail}
except Exception:
# Non-connection fault (e.g. local file I/O in _prepare_execution): don't
# trip the breaker, but release any HALF_OPEN probe slot we acquired so the
Expand Down
9 changes: 8 additions & 1 deletion remote_control/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,14 @@ def _prepare_execution(temp_dir: str, code: str) -> tuple[str, str, str]:


def _build_exec_payload(script_file: str) -> dict:
"""Build the Remote Control call payload for script execution."""
"""Build the Remote Control call payload for script execution.

Note: on UE 5.8 this (and every /remote/object/call) is rejected with HTTP
400 "not allowed by remote control settings" unless the project sets
bAllowAnyRemoteFunctionCall=True -- a 5.8 security default that silently
broke the bridge (it worked on 5.7). The fix lives in
Config/DefaultRemoteControl.ini, not here.
"""
return {
"objectPath": "/Script/Engine.Default__KismetSystemLibrary",
"functionName": "ExecuteConsoleCommand",
Expand Down
193 changes: 193 additions & 0 deletions remote_control/preflight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""
preflight.py

The Capability Ladder: a runtime probe that answers "can the bridge actually
EXECUTE against the editor?" -- not merely "is it reachable?" -- and turns any
failure into a named cause plus a one-line fix.

Why this exists: connectivity, permission, and capability are THREE different
properties, and the bridge only ever measured the first. `ue_status` does a GET;
the circuit breaker only sees connection errors; the mock test suite never makes
a real Remote Control call. So the bridge can report "connected" and pass CI
while every codegen tool 400s live -- exactly what UE 5.8 caused by defaulting
`bAllowAnyRemoteFunctionCall` to False. This probe measures the other two
properties, and preserves the RC error body the client otherwise discards, so a
silent "400 " becomes an actionable diagnosis.

Provides:
- preflight(rc) -> PreflightResult : the ladder (Reachable -> Permitted -> Capable -> RoundTrip)
- diagnose(status, body, exc) -> Diagnosis : error signature -> cause + fix
- http_error_detail(exc) -> str : rich error string INCLUDING the RC response body
- PreflightResult, Diagnosis, RUNGS
"""

from __future__ import annotations

import json
from dataclasses import dataclass

# The rungs, cheapest -> deepest. The probe stops at the first red.
RUNGS = ("Reachable", "Permitted", "Capable", "RoundTrip")

# A pure, side-effect-free engine function. It doubles as the Permitted probe
# (does the call get rejected?) and the Capable probe (does a value round-trip?).
_PROBE_OBJECT = "/Script/Engine.Default__KismetSystemLibrary"
_PROBE_FUNCTION = "GetEngineVersion"


@dataclass
class Diagnosis:
cause: str
fix: str


@dataclass
class PreflightResult:
ok: bool
rung: str # Reachable | Permitted | Capable | RoundTrip | OK
cause: str = ""
fix: str = ""
evidence: str = "" # raw status + body, for the operator
engine_version: str = ""


def http_error_detail(exc: Exception) -> str:
"""Rich detail from an httpx error, INCLUDING the Remote Control response body.

The bare str() of an httpx.HTTPStatusError is "Client error '400 ' for url ..."
-- it drops the JSON body that says *why*. This keeps it, so a caller can see
(and diagnose) the actual reason.
"""
resp = getattr(exc, "response", None)
if resp is not None:
try:
body = (resp.text or "").strip()
except Exception:
body = ""
return f"HTTP {resp.status_code}: {body}" if body else f"HTTP {resp.status_code}"
return f"{type(exc).__name__}: {exc}"


def diagnose(status: int | None, body: str | None, exc: Exception | None) -> Diagnosis:
"""Map a Remote Control failure signature to a named cause + one-line fix.

Extend by adding a branch: every hard-won live failure should become a rule
here so the next occurrence self-explains.
"""
b = (body or "").lower()

if exc is not None:
return Diagnosis(
f"Remote Control is unreachable ({type(exc).__name__}).",
"Open the UE editor with the Remote Control plugin enabled and its Web "
"Server started on :30010.",
)

if status == 400 and "not allowed by remote control settings" in b:
if "python" in b:
return Diagnosis(
"Remote Python execution is disabled "
"(RemoteControlSettings.bEnableRemotePythonExecution = False).",
"Set bEnableRemotePythonExecution=True in the project's "
"Config/DefaultRemoteControl.ini; restart the editor.",
)
return Diagnosis(
"UE 5.8 blocks remote function calls by default "
"(RemoteControlSettings.bAllowAnyRemoteFunctionCall = False).",
"Set bAllowAnyRemoteFunctionCall=True in Config/DefaultRemoteControl.ini "
"(or Project Settings -> Plugins -> Remote Control -> Security -> "
"'Allow Any Remote Function Call'); restart the editor.",
)

if status in (401, 403) or "passphrase" in b:
return Diagnosis(
"Remote Control requires a passphrase (bRestrictServerAccess = True).",
"Set bRestrictServerAccess=False, or configure the MCP client to send "
"the passphrase.",
)

if status == 404:
return Diagnosis(
"The target object or function is not remotely accessible.",
"Verify the objectPath/functionName; some engine objects (e.g. settings "
"CDOs) cannot be reached remotely.",
)

return Diagnosis(
f"Unrecognized Remote Control failure (HTTP {status}).",
"Inspect the evidence body and add a diagnose() rule for this signature.",
)


def _evidence(status: int | None, body: str | None, exc: Exception | None) -> str:
if exc is not None:
return f"{type(exc).__name__}: {exc}"
return f"HTTP {status}: {(body or '').strip()}"


def _extract_return_value(body: str) -> str:
"""Pull the scalar return value out of an RC /remote/object/call response."""
try:
data = json.loads(body)
except (json.JSONDecodeError, TypeError):
return ""
if isinstance(data, dict):
# RC names the return "ReturnValue"; fall back to the first non-empty string.
if isinstance(data.get("ReturnValue"), str):
return data["ReturnValue"]
for value in data.values():
if isinstance(value, str) and value:
return value
return ""


async def preflight(rc) -> PreflightResult:
"""Run the capability ladder against a Remote Control client.

`rc` must provide:
async _probe_call(path, method="GET", payload=None) -> (status|None, body, exc|None)
async execute_python(code) -> {"result", "output", "error"}

Stops at the first failed rung and returns a named cause + fix.
"""
# Rung 0 -- Reachable
status, body, exc = await rc._probe_call("/remote/info", "GET")
if exc is not None or status != 200:
d = diagnose(status, body, exc)
return PreflightResult(False, "Reachable", d.cause, d.fix, _evidence(status, body, exc))

# Rungs 1+2 -- Permitted (does the call get rejected?) and Capable (does a value return?)
status, body, exc = await rc._probe_call(
"/remote/object/call",
"PUT",
{"objectPath": _PROBE_OBJECT, "functionName": _PROBE_FUNCTION},
)
if exc is not None or status != 200:
rung = "Reachable" if exc is not None else "Permitted"
d = diagnose(status, body, exc)
return PreflightResult(False, rung, d.cause, d.fix, _evidence(status, body, exc))

version = _extract_return_value(body)
if not version:
return PreflightResult(
False,
"Capable",
"A remote function call was permitted but returned no value.",
"Unexpected Remote Control response shape; check the RC/engine version.",
_evidence(status, body, None),
)

# Rung 3 -- RoundTrip (the full write -> exec -> poll -> result Python path)
result = await rc.execute_python('print("RESULT: PREFLIGHT_OK")')
if result.get("result") != "PREFLIGHT_OK":
return PreflightResult(
False,
"RoundTrip",
"Function calls work, but the Python write->exec->poll result path failed.",
"Check the Python Editor Script Plugin and the temp-script directory; "
"see remote_control/execution.py.",
str(result.get("error") or result),
engine_version=version,
)

return PreflightResult(True, "OK", engine_version=version)
7 changes: 5 additions & 2 deletions remote_control/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
_poll_result_sync,
_prepare_execution,
)
from .preflight import http_error_detail


class UnrealRemoteControl:
Expand Down Expand Up @@ -104,8 +105,10 @@ def execute_python(self, code: str) -> dict:
self._cb.record_failure()
metrics.inc("requests.error")
metrics.record_latency("execute_python", time.time() - t0)
logger.error("UE5 connection failed: %s", e)
return {"result": None, "output": "", "error": f"Connection failed: {e}"}
# Preserve the Remote Control response body (see async_client / preflight).
detail = http_error_detail(e)
logger.error("UE5 remote call failed: %s", detail)
return {"result": None, "output": "", "error": detail}
except Exception:
# Non-connection fault (e.g. local file I/O in _prepare_execution): don't
# trip the breaker, but release any HALF_OPEN probe slot we acquired so the
Expand Down
Loading
Loading