using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
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
{
private const long DefaultMaxRequestBodyBytes = 1 * 1024 * 1024;
///
/// Maximum accepted request body size. Larger requests are rejected with 413, bounding the memory
/// one remote request can force the server to buffer. Settable so a server whose clients
/// legitimately post larger documents can raise it without every other server paying for it.
///
public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes;
///
/// 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;
///
/// Maximum accepted body size for /api/job/cancel (4 KiB). The body is just an
/// {"id": "..."} object, so it needs far less headroom than .
///
private const long MaxCancelBodyBytes = 4 * 1024;
///
/// Dispatcher wait for a detached job's main-thread execution. Nothing is synchronously
/// waiting on this thread (the job's HTTP response was already sent), so this only needs
/// to be larger than any command's own timeout (e.g. eval's 24h cap) rather than bounded
/// by an HTTP-facing default.
///
private const int UnboundedJobDispatcherTimeoutMs = int.MaxValue;
private bool m_IsRunning;
private int m_Port;
///
/// Serializes /api/exec command execution now that requests are processed
/// concurrently: execs queue exactly as they did when the accept loop was serial,
/// while read-only endpoints (/api/status, /api/progress, …) answer immediately.
///
private readonly SemaphoreSlim m_ExecGate = new SemaphoreSlim(1, 1);
private HttpListener m_HttpListener;
private readonly Dispatcher m_Dispatcher = new Dispatcher();
/// This server's own progress-reporting state (see CliProgress).
internal CliProgressState Progress { get; } = new CliProgressState();
/// This server's own modal-dialog state (see EditorDialogStateMirror, Task 7).
internal DialogStateTracker Dialogs { get; } = new DialogStateTracker();
/// This server's own detached-job registry (see PipelineCancellation, /api/job).
internal PipelineJobRegistry JobRegistry { get; } = new PipelineJobRegistry();
///
/// The server instance currently executing a command on this thread, if any. Set only
/// around the actual command invocation (see ) so
/// CliProgress.Report/PipelineCancellation — called from arbitrary command code with no
/// reference to "which server is running me" — resolve to the right instance. Correct
/// regardless of which physical thread ends up running the command, since the push/read/pop
/// all happen within one synchronous call frame; a command that awaits and resumes on a
/// different thread before calling either API would not see it (no command does today).
///
[ThreadStatic] private static BasePipelineServer m_CurrentServer;
internal static BasePipelineServer CurrentServer => m_CurrentServer;
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 RuntimePipelineDriver.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;
/// UTC time this server instance started listening.
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;
///
/// Whether requests from a sandboxed browser frame (Origin: null) are accepted. Off by default;
/// an ordinary web page is refused either way, and a sandboxed one still needs the bearer token,
/// which lives in a file no browser can read.
///
public bool AllowSandboxedBrowserClients
{
get => m_AllowSandboxedBrowserClients;
set => m_AllowSandboxedBrowserClients = value;
}
// Read on the listener thread, so volatile: a value set on the main thread must be seen there.
private volatile bool m_AllowSandboxedBrowserClients;
/// The only Origin a sandboxed frame reports; a real page sends its own origin.
private const string SandboxedOrigin = "null";
/// Write the shared instance descriptor file so clients can discover this server.
protected abstract void CreateInstanceDescriptor();
/// Remove the shared instance descriptor file on shutdown.
protected abstract void DeleteInstanceDescriptor();
/// Refresh the descriptor's heartbeat timestamp so discovery can detect a live server.
protected abstract void UpdateHeartBeat();
/// Build the host-specific payload for /api/status (Editor vs. Player fields differ).
/// The status payload.
protected abstract object GetServerStatus();
/// The bearer token clients must present to authenticate requests.
/// The current token.
protected abstract string GetToken();
///
/// Optional wire features this server understands, advertised on /api/status and in the
/// port descriptor.
///
/// One array feeds both carriers so they cannot drift: a server that claims a capability
/// it lacks is worse than one that claims nothing, because a client's fallback is keyed on
/// the claim. Absence of the key entirely means the server is too old to have it, and
/// clients must then assume no raw command-line support.
///
internal static readonly string[] Capabilities = { "exec.argv", "exec.commandLine" };
/// Hook invoked once the listener is accepting requests. No-op by default.
protected virtual void ServerStarted()
{
}
///
/// Busy probe for the /api/exec gate: return a non-null, human-readable reason when the
/// host cannot service right now (e.g. the Editor is still
/// importing/compiling while settling after a cold start), or null to let it run. A busy
/// command is rejected with HTTP 503 and a structured, retryable envelope — before sync
/// execution and before a detached job is created — instead of executing into a
/// half-ready host and failing opaquely (AUTHAPI-35). Called on the request thread, so
/// implementations must only read thread-safe state. Default: never busy.
///
/// The command about to be dispatched.
/// A human-readable busy reason, or null to let the command run.
protected virtual string GetBusyReason(CommandInfo command) => null;
///
/// For a command otherwise blocked by the dialog gate, an optional cached-but-still-valid
/// result to serve as a normal 200 success instead of the 503 (e.g. editor_status: nothing
/// about compilation/play-mode/domain-reload can change while the main thread is stuck
/// inside the dialog's native message loop, so a snapshot taken just before it blocked
/// stays correct for the whole window — see EditorPipelineServer's override). Called on the
/// request thread, so implementations must only read thread-safe state. Default: no
/// fallback exists, so every MainThreadRequired command stays hard-gated.
///
/// Internal, not protected: DialogInfo is internal, and a protected member can't expose a
/// less-accessible type in its signature — a subclass outside this assembly (without
/// InternalsVisibleTo) couldn't reference DialogInfo to override it. EditorPipelineServer
/// overrides this from Unity.Pipeline.Editor, which already has that grant (same as
/// Dialogs and BuildDialogPayload above).
///
internal virtual object TryGetDialogBlockedFallback(CommandInfo command, DialogInfo blockingDialog) => null;
/// Hook invoked once the listener has stopped accepting requests. No-op by default.
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();
///
/// Whether was picked from this server's range rather than requested by
/// the caller. Recorded at bind time, so it still describes the running listener after the
/// settings asset is edited.
///
internal bool PortAutoAssigned { get; private set; }
///
/// 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;
PortAutoAssigned = port == 0;
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();
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 RuntimePipelineDriver.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 RuntimePipelineDriver.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();
// Process detached so the accept loop keeps serving while a long command
// holds its /api/exec connection open. Without this, ANY in-flight exec
// blocked every other request — including /api/status probes and the
// /api/progress polls that exist precisely for that situation (CLI-488;
// the "editor is unresponsive until the command finishes" report in
// CLI-335). Command execution itself stays strictly serialized via
// m_ExecGate below, so exec ordering semantics are unchanged.
_ = ProcessRequestDetached(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;
}
///
/// Run ProcessRequest without the accept loop awaiting it; a per-request failure is
/// logged and must never tear down the listener loop.
///
private async Task ProcessRequestDetached(HttpListenerContext context)
{
try
{
await ProcessRequest(context);
}
catch (Exception ex)
{
Debug.LogError(ex);
}
}
///
/// Process individual HTTP request and route to appropriate handler.
///
/// The incoming HTTP request/response context.
/// A task that completes once the response has been written.
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). A server that opts into
// AllowSandboxedBrowserClients additionally admits Origin: null.
var origin = request.Headers["Origin"];
if (!string.IsNullOrEmpty(origin))
{
if (!AllowSandboxedBrowserClients || origin != SandboxedOrigin)
{
await SendStatusResponse(response, 403, "Forbidden", "Cross-origin requests are not allowed");
return;
}
// "*" rather than echoing "null": the gate above already decides who is served, no
// credentials are involved, and it is better supported for an opaque origin.
response.AddHeader("Access-Control-Allow-Origin", "*");
response.AddHeader("Vary", "Origin");
}
// Preflight is answered before auth by necessity: the browser sends OPTIONS with no
// Authorization header, so requiring the token here would fail every real request.
if (request.HttpMethod == "OPTIONS")
{
if (string.IsNullOrEmpty(origin))
{
await SendStatusResponse(response, 405, "Method Not Allowed", "OPTIONS is only used for CORS preflight");
return;
}
response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
response.AddHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
response.AddHeader("Access-Control-Max-Age", "600");
// Chrome's Private Network Access: a page on the public internet reaching a loopback
// address must be granted it explicitly, or the preflight fails.
if (!string.IsNullOrEmpty(request.Headers["Access-Control-Request-Private-Network"]))
response.AddHeader("Access-Control-Allow-Private-Network", "true");
response.StatusCode = 204;
response.ContentLength64 = 0;
response.OutputStream.Close();
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(request, response);
break;
case "/api/exec":
if (request.HttpMethod == "POST")
await HandleExecRequest(request, response);
else
await HandleMethodNotAllowed(response, "POST");
break;
case "/api/test-status":
await HandleTestStatusRequest(response);
break;
case "/api/progress":
await HandleProgressRequest(request, response);
break;
case "/api/dialog":
await HandleDialogRequest(response);
break;
case "/api/job":
if (request.HttpMethod == "GET")
await HandleJobStatusRequest(request, response);
else
await HandleMethodNotAllowed(response, "GET");
break;
case "/api/job/cancel":
if (request.HttpMethod == "POST")
await HandleJobCancelRequest(request, response);
else
await HandleMethodNotAllowed(response, "POST");
break;
default:
await HandleNotFound(response);
break;
}
}
catch (Exception e)
{
// A silent 500 leaves the client with nothing to go on; name the request and the fault.
Debug.LogError($"Pipeline: unhandled error serving {request.HttpMethod} "
+ $"{request.Url?.AbsolutePath}: {e}");
// 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 { }
}
}
///
/// Parse the omit_nulls query parameter (snake_case like the other query
/// parameters, e.g. group_by) — the GET-endpoint equivalent of exec's omitNulls
/// JSON body flag, for /api/job, /api/job/cancel, and /api/progress. Accepted values:
/// "true"/"false", case-insensitive; absent means false. Any other value (e.g.
/// omit_nulls=1) is NOT silently coerced to false: it yields false plus a
/// the caller surfaces in the response's "warnings" array, so
/// an agent learns the accepted spelling instead of guessing why nulls are still present.
/// (Exec body requests don't need this: JSON deserialization coerces their boolean.)
///
private static bool ParseOmitNulls(HttpListenerRequest request, out string warning)
{
warning = null;
var raw = request.QueryString["omit_nulls"];
if (raw == null)
{
// A bare "?omit_nulls" (no '=') is not the same as absent: .NET files valueless
// query tokens under the null key, so look there to warn instead of silently
// treating an intended opt-in as false.
var valueless = request.QueryString.GetValues(null);
if (valueless != null && Array.IndexOf(valueless, "omit_nulls") >= 0)
warning = "omit_nulls given without a value ignored; use omit_nulls=true";
return false;
}
if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase))
return true;
if (string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase))
return false;
warning = $"omit_nulls='{raw}' ignored; expected 'true' or 'false'";
return false;
}
///
/// Serialize and send it as the JSON body. Null fields are
/// INCLUDED by default — on /api/job and /api/progress every field is payload (there is
/// no envelope/payload split like /api/exec's), and an absent key is indistinguishable
/// from a nonexistent or misspelled one. Callers pass from
/// the endpoint's omit_nulls query parameter (see )
/// to opt out (AUTHAPI-21).
/// Shares 's write guard: a failure while writing
/// (e.g. the client disconnected mid-response) is swallowed here instead of propagating
/// into the caller's own catch block, which would otherwise attempt a second response on
/// the now-dead connection.
///
private async Task SendJsonResponse(HttpListenerResponse response, int statusCode, object payload, bool omitNulls = false)
{
try
{
var json = JsonConvert.SerializeObject(payload,
new JsonSerializerSettings
{
NullValueHandling = omitNulls ? NullValueHandling.Ignore : NullValueHandling.Include
});
response.StatusCode = statusCode;
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
{
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??
// Queue behind the same gate as /api/exec so this doesn't run concurrently with
// an in-flight exec (or another editor_status/test_status call): it dispatches
// onto the main thread just like exec does, so left ungated it would race rather
// than queue the way it did under the old serial accept loop.
object result;
await m_ExecGate.WaitAsync();
try
{
result = await ExecuteCommandByName("editor_status", new JObject());
}
finally
{
m_ExecGate.Release();
}
// 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 the available CLI commands.
///
/// Optional query parameters (filters combine with AND):
/// - detail: 'full' (default) includes parameters and the generated JSON schema;
/// 'compact' returns a lightweight index (name, description, tags, package) so a
/// client can cheaply browse, then fetch full detail only for the commands it
/// intends to invoke.
/// - query: case-insensitive substring match on name, description, or any tag.
/// - tag: scope to a tag subtree via segment-aware prefix match ('assets' matches
/// 'assets' and 'assets/import' but not 'assetsx').
/// - group_by: 'flat' (default) returns a 'commands' array; 'package' and 'tag'
/// return a 'groups' array instead ('tag' is a nested tree mirroring tag/subtag;
/// untagged commands land in a node with an empty tag).
/// - sort: 'name' (default) or 'package' (originating package, name as tiebreak);
/// order: 'asc' (default) or 'desc'. Sorting applies to the flat list before
/// pagination and grouping.
/// - offset / limit: paginate the filtered, sorted flat list (applied before
/// grouping, so pages are deterministic); 'total' reports the match count before
/// pagination while 'count' is the number of commands actually returned.
///
private async Task HandleCommandsRequest(HttpListenerRequest request, HttpListenerResponse response)
{
try
{
var queryString = request.QueryString;
var detail = queryString["detail"] ?? "full";
bool fullDetail;
if (string.Equals(detail, "full", StringComparison.OrdinalIgnoreCase))
fullDetail = true;
else if (string.Equals(detail, "compact", StringComparison.OrdinalIgnoreCase))
fullDetail = false;
else
{
await SendStatusResponse(response, 400, "Invalid Request",
$"Unknown detail value '{detail}'. Accepted values: compact, full");
return;
}
var groupBy = queryString["group_by"] ?? "flat";
var groupByPackage = string.Equals(groupBy, "package", StringComparison.OrdinalIgnoreCase);
var groupByTag = string.Equals(groupBy, "tag", StringComparison.OrdinalIgnoreCase);
if (!groupByPackage && !groupByTag && !string.Equals(groupBy, "flat", StringComparison.OrdinalIgnoreCase))
{
await SendStatusResponse(response, 400, "Invalid Request",
$"Unknown group_by value '{groupBy}'. Accepted values: flat, package, tag");
return;
}
var sort = queryString["sort"] ?? "name";
var sortByPackage = string.Equals(sort, "package", StringComparison.OrdinalIgnoreCase);
if (!sortByPackage && !string.Equals(sort, "name", StringComparison.OrdinalIgnoreCase))
{
await SendStatusResponse(response, 400, "Invalid Request",
$"Unknown sort value '{sort}'. Accepted values: name, package");
return;
}
var order = queryString["order"] ?? "asc";
var descending = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
if (!descending && !string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase))
{
await SendStatusResponse(response, 400, "Invalid Request",
$"Unknown order value '{order}'. Accepted values: asc, desc");
return;
}
var offset = 0;
if (queryString["offset"] != null
&& (!int.TryParse(queryString["offset"], out offset) || offset < 0))
{
await SendStatusResponse(response, 400, "Invalid Request",
$"Invalid offset value '{queryString["offset"]}'. Expected a non-negative integer");
return;
}
int? limit = null;
if (queryString["limit"] != null)
{
if (!int.TryParse(queryString["limit"], out var parsedLimit) || parsedLimit < 0)
{
await SendStatusResponse(response, 400, "Invalid Request",
$"Invalid limit value '{queryString["limit"]}'. Expected a non-negative integer");
return;
}
limit = parsedLimit;
}
var query = queryString["query"];
var tag = queryString["tag"];
var filtered = CommandRegistry.DiscoverCommands()
.Where(c => IncludeRuntimeOnlyCommands || !c.RuntimeOnly)
.Where(c => MatchesQuery(c, query) && MatchesTagSubtree(c, tag));
var matching = SortCommands(filtered, sortByPackage, descending).ToList();
// Paginate the flat, sorted list before any grouping so pages are deterministic.
IEnumerable window = matching.Skip(offset);
if (limit.HasValue)
window = window.Take(limit.Value);
var page = window.ToList();
Func project = c =>
fullDetail ? BuildFullCommandResponse(c) : BuildCompactCommandResponse(c);
var responseData = new Dictionary();
if (groupByPackage)
responseData["groups"] = BuildPackageGroups(page, project);
else if (groupByTag)
responseData["groups"] = BuildTagTree(page, project);
else
responseData["commands"] = page.Select(project).ToList();
responseData["count"] = page.Count;
responseData["total"] = matching.Count;
responseData["offset"] = offset;
responseData["limit"] = limit;
responseData["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 the lightweight index entry for a single command (detail=compact).
///
private static object BuildCompactCommandResponse(CommandInfo command)
{
return new
{
name = command.Name,
description = command.Description,
tags = command.Tags,
package = command.Package
};
}
///
/// Build the complete JSON response object for a single command (detail=full).
///
private object BuildFullCommandResponse(CommandInfo command)
{
var entry = BuildCommandEntry(command);
entry["schema"] = JsonSchemaGenerator.GenerateCommandSchema(command);
return entry;
}
///
/// One command's catalog entry: everything /api/commands serves for it except the
/// generated JSON schema.
///
/// Shared by the catalog and by an argument-error envelope, so a client's entry renderer
/// consumes both unchanged and the two cannot drift. The error path takes this directly
/// rather than building the full response and dropping the schema, because generating and
/// serializing a schema nobody reads is pure waste on the interactive retry path.
///
private JObject BuildCommandEntry(CommandInfo command)
{
return JObject.FromObject(new
{
name = command.Name,
description = command.Description,
tags = command.Tags,
package = command.Package,
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()
});
}
///
/// Attaches the bound-parameter echo for raw-form requests, and only for those, leaving
/// the structured path's envelope unchanged.
///
/// The value is exactly what the binder stored, so the echo cannot disagree with what the
/// command executes with. A client that does not bind locally has no other way to report
/// what the command received.
///
private static CommandExecutionResponse WithBoundParameters(
CommandExecutionResponse response, CommandExecutionRequest request)
{
if (request.IsRawForm)
response.BoundParameters = request.Parameters ?? new JObject();
return response;
}
///
/// Renders argument problems as English prose for errorDetails.
///
/// This is the fallback, not the primary channel: a client that understands
/// argProblems renders and localizes them itself. This text serves curl, MCP, and
/// any client that meets a kind it does not recognize, so it must stay useful on
/// its own.
///
private static string DescribeArgProblems(CommandInfo command, List problems)
{
if (problems == null || problems.Count == 0)
return "Invalid arguments";
var parts = new List(problems.Count);
foreach (var problem in problems)
{
switch (problem.Kind)
{
case ArgProblemKind.EmptyName:
parts.Add($"'{problem.Token}' is not a valid flag: the name is empty.");
break;
case ArgProblemKind.EmptyValue:
parts.Add($"--{problem.Name} needs a value.");
break;
case ArgProblemKind.SingleDash:
parts.Add($"'{problem.Token}' is not a valid flag: single-dash flags are not supported. Use a double dash, or pass it after -- to send it as a value.");
break;
case ArgProblemKind.Duplicate:
parts.Add($"--{problem.Name} was given more than once.");
break;
case ArgProblemKind.UnknownName:
parts.Add(problem.Suggestion != null
? $"{command.Name} has no parameter --{problem.Name}. Did you mean --{problem.Suggestion}?"
: $"{command.Name} has no parameter --{problem.Name}. Valid parameters: {DescribeParameterNames(command)}.");
break;
case ArgProblemKind.BareAssignment:
parts.Add($"'{problem.Token}' looks like a flag written without dashes. Use --name value instead.");
break;
case ArgProblemKind.ExcessPositional:
parts.Add($"{command.Name} takes {problem.Capacity} argument(s) but {problem.Given} were given (starting at '{problem.Token}').");
break;
case ArgProblemKind.PositionalConflict:
parts.Add($"--{problem.Name} is already set by an explicit flag, so there is no slot for '{problem.Token}'.");
break;
case ArgProblemKind.TypeMismatch:
parts.Add($"--{problem.Name} expects {problem.ExpectedType}, but got '{problem.Token}'.{DescribeValidValues(command, problem.Name)}");
break;
default:
parts.Add($"{problem.Kind}: {problem.Token ?? problem.Name}");
break;
}
}
return string.Join(" ", parts);
}
/// Every declared parameter of the command, flag-spelled, or "(none)".
private static string DescribeParameterNames(CommandInfo command)
{
if (command.Parameters.Count == 0)
return "(none)";
return string.Join(", ", command.Parameters.Select(p => "--" + p.Name));
}
///
/// For an enum parameter, the legal names, following the same enumerate-the-valid-set
/// convention used for unknown command names and query values. Empty for every other type.
///
private static string DescribeValidValues(CommandInfo command, string parameterName)
{
foreach (var parameter in command.Parameters)
{
if (!string.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase))
continue;
var type = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType;
if (type.IsEnum)
return " Valid values: " + string.Join(", ", Enum.GetNames(type)) + ".";
break;
}
return string.Empty;
}
///
/// Order the filtered command list: by name, or by originating package with name as
/// tiebreak. Both keys follow the requested direction.
///
private static IEnumerable SortCommands(IEnumerable commands, bool byPackage, bool descending)
{
if (byPackage)
{
var byPkg = descending
? commands.OrderByDescending(c => c.Package ?? string.Empty, StringComparer.Ordinal)
: commands.OrderBy(c => c.Package ?? string.Empty, StringComparer.Ordinal);
return descending
? byPkg.ThenByDescending(c => c.Name, StringComparer.Ordinal)
: byPkg.ThenBy(c => c.Name, StringComparer.Ordinal);
}
return descending
? commands.OrderByDescending(c => c.Name, StringComparer.Ordinal)
: commands.OrderBy(c => c.Name, StringComparer.Ordinal);
}
///
/// Case-insensitive substring match on the command's name, description, or any tag.
/// A null/empty query matches everything.
///
private static bool MatchesQuery(CommandInfo command, string query)
{
if (string.IsNullOrEmpty(query))
return true;
return command.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0
|| command.Description.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0
|| command.Tags.Any(t => t.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0);
}
///
/// Segment-aware tag subtree match: 'assets' matches tags 'assets' and 'assets/import'
/// but not 'assetsx'. A null/empty tag matches everything.
///
private static bool MatchesTagSubtree(CommandInfo command, string tag)
{
if (string.IsNullOrEmpty(tag))
return true;
return command.Tags.Any(t => t.Equals(tag, StringComparison.OrdinalIgnoreCase)
|| t.StartsWith(tag + "/", StringComparison.OrdinalIgnoreCase));
}
///
/// Group a page of commands by originating package, sorted by package name.
///
private static List