365 lines
13 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|