using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Unity.Pipeline.Models;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Security;
using Unity.Pipeline.Threading;
using UnityEngine;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Unity.Pipeline
{
///
/// HTTP server that enables CLI tools to execute commands in Unity Editor.
/// Represents an Instance that can serve as a Pipeline Element for automation.
///
public abstract class BasePipelineServer
{
///
/// Maximum accepted request body size (1 MiB). Requests larger than this are rejected with
/// 413 to bound the memory a single remote request can force the server to buffer.
///
private const long MaxRequestBodyBytes = 1 * 1024 * 1024;
///
/// Upper bound on how many bytes we read-and-discard from an over-limit request body before
/// sending the 413. HttpListener resets the TCP connection if we close the response while the
/// client is still uploading, and the client then sees a connection error instead of the 413.
/// Draining lets the client finish so it can read the response. Kept comfortably above
/// to cover clients only slightly over the cap, while still
/// bounding the work a single abusive request can force (a larger body simply resets).
///
private const long MaxDrainBytes = 8 * 1024 * 1024;
private bool m_IsRunning;
private int m_Port;
private HttpListener m_HttpListener;
private readonly Dispatcher m_Dispatcher = new Dispatcher();
private bool m_WatchdogEnabled;
private bool m_WatchdogArmed;
private DateTime m_LastWatchdogCheck;
///
/// This server's own main-thread dispatcher. Each server instance owns one (no global
/// singleton), so tests that start their own server never affect any other server's
/// dispatch. Pump it via ProcessWorkQueue from the main thread (auto-pumped on
/// EditorApplication.update in the editor; pumped by RuntimePipelineManager.Update in a
/// player; pumped explicitly by tests).
///
public Dispatcher Dispatcher => m_Dispatcher;
///
/// Whether the server is running AND its HTTP listener is actually listening. More accurate
/// than the internal running flag alone: returns false if the listener was stopped/disposed
/// (e.g. by a domain reload) even when the flag is stale-true. Cheap and non-blocking.
///
/// A self-HTTP probe was considered for "does it actually respond" but rejected: the server
/// processes requests strictly one-at-a-time (HandleRequests awaits ProcessRequest), so a
/// self-probe deadlocks if issued from within a handler and false-negatives whenever a
/// request is in flight — unsafe for a watchdog. IsListening reliably catches the realistic
/// failure (listener stopped by a domain reload), which is what we need.
///
public bool IsRunning => m_IsRunning && m_HttpListener != null && m_HttpListener.IsListening;
///
/// When enabled, the server periodically checks its own HTTP listener and re-opens it in
/// place if it died without going through (e.g. an unexpected listener
/// fault). The server instance survives such failures — only the listener dies — so it can
/// self-heal without an external restart. Default OFF: transient/test servers must not
/// watchdog. The editor owner enables it for the live server.
///
/// Setting this while the server is running arms/disarms the watchdog immediately; otherwise
/// it takes effect on the next . Domain reloads are NOT handled here (the
/// instance is torn down and recreated by [InitializeOnLoad]); the watchdog only revives a
/// listener that died while the instance is still alive.
///
public bool WatchdogEnabled
{
get => m_WatchdogEnabled;
set
{
if (m_WatchdogEnabled == value)
return;
m_WatchdogEnabled = value;
if (!m_IsRunning)
return;
if (value)
ArmWatchdog();
else
DisarmWatchdog();
}
}
///
/// How often the watchdog checks the listener (seconds). Default 5.
///
public double WatchdogIntervalSeconds { get; set; } = 5.0;
///
/// Port number the server is listening on. 0 if not running.
/// Range: 7800-7899 for Editor, 7900-7999 for Runtime (avoids unity-tools port range 37800-37899).
///
public int Port => m_Port;
public abstract DateTime StartedAt { get; }
///
/// Whether this server writes/deletes the shared instance descriptor (.unity-pipeline-port).
/// Test servers override this to false so they never clobber the live server's descriptor —
/// the test already knows its port, so no discovery file is needed.
///
protected virtual bool WritesDescriptor => true;
///
/// Whether this server advertises commands marked [CliCommand(RuntimeOnly = true)] in
/// its /api/commands listing. Runtime servers list them; Editor servers hide them so a
/// client connected to an Editor only sees the Editor command surface.
///
protected virtual bool IncludeRuntimeOnlyCommands => true;
protected abstract void CreateInstanceDescriptor();
protected abstract void DeleteInstanceDescriptor();
protected abstract void UpdateHeartBeat();
protected abstract object GetServerStatus();
protected abstract string GetToken();
protected virtual void ServerStarted()
{
}
protected virtual void ServerStopped()
{
}
///
/// This server's current auth token. Exposed to the test assembly (via InternalsVisibleTo)
/// so the test client can authenticate without re-deriving the token.
///
internal string Token => GetToken();
///
/// Start the HTTP server on the specified port or auto-assign from range.
///
/// Port to bind to, or 0 for auto-assignment from server-specific range
public void Start(int port = 0)
{
if (m_IsRunning)
return;
m_Port = port == 0 ? FindAvailablePort() : port;
try
{
// Initialize this server's own dispatcher (captures the main thread; Start() is
// always called from the main thread).
m_Dispatcher.Initialize();
// Warm the token cache on the main thread before the listener accepts requests:
// per-request auth runs on background threads and the Editor token is SessionState-
// backed (main-thread-only). Re-runs after every domain reload via [InitializeOnLoad].
SecurityTokenManager.GetOrCreateToken();
// Mark running before opening the listener so HandleRequests' loop guard stays true
// as soon as it starts on the threadpool.
m_IsRunning = true;
OpenListener();
System.Console.WriteLine($"Start HTTP server: port:{m_Port}");
if (WritesDescriptor)
CreateInstanceDescriptor();
ArmWatchdog();
// Keep the Editor and player loop (and thus Update -> Dispatcher.ProcessWorkQueue) running
// while the window is unfocused or minimized this is similar to auto_tick (especially for the Player).
Application.runInBackground = true;
ServerStarted();
}
catch (Exception)
{
m_IsRunning = false;
m_HttpListener?.Stop();
m_HttpListener = null;
throw;
}
}
///
/// Create the HTTP listener, bind it, and start the request-handling loop. Extracted from
/// so the watchdog can re-open a dead listener in place without
/// re-running the rest of startup (dispatcher init, descriptor). Caller sets m_IsRunning.
///
private void OpenListener()
{
m_HttpListener = new HttpListener();
AddLoopbackPrefixes(m_HttpListener, m_Port);
m_HttpListener.Start();
// Start request handling
_ = Task.Run(HandleRequests);
}
///
/// Bind a wildcard-host prefix so the listener accepts any Host header ("127.0.0.1",
/// "localhost", "::1") no matter how a client — or its DNS resolver — resolves the name.
/// This is required because Mono's HttpListener binds a hostname prefix to a single resolved
/// address family and then matches the request's Host literally per prefix: an
/// explicit "http://127.0.0.1/" prefix rejects "Host: localhost" (and vice-versa) with a
/// 400. A wildcard sidesteps that. The wildcard binds all interfaces at the socket level, so
/// access is confined to the local machine by the loopback check in
/// (plus bearer-token auth).
///
private static void AddLoopbackPrefixes(HttpListener listener, int port)
{
listener.Prefixes.Add($"http://+:{port}/");
}
///
/// Stop the HTTP server and clean up resources.
///
public void Stop()
{
if (!m_IsRunning)
return;
m_IsRunning = false;
DisarmWatchdog();
System.Console.WriteLine("Pipeline Server stopped");
try
{
if (WritesDescriptor)
DeleteInstanceDescriptor();
m_HttpListener?.Stop();
m_HttpListener?.Close();
ServerStopped();
}
catch
{
// Ignore cleanup errors
}
finally
{
m_HttpListener = null;
m_Dispatcher.Shutdown();
}
// Note: Keep port value for diagnostic purposes
}
///
/// Arm the watchdog (no-op if disabled or already armed). In the editor the tick rides
/// EditorApplication.update; in a player it is driven by RuntimePipelineManager.Update via
/// .
///
private void ArmWatchdog()
{
if (!m_WatchdogEnabled || m_WatchdogArmed)
return;
m_WatchdogArmed = true;
m_LastWatchdogCheck = DateTime.UtcNow;
#if UNITY_EDITOR
UnityEditor.EditorApplication.update += WatchdogTick;
#endif
}
private void DisarmWatchdog()
{
if (!m_WatchdogArmed)
return;
m_WatchdogArmed = false;
#if UNITY_EDITOR
UnityEditor.EditorApplication.update -= WatchdogTick;
#endif
}
///
/// Watchdog heartbeat: throttled to , re-opens the HTTP
/// listener in place if it died while the server is still meant to be running. Safe to call
/// every frame. In the editor it is subscribed to EditorApplication.update by ArmWatchdog;
/// in a player RuntimePipelineManager.Update calls it.
///
public void WatchdogTick()
{
if (!m_WatchdogArmed)
return;
#if UNITY_EDITOR
// Don't fight domain reloads / asset updates — the listener is expected to be torn down
// then, and [InitializeOnLoad] recreates the server afterwards.
if (UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isUpdating)
return;
#endif
var now = DateTime.UtcNow;
if ((now - m_LastWatchdogCheck).TotalSeconds < WatchdogIntervalSeconds)
return;
m_LastWatchdogCheck = now;
if (m_HttpListener != null && m_HttpListener.IsListening)
return; // healthy
try
{
// Dispose the dead listener so it releases the port binding before we re-bind a new
// one on the same port (a stopped-but-not-closed listener can keep the port held).
try { m_HttpListener?.Close(); } catch { }
m_HttpListener = null;
// The instance is alive and the watchdog is armed → the server is meant to be
// listening. Re-open the listener in place.
m_IsRunning = true;
OpenListener();
Debug.Log($"Pipeline watchdog re-opened HTTP listener on port {m_Port}");
}
catch (Exception ex)
{
Debug.LogWarning($"Pipeline watchdog failed to re-open listener on port {m_Port}: {ex.Message}");
}
}
///
/// Handle incoming HTTP requests with routing to appropriate endpoints.
///
private async Task HandleRequests()
{
while (m_IsRunning && m_HttpListener != null && m_HttpListener.IsListening)
{
try
{
var context = await m_HttpListener.GetContextAsync();
await ProcessRequest(context);
}
catch (ObjectDisposedException)
{
// Listener was stopped, exit gracefully
break;
}
catch (HttpListenerException)
{
// Listener was stopped, exit gracefully
break;
}
catch (Exception ex)
{
Debug.LogError(ex);
}
}
m_IsRunning = false;
}
///
/// Process individual HTTP request and route to appropriate handler.
///
protected virtual async Task ProcessRequest(HttpListenerContext context)
{
var request = context.Request;
var response = context.Response;
try
{
// Confine access to the local machine. The listener binds a wildcard host (see
// AddLoopbackPrefixes) so it accepts any Host header, which also means it accepts
// connections on non-loopback interfaces — reject anything that isn't loopback
// before doing any other work. Bearer-token auth is still enforced below.
var remoteAddress = request.RemoteEndPoint?.Address;
if (remoteAddress == null || !IPAddress.IsLoopback(remoteAddress))
{
await SendStatusResponse(response, 403, "Forbidden", "Only loopback connections are allowed");
return;
}
// Reject browser-originated requests. Legitimate non-browser clients (CLI, CI) never
// send an Origin header; emitting no CORS headers and refusing any request that
// carries one prevents a website in the developer's browser from reaching this
// local server (and short-circuits CORS preflights).
if (!string.IsNullOrEmpty(request.Headers["Origin"]))
{
await SendStatusResponse(response, 403, "Forbidden", "Cross-origin requests are not allowed");
return;
}
// Authenticate every request with the bearer token before routing.
if (!IsAuthorized(request))
{
await SendStatusResponse(response, 401, "Unauthorized", "Missing or invalid authentication token");
return;
}
// Route to appropriate endpoint
var path = request.Url.AbsolutePath.ToLowerInvariant();
switch (path)
{
case "/api/status":
await HandleStatusRequest(response);
break;
case "/api/editor_status":
await HandleEditorStatusRequest(response);
break;
case "/api/commands":
await HandleCommandsRequest(response);
break;
case "/api/exec":
if (request.HttpMethod == "POST")
await HandleExecRequest(request, response);
else
await HandleMethodNotAllowed(response);
break;
case "/api/test-status":
await HandleTestStatusRequest(response);
break;
default:
await HandleNotFound(response);
break;
}
}
catch (Exception)
{
// Ensure response is always closed
try
{
if (response.OutputStream.CanWrite)
{
response.StatusCode = 500;
response.OutputStream.Close();
}
}
catch { }
}
}
///
/// Validate the request's bearer token against this server's token.
///
private bool IsAuthorized(HttpListenerRequest request)
{
var header = request.Headers["Authorization"];
if (string.IsNullOrEmpty(header))
return false;
const string prefix = "Bearer ";
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return false;
var token = header.Substring(prefix.Length).Trim();
return SecurityTokenManager.ConstantTimeEquals(token, GetToken());
}
///
/// Send a structured JSON error response with an explicit HTTP status code.
///
private async Task SendStatusResponse(HttpListenerResponse response, int statusCode, string error, string details)
{
try
{
response.StatusCode = statusCode;
response.ContentType = "application/json";
var json = JsonConvert.SerializeObject(BaseResponse.Failure(error, details), Formatting.Indented);
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
catch
{
try { response.OutputStream.Close(); } catch { }
}
}
///
/// Handle /api/status endpoint - returns basic server health information.
/// No Editor API access required, always fast response.
///
private async Task HandleStatusRequest(HttpListenerResponse response)
{
try
{
var basicStatus = GetServerStatus();
var json = JsonConvert.SerializeObject(basicStatus, Formatting.Indented);
response.StatusCode = 200;
response.ContentType = "application/json";
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
catch (Exception ex)
{
Debug.LogError($"HandleStatusRequest failed: {ex.Message}");
await SendErrorResponse(response, "Status Error", $"Failed to get status: {ex.Message}");
}
}
///
/// Handle /api/editor_status endpoint - returns detailed Editor state via command execution.
/// Executes the "editor_status" command to get rich Editor information.
///
private async Task HandleEditorStatusRequest(HttpListenerResponse response)
{
try
{
// TODO: should this be implemented as a command??
// Execute editor_status command to get rich Editor information
var result = await ExecuteCommandByName("editor_status", new JObject());
// The editor_status command returns a StatusResponse directly
var editorStatus = result as StatusResponse;
if (editorStatus != null)
{
// Update with server-specific information
UpdateStatusWithServerInfo(editorStatus);
var json = JsonConvert.SerializeObject(editorStatus, Formatting.Indented);
response.StatusCode = 200;
response.ContentType = "application/json";
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
else
{
Debug.LogError($"HandleEditorStatusRequest: editor_status command returned wrong type: {result?.GetType().FullName ?? "null"}");
await SendErrorResponse(response, "Editor Status Error", "editor_status command did not return valid StatusResponse");
}
}
catch (Exception ex)
{
var errorMessage = !string.IsNullOrEmpty(ex.Message) ? ex.Message : "[EMPTY EXCEPTION MESSAGE]";
Debug.LogError($"HandleEditorStatusRequest failed:");
Debug.LogError($" Exception Type: {ex.GetType().FullName}");
Debug.LogError($" Message: '{errorMessage}'");
Debug.LogError($" Full Exception: {ex}");
await SendErrorResponse(response, "Editor Status Error", $"Failed to get editor status: {errorMessage}");
}
}
///
/// Update StatusResponse with server-specific information.
/// Used by /api/editor_status to add server metadata to command result.
///
private void UpdateStatusWithServerInfo(StatusResponse editorStatus)
{
UpdateHeartBeat();
// Ensure heartbeat is current
editorStatus.LastHeartbeat = DateTime.UtcNow;
}
///
/// Handle /api/commands endpoint - returns available CLI commands with schemas.
///
private async Task HandleCommandsRequest(HttpListenerResponse response)
{
try
{
var commands = CommandRegistry.DiscoverCommands()
.Where(c => IncludeRuntimeOnlyCommands || !c.RuntimeOnly)
.ToList();
var commandsJson = commands.Select(BuildCommandResponse).ToList();
var responseData = new
{
commands = commandsJson,
count = commands.Count,
server = new
{
version = "0.0.1", // TODO: Get from package.json
port = m_Port,
startTime = StartedAt
}
};
var json = JsonConvert.SerializeObject(responseData, Formatting.Indented);
response.StatusCode = 200;
response.ContentType = "application/json";
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
catch (Exception ex)
{
Debug.LogError($"Failed to handle /api/commands request: {ex.Message}");
// Return error response
response.StatusCode = 500;
response.ContentType = "application/json";
var errorResponse = new { error = "Internal server error", message = ex.Message };
var errorJson = JsonConvert.SerializeObject(errorResponse);
var errorBuffer = Encoding.UTF8.GetBytes(errorJson);
response.ContentLength64 = errorBuffer.Length;
await response.OutputStream.WriteAsync(errorBuffer, 0, errorBuffer.Length);
response.OutputStream.Close();
}
}
///
/// Build JSON response object for a single command.
///
private object BuildCommandResponse(CommandInfo command)
{
return new
{
name = command.Name,
description = command.Description,
mainThreadRequired = command.MainThreadRequired,
runtimeOnly = command.RuntimeOnly,
parameters = command.Parameters.Select(p => new
{
name = p.Name,
description = p.Description,
type = p.ParameterType.Name,
required = p.Required,
defaultValue = p.DefaultValue
}).ToList(),
schema = JsonSchemaGenerator.GenerateCommandSchema(command)
};
}
///
/// Handle 404 Not Found responses.
///
private async Task HandleNotFound(HttpListenerResponse response)
{
response.StatusCode = 404;
response.ContentType = "text/plain";
var responseText = "Not Found";
var buffer = Encoding.UTF8.GetBytes(responseText);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
///
/// Handle 405 Method Not Allowed responses.
///
private async Task HandleMethodNotAllowed(HttpListenerResponse response)
{
await SendErrorResponse(response, "Method Not Allowed", "This endpoint only supports POST requests");
}
///
/// Handle /api/test-status endpoint - returns status of async test execution.
///
private async Task HandleTestStatusRequest(HttpListenerResponse response)
{
try
{
// Execute test_status command to get current test status
var result = await ExecuteCommandByName("test_status", new JObject());
string jsonResponse;
if (result is string statusString)
{
// test_status command returns JSON string directly
jsonResponse = statusString;
}
else
{
// Fallback - serialize whatever was returned
jsonResponse = JsonConvert.SerializeObject(result ?? new { status = "no_tests", message = "No test run in progress" });
}
response.StatusCode = 200;
response.ContentType = "application/json";
var buffer = Encoding.UTF8.GetBytes(jsonResponse);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
catch (Exception ex)
{
Debug.LogError($"HandleTestStatusRequest failed: {ex.Message}");
await SendErrorResponse(response, "Test Status Error", $"Failed to get test status: {ex.Message}");
}
}
///
/// Handle /api/exec endpoint - execute CLI commands with parameters.
///
private async Task HandleExecRequest(HttpListenerRequest request, HttpListenerResponse response)
{
var cmd = "";
string requestBody = null;
try
{
// Reject oversized bodies up front via Content-Length (cheap, before reading anything).
if (request.ContentLength64 > MaxRequestBodyBytes)
{
// Drain the body first so the client can read the 413 (see DrainRequestBody).
await DrainRequestBody(request, MaxDrainBytes);
await SendExecResponse(response, 413,
BaseResponse.Failure("Payload Too Large",
$"Request body exceeds the maximum allowed size of {MaxRequestBodyBytes} bytes"), null);
return;
}
// Read request body, enforcing the same cap while reading in case Content-Length is
// absent or untruthful (e.g. chunked transfer-encoding).
try
{
// leaveOpen: on an over-limit read we still need the raw InputStream open to drain
// the unsent remainder before responding, so it must outlive the reader/wrapper.
using (var limited = new MaxLengthStream(request.InputStream, MaxRequestBodyBytes, leaveOpen: true))
using (var reader = new StreamReader(limited))
{
requestBody = await reader.ReadToEndAsync();
}
}
catch (RequestTooLargeException ex)
{
// Drain the body first so the client can read the 413 (see DrainRequestBody).
await DrainRequestBody(request, MaxDrainBytes);
await SendExecResponse(response, 413,
BaseResponse.Failure("Payload Too Large", ex.Message), null);
return;
}
finally
{
// MaxLengthStream left the InputStream open; dispose it now that any read/drain is done.
request.InputStream.Dispose();
}
if (string.IsNullOrEmpty(requestBody))
{
await SendExecResponse(response, 400,
BaseResponse.Failure("Bad Request", "Request body is required"), requestBody);
return;
}
// Parse command request
CommandExecutionRequest commandRequest;
try
{
commandRequest = JsonConvert.DeserializeObject(requestBody);
}
catch (JsonException ex)
{
await SendExecResponse(response, 400,
BaseResponse.Failure("Invalid JSON", $"Failed to parse request body: {ex.Message}"), requestBody);
return;
}
// Validate request structure
var requestValidationError = commandRequest?.Validate();
if (!string.IsNullOrEmpty(requestValidationError))
{
await SendExecResponse(response, 400,
BaseResponse.Failure("Invalid Request", requestValidationError), requestBody);
return;
}
cmd = commandRequest.Command;
// Execute command using shared execution logic
var result = await ExecuteCommandByName(commandRequest.Command, commandRequest.Parameters);
// Send success response
await SendExecResponse(response, 200,
CommandExecutionResponse.CmdSuccess(commandRequest.Command, result), requestBody);
}
catch (ArgumentException ex)
{
// Parameter validation errors
await SendExecResponse(response, 400,
CommandExecutionResponse.CmdFailure(cmd, "Parameter Validation Failed", ex.Message), requestBody);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("No command named"))
{
// Command not found errors
await SendExecResponse(response, 400,
CommandExecutionResponse.CmdFailure(cmd, "Command Not Found", ex.Message), requestBody);
}
catch (InvalidOperationException ex)
{
// Command execution errors
await SendExecResponse(response, 400,
CommandExecutionResponse.CmdFailure(cmd, "Command Execution Failed", ex.Message), requestBody);
}
catch (Exception ex)
{
Debug.LogError($"Failed to handle /api/exec request: {ex.Message}");
await SendExecResponse(response, 400,
CommandExecutionResponse.CmdFailure(cmd, "Internal Server Error", ex.Message), requestBody);
}
}
///
/// Read and discard the remaining request body, up to bytes.
/// When we reject a request early (e.g. 413) the client may still be uploading; if we close the
/// response while unread data is in flight, HttpListener resets the TCP connection and the client
/// sees a connection error instead of our HTTP response. Draining lets the upload complete so the
/// response is delivered. Best-effort and bounded: a body larger than the budget stops draining
/// (and may reset), which is acceptable for an abusive request.
///
private static async Task DrainRequestBody(HttpListenerRequest request, long maxDrainBytes)
{
try
{
var input = request.InputStream;
var buffer = new byte[16 * 1024];
long drained = 0;
while (drained < maxDrainBytes)
{
var read = await input.ReadAsync(buffer, 0, buffer.Length);
if (read <= 0)
break;
drained += read;
}
}
catch
{
// Best-effort: if the client already closed the connection or the stream is gone,
// there is nothing left to drain and the response send below will handle the rest.
}
}
///
/// Send error response with structured JSON format.
///
private async Task SendErrorResponse(HttpListenerResponse response, string error, string details = null)
{
try
{
var errorResponse = BaseResponse.Failure(error, details);
await SendResponse(response, 400, errorResponse);
}
catch
{
// Ignore errors in error handling
}
}
private async Task SendResponse(HttpListenerResponse response, int statusCode, BaseResponse pipelineResponse)
{
response.StatusCode = 400;
response.ContentType = "application/json";
var json = JsonConvert.SerializeObject(pipelineResponse, Formatting.Indented);
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
///
/// Send an /api/exec response and notify the transaction hook with the raw request and
/// response JSON. Single send point for the exec handler so every branch (success and
/// error) is captured uniformly.
///
private async Task SendExecResponse(HttpListenerResponse response, int statusCode,
BaseResponse body, string requestJson)
{
response.StatusCode = statusCode;
response.ContentType = "application/json";
var json = JsonConvert.SerializeObject(body, Formatting.Indented);
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
OnTransactionProcessed(requestJson, json);
}
///
/// Hook invoked after an /api/exec transaction is sent, with the raw request and response
/// JSON. No-op by default (the Player never logs); the Editor server overrides this to
/// write the transaction log.
///
protected virtual void OnTransactionProcessed(string requestJson, string responseJson) { }
///
/// Convert a single JSON parameter token to the command's parameter type.
///
/// Agents and the CLI frequently pass structured parameters (e.g. float[] position,
/// JObject properties) as a JSON-ENCODED STRING — e.g. position "[0,0,0]" or
/// properties "{\"m_Mass\":0.17}". Newtonsoft's JValue(string).ToObject(float[])
/// (and likewise for JObject) returns null, so the parameter silently drops out:
/// set_transform applies nothing and set_component_properties fails Required-parameter
/// validation (CLI-219 / CLI-220). To fix this generally — without special-casing any command —
/// when the token is a string but the target type is NOT string AND the trimmed string starts
/// with '{' or '[', we re-parse it as a JSON document before converting.
///
/// The '{'/'[' guard is deliberate and narrow: ordinary string params (and ObjectRef string
/// handles like "/Player", "Assets/Foo.prefab", "guid:..." — none of which start with '{'/'[')
/// fall straight through to token.ToObject, so the class-level
/// and plain-string parameters keep
/// working unchanged. A re-parse that fails falls through to the normal conversion path (the
/// caller's try/catch then handles any remaining failure).
///
private static object ConvertParameterToken(Newtonsoft.Json.Linq.JToken token, System.Type targetType)
{
if (token == null)
return null;
if (token.Type == Newtonsoft.Json.Linq.JTokenType.String
&& targetType != typeof(string)
&& !targetType.IsPrimitive
&& !targetType.IsEnum)
{
var s = token.Value();
if (!string.IsNullOrWhiteSpace(s))
{
var t = s.TrimStart();
if (t.Length > 0 && (t[0] == '{' || t[0] == '['))
{
try { return Newtonsoft.Json.Linq.JToken.Parse(s).ToObject(targetType); }
catch (Newtonsoft.Json.JsonException) { /* fall through to the direct conversion */ }
}
}
}
return token.ToObject(targetType);
}
///
/// Extract command parameters from JSON request and convert to appropriate types.
///
private object[] ExtractCommandParameters(CommandInfo command, Newtonsoft.Json.Linq.JObject parametersJson)
{
var parameterValues = new object[command.Parameters.Count];
for (int i = 0; i < command.Parameters.Count; i++)
{
var paramInfo = command.Parameters[i];
var paramName = paramInfo.Name;
// Try to get value from JSON parameters
object jsonValue = null;
if (parametersJson != null && parametersJson.ContainsKey(paramName))
{
try
{
jsonValue = ConvertParameterToken(parametersJson[paramName], paramInfo.ParameterType);
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to convert parameter '{paramName}' to {paramInfo.ParameterType.Name}: {ex.Message}");
// Conversion failed, will use default value
}
}
// Use provided value or default value
if (jsonValue != null)
{
parameterValues[i] = jsonValue;
}
else if (paramInfo.DefaultValue != null)
{
parameterValues[i] = paramInfo.DefaultValue;
}
else
{
// Use type's default value for value types, null for reference types
parameterValues[i] = paramInfo.ParameterType.IsValueType
? Activator.CreateInstance(paramInfo.ParameterType)
: null;
}
}
return parameterValues;
}
///
/// Validate that all required command parameters are provided.
///
private string ValidateCommandParameters(CommandInfo command, object[] parameters)
{
for (int i = 0; i < command.Parameters.Count; i++)
{
var paramInfo = command.Parameters[i];
var value = parameters[i];
if (paramInfo.Required && (value == null ||
(value is string str && string.IsNullOrEmpty(str))))
{
return $"Required parameter '{paramInfo.Name}' is missing or empty";
}
}
return null; // No validation errors
}
///
/// Execute a command by name with JSON parameters.
/// Handles command lookup, parameter validation, and execution.
/// Shared logic for both /api/exec and /api/editor_status endpoints.
///
private async Task