feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class OpenClawDeviceIdentityAndAuditTests
|
||||
{
|
||||
private const string TraceParent =
|
||||
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
|
||||
|
||||
[Fact]
|
||||
public async Task DeviceIdentity_IsStableSignsCanonicalPayloadAndPersistsDeviceToken()
|
||||
{
|
||||
var testDirectory = CreateTestDirectory();
|
||||
try
|
||||
{
|
||||
var statePath = Path.Combine(testDirectory, "device.json");
|
||||
var options = Options.Create(new GatewayConnectorOptions
|
||||
{
|
||||
DeviceStatePath = statePath
|
||||
});
|
||||
var firstStore = new OpenClawDeviceIdentityStore(
|
||||
options,
|
||||
NullLogger<OpenClawDeviceIdentityStore>.Instance);
|
||||
var firstIdentity = await firstStore.LoadOrCreateAsync();
|
||||
var payload = OpenClawGatewayProtocol.BuildDeviceAuthPayloadV3(
|
||||
firstIdentity.DeviceId,
|
||||
"gateway-client",
|
||||
"backend",
|
||||
"operator",
|
||||
["operator.read"],
|
||||
1_737_264_000_000,
|
||||
"bootstrap-token",
|
||||
"nonce-1",
|
||||
"linux",
|
||||
"server");
|
||||
var signature = firstIdentity.Sign(payload);
|
||||
|
||||
Assert.Equal(64, firstIdentity.DeviceId.Length);
|
||||
Assert.True(firstIdentity.Verify(payload, signature));
|
||||
|
||||
await firstStore.StoreTokenAsync(
|
||||
firstIdentity.DeviceId,
|
||||
"operator",
|
||||
"gateway-binding-1",
|
||||
"paired-device-token",
|
||||
["operator.read"]);
|
||||
|
||||
var secondStore = new OpenClawDeviceIdentityStore(
|
||||
options,
|
||||
NullLogger<OpenClawDeviceIdentityStore>.Instance);
|
||||
var secondIdentity = await secondStore.LoadOrCreateAsync();
|
||||
var token = await secondStore.LoadTokenAsync(
|
||||
secondIdentity.DeviceId,
|
||||
"operator",
|
||||
"gateway-binding-1");
|
||||
var wrongGatewayToken = await secondStore.LoadTokenAsync(
|
||||
secondIdentity.DeviceId,
|
||||
"operator",
|
||||
"gateway-binding-2");
|
||||
|
||||
Assert.Equal(firstIdentity.DeviceId, secondIdentity.DeviceId);
|
||||
Assert.Equal(firstIdentity.PublicKey, secondIdentity.PublicKey);
|
||||
Assert.NotNull(token);
|
||||
Assert.Equal("paired-device-token", token!.Token);
|
||||
Assert.Equal(["operator.read"], token.Scopes);
|
||||
Assert.Equal("gateway-binding-1", token.GatewayBinding);
|
||||
Assert.Null(wrongGatewayToken);
|
||||
|
||||
Assert.True(await secondStore.RemoveTokenAsync(
|
||||
secondIdentity.DeviceId,
|
||||
"operator",
|
||||
"gateway-binding-1"));
|
||||
var detachedStore = new OpenClawDeviceIdentityStore(
|
||||
options,
|
||||
NullLogger<OpenClawDeviceIdentityStore>.Instance);
|
||||
Assert.Null(await detachedStore.LoadTokenAsync(
|
||||
secondIdentity.DeviceId,
|
||||
"operator",
|
||||
"gateway-binding-1"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteTestDirectory(testDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeviceIdentity_CorruptStateFailsClosedInsteadOfRotatingIdentity()
|
||||
{
|
||||
var testDirectory = CreateTestDirectory();
|
||||
try
|
||||
{
|
||||
var statePath = Path.Combine(testDirectory, "device.json");
|
||||
await File.WriteAllTextAsync(statePath, """{ "schemaVersion": 1, "deviceId": "wrong" }""");
|
||||
var store = new OpenClawDeviceIdentityStore(
|
||||
Options.Create(new GatewayConnectorOptions { DeviceStatePath = statePath }),
|
||||
NullLogger<OpenClawDeviceIdentityStore>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => store.LoadOrCreateAsync());
|
||||
|
||||
var unchanged = await File.ReadAllTextAsync(statePath);
|
||||
Assert.Contains("\"wrong\"", unchanged, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteTestDirectory(testDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OperationAudit_ReplaysCompletedKeyAcrossStoreRestartWithoutRawKey()
|
||||
{
|
||||
var testDirectory = CreateTestDirectory();
|
||||
try
|
||||
{
|
||||
var auditPath = Path.Combine(testDirectory, "operations.jsonl");
|
||||
var options = Options.Create(new GatewayConnectorOptions
|
||||
{
|
||||
OperationAuditPath = auditPath,
|
||||
DeviceStatePath = Path.Combine(testDirectory, "device.json")
|
||||
});
|
||||
var context = OpenClawInvocationContext.Create(
|
||||
actor: "owner-1",
|
||||
idempotencyKey: "client-secret-shaped-idempotency-value",
|
||||
correlationId: "corr-1",
|
||||
traceParent: TraceParent);
|
||||
var operation = new OpenClawOperationDescriptor(
|
||||
"tasks.cancel",
|
||||
"task",
|
||||
"task-1",
|
||||
OpenClawInvocationContextFactory.Hash("tasks.cancel|task-1|reason"));
|
||||
|
||||
var firstStore = new OpenClawOperationAuditStore(options);
|
||||
var firstClaim = await firstStore.ClaimAsync(context, operation);
|
||||
Assert.Equal(OpenClawOperationClaimDisposition.Started, firstClaim.Disposition);
|
||||
await firstStore.CompleteAsync(
|
||||
context,
|
||||
operation,
|
||||
ok: true,
|
||||
state: "completed",
|
||||
message: "Task cancelled.");
|
||||
|
||||
var restartedStore = new OpenClawOperationAuditStore(options);
|
||||
var replay = await restartedStore.ClaimAsync(context, operation);
|
||||
|
||||
Assert.Equal(OpenClawOperationClaimDisposition.Replayed, replay.Disposition);
|
||||
Assert.True(replay.PreviousOk);
|
||||
var persisted = await File.ReadAllTextAsync(auditPath);
|
||||
Assert.DoesNotContain(context.IdempotencyKey, persisted, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("token", persisted, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteTestDirectory(testDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OperationAudit_RejectsKeyReuseForDifferentIntentAndBlocksInDoubtRetry()
|
||||
{
|
||||
var testDirectory = CreateTestDirectory();
|
||||
try
|
||||
{
|
||||
var options = Options.Create(new GatewayConnectorOptions
|
||||
{
|
||||
OperationAuditPath = Path.Combine(testDirectory, "operations.jsonl"),
|
||||
DeviceStatePath = Path.Combine(testDirectory, "device.json")
|
||||
});
|
||||
var context = OpenClawInvocationContext.Create(
|
||||
idempotencyKey: "idem-1",
|
||||
correlationId: "corr-1",
|
||||
traceParent: TraceParent);
|
||||
var first = new OpenClawOperationDescriptor(
|
||||
"cron.run",
|
||||
"cron-job",
|
||||
"job-1",
|
||||
OpenClawInvocationContextFactory.Hash("force"));
|
||||
var conflict = first with
|
||||
{
|
||||
IntentFingerprint = OpenClawInvocationContextFactory.Hash("different")
|
||||
};
|
||||
|
||||
var store = new OpenClawOperationAuditStore(options);
|
||||
Assert.Equal(
|
||||
OpenClawOperationClaimDisposition.Started,
|
||||
(await store.ClaimAsync(context, first)).Disposition);
|
||||
Assert.Equal(
|
||||
OpenClawOperationClaimDisposition.Conflict,
|
||||
(await store.ClaimAsync(context, conflict)).Disposition);
|
||||
|
||||
var restartedStore = new OpenClawOperationAuditStore(options);
|
||||
Assert.Equal(
|
||||
OpenClawOperationClaimDisposition.InDoubt,
|
||||
(await restartedStore.ClaimAsync(context, first)).Disposition);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteTestDirectory(testDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTestDirectory()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "nexus-openclaw-tests");
|
||||
Directory.CreateDirectory(root);
|
||||
var path = Path.Combine(root, Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void DeleteTestDirectory(string path)
|
||||
{
|
||||
var root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "nexus-openclaw-tests"));
|
||||
var resolved = Path.GetFullPath(path);
|
||||
if (!resolved.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("Refusing to delete a test directory outside the expected root.");
|
||||
if (Directory.Exists(resolved))
|
||||
Directory.Delete(resolved, recursive: true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user