Files
nexus/backend/Services/OpenClawGatewayProtocol.cs
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

365 lines
13 KiB
C#

using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Nexus.Api.Services;
public sealed record OpenClawGatewayHello(
int Protocol,
string? ServerVersion,
string? ConnectionId,
IReadOnlySet<string> Methods,
IReadOnlySet<string> Events,
IReadOnlySet<string> Scopes,
int? MaxPayload,
int? MaxBufferedBytes,
int? TickIntervalMs,
string? DeviceToken,
string? Role);
public sealed record OpenClawGatewayDeviceProof(
string Id,
string PublicKey,
string Signature,
long SignedAt,
string Nonce);
/// <summary>
/// Small protocol-v4 boundary used by <see cref="GatewayConnector"/>.
/// Keeping frame construction and parsing here makes the integration contract
/// independently testable without a live Gateway.
/// </summary>
public static class OpenClawGatewayProtocol
{
public const int CurrentProtocol = 4;
public const string DefaultRequiredGatewayVersion = "2026.7.1";
public static JsonObject BuildConnectRequest(
string requestId,
GatewayConnectorOptions options,
string? token,
string? password,
string clientVersion,
string platform,
string locale,
OpenClawGatewayDeviceProof? device = null,
IReadOnlyList<string>? scopes = null,
string? deviceFamily = null,
string? deviceToken = null)
{
var auth = new JsonObject();
if (!string.IsNullOrWhiteSpace(deviceToken))
{
auth["token"] = deviceToken;
auth["deviceToken"] = deviceToken;
}
else if (!string.IsNullOrWhiteSpace(password))
auth["password"] = password;
else if (!string.IsNullOrWhiteSpace(token))
auth["token"] = token;
var client = new JsonObject
{
["id"] = options.ClientId,
["version"] = clientVersion,
["platform"] = platform,
["mode"] = options.ClientMode,
["displayName"] = options.ClientDisplayName
};
if (!string.IsNullOrWhiteSpace(options.ClientInstanceId))
client["instanceId"] = options.ClientInstanceId.Trim();
if (!string.IsNullOrWhiteSpace(deviceFamily))
client["deviceFamily"] = deviceFamily.Trim();
var parameters = new JsonObject
{
["minProtocol"] = options.ProtocolVersion,
["maxProtocol"] = options.ProtocolVersion,
["client"] = client,
["role"] = "operator",
["scopes"] = ToJsonArray(scopes ?? options.Scopes),
["caps"] = ToJsonArray(options.Capabilities),
["commands"] = new JsonArray(),
["permissions"] = new JsonObject(),
["locale"] = locale,
["userAgent"] = $"nexus/{clientVersion}"
};
if (auth.Count > 0)
parameters["auth"] = auth;
if (device is not null)
{
parameters["device"] = new JsonObject
{
["id"] = device.Id,
["publicKey"] = device.PublicKey,
["signature"] = device.Signature,
["signedAt"] = device.SignedAt,
["nonce"] = device.Nonce
};
}
return new JsonObject
{
["type"] = "req",
["id"] = requestId,
["method"] = "connect",
["params"] = parameters
};
}
public static JsonObject BuildRpcRequest(
string requestId,
string method,
JsonNode? parameters,
OpenClawInvocationContext? invocationContext = null)
{
if (string.IsNullOrWhiteSpace(requestId))
throw new ArgumentException("Gateway request id is required.", nameof(requestId));
if (string.IsNullOrWhiteSpace(method))
throw new ArgumentException("Gateway method is required.", nameof(method));
var requestParameters = parameters?.DeepClone() ?? new JsonObject();
if (invocationContext?.IncludeIdempotencyParameter == true)
{
if (requestParameters is not JsonObject parameterObject)
{
throw new ArgumentException(
"Gateway idempotency can only be attached to object parameters.",
nameof(parameters));
}
if (parameterObject.TryGetPropertyValue("idempotencyKey", out var existing) &&
!string.Equals(
existing?.GetValue<string>(),
invocationContext.IdempotencyKey,
StringComparison.Ordinal))
{
throw new ArgumentException(
"Gateway parameters contain a conflicting idempotencyKey.",
nameof(parameters));
}
parameterObject["idempotencyKey"] = invocationContext.IdempotencyKey;
}
var request = new JsonObject
{
["type"] = "req",
["id"] = requestId,
["method"] = method,
["params"] = requestParameters
};
if (invocationContext is not null)
{
if (!IsValidTraceParent(invocationContext.TraceParent))
throw new ArgumentException("Invocation traceparent is not a valid W3C trace context.", nameof(invocationContext));
request["traceparent"] = invocationContext.TraceParent;
}
return request;
}
public static string BuildDeviceAuthPayloadV3(
string deviceId,
string clientId,
string clientMode,
string role,
IEnumerable<string> scopes,
long signedAtMs,
string? token,
string nonce,
string? platform,
string? deviceFamily)
{
return string.Join(
'|',
"v3",
deviceId,
clientId,
clientMode,
role,
string.Join(',', scopes),
signedAtMs.ToString(System.Globalization.CultureInfo.InvariantCulture),
token ?? string.Empty,
nonce,
NormalizeDeviceMetadata(platform),
NormalizeDeviceMetadata(deviceFamily));
}
public static bool IsConnectChallenge(JsonNode? frame, out string? nonce)
{
nonce = null;
if (!string.Equals(frame?["type"]?.GetValue<string>(), "event", StringComparison.Ordinal) ||
!string.Equals(frame?["event"]?.GetValue<string>(), "connect.challenge", StringComparison.Ordinal))
return false;
nonce = frame?["payload"]?["nonce"]?.GetValue<string>();
return !string.IsNullOrWhiteSpace(nonce);
}
public static OpenClawGatewayHello ParseHello(JsonNode? frame, string requestId)
{
if (!string.Equals(frame?["type"]?.GetValue<string>(), "res", StringComparison.Ordinal) ||
!string.Equals(frame?["id"]?.GetValue<string>(), requestId, StringComparison.Ordinal))
throw new OpenClawGatewayRpcException("INVALID_HANDSHAKE", "Gateway returned an unexpected connect response.");
if (frame?["ok"]?.GetValue<bool>() != true)
throw CreateRpcException(frame?["error"]);
var payload = frame?["payload"];
if (!string.Equals(payload?["type"]?.GetValue<string>(), "hello-ok", StringComparison.Ordinal))
throw new OpenClawGatewayRpcException("INVALID_HANDSHAKE", "Gateway connect response did not contain hello-ok.");
var protocol = payload?["protocol"]?.GetValue<int>()
?? throw new OpenClawGatewayRpcException("INVALID_HANDSHAKE", "Gateway hello-ok omitted the protocol version.");
return new OpenClawGatewayHello(
protocol,
payload?["server"]?["version"]?.GetValue<string>(),
payload?["server"]?["connId"]?.GetValue<string>(),
ReadStringSet(payload?["features"]?["methods"]),
ReadStringSet(payload?["features"]?["events"]),
ReadStringSet(payload?["auth"]?["scopes"]),
TryGetInt(payload?["policy"]?["maxPayload"]),
TryGetInt(payload?["policy"]?["maxBufferedBytes"]),
TryGetInt(payload?["policy"]?["tickIntervalMs"]),
payload?["auth"]?["deviceToken"]?.GetValue<string>(),
payload?["auth"]?["role"]?.GetValue<string>());
}
public static bool IsValidTraceParent(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
return false;
return ActivityContext.TryParse(value, null, out _);
}
public static bool RequiresDeviceIdentity(Uri endpoint)
{
ArgumentNullException.ThrowIfNull(endpoint);
return !(endpoint.IsLoopback ||
string.Equals(endpoint.Host, "localhost", StringComparison.OrdinalIgnoreCase));
}
public static void ValidateExternalClientIdentity(GatewayConnectorOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var clientId = options.ClientId?.Trim();
var clientMode = options.ClientMode?.Trim();
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientMode))
{
throw new OpenClawGatewayRpcException(
"UNSUPPORTED_CLIENT_ID",
"Nexus requires an explicit OpenClaw client id and client mode.");
}
if (string.Equals(clientId, "gateway-client", StringComparison.Ordinal) &&
string.Equals(clientMode, "backend", StringComparison.Ordinal) &&
!options.AllowReservedInternalClientIdentity)
{
throw new OpenClawGatewayRpcException(
"RESERVED_CLIENT_ID",
"OpenClaw reserves gateway-client/backend for its own trusted internal helpers. Nexus will not impersonate it.");
}
if (!options.ExternalClientIdentitySupported)
{
throw new OpenClawGatewayRpcException(
"EXTERNAL_CLIENT_ID_UNSUPPORTED",
$"OpenClaw does not yet advertise an approved external client identity for '{clientId}'. Attach remains read-only blocked until the Gateway contract explicitly supports it.");
}
if (!string.Equals(clientId, "nexus", StringComparison.Ordinal))
{
throw new OpenClawGatewayRpcException(
"UNSUPPORTED_CLIENT_ID",
"Nexus will not impersonate OpenClaw's CLI, Control UI, native apps, probes, tests, or node hosts.");
}
}
private static string NormalizeDeviceMetadata(string? value)
=> string.IsNullOrWhiteSpace(value)
? string.Empty
: value.Trim().ToLowerInvariant();
public static OpenClawGatewayRpcException CreateRpcException(JsonNode? error)
{
var code = error?["code"]?.GetValue<string>() ?? "GATEWAY_ERROR";
var message = error?["message"]?.GetValue<string>() ?? "OpenClaw Gateway request failed.";
var retryable = error?["retryable"]?.GetValue<bool>() ?? false;
var retryAfterMs = TryGetInt(error?["retryAfterMs"]);
return new OpenClawGatewayRpcException(
code,
message,
error?["details"]?.DeepClone(),
retryable,
retryAfterMs);
}
public static bool TryReadPairingRequest(
OpenClawGatewayRpcException exception,
out string? requestId)
{
ArgumentNullException.ThrowIfNull(exception);
requestId = TryGetString(exception.Details?["requestId"]);
var detailsCode = TryGetString(exception.Details?["code"]);
return string.Equals(exception.Code, "PAIRING_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
string.Equals(detailsCode, "PAIRING_REQUIRED", StringComparison.OrdinalIgnoreCase);
}
private static JsonArray ToJsonArray(IEnumerable<string> values)
{
var result = new JsonArray();
foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal))
result.Add(value);
return result;
}
private static IReadOnlySet<string> ReadStringSet(JsonNode? node)
{
if (node is not JsonArray array)
return new HashSet<string>(StringComparer.Ordinal);
return array
.Select(item => item?.GetValue<string>())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item!)
.ToHashSet(StringComparer.Ordinal);
}
private static int? TryGetInt(JsonNode? node)
{
if (node is null)
return null;
try
{
return node.GetValueKind() switch
{
JsonValueKind.Number => node.GetValue<int>(),
JsonValueKind.String when int.TryParse(node.GetValue<string>(), out var value) => value,
_ => null
};
}
catch
{
return null;
}
}
private static string? TryGetString(JsonNode? node)
{
try
{
return node?.GetValue<string>();
}
catch
{
return null;
}
}
}