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()); Assert.Equal("connect", frame["method"]!.GetValue()); Assert.Equal("nexus", frame["params"]!["client"]!["id"]!.GetValue()); Assert.Equal("backend", frame["params"]!["client"]!["mode"]!.GetValue()); Assert.Equal("Nexus Mission Control", frame["params"]!["client"]!["displayName"]!.GetValue()); Assert.Equal(4, frame["params"]!["minProtocol"]!.GetValue()); Assert.Equal("test-token", frame["params"]!["auth"]!["token"]!.GetValue()); Assert.Null(frame["params"]!["auth"]!["password"]); Assert.Equal(2, frame["params"]!["scopes"]!.AsArray().Count); } [Fact] public void ClientIdentity_FailsClosedUntilExternalNexusIdentityIsSupported() { var exception = Assert.Throws(() => 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(() => 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()); 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().ToLowerInvariant()); Assert.Equal("device-1", parameters["device"]!["id"]!.GetValue()); Assert.Equal("nonce-1", parameters["device"]!["nonce"]!.GetValue()); Assert.Equal("device-token", parameters["auth"]!["token"]!.GetValue()); Assert.Equal("device-token", parameters["auth"]!["deviceToken"]!.GetValue()); 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()); Assert.Equal("idem-1", frame["params"]!["idempotencyKey"]!.GetValue()); } [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(() => 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( () => OpenClawGatewayProtocol.ParseHello(frame, "connect-4")); Assert.Equal("FORBIDDEN", exception.Code); Assert.Equal("MISSING_SCOPE", exception.Details!["code"]!.GetValue()); Assert.Equal("operator.approvals", exception.Details!["missingScope"]!.GetValue()); } [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); } }