feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user