301 lines
10 KiB
C#
301 lines
10 KiB
C#
using System.Text.Json.Nodes;
|
|
using Nexus.Api.Services;
|
|
using Xunit;
|
|
|
|
namespace Nexus.Api.Tests;
|
|
|
|
public sealed class OpenClawGatewayProtocolTests
|
|
{
|
|
[Fact]
|
|
public void ConnectFrame_UsesExplicitNexusIdentityAndRequestedScopes()
|
|
{
|
|
var options = new GatewayConnectorOptions
|
|
{
|
|
Scopes = ["operator.read", "operator.write"],
|
|
Capabilities = ["session-scoped-events"]
|
|
};
|
|
|
|
var frame = OpenClawGatewayProtocol.BuildConnectRequest(
|
|
"connect-1",
|
|
options,
|
|
token: "test-token",
|
|
password: null,
|
|
clientVersion: "1.2.3",
|
|
platform: "windows",
|
|
locale: "de-DE");
|
|
|
|
Assert.Equal("req", frame["type"]!.GetValue<string>());
|
|
Assert.Equal("connect", frame["method"]!.GetValue<string>());
|
|
Assert.Equal("nexus", frame["params"]!["client"]!["id"]!.GetValue<string>());
|
|
Assert.Equal("backend", frame["params"]!["client"]!["mode"]!.GetValue<string>());
|
|
Assert.Equal("Nexus Mission Control", frame["params"]!["client"]!["displayName"]!.GetValue<string>());
|
|
Assert.Equal(4, frame["params"]!["minProtocol"]!.GetValue<int>());
|
|
Assert.Equal("test-token", frame["params"]!["auth"]!["token"]!.GetValue<string>());
|
|
Assert.Null(frame["params"]!["auth"]!["password"]);
|
|
Assert.Equal(2, frame["params"]!["scopes"]!.AsArray().Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClientIdentity_FailsClosedUntilExternalNexusIdentityIsSupported()
|
|
{
|
|
var exception = Assert.Throws<OpenClawGatewayRpcException>(() =>
|
|
OpenClawGatewayProtocol.ValidateExternalClientIdentity(new GatewayConnectorOptions()));
|
|
|
|
Assert.Equal("EXTERNAL_CLIENT_ID_UNSUPPORTED", exception.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClientIdentity_RejectsReservedInternalGatewayIdentity()
|
|
{
|
|
var options = new GatewayConnectorOptions
|
|
{
|
|
ClientId = "gateway-client",
|
|
ClientMode = "backend",
|
|
ExternalClientIdentitySupported = true
|
|
};
|
|
|
|
var exception = Assert.Throws<OpenClawGatewayRpcException>(() =>
|
|
OpenClawGatewayProtocol.ValidateExternalClientIdentity(options));
|
|
|
|
Assert.Equal("RESERVED_CLIENT_ID", exception.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClientIdentity_AcceptsNexusOnlyAfterExplicitContractSupport()
|
|
{
|
|
var options = new GatewayConnectorOptions
|
|
{
|
|
ExternalClientIdentitySupported = true
|
|
};
|
|
|
|
OpenClawGatewayProtocol.ValidateExternalClientIdentity(options);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectFrame_PrefersExplicitPasswordAuth()
|
|
{
|
|
var frame = OpenClawGatewayProtocol.BuildConnectRequest(
|
|
"connect-2",
|
|
new GatewayConnectorOptions(),
|
|
token: "ignored-token",
|
|
password: "test-password",
|
|
clientVersion: "1.0.0",
|
|
platform: "linux",
|
|
locale: "en-US");
|
|
|
|
Assert.Equal("test-password", frame["params"]!["auth"]!["password"]!.GetValue<string>());
|
|
Assert.Null(frame["params"]!["auth"]!["token"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectFrame_IncludesChallengeBoundDeviceProofAndDeviceToken()
|
|
{
|
|
var proof = new OpenClawGatewayDeviceProof(
|
|
"device-1",
|
|
"public-key",
|
|
"signature",
|
|
1_737_264_000_000,
|
|
"nonce-1");
|
|
|
|
var frame = OpenClawGatewayProtocol.BuildConnectRequest(
|
|
"connect-device",
|
|
new GatewayConnectorOptions(),
|
|
token: "device-token",
|
|
password: null,
|
|
clientVersion: "1.0.0",
|
|
platform: "Linux",
|
|
locale: "en-US",
|
|
device: proof,
|
|
scopes: ["operator.read"],
|
|
deviceFamily: "Server",
|
|
deviceToken: "device-token");
|
|
|
|
var parameters = frame["params"]!;
|
|
Assert.Equal("server", parameters["client"]!["deviceFamily"]!.GetValue<string>().ToLowerInvariant());
|
|
Assert.Equal("device-1", parameters["device"]!["id"]!.GetValue<string>());
|
|
Assert.Equal("nonce-1", parameters["device"]!["nonce"]!.GetValue<string>());
|
|
Assert.Equal("device-token", parameters["auth"]!["token"]!.GetValue<string>());
|
|
Assert.Equal("device-token", parameters["auth"]!["deviceToken"]!.GetValue<string>());
|
|
Assert.Single(parameters["scopes"]!.AsArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void DevicePayloadV3_MatchesCanonicalOpenClawOrderingAndNormalization()
|
|
{
|
|
var payload = OpenClawGatewayProtocol.BuildDeviceAuthPayloadV3(
|
|
"device-1",
|
|
"gateway-client",
|
|
"backend",
|
|
"operator",
|
|
["operator.read", "operator.write"],
|
|
1_737_264_000_000,
|
|
"token-1",
|
|
"nonce-1",
|
|
"Windows",
|
|
"Server");
|
|
|
|
Assert.Equal(
|
|
"v3|device-1|gateway-client|backend|operator|operator.read,operator.write|1737264000000|token-1|nonce-1|windows|server",
|
|
payload);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ws://127.0.0.1:18789", false)]
|
|
[InlineData("ws://localhost:18789", false)]
|
|
[InlineData("ws://[::1]:18789", false)]
|
|
[InlineData("ws://host.docker.internal:18789", true)]
|
|
[InlineData("wss://gateway.example.test", true)]
|
|
public void DeviceIdentity_IsOmittedOnlyForDirectLoopback(
|
|
string endpoint,
|
|
bool expected)
|
|
{
|
|
Assert.Equal(
|
|
expected,
|
|
OpenClawGatewayProtocol.RequiresDeviceIdentity(new Uri(endpoint)));
|
|
}
|
|
|
|
[Fact]
|
|
public void RpcFrame_PropagatesTraceparentAndSchemaConfirmedIdempotencyKey()
|
|
{
|
|
var context = OpenClawInvocationContext.Create(
|
|
actor: "owner-1",
|
|
idempotencyKey: "idem-1",
|
|
correlationId: "corr-1",
|
|
traceParent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
|
includeIdempotencyParameter: true);
|
|
|
|
var frame = OpenClawGatewayProtocol.BuildRpcRequest(
|
|
"req-1",
|
|
"chat.send",
|
|
JsonNode.Parse("""{ "sessionKey": "agent:iris:main", "message": "hello" }"""),
|
|
context);
|
|
|
|
Assert.Equal(context.TraceParent, frame["traceparent"]!.GetValue<string>());
|
|
Assert.Equal("idem-1", frame["params"]!["idempotencyKey"]!.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public void RpcFrame_DoesNotInventIdempotencyFieldWithoutSchemaOptIn()
|
|
{
|
|
var context = OpenClawInvocationContext.Create(
|
|
idempotencyKey: "idem-closed-schema",
|
|
traceParent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
|
|
|
|
var frame = OpenClawGatewayProtocol.BuildRpcRequest(
|
|
"req-2",
|
|
"tasks.cancel",
|
|
JsonNode.Parse("""{ "taskId": "task-1" }"""),
|
|
context);
|
|
|
|
Assert.Null(frame["params"]!["idempotencyKey"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void RpcFrame_RejectsInvalidTraceparentBeforeSending()
|
|
{
|
|
var context = new OpenClawInvocationContext(
|
|
"idem-1",
|
|
"corr-1",
|
|
"owner-1",
|
|
"not-a-traceparent");
|
|
|
|
Assert.Throws<ArgumentException>(() =>
|
|
OpenClawGatewayProtocol.BuildRpcRequest(
|
|
"req-3",
|
|
"tasks.cancel",
|
|
new JsonObject(),
|
|
context));
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseHello_ProjectsProtocolFeaturesAndScopes()
|
|
{
|
|
var frame = JsonNode.Parse("""
|
|
{
|
|
"type": "res",
|
|
"id": "connect-3",
|
|
"ok": true,
|
|
"payload": {
|
|
"type": "hello-ok",
|
|
"protocol": 4,
|
|
"server": { "version": "2026.7.28", "connId": "conn-1" },
|
|
"features": {
|
|
"methods": ["tasks.list", "sessions.list"],
|
|
"events": ["tick", "sessions.changed"]
|
|
},
|
|
"auth": {
|
|
"deviceToken": "paired-token",
|
|
"role": "operator",
|
|
"scopes": ["operator.read", "operator.write"]
|
|
},
|
|
"policy": {
|
|
"maxPayload": 26214400,
|
|
"maxBufferedBytes": 52428800,
|
|
"tickIntervalMs": 15000
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var hello = OpenClawGatewayProtocol.ParseHello(frame, "connect-3");
|
|
|
|
Assert.Equal(4, hello.Protocol);
|
|
Assert.Equal("2026.7.28", hello.ServerVersion);
|
|
Assert.Contains("tasks.list", hello.Methods);
|
|
Assert.Contains("sessions.changed", hello.Events);
|
|
Assert.Contains("operator.write", hello.Scopes);
|
|
Assert.Equal(26_214_400, hello.MaxPayload);
|
|
Assert.Equal("paired-token", hello.DeviceToken);
|
|
Assert.Equal("operator", hello.Role);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseHello_PreservesStructuredGatewayError()
|
|
{
|
|
var frame = JsonNode.Parse("""
|
|
{
|
|
"type": "res",
|
|
"id": "connect-4",
|
|
"ok": false,
|
|
"error": {
|
|
"code": "FORBIDDEN",
|
|
"message": "missing scope",
|
|
"retryable": false,
|
|
"details": {
|
|
"code": "MISSING_SCOPE",
|
|
"missingScope": "operator.approvals"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var exception = Assert.Throws<OpenClawGatewayRpcException>(
|
|
() => OpenClawGatewayProtocol.ParseHello(frame, "connect-4"));
|
|
|
|
Assert.Equal("FORBIDDEN", exception.Code);
|
|
Assert.Equal("MISSING_SCOPE", exception.Details!["code"]!.GetValue<string>());
|
|
Assert.Equal("operator.approvals", exception.Details!["missingScope"]!.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public void PairingError_PreservesExactRequestIdForOperatorApproval()
|
|
{
|
|
var exception = OpenClawGatewayProtocol.CreateRpcException(JsonNode.Parse(
|
|
"""
|
|
{
|
|
"code": "PAIRING_REQUIRED",
|
|
"message": "pairing required",
|
|
"retryable": true,
|
|
"details": {
|
|
"code": "PAIRING_REQUIRED",
|
|
"requestId": "pair-request-42",
|
|
"recommendedNextStep": "wait_then_retry"
|
|
}
|
|
}
|
|
"""));
|
|
|
|
Assert.True(OpenClawGatewayProtocol.TryReadPairingRequest(exception, out var requestId));
|
|
Assert.Equal("pair-request-42", requestId);
|
|
}
|
|
}
|