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