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 Methods, IReadOnlySet Events, IReadOnlySet 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); /// /// Small protocol-v4 boundary used by . /// Keeping frame construction and parsing here makes the integration contract /// independently testable without a live Gateway. /// 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? 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(), 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 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(), "event", StringComparison.Ordinal) || !string.Equals(frame?["event"]?.GetValue(), "connect.challenge", StringComparison.Ordinal)) return false; nonce = frame?["payload"]?["nonce"]?.GetValue(); return !string.IsNullOrWhiteSpace(nonce); } public static OpenClawGatewayHello ParseHello(JsonNode? frame, string requestId) { if (!string.Equals(frame?["type"]?.GetValue(), "res", StringComparison.Ordinal) || !string.Equals(frame?["id"]?.GetValue(), requestId, StringComparison.Ordinal)) throw new OpenClawGatewayRpcException("INVALID_HANDSHAKE", "Gateway returned an unexpected connect response."); if (frame?["ok"]?.GetValue() != true) throw CreateRpcException(frame?["error"]); var payload = frame?["payload"]; if (!string.Equals(payload?["type"]?.GetValue(), "hello-ok", StringComparison.Ordinal)) throw new OpenClawGatewayRpcException("INVALID_HANDSHAKE", "Gateway connect response did not contain hello-ok."); var protocol = payload?["protocol"]?.GetValue() ?? throw new OpenClawGatewayRpcException("INVALID_HANDSHAKE", "Gateway hello-ok omitted the protocol version."); return new OpenClawGatewayHello( protocol, payload?["server"]?["version"]?.GetValue(), payload?["server"]?["connId"]?.GetValue(), 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(), payload?["auth"]?["role"]?.GetValue()); } 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() ?? "GATEWAY_ERROR"; var message = error?["message"]?.GetValue() ?? "OpenClaw Gateway request failed."; var retryable = error?["retryable"]?.GetValue() ?? 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 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 ReadStringSet(JsonNode? node) { if (node is not JsonArray array) return new HashSet(StringComparer.Ordinal); return array .Select(item => item?.GetValue()) .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(), JsonValueKind.String when int.TryParse(node.GetValue(), out var value) => value, _ => null }; } catch { return null; } } private static string? TryGetString(JsonNode? node) { try { return node?.GetValue(); } catch { return null; } } }