feat: ship agent-first mission control v0.2.57
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s

This commit is contained in:
AzuTear
2026-07-31 22:39:47 +02:00
parent 3bc7622977
commit f5552218bc
535 changed files with 95242 additions and 8791 deletions
+585
View File
@@ -0,0 +1,585 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Server;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class AgentProposalServiceTests
{
[Fact]
public async Task Create_IsIdempotent_AndNeverMutatesOpenClaw()
{
await using var fixture = await AgentProposalFixture.CreateAsync(
externalIdentitySupported: false);
var request = Proposal("Release Analyst");
var invocation = Invocation("proposal-1");
var first = await fixture.Service.CreateAsync(
request,
"manual",
invocation);
var replay = await fixture.Service.CreateAsync(
request,
"manual",
invocation);
Assert.True(first.Ok);
Assert.Equal(AgentProposalStates.AwaitingApproval, first.State);
Assert.True(replay.Ok);
Assert.Equal("idempotent_replay", replay.State);
Assert.Equal(first.Proposal!.Id, replay.Proposal!.Id);
Assert.Equal(1, await fixture.Db.AgentProposals.CountAsync());
Assert.Equal(0, fixture.Gateway.CreateCalls);
}
[Fact]
public async Task ListCursor_DoesNotSkipProposalsWithTheSameCreatedAt()
{
await using var fixture = await AgentProposalFixture.CreateAsync(
externalIdentitySupported: false);
foreach (var suffix in new[] { "a", "b", "c" })
{
await fixture.Service.CreateAsync(
Proposal($"Release Analyst {suffix}") with
{
ClientRequestId = $"proposal-{suffix}"
},
"manual",
Invocation($"proposal-{suffix}"));
}
var timestamp = new DateTimeOffset(
2026,
7,
30,
12,
0,
0,
TimeSpan.Zero);
foreach (var proposal in await fixture.Db.AgentProposals.ToListAsync())
{
proposal.CreatedAt = timestamp;
proposal.UpdatedAt = timestamp;
}
await fixture.Db.SaveChangesAsync();
var first = await fixture.Service.GetAsync(limit: 2);
var second = await fixture.Service.GetAsync(
limit: 2,
cursor: first.NextCursor);
Assert.Equal(2, first.Items.Count);
Assert.Single(second.Items);
Assert.Null(second.NextCursor);
Assert.Equal(
3,
first.Items
.Concat(second.Items)
.Select(item => item.Id)
.Distinct()
.Count());
}
[Fact]
public async Task Approve_FailsClosed_WhenExternalClientIdentityIsUnsupported()
{
await using var fixture = await AgentProposalFixture.CreateAsync(
externalIdentitySupported: false);
var created = await fixture.Service.CreateAsync(
Proposal("Release Analyst"),
"manual",
Invocation("proposal-1"));
var approved = await fixture.Service.ApproveAsync(
created.Proposal!.Id,
new AgentProposalActionRequest(created.Proposal.Revision),
Invocation("approve-1"));
Assert.False(approved.Ok);
Assert.Equal("experimental_blocked", approved.State);
Assert.Equal(0, await fixture.Db.AgentProvisionRequests.CountAsync());
Assert.Equal(0, fixture.Gateway.CreateCalls);
Assert.Equal(
AgentProposalStates.AwaitingApproval,
(await fixture.Service.GetByIdAsync(created.Proposal.Id))!.Status);
}
[Fact]
public async Task ApprovedProposal_CreatesOnce_VerifiesInventory_AndFinalizesFiles()
{
await using var fixture = await AgentProposalFixture.CreateAsync(
externalIdentitySupported: true);
var created = await fixture.Service.CreateAsync(
Proposal("Release Analyst"),
"manual",
Invocation("proposal-1"));
var approved = await fixture.Service.ApproveAsync(
created.Proposal!.Id,
new AgentProposalActionRequest(created.Proposal.Revision),
Invocation("approve-1"));
Assert.True(approved.Ok);
Assert.Equal(AgentProposalStates.Provisioning, approved.State);
Assert.True(await fixture.Service.ProcessNextAsync());
var completed = await fixture.Service.GetByIdAsync(created.Proposal.Id);
Assert.NotNull(completed);
Assert.Equal(AgentProposalStates.Ready, completed!.Status);
Assert.Equal("release-analyst", completed.OpenClawAgentId);
Assert.Equal(1, fixture.Gateway.CreateCalls);
Assert.Contains(
"release-analyst/IDENTITY.md",
fixture.AgentFiles.Writes.Keys);
Assert.Contains(
"release-analyst/AGENTS.md",
fixture.AgentFiles.Writes.Keys);
Assert.Empty(completed.Files);
}
[Fact]
public async Task Provisioning_ResolvesWorkspaceFromLiveOpenClawDefaults()
{
await using var fixture = await AgentProposalFixture.CreateAsync(
externalIdentitySupported: true);
var created = await fixture.Service.CreateAsync(
Proposal("Release Analyst"),
"manual",
Invocation("proposal-live-workspace"));
fixture.AgentFiles.DefaultWorkspace = "/srv/openclaw/workspaces";
await fixture.Service.ApproveAsync(
created.Proposal!.Id,
new AgentProposalActionRequest(created.Proposal.Revision),
Invocation("approve-live-workspace"));
Assert.True(await fixture.Service.ProcessNextAsync());
var completed = await fixture.Service.GetByIdAsync(created.Proposal.Id);
Assert.Equal(
"/srv/openclaw/workspaces/release-analyst",
completed!.Workspace);
Assert.Equal(
"/srv/openclaw/workspaces/release-analyst",
completed.OpenClawWorkspace);
Assert.Equal(1, fixture.Gateway.CreateCalls);
}
[Fact]
public async Task UncertainCreate_IsNotRepeated_AndRetryReconcilesBeforeAnotherCreate()
{
await using var fixture = await AgentProposalFixture.CreateAsync(
externalIdentitySupported: true);
fixture.Gateway.ThrowUncertainCreate = true;
var created = await fixture.Service.CreateAsync(
Proposal("Release Analyst"),
"manual",
Invocation("proposal-1"));
await fixture.Service.ApproveAsync(
created.Proposal!.Id,
new AgentProposalActionRequest(created.Proposal.Revision),
Invocation("approve-1"));
Assert.True(await fixture.Service.ProcessNextAsync());
Assert.False(await fixture.Service.ProcessNextAsync());
var uncertain = await fixture.Service.GetByIdAsync(created.Proposal.Id);
Assert.Equal(AgentProposalStates.InDoubt, uncertain!.Status);
Assert.Equal(1, fixture.Gateway.CreateCalls);
var retry = await fixture.Service.RetryAsync(
created.Proposal.Id,
new AgentProposalActionRequest(uncertain.Revision),
Invocation("retry-1"));
Assert.True(retry.Ok);
Assert.True(await fixture.Service.ProcessNextAsync());
var reconciled = await fixture.Service.GetByIdAsync(created.Proposal.Id);
Assert.Equal(AgentProposalStates.Failed, reconciled!.Status);
Assert.Equal("reconciled_absent", reconciled.Error!.Code);
Assert.Equal(1, fixture.Gateway.CreateCalls);
}
[Fact]
public async Task ExpiredCreateLease_IsReclaimedAsReadOnlyReconciliation()
{
await using var fixture = await AgentProposalFixture.CreateAsync(
externalIdentitySupported: true);
var created = await fixture.Service.CreateAsync(
Proposal("Lease Sentinel"),
"manual",
Invocation("proposal-lease"));
await fixture.Service.ApproveAsync(
created.Proposal!.Id,
new AgentProposalActionRequest(created.Proposal.Revision),
Invocation("approve-lease"));
var request = await fixture.Db.AgentProvisionRequests.SingleAsync();
request.Status = AgentProvisionRequestStates.Dispatching;
request.DispatchStartedAt = DateTimeOffset.UtcNow.AddMinutes(-5);
request.LeaseOwner = "abandoned-worker";
request.LeaseUntil = DateTimeOffset.UtcNow.AddSeconds(-1);
await fixture.Db.SaveChangesAsync();
Assert.True(await fixture.Service.ProcessNextAsync());
var reconciled = await fixture.Service.GetByIdAsync(
created.Proposal.Id);
Assert.Equal(AgentProposalStates.Failed, reconciled!.Status);
Assert.Equal("reconciled_absent", reconciled.Error!.Code);
Assert.Equal(0, fixture.Gateway.CreateCalls);
Assert.Contains(
await fixture.Db.OutboxEvents.ToListAsync(),
item => item.Type == "agent.provision.lease_reclaimed");
}
[Fact]
public void Controller_AndMcpTools_ExposeExpectedSecurityMetadata()
{
var authorize = typeof(OpenClawAgentProposalsController)
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.OfType<AuthorizeAttribute>()
.Single();
Assert.Equal("owner", authorize.Roles);
var propose = typeof(NexusMcpTools).GetMethod(
nameof(NexusMcpTools.ProposeAgent))!;
var proposeTool = propose.GetCustomAttributes(
typeof(McpServerToolAttribute),
inherit: false)
.OfType<McpServerToolAttribute>()
.Single();
Assert.Equal("nexus_propose_agent", proposeTool.Name);
Assert.False(proposeTool.ReadOnly);
Assert.False(proposeTool.Destructive);
Assert.True(proposeTool.Idempotent);
Assert.False(proposeTool.OpenWorld);
Assert.True(proposeTool.UseStructuredContent);
Assert.Equal(typeof(AgentProposalToolResult), proposeTool.OutputSchemaType);
var get = typeof(NexusMcpTools).GetMethod(
nameof(NexusMcpTools.GetAgentProposal))!;
var getTool = get.GetCustomAttributes(
typeof(McpServerToolAttribute),
inherit: false)
.OfType<McpServerToolAttribute>()
.Single();
Assert.True(getTool.ReadOnly);
Assert.False(getTool.Destructive);
}
private static CreateAgentProposalRequest Proposal(string name)
=> new(
name,
Role: "Release quality",
Description: "Verify releases and report evidence.",
Model: "openai/gpt-5.5",
ClientRequestId: "proposal-1");
private static OpenClawInvocationMetadata Invocation(string key)
=> new(key, $"correlation-{key}", "bao", null);
}
internal sealed class AgentProposalFixture : IAsyncDisposable
{
private AgentProposalFixture(
NexusDbContext db,
AgentProposalService service,
ProposalGatewayConnector gateway,
ProposalAgentConfigurationService agentFiles)
{
Db = db;
Service = service;
Gateway = gateway;
AgentFiles = agentFiles;
}
public NexusDbContext Db { get; }
public AgentProposalService Service { get; }
public ProposalGatewayConnector Gateway { get; }
public ProposalAgentConfigurationService AgentFiles { get; }
public static async Task<AgentProposalFixture> CreateAsync(
bool externalIdentitySupported)
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var gateway = new ProposalGatewayConnector();
var management = new OpenClawManagementState();
management.SetEnabled(true);
db.OpenClawConnectionProfiles.Add(new OpenClawConnectionProfile
{
Endpoint = "ws://127.0.0.1:18789/",
DiscoverySource = "test",
RequiredVersion = gateway.RequiredVersion,
AdoptionState = OpenClawAdoptionStates.Adopted,
ManagementEnabled = true,
CapabilityHash = AgentProposalService.BuildCapabilityHash(gateway),
Revision = 1
});
await db.SaveChangesAsync();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["OpenClawSetup:ExternalClientIdentitySupported"] =
externalIdentitySupported.ToString()
})
.Build();
var agentFiles = new ProposalAgentConfigurationService();
var service = new AgentProposalService(
db,
gateway,
agentFiles,
new StubOpenClawWriteGate(
externalIdentitySupported
? OpenClawWriteGateDecision.Permit()
: OpenClawWriteGateDecision.Block(
"experimental_blocked",
"External identity is not supported.")),
Options.Create(new AgentProvisioningOptions()),
new AgentProvisioningSignal(),
NullLogger<AgentProposalService>.Instance);
return new AgentProposalFixture(db, service, gateway, agentFiles);
}
public ValueTask DisposeAsync() => Db.DisposeAsync();
}
internal sealed class ProposalGatewayConnector : IGatewayConnector
{
private readonly List<(string Id, string Workspace)> agents = [];
public bool ThrowUncertainCreate { get; set; }
public int CreateCalls { get; private set; }
public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected;
public string? GatewayVersion => "2026.7.1";
public string? RequiredVersion => "2026.7.1";
public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow;
public int ReconnectAttempts => 0;
public string? StatusMessage => "ready";
public string? DeviceId => "test-device";
public bool DeviceTokenConfigured => true;
public bool PairingRequired => false;
public string? PairingRequestId => null;
public int? ProtocolVersion => 4;
public IReadOnlySet<string> AdvertisedMethods { get; } = new HashSet<string>(
[
"agents.list",
"agents.create",
"agents.files.get",
"agents.files.set",
"config.get",
"models.list"
],
StringComparer.Ordinal);
public IReadOnlySet<string> AdvertisedEvents { get; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>(
["operator.read", "operator.admin"],
StringComparer.Ordinal);
public DateTimeOffset? LastEventAt => null;
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
{
if (method == "agents.list")
{
return Task.FromResult<JsonNode?>(new JsonObject
{
["agents"] = new JsonArray(agents
.Select(item => (JsonNode)new JsonObject
{
["id"] = item.Id,
["workspace"] = item.Workspace
})
.ToArray())
});
}
if (method == "models.list")
{
return Task.FromResult<JsonNode?>(new JsonObject
{
["models"] = new JsonArray
{
new JsonObject
{
["id"] = "openai/gpt-5.5",
["name"] = "GPT-5.5",
["provider"] = "openai",
["available"] = true
}
}
});
}
if (method == "agents.create")
{
CreateCalls++;
if (ThrowUncertainCreate)
{
throw new OpenClawGatewayRpcException(
"GATEWAY_DISCONNECTED",
"Connection dropped.",
retryable: true);
}
var json = JsonSerializer.SerializeToNode(parameters)!.AsObject();
var name = json["name"]!.GetValue<string>();
var id = name.Trim().ToLowerInvariant().Replace(' ', '-');
var workspace = json["workspace"]!.GetValue<string>();
agents.Add((id, workspace));
return Task.FromResult<JsonNode?>(new JsonObject
{
["ok"] = true,
["agentId"] = id,
["name"] = name,
["workspace"] = workspace
});
}
throw new OpenClawGatewayRpcException(
"METHOD_NOT_FOUND",
$"Unexpected method {method}.");
}
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100)
=> [];
}
internal sealed class ProposalAgentConfigurationService
: IOpenClawAgentConfigurationService
{
public Dictionary<string, string> Writes { get; } =
new(StringComparer.Ordinal);
public string? DefaultWorkspace { get; set; }
public Task<OpenClawAgentFileDto> GetAgentFileAsync(
string agentId,
string fileName,
CancellationToken cancellationToken = default)
{
var key = $"{agentId}/{fileName}";
if (Writes.TryGetValue(key, out var content))
{
return Task.FromResult(File(agentId, fileName, content));
}
return Task.FromResult(new OpenClawAgentFileDto(
agentId,
fileName,
true,
null,
null,
null,
OpenClawAgentConfigurationService.MissingContentHash,
DateTimeOffset.UtcNow));
}
public async Task<OpenClawAgentFileWriteDto> SetAgentFileAsync(
string agentId,
string fileName,
UpdateOpenClawAgentFileRequest request,
OpenClawInvocationContext invocationContext,
CancellationToken cancellationToken = default)
{
Writes[$"{agentId}/{fileName}"] = request.Content;
var file = await GetAgentFileAsync(agentId, fileName, cancellationToken);
return new OpenClawAgentFileWriteDto(
true,
"completed",
"verified",
file,
true,
invocationContext.IdempotencyKey,
invocationContext.CorrelationId,
DateTimeOffset.UtcNow);
}
public Task<OpenClawAgentFileCollectionDto> GetAgentFilesAsync(
string agentId,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawWorkspaceCollectionDto> GetWorkspaceAsync(
string agentId,
string? path,
int offset,
int limit,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawWorkspaceFileDto> GetWorkspaceFileAsync(
string agentId,
string path,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigSchemaLookupDto> GetConfigSchemaAsync(
string path,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigSnapshotDto> GetConfigAsync(
CancellationToken cancellationToken = default)
=> Task.FromResult(new OpenClawConfigSnapshotDto(
true,
true,
"config-hash",
new JsonObject
{
["agents"] = new JsonObject
{
["defaults"] = new JsonObject
{
["workspace"] = DefaultWorkspace
}
}
},
null,
null,
DateTimeOffset.UtcNow));
public Task<OpenClawConfigPatchDto> PatchConfigAsync(
PatchOpenClawConfigRequest request,
OpenClawInvocationContext invocationContext,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
private static OpenClawAgentFileDto File(
string agentId,
string name,
string content)
=> new(
agentId,
name,
false,
Encoding.UTF8.GetByteCount(content),
DateTimeOffset.UtcNow,
content,
Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(content)))
.ToLowerInvariant(),
DateTimeOffset.UtcNow);
}
+77 -159
View File
@@ -1,214 +1,132 @@
using Nexus.Api.Services;
using Nexus.Api.Integrations;
using Nexus.Api.Data;
using Microsoft.Extensions.Configuration;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public class AgentServiceTests
public sealed class AgentServiceTests
{
[Fact]
public async Task GetAgentsAsync_ReturnsCorrectCount()
public async Task GetAgentsAsync_ProjectsOnlyLiveOpenClawInventory()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var service = new AgentService(new StubOpenClawControlService());
var agents = await service.GetAgentsAsync(CancellationToken.None);
Assert.True(agents.Count >= 4, $"Expected at least 4 agents, got {agents.Count}");
Assert.Equal(6, agents.Count);
Assert.Contains(agents, agent =>
agent.Id == "product-owner" &&
agent.Workspace == "/workspace-po" &&
agent.Role == "Product Owner");
}
[Fact]
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var service = new AgentService(new StubOpenClawControlService());
var agent = await service.GetAgentAsync("iris", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("Orchestrator", agent.Role);
Assert.Equal("/workspace/iris", agent.Workspace);
Assert.Null(agent.AgentDir);
}
[Fact]
public async Task GetAgentAsync_Unknown_ReturnsNull()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var service = new AgentService(new StubOpenClawControlService());
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
Assert.Null(agent);
}
[Fact]
public async Task GetAllowedAgentIdsAsync_IncludesProductOwnerAndProgrammerFast()
public async Task GetAllowedAgentIdsAsync_UsesLiveIds()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var control = new StubOpenClawControlService(
agents:
[
Agent("custom-live", "Custom"),
Agent("product-owner", "Product Owner")
]);
var service = new AgentService(control);
var ids = await service.GetAllowedAgentIdsAsync(CancellationToken.None);
Assert.Equal(2, ids.Count);
Assert.Contains("custom-live", ids);
Assert.Contains("product-owner", ids);
Assert.Contains("programmer-fast", ids);
Assert.DoesNotContain("iris", ids);
}
[Fact]
public async Task GetAgentAsync_ProgrammerFast_UsesPrimaryModelAndDeveloperRole()
public async Task GetAgentAsync_LatestSessionModelOverridesInventoryModel()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var older = DateTimeOffset.UtcNow.AddMinutes(-10);
var newer = DateTimeOffset.UtcNow;
var control = new StubOpenClawControlService(
agents: [Agent("programmer-fast", "Programmer Fast", "openai/default")],
sessions:
[
Session("old", "programmer-fast", "openai/gpt-5.3", older),
Session("new", "programmer-fast", "openai/gpt-5.5", newer)
]);
var service = new AgentService(control);
var agent = await service.GetAgentAsync("programmer-fast", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("Developer", agent.Role);
Assert.Equal("openai/gpt-5.3-codex-spark", agent.Model);
Assert.Equal("openai/gpt-5.5", agent.Model);
Assert.Equal(newer, agent.LastSeen);
}
[Fact]
public async Task GetAgentAsync_LegacyStringModel_IsSupported()
public async Task GetAgentsAsync_DisconnectedGatewayMarksAgentsOffline()
{
var configPath = CreateAgentConfigFile(
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": "deepseek/deepseek-v4-flash"
},
"list": [
{
"id": "iris",
"name": "iris",
"model": "openai/gpt-5.5"
}
]
}
}
""");
var config = CreateConfiguration(configPath);
var service = new AgentService(config, new FakeRuntime());
var service = new AgentService(new StubOpenClawControlService(connected: false));
var agent = await service.GetAgentAsync("iris", CancellationToken.None);
var agents = await service.GetAgentsAsync(CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("openai/gpt-5.5", agent!.Model);
Assert.All(agents, agent => Assert.Equal(OperationalStatus.Offline, agent.Status));
}
[Fact]
public async Task GetAgentAsync_ObjectModel_InheritsStringDefaultModel()
{
var configPath = CreateAgentConfigFile(
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": "openai/gpt-5.5-mini"
},
"list": [
{
"id": "reviewer",
"name": "reviewer"
}
]
}
}
""");
var config = CreateConfiguration(configPath);
var service = new AgentService(config, new FakeRuntime());
private static OpenClawAgentDto Agent(
string id,
string name,
string model = "openai/gpt-5.5")
=> new(
Id: id,
Name: name,
Description: null,
Model: model,
Provider: "openai",
Workspace: $"/live/{id}",
Status: "ready");
var agent = await service.GetAgentAsync("reviewer", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("openai/gpt-5.5-mini", agent!.Model);
}
private static IConfiguration CreateConfiguration(string configPath)
=> new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = configPath
})
.Build();
private static string CreateAgentConfigFile(string? json = null)
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path, json ??
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": {
"primary": "deepseek/deepseek-v4-flash"
}
},
"list": [
{
"id": "iris",
"name": "iris",
"model": { "primary": "openai/gpt-5.5" }
},
{
"id": "product-owner",
"name": "product-owner",
"model": { "primary": "openai/gpt-5.5" }
},
{
"id": "programmer",
"name": "programmer",
"model": { "primary": "openai/gpt-5.4" }
},
{
"id": "programmer-fast",
"name": "programmer-fast",
"model": { "primary": "openai/gpt-5.3-codex-spark" }
},
{
"id": "reviewer",
"name": "reviewer",
"model": { "primary": "openai/gpt-5.5" }
},
{
"id": "architekt",
"name": "architekt",
"model": { "primary": "openai/gpt-5.5" }
}
]
}
}
""");
return path;
}
}
public sealed class FakeRuntime : IAgentRuntime
{
public string Name => "FakeRuntime";
public Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentRuntimeStatus(
Runtime: "OpenClaw",
Status: OperationalStatus.Online,
Latency: TimeSpan.FromMilliseconds(10),
Detail: "Fake runtime for testing"));
public Task<AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentChatResult(
Runtime: "OpenClaw",
private static OpenClawSessionDto Session(
string key,
string agentId,
string model,
DateTimeOffset updatedAt)
=> new(
Key: key,
SessionId: key,
AgentId: agentId,
ConversationId: conversationId,
Content: "Echo: " + message));
Title: key,
Status: "active",
Kind: "agent",
Channel: null,
Model: model,
Provider: "openai",
RunId: null,
UpdatedAt: updatedAt,
InputTokens: null,
OutputTokens: null,
TotalTokens: null,
CanAbort: true);
}
@@ -0,0 +1,107 @@
using System.Diagnostics.Metrics;
using Microsoft.AspNetCore.Http;
using Nexus.Api.Controllers;
using Nexus.Api.Models;
using Nexus.Api.Observability;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class BrowserTelemetryControllerTests
{
[Fact]
public void Record_AcceptsAllowlistedContentFreeMetric()
{
var controller = new BrowserTelemetryController();
var result = controller.Record(new BrowserMetricRequest(
"board_content_visible",
314.2,
"custom",
"Task Board",
"test",
"spa",
"live",
null));
Assert.Equal(StatusCodes.Status204NoContent, ResultStatusCode(result));
}
[Fact]
public void Record_RejectsUnknownMetricAndInvalidValue()
{
var controller = new BrowserTelemetryController();
Assert.Equal(
StatusCodes.Status400BadRequest,
ResultStatusCode(controller.Record(new BrowserMetricRequest(
"task-content",
1,
"custom",
"Tasks",
"test",
"spa",
"live",
null))));
Assert.Equal(
StatusCodes.Status400BadRequest,
ResultStatusCode(controller.Record(new BrowserMetricRequest(
"LCP",
double.PositiveInfinity,
"good",
"Tasks",
"test",
"navigate",
"unknown",
null))));
}
[Fact]
public void Record_RoutesClsToUnitlessScoreAndDurationsToMilliseconds()
{
var measurements = new List<(string Name, string? Unit, double Value)>();
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, activeListener) =>
{
if (instrument.Meter.Name == NexusTelemetry.SourceName &&
instrument.Name is "nexus.browser.score" or "nexus.browser.duration")
{
activeListener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<double>((instrument, value, _, _) =>
measurements.Add((instrument.Name, instrument.Unit, value)));
listener.Start();
var controller = new BrowserTelemetryController();
controller.Record(new BrowserMetricRequest(
"CLS",
0.123456,
"good",
"Dashboard",
"test",
"navigate",
"live",
null));
controller.Record(new BrowserMetricRequest(
"LCP",
1234.5678,
"good",
"Dashboard",
"test",
"navigate",
"live",
null));
Assert.Contains(
measurements,
item => item is ("nexus.browser.score", "1", 0.123456));
Assert.Contains(
measurements,
item => item is ("nexus.browser.duration", "ms", 1234.5678));
}
private static int? ResultStatusCode(IResult result)
=> result is IStatusCodeHttpResult statusCodeResult
? statusCodeResult.StatusCode
: null;
}
+15 -1
View File
@@ -8,10 +8,24 @@ namespace Nexus.Api.Tests;
public sealed class ChatControllerTests
{
[Fact]
public void ChatController_RequiresAuthorization()
public void ChatController_RequiresOwnerAuthorization()
{
var attribute = typeof(ChatController).GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(attribute);
Assert.Equal("owner", attribute.Roles);
}
[Fact]
public void Legacy_dashboard_chat_adapter_is_owner_only_and_deprecated()
{
var method = typeof(DashboardController).GetMethod(
nameof(DashboardController.SendChat));
Assert.NotNull(method);
Assert.Equal(
"owner",
method!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
Assert.NotNull(method.GetCustomAttribute<ObsoleteAttribute>());
}
}
@@ -0,0 +1,60 @@
using Xunit;
namespace Nexus.Api.Tests;
internal static class DockerIntegrationTestEnvironment
{
public const string CollectionName = "Docker integration";
public const string PostgreSqlOptIn = "NEXUS_RUN_DOCKER_INTEGRATION_TESTS";
public const string ToxiproxyOptIn = "NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS";
public static bool PostgreSqlEnabled => IsEnabled(PostgreSqlOptIn);
public static bool ToxiproxyEnabled =>
PostgreSqlEnabled && IsEnabled(ToxiproxyOptIn);
private static bool IsEnabled(string variable)
{
var value = Environment.GetEnvironmentVariable(variable)?.Trim();
return value is not null
&& (string.Equals(value, "1", StringComparison.Ordinal)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "on", StringComparison.OrdinalIgnoreCase));
}
}
[AttributeUsage(AttributeTargets.Method)]
public sealed class PostgreSqlIntegrationFactAttribute : FactAttribute
{
public PostgreSqlIntegrationFactAttribute()
{
if (!DockerIntegrationTestEnvironment.PostgreSqlEnabled)
{
Skip =
$"Requires Docker and explicit {DockerIntegrationTestEnvironment.PostgreSqlOptIn}=true opt-in.";
}
}
}
[AttributeUsage(AttributeTargets.Method)]
public sealed class ToxiproxyIntegrationFactAttribute : FactAttribute
{
public ToxiproxyIntegrationFactAttribute()
{
if (!DockerIntegrationTestEnvironment.ToxiproxyEnabled)
{
Skip =
"Requires Docker plus explicit "
+ $"{DockerIntegrationTestEnvironment.PostgreSqlOptIn}=true and "
+ $"{DockerIntegrationTestEnvironment.ToxiproxyOptIn}=true opt-in.";
}
}
}
[CollectionDefinition(
DockerIntegrationTestEnvironment.CollectionName,
DisableParallelization = true)]
public sealed class DockerIntegrationCollectionDefinition
{
}
+222
View File
@@ -0,0 +1,222 @@
using System.Text.Json;
using System.Text;
using System.Threading.Channels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class DomainEventOutboxTests
{
[Fact]
public async Task Task_mutation_persists_content_minimized_outbox_event()
{
await using var db = CreateDatabase();
var repository = new TaskRepository(db);
var task = new WorkTask
{
Title = "Sensitive title that must not enter the event payload",
Detail = "Sensitive details",
AssignedTo = "programmer"
};
await repository.AddAsync(task);
var created = await db.OutboxEvents.SingleAsync();
Assert.Equal("task.created", created.Type);
Assert.Equal("task", created.AggregateType);
Assert.Equal(task.Id.ToString(), created.AggregateId);
Assert.DoesNotContain(task.Title, created.PayloadJson, StringComparison.Ordinal);
Assert.DoesNotContain(task.Detail, created.PayloadJson, StringComparison.Ordinal);
using var payload = JsonDocument.Parse(created.PayloadJson);
Assert.Equal(task.Id, payload.RootElement.GetProperty("id").GetGuid());
Assert.Equal("Backlog", payload.RootElement.GetProperty("state").GetString());
task.State = "In progress";
await repository.UpdateAsync(task);
var events = await db.OutboxEvents
.OrderBy(item => item.Sequence)
.ToListAsync();
Assert.Equal(2, events.Count);
Assert.Equal("task.updated", events[1].Type);
using var updatedPayload = JsonDocument.Parse(events[1].PayloadJson);
Assert.Equal(
"In progress",
updatedPayload.RootElement.GetProperty("state").GetString());
}
[Fact]
public async Task Activity_event_keeps_message_content_out_of_domain_stream()
{
await using var db = CreateDatabase();
var repository = new ActivityRepository(db, new LiveUpdateService());
var taskId = Guid.NewGuid();
const string sensitiveMessage = "Agent programmer handled private workspace details";
await repository.AddAsync(new ActivityEvent
{
Type = "comment",
Message = sensitiveMessage,
TaskId = taskId
});
var stored = await db.OutboxEvents.SingleAsync();
Assert.Equal("activity.created", stored.Type);
Assert.Equal("activity", stored.AggregateType);
Assert.DoesNotContain(sensitiveMessage, stored.PayloadJson, StringComparison.Ordinal);
using var payload = JsonDocument.Parse(stored.PayloadJson);
Assert.Equal(taskId, payload.RootElement.GetProperty("taskId").GetGuid());
Assert.Equal("comment", payload.RootElement.GetProperty("type").GetString());
}
[Fact]
public void Outbox_mapper_normalizes_entity_reference_and_clones_payload()
{
var stored = new OutboxEvent
{
Sequence = 42,
Type = "agent.proposal.created",
AggregateType = "AgentProposal",
AggregateId = Guid.NewGuid().ToString(),
AggregateRevision = 3,
PayloadJson = """{"state":"awaiting_approval"}"""
};
var mapped = DomainEventStreamService.Map(stored);
Assert.Equal(42, mapped.Sequence);
Assert.Equal("agent-proposal", mapped.Entity.Type);
Assert.Equal(3, mapped.EntityRevision);
Assert.Equal(
"awaiting_approval",
mapped.Payload.GetProperty("state").GetString());
}
[Fact]
public async Task Resync_required_advances_sse_cursor_to_current_sequence()
{
await using var db = CreateDatabase();
db.OutboxEvents.Add(new OutboxEvent
{
Sequence = 10,
Type = "task.updated",
AggregateType = "task",
AggregateId = Guid.NewGuid().ToString(),
PayloadJson = """{"state":"Backlog"}""",
PublishedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
var stream = new FixedDomainEventStream(currentSequence: 25);
var controller = new DomainEventsController(
db,
stream,
NullLogger<DomainEventsController>.Instance);
var body = new MemoryStream();
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
controller.Response.Body = body;
await controller.Get(
channels: null,
afterSequence: 1,
CancellationToken.None);
body.Position = 0;
var content = await new StreamReader(
body,
Encoding.UTF8,
leaveOpen: true).ReadToEndAsync();
Assert.Contains("id: 25", content, StringComparison.Ordinal);
Assert.Contains("\"eventType\":\"resync_required\"", content, StringComparison.Ordinal);
Assert.Contains("\"resumeSequence\":25", content, StringComparison.Ordinal);
}
[Fact]
public async Task Replay_exposes_only_events_committed_as_published()
{
await using var db = CreateDatabase();
var entityId = Guid.NewGuid().ToString();
db.OutboxEvents.AddRange(
new OutboxEvent
{
Sequence = 1,
Type = "task.updated",
AggregateType = "task",
AggregateId = entityId,
PayloadJson = """{"state":"Review"}""",
PublishedAt = DateTimeOffset.UtcNow
},
new OutboxEvent
{
Sequence = 2,
Type = "task.updated",
AggregateType = "task",
AggregateId = entityId,
PayloadJson = """{"state":"Done"}"""
});
await db.SaveChangesAsync();
var controller = new DomainEventsController(
db,
new FixedDomainEventStream(currentSequence: 2),
NullLogger<DomainEventsController>.Instance);
var body = new MemoryStream();
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
controller.Response.Body = body;
await controller.Get(
channels: null,
afterSequence: 0,
CancellationToken.None);
body.Position = 0;
var content = await new StreamReader(
body,
Encoding.UTF8,
leaveOpen: true).ReadToEndAsync();
Assert.Contains("id: 1", content, StringComparison.Ordinal);
Assert.Contains("\"state\":\"Review\"", content, StringComparison.Ordinal);
Assert.DoesNotContain("id: 2", content, StringComparison.Ordinal);
Assert.DoesNotContain("\"state\":\"Done\"", content, StringComparison.Ordinal);
}
private static NexusDbContext CreateDatabase()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase($"domain-outbox-{Guid.NewGuid():N}")
.Options;
return new NexusDbContext(options);
}
private sealed class FixedDomainEventStream(long currentSequence)
: IDomainEventStreamService
{
public long CurrentSequence { get; } = currentSequence;
public DomainEventSubscription Subscribe(IReadOnlySet<string> channels)
{
var channel = Channel.CreateUnbounded<DomainEventDto>();
channel.Writer.TryComplete();
return new DomainEventSubscription(
channel.Reader,
CurrentSequence,
() => ValueTask.CompletedTask);
}
}
}
+64 -2
View File
@@ -1,8 +1,10 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Nexus.Api.Controllers;
using Nexus.Api.Services;
@@ -15,10 +17,16 @@ public class GatewayConnectorTests
// ── Options / Configuration tests ──
[Fact]
public void Options_DefaultWebSocketPath_IsWs()
public void Options_DefaultWebSocketPath_IsGatewayRoot()
{
var options = new GatewayConnectorOptions();
Assert.Equal("/ws", options.WebSocketPath);
Assert.Equal("/", options.WebSocketPath);
Assert.Equal(4, options.ProtocolVersion);
Assert.Equal(["operator.read"], options.Scopes);
Assert.Equal("nexus", options.ClientId);
Assert.Equal("backend", options.ClientMode);
Assert.False(options.ExternalClientIdentitySupported);
Assert.False(options.AllowReservedInternalClientIdentity);
}
[Fact]
@@ -49,6 +57,26 @@ public class GatewayConnectorTests
Assert.False(options.FailFastOnMissingVersion);
}
[Fact]
public void Connector_DefaultsToVerifiedGatewayVersionPin()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Integrations:OpenClaw:BaseUrl"] = "http://127.0.0.1:18789"
})
.Build();
var connector = new GatewayConnector(
configuration,
Options.Create(new GatewayConnectorOptions()),
NullLogger<GatewayConnector>.Instance);
Assert.Equal(
OpenClawGatewayProtocol.DefaultRequiredGatewayVersion,
connector.RequiredVersion);
Assert.Equal("2026.7.1", connector.RequiredVersion);
}
// ── Configuration binding tests ──
[Fact]
@@ -58,6 +86,7 @@ public class GatewayConnectorTests
.AddInMemoryCollection(new Dictionary<string, string?>
{
["GatewayConnector:WebSocketPath"] = "/events",
["GatewayConnector:ProtocolVersion"] = "4",
["GatewayConnector:ReconnectInitialDelayMs"] = "2000",
["GatewayConnector:ReconnectMaxDelayMs"] = "60000",
["GatewayConnector:FailFastOnVersionMismatch"] = "false",
@@ -73,6 +102,7 @@ public class GatewayConnectorTests
var options = sp.GetRequiredService<IOptions<GatewayConnectorOptions>>().Value;
Assert.Equal("/events", options.WebSocketPath);
Assert.Equal(4, options.ProtocolVersion);
Assert.Equal(2000, options.ReconnectInitialDelayMs);
Assert.Equal(60000, options.ReconnectMaxDelayMs);
Assert.False(options.FailFastOnVersionMismatch);
@@ -308,4 +338,36 @@ public sealed class FakeGatewayConnector : IGatewayConnector
public DateTimeOffset? LastConnectedAt => _lastConnectedAt;
public int ReconnectAttempts => _reconnectAttempts;
public string? StatusMessage => _statusMessage;
public string? DeviceId => null;
public bool DeviceTokenConfigured => false;
public bool PairingRequired => false;
public string? PairingRequestId => null;
public int? ProtocolVersion => _state == GatewayConnectionState.Connected ? 4 : null;
public IReadOnlySet<string> AdvertisedMethods { get; } = new HashSet<string>(StringComparer.Ordinal)
{
"health",
"tasks.list"
};
public IReadOnlySet<string> AdvertisedEvents { get; } = new HashSet<string>(StringComparer.Ordinal)
{
"health",
"tick"
};
public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>(StringComparer.Ordinal)
{
"operator.read"
};
public DateTimeOffset? LastEventAt => null;
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Task.FromResult<JsonNode?>(null);
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
}
+15 -34
View File
@@ -98,15 +98,17 @@ public sealed class McpToolsTests
"nexus_create_child_task",
"nexus_create_task",
"nexus_get_activity",
"nexus_get_agent_proposal",
"nexus_get_board",
"nexus_get_children",
"nexus_get_task",
"nexus_handoff",
"nexus_propose_agent",
"nexus_update_status"
}.OrderBy(n => n).ToList();
Assert.Equal(expected, toolMethods);
Assert.Equal(10, toolMethods.Count);
Assert.Equal(12, toolMethods.Count);
}
[Fact]
@@ -415,7 +417,7 @@ public sealed class McpToolsTests
public async Task ResolveCaller_RejectsUnknownAgentId()
{
await using var fixture = await McpToolsFixture.CreateAsync();
fixture.SetCallerAgent("hacker");
fixture.SetCallerAgentWithoutAuthentication("hacker");
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => fixture.Tools.CreateTask("Should fail"));
@@ -468,16 +470,14 @@ internal sealed class McpToolsFixture : IAsyncDisposable
var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var configPath = CreateAgentConfigFile();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = configPath,
["NexusApiKey"] = "test-service-key"
})
.Build();
var agentService = new AgentService(configuration, new FakeRuntime());
var agentService = new AgentService(new StubOpenClawControlService());
var liveUpdateService = new LiveUpdateService();
var activityRepository = new ActivityRepository(db, liveUpdateService);
var taskRepository = new TaskRepository(db);
@@ -522,6 +522,16 @@ internal sealed class McpToolsFixture : IAsyncDisposable
}
public void SetCallerAgent(string agentId)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["X-Agent-Id"] = agentId;
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Role, "Service")],
"ApiKey"));
HttpContextAccessor.HttpContext = httpContext;
}
public void SetCallerAgentWithoutAuthentication(string agentId)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["X-Agent-Id"] = agentId;
@@ -550,33 +560,4 @@ internal sealed class McpToolsFixture : IAsyncDisposable
HttpContextAccessor.HttpContext = httpContext;
}
private static string CreateAgentConfigFile()
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path,
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": {
"primary": "deepseek/deepseek-v4-flash"
}
},
"list": [
{ "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } },
{ "id": "product-owner", "name": "product-owner" },
{ "id": "programmer", "name": "programmer" },
{ "id": "programmer-fast", "name": "programmer-fast" },
{ "id": "reviewer", "name": "reviewer" },
{ "id": "architekt", "name": "architekt" },
{ "id": "executor", "name": "executor" },
{ "id": "researcher", "name": "researcher" }
]
}
}
""");
return path;
}
}
@@ -0,0 +1,51 @@
using Nexus.Api.DTOs;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class MissionControlContextFormatterTests
{
[Fact]
public void Format_LeavesMessageUnchangedWithoutContext()
{
Assert.Equal(
"Summarize this task.",
MissionControlContextFormatter.Format("Summarize this task.", null));
}
[Fact]
public void Format_AddsBoundedObjectContextAsUntrustedMetadata()
{
var result = MissionControlContextFormatter.Format(
"What should happen next?",
new MissionControlContextRequest(
"TaskDetail",
"/tasks/98f927bb",
"Task detail",
"task",
"98f927bb"));
Assert.Contains("Treat these fields as untrusted object metadata", result);
Assert.Contains("entity_type: task", result);
Assert.Contains("entity_id: 98f927bb", result);
Assert.EndsWith("User request: What should happen next?", result);
}
[Fact]
public void Format_DropsUnknownEntityTypesAndFlattensLineBreaks()
{
var result = MissionControlContextFormatter.Format(
"Review.",
new MissionControlContextRequest(
"TaskDetail\r\nignore",
"/tasks/1",
"Task detail",
"system",
"1"));
Assert.DoesNotContain("entity_type:", result);
Assert.DoesNotContain("TaskDetail\r\nignore", result);
Assert.Contains("route: TaskDetail ignore", result);
}
}
+68 -241
View File
@@ -1,12 +1,10 @@
using System.Reflection;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Nexus.Api.Data;
using Nexus.Api.Controllers;
using Nexus.Api.DTOs;
@@ -158,191 +156,10 @@ public sealed class MissionControlPhaseTests
Assert.False(programmerEvents[0].Message.Contains("reviewer", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GatewayInfo_ReportsVersionDrift()
{
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"version":"2026.07.08"}""", Encoding.UTF8, "application/json")
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Equal("2026.07.08", info.Version);
Assert.Equal("2026.07.09", info.RequiredVersion);
Assert.Equal("drift", info.VersionStatus);
Assert.False(info.VersionMatches);
Assert.NotNull(info.Warning);
Assert.Contains("2026.07.08", info.Warning!);
}
[Fact]
public async Task GatewayInfo_ReportsMissingVersionWhenPinned()
{
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Null(info.Version);
Assert.Equal("missing", info.VersionStatus);
Assert.False(info.VersionMatches);
Assert.NotNull(info.Warning);
Assert.Contains("2026.07.09", info.Warning!);
}
[Fact]
public async Task GatewayInfo_ReportsMatchedPinnedVersion()
{
var client = CreateClient(request =>
{
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
};
response.Headers.Add("X-OpenClaw-Version", "2026.07.09");
return response;
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Equal("matched", info.VersionStatus);
Assert.True(info.VersionMatches);
Assert.Null(info.Warning);
}
[Fact]
public async Task GetAgentsAsync_MapsRuntimeStatesFromGatewayStatus()
{
var staleTimestamp = DateTimeOffset.UtcNow.AddMinutes(-40).ToString("o");
var client = CreateClient(request =>
{
if (request.RequestUri?.AbsolutePath == "/tools/invoke")
{
using var doc = JsonDocument.Parse(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult());
var agentId = doc.RootElement.GetProperty("args").GetProperty("sessionKey").GetString()!
.Split(':', StringSplitOptions.RemoveEmptyEntries)[1];
object status = agentId switch
{
"iris" => new { status = "active", isActive = true, currentTask = "Coordinate launch", model = "openai/gpt-5.5" },
"programmer" => new { status = "idle", lastActivity = staleTimestamp, model = "openai/gpt-5.4" },
"reviewer" => new { status = "failed", error = "gateway timeout", model = "openai/gpt-5.5" },
"architekt" => new { status = "unsupported", message = "tool not available", model = "openai/gpt-5.5" },
_ => new { status = "ready", model = "openai/gpt-5.5" }
};
return ToolResult(status);
}
return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
}, agentIds: ["iris", "programmer", "reviewer", "architekt"]);
var agents = await client.GetAgentsAsync();
Assert.Collection(agents.OrderBy(a => a.Id),
architekt =>
{
Assert.Equal("architekt", architekt.Id);
Assert.Equal("unsupported", architekt.StatusKind);
Assert.Equal("Unsupported", architekt.StatusLabel);
Assert.Equal("tool not available", architekt.StatusDetail);
},
iris =>
{
Assert.Equal("iris", iris.Id);
Assert.Equal("connected", iris.StatusKind);
Assert.Equal("Arbeitet", iris.StatusLabel);
},
programmer =>
{
Assert.Equal("programmer", programmer.Id);
Assert.Equal("stale", programmer.StatusKind);
Assert.Equal("Stale", programmer.StatusLabel);
Assert.NotNull(programmer.StatusDetail);
Assert.Contains("40m", programmer.StatusDetail!);
},
reviewer =>
{
Assert.Equal("reviewer", reviewer.Id);
Assert.Equal("error", reviewer.StatusKind);
Assert.Equal("Fehler", reviewer.StatusLabel);
Assert.Equal("gateway timeout", reviewer.StatusDetail);
});
}
[Fact]
public async Task AgentConfigService_RejectsNullBytesBeforeReplacingFile()
{
var agentId = $"phase-p4-{Guid.NewGuid():N}";
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
Directory.CreateDirectory(workspacePath);
var configPath = Path.Combine(workspacePath, "TOOLS.md");
await File.WriteAllTextAsync(configPath, "original");
try
{
var service = new AgentConfigService();
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "bad\0content");
Assert.NotNull(attempt.Failure);
Assert.Equal("validation_failed", attempt.Failure!.Code);
Assert.Equal("failed", attempt.Failure.Validation.Status);
Assert.Contains(attempt.Failure.Validation.Errors, error => error.Contains("null bytes", StringComparison.OrdinalIgnoreCase));
Assert.Equal("original", await File.ReadAllTextAsync(configPath));
}
finally
{
Directory.Delete(workspacePath, recursive: true);
}
}
[Fact]
public async Task AgentConfigService_ReturnsBackupAndReloadShape_OnSuccessfulSave()
{
var agentId = $"phase-p4-{Guid.NewGuid():N}";
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
Directory.CreateDirectory(workspacePath);
var configPath = Path.Combine(workspacePath, "TOOLS.md");
await File.WriteAllTextAsync(configPath, "before");
try
{
var service = new AgentConfigService();
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "after");
Assert.NotNull(attempt.SaveResult);
var result = attempt.SaveResult!;
Assert.Equal("passed", result.Validation.Status);
Assert.Equal("markdown", result.Validation.FileKind);
Assert.Equal("created", result.Backup.Status);
Assert.True(result.Backup.BackupCreated);
Assert.Equal("not_supported", result.ReloadCheck.Status);
Assert.False(string.IsNullOrWhiteSpace(result.ReloadCheck.Message));
Assert.Equal("before", await File.ReadAllTextAsync(configPath + ".bak"));
Assert.Equal("after", await File.ReadAllTextAsync(configPath));
}
finally
{
Directory.Delete(workspacePath, recursive: true);
}
}
[Fact]
public async Task AgentConfigSave_AuditsFailureWithoutLeakingContent()
{
var configService = new FakeAgentConfigService(new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"validation_failed",
new AgentConfigValidationResult("failed", "markdown", ["Content contains null bytes."]),
new AgentConfigBackupResult("not_applicable", false),
new AgentConfigReloadCheckResult("not_supported", "No hot reload available."))));
var configService = new RejectingOpenClawAgentConfigurationService();
var activityRepo = new CapturingActivityRepository();
var controller = new AgentsController(
@@ -365,8 +182,13 @@ public sealed class MissionControlPhaseTests
}
}
};
controller.Request.Headers["Idempotency-Key"] = "test-config-save";
var result = await controller.SaveConfigFile("programmer", "TOOLS.md", new SaveConfigRequest("secret\0payload"), CancellationToken.None);
var result = await controller.SaveConfigFile(
"programmer",
"TOOLS.md",
new SaveConfigRequest("secret\0payload", "expected-hash"),
CancellationToken.None);
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode);
@@ -377,66 +199,60 @@ public sealed class MissionControlPhaseTests
Assert.DoesNotContain("payload", audit.Message, StringComparison.OrdinalIgnoreCase);
}
private static OpenClawGatewayClient CreateClient(
Func<HttpRequestMessage, HttpResponseMessage> responder,
string? requiredVersion = null,
string[]? agentIds = null)
{
var configValues = new Dictionary<string, string?>
{
["Integrations:OpenClaw:RequiredVersion"] = requiredVersion
};
if (agentIds is not null)
{
var configPath = Path.GetTempFileName();
File.WriteAllText(configPath, JsonSerializer.Serialize(new
{
agents = new
{
list = agentIds.Select(id => new { id }).ToArray()
}
}));
configValues["AgentConfigPath"] = configPath;
}
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configValues)
.Build();
var httpClient = new HttpClient(new StubHttpMessageHandler(responder))
{
BaseAddress = new Uri("http://gateway.local")
};
return new OpenClawGatewayClient(httpClient, configuration);
}
private static HttpResponseMessage ToolResult(object payload)
=> new(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(
JsonSerializer.Serialize(new { ok = true, result = payload }),
Encoding.UTF8,
"application/json")
};
}
file sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
file sealed class RejectingOpenClawAgentConfigurationService
: IOpenClawAgentConfigurationService
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(responder(request));
}
public Task<OpenClawAgentFileCollectionDto> GetAgentFilesAsync(
string agentId,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
file sealed class FakeAgentConfigService(AgentConfigSaveAttempt attempt) : IAgentConfigService
{
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId) => [];
public Task<OpenClawAgentFileDto> GetAgentFileAsync(
string agentId,
string fileName,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
=> Task.FromResult<AgentConfigFileContent?>(null);
public Task<OpenClawAgentFileWriteDto> SetAgentFileAsync(
string agentId,
string fileName,
UpdateOpenClawAgentFileRequest request,
OpenClawInvocationContext invocationContext,
CancellationToken cancellationToken = default)
=> throw new OpenClawAgentConfigurationValidationException(
"content",
"Content contains null bytes.");
public Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
=> Task.FromResult(attempt);
public Task<OpenClawWorkspaceCollectionDto> GetWorkspaceAsync(
string agentId,
string? path,
int offset,
int limit,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawWorkspaceFileDto> GetWorkspaceFileAsync(
string agentId,
string path,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigSchemaLookupDto> GetConfigSchemaAsync(
string path,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigSnapshotDto> GetConfigAsync(
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigPatchDto> PatchConfigAsync(
PatchOpenClawConfigRequest request,
OpenClawInvocationContext invocationContext,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
file sealed class CapturingActivityRepository : IActivityRepository
@@ -467,7 +283,9 @@ file sealed class FakeAgentService : IAgentService
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris", "bao", "programmer" });
}
file sealed class FakeAgentRuntime : Nexus.Api.Integrations.IAgentRuntime
file sealed class FakeAgentRuntime :
Nexus.Api.Integrations.IAgentRuntime,
IOpenClawChatService
{
public string Name => "fake";
@@ -476,6 +294,14 @@ file sealed class FakeAgentRuntime : Nexus.Api.Integrations.IAgentRuntime
public Task<Nexus.Api.Integrations.AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken)
=> Task.FromResult(new Nexus.Api.Integrations.AgentChatResult("fake", agentId, conversationId, "ok"));
public Task<Nexus.Api.Integrations.AgentChatResult> SendAsync(
string message,
string conversationId,
string agentId,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
=> ChatAsync(message, conversationId, agentId, cancellationToken);
}
file sealed class FakeDashboardService : IDashboardService
@@ -492,5 +318,6 @@ file sealed class FakeDashboardService : IDashboardService
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
public List<ModelOption> GetAvailableModels() => [];
public Task<List<ModelOption>> GetAvailableModelsAsync(CancellationToken ct)
=> Task.FromResult(new List<ModelOption>());
}
+2
View File
@@ -13,6 +13,8 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.13.0" />
<PackageReference Include="Testcontainers.Toxiproxy" Version="4.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<PrivateAssets>all</PrivateAssets>
+2
View File
@@ -30,10 +30,12 @@ public sealed class NexusMcpToolsTests
"nexus_create_child_task",
"nexus_create_task",
"nexus_get_activity",
"nexus_get_agent_proposal",
"nexus_get_board",
"nexus_get_children",
"nexus_get_task",
"nexus_handoff",
"nexus_propose_agent",
"nexus_update_status"
], toolNames);
}
+44
View File
@@ -0,0 +1,44 @@
using System.Diagnostics;
using Nexus.Api.Observability;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class NexusTelemetryTests
{
[Fact]
public void RedactionProcessor_RemovesContentBearingTagsAndKeepsSafeDimensions()
{
using var activity = new Activity("redaction-test");
activity.SetTag("url.query", "token=secret");
activity.SetTag("url.full", "https://nexus.test/tasks?token=secret");
activity.SetTag("url.path", "/api/v1/openclaw/agents/private-agent/files/SOUL.md");
activity.SetTag("http.url", "https://nexus.test/tasks?token=secret");
activity.SetTag("http.target", "/tasks?token=secret");
activity.SetTag("exception.message", "secret prompt");
activity.SetTag("exception.stacktrace", "C:\\private\\workspace");
activity.SetTag("db.statement", "select * from secret");
activity.SetTag("db.query.text", "select * from secret");
activity.SetTag("http.request.method", "GET");
new NexusTelemetryRedactionProcessor().OnEnd(activity);
Assert.Null(activity.GetTagItem("url.query"));
Assert.Null(activity.GetTagItem("url.full"));
Assert.Null(activity.GetTagItem("url.path"));
Assert.Null(activity.GetTagItem("http.url"));
Assert.Null(activity.GetTagItem("http.target"));
Assert.Null(activity.GetTagItem("exception.message"));
Assert.Null(activity.GetTagItem("exception.stacktrace"));
Assert.Null(activity.GetTagItem("db.statement"));
Assert.Null(activity.GetTagItem("db.query.text"));
Assert.Equal("GET", activity.GetTagItem("http.request.method"));
}
[Fact]
public void BrowserMetrics_UseSemanticUnits()
{
Assert.Equal("ms", NexusTelemetry.BrowserDuration.Unit);
Assert.Equal("1", NexusTelemetry.BrowserScore.Unit);
}
}
@@ -0,0 +1,502 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Nexus.Api.Controllers;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawAgentConfigurationServiceTests
{
[Fact]
public async Task AgentFileRead_UsesGatewayContentAndDoesNotExposeHostPaths()
{
var gateway = Connected(
["agents.files.get"],
["operator.read"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""
{
"agentId": "product-owner",
"workspace": "/home/node/.openclaw/workspace-po",
"file": {
"name": "AGENTS.md",
"path": "/home/node/.openclaw/workspace-po/AGENTS.md",
"missing": false,
"size": 18,
"updatedAtMs": 1785000000000,
"content": "# Standing orders"
}
}
""");
var service = CreateService(gateway);
var result = await service.GetAgentFileAsync("product-owner", "agents.md");
Assert.Equal("product-owner", result.AgentId);
Assert.Equal("AGENTS.md", result.Name);
Assert.Equal("# Standing orders", result.Content);
Assert.Equal(Hash("# Standing orders"), result.ContentHash);
Assert.DoesNotContain(
result.GetType().GetProperties(),
property => property.Name is "Path" or "Workspace");
var invocation = Assert.Single(gateway.Invocations);
Assert.Equal("agents.files.get", invocation.Method);
Assert.Equal("AGENTS.md", invocation.Parameters?["name"]?.GetValue<string>());
}
[Fact]
public async Task AgentFileList_OnlyReturnsSupportedFiles()
{
var gateway = Connected(
["agents.files.list"],
["operator.read"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""
{
"workspace": "/home/node/.openclaw/workspace",
"files": [
{ "name": "SOUL.md", "path": "/home/node/.openclaw/workspace/SOUL.md", "missing": false },
{ "name": "MEMORY.md", "path": "/home/node/.openclaw/workspace/MEMORY.md", "missing": true },
{ "name": "DREAMS.md", "path": "/home/node/.openclaw/workspace/DREAMS.md", "missing": false }
]
}
""");
var service = CreateService(gateway);
var result = await service.GetAgentFilesAsync("main");
Assert.Equal(["SOUL.md", "MEMORY.md"], result.Files.Select(file => file.Name));
Assert.Equal(
OpenClawAgentConfigurationService.MissingContentHash,
result.Files.Single(file => file.Name == "MEMORY.md").ContentHash);
}
[Fact]
public async Task AgentFileWrite_RejectsStaleExpectedHashBeforeMutation()
{
var gateway = Connected(
["agents.files.get", "agents.files.set"],
["operator.admin"]);
gateway.Handler = (method, _) => method == "agents.files.get"
? AgentFile("SOUL.md", "current")
: JsonNode.Parse("""{ "ok": true }""");
var audit = new FakeOperationAuditStore();
var service = CreateService(gateway, audit);
var exception = await Assert.ThrowsAsync<OpenClawAgentConfigurationConflictException>(
() => service.SetAgentFileAsync(
"iris",
"SOUL.md",
new UpdateOpenClawAgentFileRequest("next", new string('0', 64)),
Invocation()));
Assert.Equal("content_hash_mismatch", exception.Code);
Assert.Equal(Hash("current"), exception.CurrentHash);
Assert.DoesNotContain(gateway.Invocations, item => item.Method == "agents.files.set");
var completion = Assert.Single(audit.Completions);
Assert.False(completion.Ok);
Assert.Equal("conflict", completion.State);
}
[Fact]
public async Task AgentFileWrite_IsReadBackVerifiedAndCarriesInvocationMetadata()
{
var gateway = Connected(
["agents.files.get", "agents.files.set"],
["operator.admin"]);
var reads = 0;
gateway.Handler = (method, parameters) =>
{
if (method == "agents.files.set")
{
Assert.Null(parameters?["idempotencyKey"]);
Assert.Equal("next", parameters?["content"]?.GetValue<string>());
return JsonNode.Parse("""{ "ok": true }""");
}
reads++;
return AgentFile("SOUL.md", reads == 1 ? "current" : "next");
};
var audit = new FakeOperationAuditStore();
var service = CreateService(gateway, audit);
var invocation = Invocation();
var result = await service.SetAgentFileAsync(
"iris",
"SOUL.md",
new UpdateOpenClawAgentFileRequest("next", Hash("current")),
invocation);
Assert.True(result.Ok);
Assert.True(result.Verified);
Assert.Equal("completed", result.State);
Assert.Equal(invocation.IdempotencyKey, result.IdempotencyKey);
Assert.Equal(Hash("next"), result.File.ContentHash);
Assert.Equal(
["agents.files.get", "agents.files.set", "agents.files.get"],
gateway.Invocations.Select(item => item.Method));
Assert.Equal(invocation, gateway.Invocations[1].Context);
Assert.True(Assert.Single(audit.Completions).Ok);
}
[Theory]
[InlineData("../secrets/.env")]
[InlineData("/home/node/.openclaw/openclaw.json")]
[InlineData("memory/../../openclaw.json")]
[InlineData("credentials.json")]
public async Task WorkspaceRead_RejectsUnsafePathsBeforeGateway(string path)
{
var gateway = Connected(
["agents.workspace.get"],
["operator.read"]);
var service = CreateService(gateway);
await Assert.ThrowsAsync<OpenClawAgentConfigurationValidationException>(
() => service.GetWorkspaceFileAsync("main", path));
Assert.Empty(gateway.Invocations);
}
[Fact]
public async Task WorkspaceList_FiltersSensitiveAndAbsoluteEntries()
{
var gateway = Connected(
["agents.workspace.list"],
["operator.read"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""
{
"agentId": "main",
"path": "",
"entries": [
{ "path": "DREAMS.md", "name": "DREAMS.md", "kind": "file", "size": 12 },
{ "path": ".env", "name": ".env", "kind": "file", "size": 20 },
{ "path": "/home/node/.openclaw/openclaw.json", "name": "openclaw.json", "kind": "file" }
],
"totalEntries": 3,
"offset": 0
}
""");
var service = CreateService(gateway);
var result = await service.GetWorkspaceAsync("main", null, 0, 250);
var entry = Assert.Single(result.Entries);
Assert.Equal("DREAMS.md", entry.Path);
}
[Fact]
public async Task ConfigRead_RedactsSecretsAndAbsoluteHostPaths()
{
var gateway = Connected(
["config.get"],
["operator.read"]);
gateway.Handler = (_, _) => ConfigSnapshot(
new string('a', 64),
new JsonObject
{
["gateway"] = new JsonObject
{
["auth"] = new JsonObject { ["token"] = "super-secret-token" }
},
["agents"] = new JsonObject
{
["defaults"] = new JsonObject
{
["workspace"] = "/home/node/.openclaw/workspace"
}
},
["safeValue"] = "visible"
});
var service = CreateService(gateway);
var result = await service.GetConfigAsync();
Assert.Equal(
"[redacted]",
result.Config?["gateway"]?["auth"]?["token"]?.GetValue<string>());
Assert.Equal(
"[host-path-redacted]",
result.Config?["agents"]?["defaults"]?["workspace"]?.GetValue<string>());
Assert.Equal("visible", result.Config?["safeValue"]?.GetValue<string>());
}
[Fact]
public async Task ConfigPatch_UsesBaseHashReplacePathsAndValidReadBack()
{
var beforeHash = new string('a', 64);
var afterHash = new string('b', 64);
var gateway = Connected(
["config.get", "config.patch"],
["operator.admin"]);
var reads = 0;
gateway.Handler = (method, parameters) =>
{
if (method == "config.get")
{
reads++;
return ConfigSnapshot(
reads == 1 ? beforeHash : afterHash,
new JsonObject
{
["channels"] = new JsonObject
{
["telegram"] = new JsonObject
{
["enabled"] = reads > 1
}
}
});
}
Assert.Equal(beforeHash, parameters?["baseHash"]?.GetValue<string>());
Assert.Contains(
"\"enabled\":true",
parameters?["raw"]?.GetValue<string>());
Assert.Equal(
"channels.telegram",
parameters?["replacePaths"]?[0]?.GetValue<string>());
Assert.Null(parameters?["idempotencyKey"]);
return JsonNode.Parse(
"""{ "ok": true, "restart": { "required": false } }""");
};
var audit = new FakeOperationAuditStore();
var service = CreateService(gateway, audit);
var invocation = Invocation();
var result = await service.PatchConfigAsync(
new PatchOpenClawConfigRequest(
new JsonObject
{
["channels"] = new JsonObject
{
["telegram"] = new JsonObject { ["enabled"] = true }
}
},
beforeHash,
["channels.telegram"]),
invocation);
Assert.True(result.Ok);
Assert.True(result.Verified);
Assert.Equal(afterHash, result.Snapshot.Hash);
Assert.Equal(
["config.get", "config.patch", "config.get"],
gateway.Invocations.Select(item => item.Method));
Assert.True(Assert.Single(audit.Completions).Ok);
}
[Fact]
public async Task ConfigPatch_RejectsLiteralSecretsBeforeGatewayOrAudit()
{
var gateway = Connected(
["config.patch"],
["operator.admin"]);
var audit = new FakeOperationAuditStore();
var service = CreateService(gateway, audit);
var exception = await Assert.ThrowsAsync<OpenClawAgentConfigurationValidationException>(
() => service.PatchConfigAsync(
new PatchOpenClawConfigRequest(
new JsonObject
{
["models"] = new JsonObject
{
["providers"] = new JsonObject
{
["openai"] = new JsonObject { ["apiKey"] = "sk-test-secret" }
}
}
},
new string('a', 64)),
Invocation()));
Assert.Equal("patch", exception.Field);
Assert.Empty(gateway.Invocations);
Assert.Empty(audit.Claims);
}
[Fact]
public async Task Controller_MapsContentConflictTo409AndEchoesIdempotencyKey()
{
var authorize = Assert.Single(
typeof(OpenClawAgentConfigurationController)
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>());
Assert.Equal("owner", authorize.Roles);
var gateway = Connected(
["agents.files.get", "agents.files.set"],
["operator.admin"]);
gateway.Handler = (_, _) => AgentFile("AGENTS.md", "current");
var service = CreateService(gateway);
var controller = new OpenClawAgentConfigurationController(service);
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["Idempotency-Key"] = "agent-file-test-1";
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim("sub", "bao"),
new Claim(ClaimTypes.Role, "owner")
],
authenticationType: "test"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var response = await controller.SetAgentFile(
"main",
"AGENTS.md",
new UpdateOpenClawAgentFileRequest("next", new string('0', 64)),
CancellationToken.None);
var result = Assert.IsType<ObjectResult>(response.Result);
Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode);
Assert.Equal(
"agent-file-test-1",
httpContext.Response.Headers["Idempotency-Key"].ToString());
}
private static OpenClawAgentConfigurationService CreateService(
AgentConfigurationStubConnector gateway,
FakeOperationAuditStore? audit = null)
=> new(
gateway,
audit ?? new FakeOperationAuditStore(),
new StubOpenClawWriteGate(),
NullLogger<OpenClawAgentConfigurationService>.Instance);
private static AgentConfigurationStubConnector Connected(
IEnumerable<string> methods,
IEnumerable<string> scopes)
=> new()
{
ConnectionState = GatewayConnectionState.Connected,
AdvertisedMethods = methods.ToHashSet(StringComparer.Ordinal),
GrantedScopes = scopes.ToHashSet(StringComparer.Ordinal)
};
private static OpenClawInvocationContext Invocation()
=> OpenClawInvocationContext.Create(
actor: "bao",
idempotencyKey: $"test-{Guid.NewGuid():N}",
correlationId: $"corr-{Guid.NewGuid():N}");
private static JsonNode? AgentFile(string name, string content)
=> JsonNode.Parse(
$$"""
{
"file": {
"name": "{{name}}",
"path": "/home/node/.openclaw/workspace/{{name}}",
"missing": false,
"size": {{Encoding.UTF8.GetByteCount(content)}},
"updatedAtMs": 1785000000000,
"content": {{System.Text.Json.JsonSerializer.Serialize(content)}}
}
}
""");
private static JsonNode ConfigSnapshot(string hash, JsonNode config)
=> new JsonObject
{
["exists"] = true,
["valid"] = true,
["hash"] = hash,
["config"] = config,
["issues"] = new JsonArray(),
["warnings"] = new JsonArray()
};
private static string Hash(string content)
=> Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
}
internal sealed record AgentConfigurationInvocation(
string Method,
JsonNode? Parameters,
OpenClawInvocationContext? Context);
internal sealed class AgentConfigurationStubConnector : IGatewayConnector
{
public GatewayConnectionState ConnectionState { get; set; } = GatewayConnectionState.Initializing;
public string? GatewayVersion { get; set; } = "2026.7.1";
public string? RequiredVersion { get; set; }
public DateTimeOffset? LastConnectedAt { get; set; }
public int ReconnectAttempts { get; set; }
public string? StatusMessage { get; set; }
public string? DeviceId { get; set; }
public bool DeviceTokenConfigured { get; set; }
public bool PairingRequired { get; set; }
public string? PairingRequestId { get; set; }
public int? ProtocolVersion { get; set; } = 4;
public IReadOnlySet<string> AdvertisedMethods { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> AdvertisedEvents { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> GrantedScopes { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public DateTimeOffset? LastEventAt { get; set; }
public Func<string, JsonNode?, JsonNode?>? Handler { get; set; }
public List<AgentConfigurationInvocation> Invocations { get; } = [];
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
{
var node = parameters switch
{
null => null,
JsonNode jsonNode => jsonNode.DeepClone(),
_ => System.Text.Json.JsonSerializer.SerializeToNode(parameters)
};
Invocations.Add(new AgentConfigurationInvocation(method, node, invocationContext));
return Task.FromResult(Handler?.Invoke(method, node));
}
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
}
internal sealed class FakeOperationAuditStore : IOpenClawOperationAuditStore
{
public string AuditPath => "test-only";
public OpenClawOperationClaim NextClaim { get; set; } =
new(OpenClawOperationClaimDisposition.Started);
public List<(OpenClawInvocationContext Context, OpenClawOperationDescriptor Descriptor)> Claims
{
get;
} = [];
public List<(bool Ok, string State, string Message, string? ErrorCode)> Completions { get; } = [];
public Task<OpenClawOperationClaim> ClaimAsync(
OpenClawInvocationContext context,
OpenClawOperationDescriptor operation,
CancellationToken cancellationToken = default)
{
Claims.Add((context, operation));
return Task.FromResult(NextClaim);
}
public Task CompleteAsync(
OpenClawInvocationContext context,
OpenClawOperationDescriptor operation,
bool ok,
string state,
string message,
string? errorCode = null,
CancellationToken cancellationToken = default)
{
Completions.Add((ok, state, message, errorCode));
return Task.CompletedTask;
}
}
+142
View File
@@ -0,0 +1,142 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawChatServiceTests
{
[Fact]
public async Task Send_uses_durable_protocol_v4_run_and_canonical_agent_session()
{
var runId = Guid.NewGuid();
var runs = new CapturingRunService(runId, ok: true);
var service = new OpenClawChatService(runs);
var invocation = new OpenClawInvocationMetadata(
"idem-chat",
"corr-chat",
"owner",
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
var result = await service.SendAsync(
"coordinate this work",
"browser-conversation",
"Iris",
invocation);
Assert.NotNull(runs.Request);
Assert.Equal("iris", runs.Request.AgentId);
Assert.Equal("agent:iris:main", runs.Request.SessionKey);
Assert.Equal("coordinate this work", runs.Request.Prompt);
Assert.Equal(runId, result.RunId);
Assert.Equal("OpenClaw Protocol v4", result.Runtime);
}
[Fact]
public async Task Send_does_not_claim_success_when_gateway_dispatch_is_blocked()
{
var runs = new CapturingRunService(Guid.NewGuid(), ok: false);
var service = new OpenClawChatService(runs);
var exception = await Assert.ThrowsAsync<OpenClawChatDispatchException>(
() => service.SendAsync(
"coordinate this work",
"browser-conversation",
"iris",
new OpenClawInvocationMetadata(
"idem-chat",
"corr-chat",
"owner",
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")));
Assert.Equal("blocked", exception.State);
}
private sealed class CapturingRunService(Guid runId, bool ok) :
IOpenClawRunService
{
public StartOpenClawRunRequest? Request { get; private set; }
public Task<OpenClawRunOperationDto> StartAsync(
StartOpenClawRunRequest request,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
Request = request;
var run = new OpenClawRunDto(
runId,
"Chat",
request.Prompt,
request.AgentId,
request.SessionKey,
ok ? "running" : "blocked",
request.TaskId,
request.ProjectId,
null,
null,
invocation.CorrelationId,
invocation.Actor,
ok ? null : "Gateway disconnected",
null,
false,
ok,
!ok,
false,
null,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
null,
null);
return Task.FromResult(new OpenClawRunOperationDto(
ok,
ok ? "running" : "blocked",
ok ? "OpenClaw accepted the run." : "Gateway disconnected.",
run,
null,
DateTimeOffset.UtcNow));
}
public Task<OpenClawRunCollectionDto> GetAsync(
OpenClawRunQuery query,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<OpenClawRunDto?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<OpenClawRunOperationDto?> StopAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<OpenClawRunOperationDto?> ResumeAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<OpenClawRunOperationDto?> RetryAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<OpenClawRunHistoryResponse?> GetHistoryAsync(
Guid id,
int gatewayLimit = 200,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task ReconcileAsync(
GatewayEventEnvelope gatewayEvent,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}
@@ -0,0 +1,348 @@
using System.Collections.Concurrent;
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Nexus.Api.Controllers;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawContentServicesTests
{
[Fact]
public async Task Memory_uses_agent_files_and_workspace_rpc_with_source_metadata()
{
var gateway = new ContentConfigurationStub();
gateway.AgentFiles =
[
new OpenClawAgentFileSummaryDto(
"MEMORY.md",
false,
15,
DateTimeOffset.UtcNow,
"hash")
];
gateway.Listings["memory"] =
[
Entry("memory/2026-07-31.md", 24)
];
gateway.AgentFileContent = "Long term memory";
gateway.Files["memory/2026-07-31.md"] =
File("bao-agent", "memory/2026-07-31.md", "Daily memory needle");
var service = new MemoryService(gateway);
var listed = await service.GetAllAsync("bao-agent");
var found = await service.SearchAsync("needle", "bao-agent");
var longTerm = await service.GetFileAsync("MEMORY.md", "bao-agent");
Assert.Collection(
listed,
item =>
{
Assert.Equal("MEMORY.md", item.WorkspacePath);
Assert.Equal("bao-agent", item.SourceAgentId);
},
item =>
{
Assert.Equal("memory/2026-07-31.md", item.WorkspacePath);
Assert.Equal("2026-07-31.md", item.Path);
});
Assert.Equal(
"memory/2026-07-31.md",
Assert.Single(found).WorkspacePath);
Assert.Equal("MEMORY.md", longTerm?.WorkspacePath);
Assert.DoesNotContain(
listed.Select(item => item.WorkspacePath),
path => path.StartsWith('/'));
}
[Fact]
public async Task Memory_search_limits_live_file_reads_to_four()
{
var gateway = new ContentConfigurationStub
{
ReadDelay = TimeSpan.FromMilliseconds(20)
};
gateway.Listings["memory"] = Enumerable.Range(1, 12)
.Select(index => Entry($"memory/{index:00}.md", 20))
.ToArray();
foreach (var entry in gateway.Listings["memory"])
{
gateway.Files[entry.Path] =
File("iris", entry.Path, $"needle {entry.Name}");
}
var results = await new MemoryService(gateway)
.SearchAsync("needle", "iris");
Assert.Equal(12, results.Count);
Assert.InRange(gateway.MaxConcurrentReads, 1, 4);
}
[Fact]
public async Task Missing_daily_memory_directory_preserves_memory_file()
{
var gateway = new ContentConfigurationStub
{
MissingMemoryDirectory = true,
AgentFileContent = "Long term needle"
};
gateway.AgentFiles =
[
new OpenClawAgentFileSummaryDto(
"MEMORY.md",
false,
16,
DateTimeOffset.UtcNow,
"hash")
];
var service = new MemoryService(gateway);
var listed = await service.GetAllAsync("iris");
var searched = await service.SearchAsync("needle", "iris");
Assert.Equal("MEMORY.md", Assert.Single(listed).WorkspacePath);
Assert.Equal("MEMORY.md", Assert.Single(searched).WorkspacePath);
}
[Fact]
public async Task Docs_and_incidents_use_workspace_relative_rpc_paths()
{
var gateway = new ContentConfigurationStub();
gateway.Listings[""] = [Entry("README.md", 30)];
gateway.Listings["nexus-phases"] =
[Entry("nexus-phases/phase-1.md", 30)];
gateway.Listings["skills"] = [];
gateway.Listings["nexus"] = [];
gateway.Listings["nexus/phases"] = [];
gateway.Listings["memory/incidents"] =
[Entry("memory/incidents/2026-07-31-gateway.md", 90)];
gateway.Files["README.md"] =
File("iris", "README.md", "# Nexus");
gateway.Files["memory/incidents/2026-07-31-gateway.md"] =
File(
"iris",
"memory/incidents/2026-07-31-gateway.md",
"# Gateway outage\n**Severity:** high\n\nRecovered.");
var docs = await new DocService(gateway).GetAllAsync("iris");
var incidents = await new IncidentService(gateway).GetAllAsync("iris");
Assert.Contains(
docs,
item => item.WorkspacePath == "README.md"
&& item.SourceAgentId == "iris");
var incident = Assert.Single(incidents);
Assert.Equal("high", incident.Severity);
Assert.Equal(
"memory/incidents/2026-07-31-gateway.md",
incident.WorkspacePath);
}
[Fact]
public void Sensitive_content_controllers_are_owner_only()
{
Type[] controllers =
[
typeof(MemoryController),
typeof(DocsController),
typeof(IncidentsController)
];
foreach (var controller in controllers)
{
var authorize = Assert.Single(
controller.GetCustomAttributes<AuthorizeAttribute>());
Assert.Equal("owner", authorize.Roles);
}
}
[Fact]
public async Task Adapter_reports_gateway_unavailable_instead_of_empty_success()
{
var controller = new MemoryController(new UnavailableMemoryService());
var result = await controller.GetAll();
Assert.Equal(
StatusCodes.Status503ServiceUnavailable,
Assert.IsAssignableFrom<IStatusCodeHttpResult>(result)
.StatusCode);
}
private static OpenClawWorkspaceEntryDto Entry(string path, long size)
=> new(
path,
Path.GetFileName(path),
"file",
size,
DateTimeOffset.UtcNow);
private static OpenClawWorkspaceFileDto File(
string agentId,
string path,
string content)
=> new(
agentId,
path,
Path.GetFileName(path),
System.Text.Encoding.UTF8.GetByteCount(content),
DateTimeOffset.UtcNow,
"text/markdown",
"utf8",
content,
"hash",
DateTimeOffset.UtcNow);
private sealed class ContentConfigurationStub
: IOpenClawAgentConfigurationService
{
private int activeReads;
private int maxConcurrentReads;
public IReadOnlyList<OpenClawAgentFileSummaryDto> AgentFiles { get; set; }
= [];
public string AgentFileContent { get; set; } = string.Empty;
public ConcurrentDictionary<
string,
IReadOnlyList<OpenClawWorkspaceEntryDto>> Listings { get; } =
new(StringComparer.Ordinal);
public ConcurrentDictionary<string, OpenClawWorkspaceFileDto> Files { get; }
= new(StringComparer.Ordinal);
public TimeSpan ReadDelay { get; set; }
public bool MissingMemoryDirectory { get; set; }
public int MaxConcurrentReads => Volatile.Read(ref maxConcurrentReads);
public Task<OpenClawAgentFileCollectionDto> GetAgentFilesAsync(
string agentId,
CancellationToken cancellationToken = default)
=> Task.FromResult(new OpenClawAgentFileCollectionDto(
agentId,
AgentFiles,
DateTimeOffset.UtcNow));
public Task<OpenClawAgentFileDto> GetAgentFileAsync(
string agentId,
string fileName,
CancellationToken cancellationToken = default)
=> Task.FromResult(new OpenClawAgentFileDto(
agentId,
fileName,
false,
System.Text.Encoding.UTF8.GetByteCount(AgentFileContent),
DateTimeOffset.UtcNow,
AgentFileContent,
"hash",
DateTimeOffset.UtcNow));
public Task<OpenClawWorkspaceCollectionDto> GetWorkspaceAsync(
string agentId,
string? path,
int offset,
int limit,
CancellationToken cancellationToken = default)
{
var normalized = path ?? string.Empty;
if (MissingMemoryDirectory
&& string.Equals(normalized, "memory", StringComparison.Ordinal))
{
throw new OpenClawGatewayRpcException(
"PATH_NOT_FOUND",
"Optional memory directory is absent.");
}
Listings.TryGetValue(normalized, out var entries);
entries ??= [];
return Task.FromResult(new OpenClawWorkspaceCollectionDto(
agentId,
normalized,
null,
entries.Take(limit).ToArray(),
entries.Count,
offset,
DateTimeOffset.UtcNow));
}
public async Task<OpenClawWorkspaceFileDto> GetWorkspaceFileAsync(
string agentId,
string path,
CancellationToken cancellationToken = default)
{
var active = Interlocked.Increment(ref activeReads);
UpdateMaximum(active);
try
{
if (ReadDelay > TimeSpan.Zero)
await Task.Delay(ReadDelay, cancellationToken);
return Files[path];
}
finally
{
Interlocked.Decrement(ref activeReads);
}
}
private void UpdateMaximum(int value)
{
while (true)
{
var current = Volatile.Read(ref maxConcurrentReads);
if (value <= current
|| Interlocked.CompareExchange(
ref maxConcurrentReads,
value,
current) == current)
{
return;
}
}
}
public Task<OpenClawAgentFileWriteDto> SetAgentFileAsync(
string agentId,
string fileName,
UpdateOpenClawAgentFileRequest request,
OpenClawInvocationContext invocationContext,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigSchemaLookupDto> GetConfigSchemaAsync(
string path,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigSnapshotDto> GetConfigAsync(
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawConfigPatchDto> PatchConfigAsync(
PatchOpenClawConfigRequest request,
OpenClawInvocationContext invocationContext,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
private sealed class UnavailableMemoryService : IMemoryService
{
public Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync(
string agentId = "iris",
CancellationToken cancellationToken = default)
=> throw new OpenClawAgentConfigurationUnavailableException(
"disconnected",
"agents.files.list",
"operator.read",
"Gateway unavailable.");
public Task<IReadOnlyList<MemorySearchResult>> SearchAsync(
string query,
string agentId = "iris",
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<MemoryFileContent?> GetFileAsync(
string name,
string agentId = "iris",
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -0,0 +1,839 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawControlServiceTests
{
[Fact]
public async Task DisconnectedTaskList_ReportsRecoveryWithoutInvokingGateway()
{
var gateway = new StubOpenClawConnector
{
ConnectionState = GatewayConnectionState.Disconnected
};
var service = CreateService(gateway);
var result = await service.GetTasksAsync();
Assert.Equal("disconnected", result.State);
Assert.Empty(result.Items);
Assert.NotNull(result.Recovery);
Assert.Empty(gateway.Invocations);
}
[Fact]
public async Task DisconnectedMutation_WithInvalidMetadata_ReturnsControlledInvalidResult()
{
var gateway = new StubOpenClawConnector
{
ConnectionState = GatewayConnectionState.Disconnected
};
var service = CreateService(gateway);
var invalidContext = new OpenClawInvocationContext(
"idem-task-1",
"corr-task-1",
"owner-1",
"not-a-traceparent");
var result = await service.CancelTaskAsync(
"task-1",
null,
invocationContext: invalidContext);
Assert.False(result.Ok);
Assert.Equal("invalid", result.State);
Assert.Empty(gateway.Invocations);
}
[Fact]
public void PairingRecovery_OnlyEmbedsShellSafeRequestId()
{
var gateway = new StubOpenClawConnector
{
ConnectionState = GatewayConnectionState.Disconnected,
PairingRequired = true,
PairingRequestId = "request'; remove-item secret"
};
var service = CreateService(gateway);
var connection = service.GetConnection();
Assert.True(connection.PairingRequired);
Assert.Equal("request'; remove-item secret", connection.PairingRequestId);
Assert.NotNull(connection.Recovery);
Assert.DoesNotContain("request'; remove-item secret", connection.Recovery);
Assert.DoesNotContain("openclaw devices approve", connection.Recovery);
}
[Fact]
public async Task TaskList_NormalizesGatewayLedgerShape()
{
var gateway = Connected(
methods: ["tasks.list", "tasks.cancel"],
scopes: ["operator.read", "operator.write"]);
gateway.Handler = (method, _) => method == "tasks.list"
? JsonNode.Parse(
"""
{
"tasks": [
{
"id": "task-1",
"title": "Index workspace",
"status": "running",
"agentId": "researcher",
"sessionKey": "agent:researcher:main",
"runId": "run-1",
"startedAtMs": 1785000000000,
"progress": 42
},
{
"id": "task-2",
"title": "Review output",
"status": "completed"
}
],
"nextCursor": "next-1"
}
""")
: null;
var service = CreateService(gateway);
var result = await service.GetTasksAsync();
Assert.Equal("ready", result.State);
Assert.Equal("next-1", result.NextCursor);
Assert.Equal(2, result.Items.Count);
Assert.Equal("running", result.Items[0].Status);
Assert.True(result.Items[0].CanCancel);
Assert.Equal("succeeded", result.Items[1].Status);
Assert.False(result.Items[1].CanCancel);
}
[Fact]
public async Task CronList_MapsScheduleAndRequiresAdminForRun()
{
var gateway = Connected(
methods: ["cron.list", "cron.run"],
scopes: ["operator.read"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""
{
"jobs": [{
"id": "job-1",
"name": "Morning brief",
"enabled": true,
"schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "Europe/Berlin" },
"state": { "nextRunAtMs": 1785000000000, "lastRunStatus": "ok" }
}]
}
""");
var service = CreateService(gateway);
var result = await service.GetCronJobsAsync();
var job = Assert.Single(result.Items);
Assert.Equal("0 7 * * *", job.Schedule);
Assert.Equal("Europe/Berlin", job.TimeZone);
Assert.Equal("ok", job.Status);
Assert.False(job.CanRun);
}
[Fact]
public async Task CronList_ForwardsFiltersAndTranslatesOffsetCursor()
{
var gateway = Connected(
methods: ["cron.list"],
scopes: ["operator.read"]);
gateway.Handler = (_, parameters) =>
{
var offset = parameters?["offset"]?.GetValue<int>() ?? 0;
return offset == 0
? JsonNode.Parse(
"""
{
"jobs": [{ "id": "job-1", "name": "First", "enabled": true,
"schedule": { "kind": "every", "everyMs": 60000 }, "state": {} }],
"nextOffset": 1
}
""")
: JsonNode.Parse(
"""
{
"jobs": [{ "id": "job-2", "name": "Second", "enabled": true,
"schedule": { "kind": "every", "everyMs": 120000 }, "state": {} }],
"nextOffset": null
}
""");
};
var service = CreateService(gateway);
var first = await service.GetCronJobsAsync(
includeDisabled: false,
limit: 1);
var second = await service.GetCronJobsAsync(
includeDisabled: false,
limit: 1,
cursor: first.NextCursor);
Assert.NotNull(first.NextCursor);
Assert.Equal("job-2", Assert.Single(second.Items).Id);
Assert.Collection(
gateway.Invocations,
invocation =>
{
Assert.Equal(false, invocation.Parameters?["includeDisabled"]?.GetValue<bool>());
Assert.Equal(0, invocation.Parameters?["offset"]?.GetValue<int>());
},
invocation => Assert.Equal(1, invocation.Parameters?["offset"]?.GetValue<int>()));
}
[Fact]
public async Task CronDetail_MapsEditableDefinitionAndStableResourceHash()
{
var gateway = Connected(
methods: ["cron.get", "cron.update", "cron.remove", "cron.run"],
scopes: ["operator.read", "operator.admin"]);
gateway.Handler = (_, _) => CronJob(
name: "Morning brief",
enabled: true,
deliveryTarget: "+49 151 12345678");
var service = CreateService(gateway);
var first = await service.GetCronJobAsync("job-1");
var second = await service.GetCronJobAsync("job-1");
Assert.True(first.Ok);
Assert.NotNull(first.Data);
Assert.Equal("cron", first.Data.Schedule.Kind);
Assert.Equal("agentTurn", first.Data.Payload.Kind);
Assert.Equal("+49 151 12345678", first.Data.Delivery?.Target);
Assert.Equal(first.Data.ResourceHash, second.Data?.ResourceHash);
Assert.True(first.Data.CanUpdate);
Assert.True(first.Data.CanDelete);
}
[Fact]
public async Task CronRuns_UsesRunFilterAndRedactsDeliveryTargets()
{
var gateway = Connected(
methods: ["cron.runs"],
scopes: ["operator.read"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""
{
"entries": [{
"ts": 1785000000000,
"jobId": "job-1",
"runId": "run-1",
"action": "finished",
"status": "error",
"summary": "Delivery to +49 151 12345678 failed",
"deliveryError": "Webhook https://example.invalid/hooks/secret failed"
}],
"nextOffset": 1
}
""");
var service = CreateService(gateway);
var result = await service.GetCronRunsAsync(
"job-1",
limit: 1,
runId: "run-1");
var run = Assert.Single(result.Items);
Assert.DoesNotContain("12345678", run.Summary);
Assert.DoesNotContain("example.invalid", run.DeliveryError);
Assert.NotNull(result.NextCursor);
var invocation = Assert.Single(gateway.Invocations);
Assert.Equal("job", invocation.Parameters?["scope"]?.GetValue<string>());
Assert.Equal("job-1", invocation.Parameters?["id"]?.GetValue<string>());
Assert.Equal("run-1", invocation.Parameters?["runId"]?.GetValue<string>());
}
[Fact]
public async Task CronCreate_BlocksCommandPayloadBeforeGatewayInvocation()
{
var gateway = Connected(
methods: ["cron.add"],
scopes: ["operator.admin"]);
var service = CreateService(gateway, allowCommandCron: false);
var request = new Nexus.Api.Models.CreateOpenClawCronJobRequest(
"Unsafe",
JsonNode.Parse("""{ "kind": "every", "everyMs": 60000 }""")!.AsObject(),
"isolated",
"now",
JsonNode.Parse("""{ "kind": "command", "argv": ["whoami"] }""")!.AsObject());
var result = await service.CreateCronJobAsync(request);
Assert.False(result.Ok);
Assert.Equal("restricted", result.State);
Assert.Equal("cron", result.Operation?.PrimaryRef?.Type);
Assert.StartsWith("create:", result.Operation?.PrimaryRef?.Id);
Assert.Empty(gateway.Invocations);
}
[Fact]
public async Task CronCreate_UsesOfficialAddShapeAndReturnsTypedDetail()
{
var gateway = Connected(
methods: ["cron.add"],
scopes: ["operator.admin"]);
gateway.Handler = (_, _) => new JsonObject
{
["created"] = true,
["job"] = CronJob(name: "Safe reminder", enabled: true)
};
var service = CreateService(gateway);
var request = new Nexus.Api.Models.CreateOpenClawCronJobRequest(
"Safe reminder",
JsonNode.Parse("""{ "kind": "every", "everyMs": 60000 }""")!.AsObject(),
"main",
"now",
JsonNode.Parse("""{ "kind": "systemEvent", "text": "Check status" }""")!.AsObject(),
DeclarationKey: "nexus.safe-reminder");
var result = await service.CreateCronJobAsync(request);
Assert.True(result.Ok);
Assert.Equal("job-1", result.Data?.Id);
Assert.Equal("cron", result.Operation?.PrimaryRef?.Type);
Assert.Equal("job-1", result.Operation?.PrimaryRef?.Id);
var invocation = Assert.Single(gateway.Invocations);
Assert.Equal("cron.add", invocation.Method);
Assert.Equal("Safe reminder", invocation.Parameters?["name"]?.GetValue<string>());
Assert.Equal("every", invocation.Parameters?["schedule"]?["kind"]?.GetValue<string>());
Assert.Equal("systemEvent", invocation.Parameters?["payload"]?["kind"]?.GetValue<string>());
Assert.Equal(
"nexus.safe-reminder",
invocation.Parameters?["declarationKey"]?.GetValue<string>());
}
[Fact]
public async Task CronPatch_RejectsStaleHashWithoutMutatingGateway()
{
var gateway = Connected(
methods: ["cron.get", "cron.update"],
scopes: ["operator.read", "operator.admin"]);
gateway.Handler = (method, _) => method == "cron.get"
? CronJob(name: "Morning brief", enabled: true)
: throw new InvalidOperationException("stale writes must not reach cron.update");
var service = CreateService(gateway);
var patch = JsonNode.Parse("""{ "enabled": false }""")!.AsObject();
var result = await service.PatchCronJobAsync(
"job-1",
patch,
expectedHash: "stale");
Assert.False(result.Ok);
Assert.Equal("conflict", result.State);
Assert.Single(gateway.Invocations);
Assert.Equal("cron.get", gateway.Invocations[0].Method);
}
[Fact]
public async Task CronPatch_UsesOfficialPatchShapeAfterHashCheck()
{
var gateway = Connected(
methods: ["cron.get", "cron.update"],
scopes: ["operator.read", "operator.admin"]);
gateway.Handler = (method, parameters) => method switch
{
"cron.get" => CronJob(name: "Morning brief", enabled: true),
"cron.update" => CronJob(
name: "Morning brief",
enabled: parameters?["patch"]?["enabled"]?.GetValue<bool>() ?? true),
_ => null
};
var service = CreateService(gateway);
var detail = await service.GetCronJobAsync("job-1");
var result = await service.PatchCronJobAsync(
"job-1",
JsonNode.Parse("""{ "enabled": false }""")!.AsObject(),
detail.Data!.ResourceHash);
Assert.True(result.Ok);
Assert.False(result.Data!.Enabled);
Assert.Equal("cron.update", gateway.Invocations.Last().Method);
Assert.Equal("job-1", gateway.Invocations.Last().Parameters?["id"]?.GetValue<string>());
Assert.Equal(
false,
gateway.Invocations.Last().Parameters?["patch"]?["enabled"]?.GetValue<bool>());
}
[Fact]
public async Task CronDelete_ChecksHashAndUsesOfficialRemoveShape()
{
var gateway = Connected(
methods: ["cron.get", "cron.remove"],
scopes: ["operator.read", "operator.admin"]);
gateway.Handler = (method, _) => method switch
{
"cron.get" => CronJob(name: "Morning brief", enabled: true),
"cron.remove" => JsonNode.Parse("""{ "removed": true }"""),
_ => null
};
var service = CreateService(gateway);
var detail = await service.GetCronJobAsync("job-1");
var result = await service.DeleteCronJobAsync(
"job-1",
detail.Data!.ResourceHash);
Assert.True(result.Ok);
Assert.Equal("cron.remove", gateway.Invocations.Last().Method);
Assert.Equal("job-1", gateway.Invocations.Last().Parameters?["id"]?.GetValue<string>());
}
[Fact]
public async Task CronMutation_RequiresLocalManagementGate()
{
var gateway = Connected(
methods: ["cron.run"],
scopes: ["operator.admin"]);
var service = CreateService(gateway, managementEnabled: false);
var result = await service.RunCronJobAsync("job-1");
Assert.False(result.Ok);
Assert.Equal("management_disabled", result.State);
Assert.Equal("cron", result.Operation?.PrimaryRef?.Type);
Assert.Equal("job-1", result.Operation?.PrimaryRef?.Id);
Assert.Empty(gateway.Invocations);
}
[Fact]
public void Capabilities_DistinguishMissingMethodFromMissingScope()
{
var gateway = Connected(
methods: ["tasks.list", "tasks.cancel"],
scopes: ["operator.read"]);
var service = CreateService(gateway);
var capabilities = service.GetCapabilities();
Assert.Equal("ready", capabilities.Single(item => item.Id == "tasks-read").State);
Assert.Equal("forbidden", capabilities.Single(item => item.Id == "tasks-cancel").State);
Assert.Equal("unsupported", capabilities.Single(item => item.Id == "sessions-read").State);
}
[Fact]
public async Task CancelTask_UsesDocumentedTaskIdAndReason()
{
var gateway = Connected(
methods: ["tasks.cancel"],
scopes: ["operator.write"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""
{
"found": true,
"cancelled": true,
"task": { "id": "task-9", "title": "Long run", "status": "cancelled" }
}
""");
var service = CreateService(gateway);
var result = await service.CancelTaskAsync("task-9", "Operator stop");
Assert.True(result.Ok);
Assert.Equal("openclaw-task", result.Operation?.PrimaryRef?.Type);
Assert.Equal("task-9", result.Operation?.PrimaryRef?.Id);
var invocation = Assert.Single(gateway.Invocations);
Assert.Equal("tasks.cancel", invocation.Method);
Assert.Equal("task-9", invocation.Parameters?["taskId"]?.GetValue<string>());
Assert.Equal("Operator stop", invocation.Parameters?["reason"]?.GetValue<string>());
}
[Fact]
public async Task ResolveApproval_RejectsUnknownDecisionBeforeGatewayCall()
{
var gateway = Connected(
methods: ["approval.resolve"],
scopes: ["operator.approvals"]);
var service = CreateService(gateway);
var result = await service.ResolveApprovalAsync("approval-1", "exec", "approve");
Assert.False(result.Ok);
Assert.Equal("invalid", result.State);
Assert.Equal("approval", result.Operation?.PrimaryRef?.Type);
Assert.Equal("approval-1", result.Operation?.PrimaryRef?.Id);
Assert.Empty(gateway.Invocations);
}
[Fact]
public async Task PatchSessionModel_UsesDocumentedSessionKeyAndModel()
{
var gateway = Connected(
methods: ["sessions.patch"],
scopes: ["operator.write"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""{ "model": "openai/gpt-5.4", "provider": "openai" }""");
var service = CreateService(gateway);
var result = await service.PatchSessionModelAsync(
"agent:iris:main",
"openai/gpt-5.4");
Assert.True(result.Ok);
var invocation = Assert.Single(gateway.Invocations);
Assert.Equal("sessions.patch", invocation.Method);
Assert.Equal("agent:iris:main", invocation.Parameters?["key"]?.GetValue<string>());
Assert.Equal("openai/gpt-5.4", invocation.Parameters?["model"]?.GetValue<string>());
}
[Fact]
public async Task ModelAuthStatus_AggregatesProfilesAndDropsSensitiveGatewayFields()
{
var gateway = Connected(
methods: ["models.authStatus"],
scopes: ["operator.read"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""
{
"ts": 1785400000000,
"providers": [{
"provider": "openai-codex",
"displayName": "OpenAI Codex",
"status": "expiring",
"expiry": {
"at": 1785486400000,
"remainingMs": 86400000,
"label": "untrusted gateway label"
},
"profiles": [
{
"profileId": "bao@example.test",
"type": "oauth",
"status": "ok",
"accessToken": "secret-oauth-token"
},
{
"profileId": "second-private-profile",
"type": "oauth",
"status": "ok"
},
{
"profileId": "legacy-token-profile",
"type": "token",
"status": "expired"
}
],
"apiKey": {
"source": "env",
"envVar": "OPENAI_API_KEY",
"value": "secret-api-key"
},
"usage": {
"providerId": "openai",
"summary": "82% remaining",
"plan": "team",
"accountEmail": "billing@example.test",
"billing": [{ "amount": 99, "currency": "EUR" }],
"windows": [{ "label": "5h", "usedPercent": 18 }]
}
}]
}
""");
var service = CreateService(gateway);
var result = await service.GetModelAuthStatusAsync(refresh: true);
Assert.Equal("ready", result.State);
var provider = Assert.Single(result.Items);
Assert.Equal("openai-codex", provider.Provider);
Assert.Equal("OpenAI Codex", provider.DisplayName);
Assert.Equal("expiring", provider.Status);
Assert.Equal("1d", provider.Expiry?.Label);
Assert.Equal("env", provider.ApiKey?.Source);
Assert.Equal("OPENAI_API_KEY", provider.ApiKey?.EnvVar);
Assert.Equal("82% remaining", provider.Usage?.Summary);
Assert.Equal("team", provider.Usage?.Plan);
var oauth = Assert.Single(
provider.Profiles,
profile => profile.Type == "oauth" && profile.Status == "ok");
Assert.Equal(2, oauth.Count);
var token = Assert.Single(
provider.Profiles,
profile => profile.Type == "token" && profile.Status == "expired");
Assert.Equal(1, token.Count);
var serialized = JsonSerializer.Serialize(provider);
Assert.DoesNotContain("profileId", serialized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("bao@example.test", serialized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("billing", serialized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("secret", serialized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("windows", serialized, StringComparison.OrdinalIgnoreCase);
var invocation = Assert.Single(gateway.Invocations);
Assert.Equal("models.authStatus", invocation.Method);
Assert.True(invocation.Parameters?["refresh"]?.GetValue<bool>());
}
[Fact]
public async Task ModelAuthStatus_RejectsUnsafeMetadataAndReportsUnsupported()
{
var gateway = Connected(
methods: ["models.authStatus"],
scopes: ["operator.read"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""
{
"providers": [{
"provider": "custom",
"displayName": "Custom",
"status": "future-state",
"profiles": [{ "type": "future-profile", "status": "future-state" }],
"apiKey": { "source": "env", "envVar": "bad env name" },
"usage": {
"summary": "details at billing@example.test",
"plan": "https://provider.example/private"
}
}]
}
""");
var service = CreateService(gateway);
var result = await service.GetModelAuthStatusAsync();
var provider = Assert.Single(result.Items);
Assert.Equal("unknown", provider.Status);
var profile = Assert.Single(provider.Profiles);
Assert.Equal("unknown", profile.Type);
Assert.Equal("unknown", profile.Status);
Assert.Null(provider.ApiKey?.EnvVar);
Assert.Null(provider.Usage);
gateway.AdvertisedMethods = new HashSet<string>(StringComparer.Ordinal);
var unsupported = await service.GetModelAuthStatusAsync();
Assert.Equal("unsupported", unsupported.State);
Assert.Empty(unsupported.Items);
}
[Fact]
public async Task RunCronJob_RequiresAdminAndUsesForceMode()
{
var gateway = Connected(
methods: ["cron.run"],
scopes: ["operator.admin"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""{ "enqueued": true, "runId": "run-17" }""");
var service = CreateService(gateway);
var result = await service.RunCronJobAsync("job-17");
Assert.True(result.Ok);
var invocation = Assert.Single(gateway.Invocations);
Assert.Equal("cron.run", invocation.Method);
Assert.Equal("job-17", invocation.Parameters?["id"]?.GetValue<string>());
Assert.Equal("force", invocation.Parameters?["mode"]?.GetValue<string>());
}
[Fact]
public async Task MutationContext_DeduplicatesRetryAndDoesNotAddUndocumentedGatewayField()
{
var testRoot = Path.Combine(
Path.GetTempPath(),
"nexus-openclaw-control-tests",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(testRoot);
try
{
var gateway = Connected(
methods: ["tasks.cancel"],
scopes: ["operator.write"]);
gateway.Handler = (_, _) => JsonNode.Parse(
"""
{
"found": true,
"cancelled": true,
"task": { "id": "task-9", "title": "Long run", "status": "cancelled" }
}
""");
var audit = new OpenClawOperationAuditStore(
Options.Create(new GatewayConnectorOptions
{
DeviceStatePath = Path.Combine(testRoot, "device.json"),
OperationAuditPath = Path.Combine(testRoot, "operations.jsonl")
}));
var service = CreateService(gateway, audit);
var context = OpenClawInvocationContext.Create(
actor: "owner-1",
idempotencyKey: "idem-task-9",
correlationId: "corr-task-9",
traceParent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
includeIdempotencyParameter: true);
var first = await service.CancelTaskAsync(
"task-9",
"Operator stop",
invocationContext: context);
var retry = await service.CancelTaskAsync(
"task-9",
"Operator stop",
invocationContext: context);
var conflict = await service.CancelTaskAsync(
"task-9",
"Different reason",
invocationContext: context);
Assert.True(first.Ok);
Assert.Equal("replayed", retry.State);
Assert.Equal("idempotency_conflict", conflict.State);
var invocation = Assert.Single(gateway.Invocations);
Assert.Equal(context.TraceParent, invocation.Context?.TraceParent);
Assert.Equal(context.CorrelationId, first.CorrelationId);
Assert.Equal(context.IdempotencyKey, first.IdempotencyKey);
Assert.Equal(context.CorrelationId, first.Operation?.OperationId);
Assert.Equal("openclaw-task", retry.Operation?.PrimaryRef?.Type);
Assert.Equal("task-9", conflict.Operation?.PrimaryRef?.Id);
Assert.Null(invocation.Parameters?["idempotencyKey"]);
}
finally
{
var safeRoot = Path.GetFullPath(Path.Combine(
Path.GetTempPath(),
"nexus-openclaw-control-tests"));
var resolved = Path.GetFullPath(testRoot);
if (resolved.StartsWith(safeRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&
Directory.Exists(resolved))
{
Directory.Delete(resolved, recursive: true);
}
}
}
private static OpenClawControlService CreateService(
StubOpenClawConnector gateway,
IOpenClawOperationAuditStore? auditStore = null,
bool managementEnabled = true,
bool allowCommandCron = true)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Integrations:OpenClaw:BaseUrl"] = "http://127.0.0.1:18789",
["Integrations:OpenClaw:Token"] = "test-only-token",
["Integrations:OpenClaw:ManagementEnabled"] =
managementEnabled.ToString(CultureInfo.InvariantCulture),
["Integrations:OpenClaw:AllowCommandCron"] =
allowCommandCron.ToString(CultureInfo.InvariantCulture)
})
.Build();
return new OpenClawControlService(
gateway,
configuration,
new StubOpenClawWriteGate(
managementEnabled
? OpenClawWriteGateDecision.Permit()
: OpenClawWriteGateDecision.Block(
"management_disabled",
"OpenClaw management is disabled.")),
NullLogger<OpenClawControlService>.Instance,
operationAuditStore: auditStore);
}
private static JsonNode CronJob(
string name,
bool enabled,
string? deliveryTarget = null)
{
var job = JsonNode.Parse(
$$"""
{
"id": "job-1",
"name": {{JsonSerializer.Serialize(name)}},
"enabled": {{enabled.ToString().ToLowerInvariant()}},
"createdAtMs": 1784000000000,
"updatedAtMs": 1785000000000,
"schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "Europe/Berlin" },
"sessionTarget": "isolated",
"wakeMode": "now",
"payload": { "kind": "agentTurn", "message": "Prepare brief" },
"state": { "nextRunAtMs": 1786000000000 }
}
""")!.AsObject();
if (deliveryTarget is not null)
{
job["delivery"] = new JsonObject
{
["mode"] = "announce",
["channel"] = "telegram",
["to"] = deliveryTarget
};
}
return job;
}
private static StubOpenClawConnector Connected(
IEnumerable<string> methods,
IEnumerable<string> scopes)
{
return new StubOpenClawConnector
{
ConnectionState = GatewayConnectionState.Connected,
GatewayVersion = "2026.7.1",
ProtocolVersion = 4,
AdvertisedMethods = methods.ToHashSet(StringComparer.Ordinal),
GrantedScopes = scopes.ToHashSet(StringComparer.Ordinal)
};
}
}
public sealed class StubOpenClawConnector : IGatewayConnector
{
public GatewayConnectionState ConnectionState { get; set; } = GatewayConnectionState.Initializing;
public string? GatewayVersion { get; set; }
public string? RequiredVersion { get; set; }
public DateTimeOffset? LastConnectedAt { get; set; }
public int ReconnectAttempts { get; set; }
public string? StatusMessage { get; set; }
public string? DeviceId { get; set; }
public string? ActiveEndpoint { get; set; }
public string? ActiveTlsFingerprint { get; set; }
public bool DeviceTokenConfigured { get; set; }
public bool PairingRequired { get; set; }
public string? PairingRequestId { get; set; }
public int? ProtocolVersion { get; set; }
public IReadOnlySet<string> AdvertisedMethods { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> AdvertisedEvents { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> GrantedScopes { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public DateTimeOffset? LastEventAt { get; set; }
public Func<string, JsonNode?, JsonNode?>? Handler { get; set; }
public List<(string Method, JsonNode? Parameters, OpenClawInvocationContext? Context)> Invocations { get; } = [];
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
{
var node = parameters switch
{
null => null,
JsonNode jsonNode => jsonNode.DeepClone(),
_ => JsonSerializer.SerializeToNode(parameters)
};
Invocations.Add((method, node, invocationContext));
return Task.FromResult(Handler?.Invoke(method, node));
}
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
}
@@ -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);
}
}
@@ -0,0 +1,224 @@
using System.Reflection;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Controllers;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawEventProjectionTests
{
[Fact]
public void Project_OrdersEvents_RedactsSecrets_AndReportsSequenceGap()
{
var now = DateTimeOffset.UtcNow;
var connector = new EventConnector
{
Events =
[
new GatewayEventEnvelope(
"chat",
new JsonObject
{
["runId"] = "run-1",
["state"] = "delta",
["seq"] = 2,
["apiToken"] = "do-not-leak",
["inputTokens"] = 42
},
12,
4,
now.AddMilliseconds(20)),
new GatewayEventEnvelope(
"sessions.changed",
new JsonObject { ["sessionKey"] = "agent:iris:main" },
10,
3,
now)
]
};
var service = new OpenClawEventProjectionService(connector);
var batch = service.Project(null);
Assert.Equal(2, batch.Events.Count);
Assert.Equal("sessions.changed", batch.Events[0].EventName);
Assert.Equal("chat", batch.Events[1].EventName);
Assert.True(batch.Events[1].SequenceGapDetected);
Assert.Equal(11, batch.Events[1].MissingSequenceFrom);
Assert.Equal(11, batch.Events[1].MissingSequenceTo);
Assert.Equal("[redacted]", batch.Events[1].Payload?["apiToken"]?.GetValue<string>());
Assert.Equal(42, batch.Events[1].Payload?["inputTokens"]?.GetValue<int>());
Assert.StartsWith("gw-", batch.Events[0].Id);
Assert.Equal(batch.Events[^1].Id, batch.Cursor);
}
[Fact]
public void Project_ReplaysAfterKnownCursor_AndSignalsExpiredCursor()
{
var now = DateTimeOffset.UtcNow;
var connector = new EventConnector
{
Events =
[
Event("chat", 3, now.AddSeconds(2)),
Event("session.tool", 2, now.AddSeconds(1)),
Event("sessions.changed", 1, now)
]
};
var service = new OpenClawEventProjectionService(connector);
var initial = service.Project(null);
var replay = service.Project(initial.Events[0].Id);
var expired = service.Project("gw-expired");
Assert.Equal(2, replay.Events.Count);
Assert.False(replay.ReplayBoundaryMissed);
Assert.True(expired.ReplayBoundaryMissed);
Assert.Equal(3, expired.Events.Count);
Assert.Equal(initial.Events[^1].Id, expired.Cursor);
}
[Fact]
public void Project_SignalsExpiredCursorAfterBackendRestartWithEmptyBuffer()
{
var service = new OpenClawEventProjectionService(new EventConnector());
var batch = service.Project("gw-from-previous-process");
Assert.True(batch.ReplayBoundaryMissed);
Assert.Empty(batch.Events);
Assert.Equal("origin", batch.Cursor);
}
[Fact]
public void Project_ReportsOuterSequenceResetWithoutCallingItAMissingRange()
{
var now = DateTimeOffset.UtcNow;
var connector = new EventConnector
{
Events =
[
Event("chat", 1, now.AddSeconds(1)),
Event("chat", 80, now)
]
};
var service = new OpenClawEventProjectionService(connector);
var batch = service.Project(null);
Assert.True(batch.Events[1].SequenceResetDetected);
Assert.False(batch.Events[1].SequenceGapDetected);
Assert.Equal(80, batch.Events[1].PreviousSequence);
Assert.Equal(1, batch.Events[1].Sequence);
}
[Theory]
[InlineData("sessions.changed", "session")]
[InlineData("session.tool", "tool")]
[InlineData("exec.approval.requested", "approval")]
[InlineData("artifact.created", "artifact")]
[InlineData("chat", "run")]
public void Project_ClassifiesOperationalEventFamilies(string eventName, string category)
{
var connector = new EventConnector
{
Events = [Event(eventName, 1, DateTimeOffset.UtcNow)]
};
var item = Assert.Single(new OpenClawEventProjectionService(connector).Project(null).Events);
Assert.Equal(category, item.Category);
Assert.Equal($"openclaw.{category}", item.Type);
}
[Fact]
public async Task Controller_UsesLastEventId_AndCanReturnFiniteReplay()
{
var connector = new EventConnector
{
Events = [Event("chat", 7, DateTimeOffset.UtcNow)]
};
var projector = new OpenClawEventProjectionService(connector);
var controller = new OpenClawEventsController(projector);
var context = new DefaultHttpContext();
context.Request.Headers["Last-Event-ID"] = "gw-expired";
context.Response.Body = new MemoryStream();
controller.ControllerContext = new ControllerContext { HttpContext = context };
await controller.Stream(follow: false);
context.Response.Body.Position = 0;
using var reader = new StreamReader(context.Response.Body, Encoding.UTF8);
var body = await reader.ReadToEndAsync();
Assert.Equal("text/event-stream", context.Response.ContentType);
Assert.Contains("event: openclaw.connection", body, StringComparison.Ordinal);
Assert.Contains("event: openclaw.gap", body, StringComparison.Ordinal);
Assert.Contains("event: openclaw.run", body, StringComparison.Ordinal);
Assert.Contains("event: openclaw.heartbeat", body, StringComparison.Ordinal);
Assert.Contains("last-event-id-outside-buffer", body, StringComparison.Ordinal);
}
[Fact]
public void Controller_IsAuthenticated()
{
var authorize = typeof(OpenClawEventsController)
.GetCustomAttributes<AuthorizeAttribute>()
.SingleOrDefault();
Assert.NotNull(authorize);
}
private static GatewayEventEnvelope Event(
string name,
long sequence,
DateTimeOffset receivedAt)
=> new(
name,
new JsonObject
{
["runId"] = "run-1",
["state"] = "delta",
["seq"] = sequence
},
sequence,
sequence,
receivedAt);
private sealed class EventConnector : IGatewayConnector
{
public IReadOnlyList<GatewayEventEnvelope> Events { get; init; } = [];
public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected;
public string? GatewayVersion => "2026.7.0";
public string? RequiredVersion => "2026.7.0";
public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow;
public int ReconnectAttempts => 0;
public string? StatusMessage => "Connected";
public string? DeviceId => "nexus-test";
public bool DeviceTokenConfigured => true;
public bool PairingRequired => false;
public string? PairingRequestId => null;
public int? ProtocolVersion => 4;
public IReadOnlySet<string> AdvertisedMethods { get; } = new HashSet<string>();
public IReadOnlySet<string> AdvertisedEvents { get; } = new HashSet<string>();
public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>();
public DateTimeOffset? LastEventAt => Events.FirstOrDefault()?.ReceivedAt;
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Task.FromResult<JsonNode?>(null);
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100)
=> Events.Take(limit).ToList();
}
}
@@ -0,0 +1,132 @@
using System.Text.Json.Nodes;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Nexus.Api.Data;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawEventSubscriptionCoordinatorTests
{
[Fact]
public async Task SynchronizeOnce_SubscribesCatalogAndActiveSessions_OncePerConnection()
{
var services = new ServiceCollection();
var databaseName = $"subscription-{Guid.NewGuid():N}";
services.AddDbContext<NexusDbContext>(options =>
options.UseInMemoryDatabase(databaseName));
services.AddScoped<IOpenClawRunRepository, OpenClawRunRepository>();
await using var provider = services.BuildServiceProvider();
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
db.OpenClawRuns.Add(new OpenClawRun
{
Title = "Active run",
Prompt = "Do work",
AgentId = "iris",
SessionKey = "agent:iris:main",
Status = OpenClawRunStates.Running,
StartIdempotencyKey = "subscription-key",
CorrelationId = "subscription-correlation",
Actor = "owner"
});
await db.SaveChangesAsync();
}
var connector = new SubscriptionConnector();
var coordinator = new OpenClawEventSubscriptionCoordinator(
connector,
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<OpenClawEventSubscriptionCoordinator>.Instance);
await coordinator.SynchronizeOnceAsync();
await coordinator.SynchronizeOnceAsync();
Assert.Equal(2, connector.Calls.Count);
Assert.Equal("sessions.subscribe", connector.Calls[0].Method);
Assert.Equal("sessions.messages.subscribe", connector.Calls[1].Method);
Assert.Equal("agent:iris:main", connector.Calls[1].Parameters?["key"]?.GetValue<string>());
Assert.Equal("iris", connector.Calls[1].Parameters?["agentId"]?.GetValue<string>());
Assert.True(connector.Calls[1].Parameters?["includeApprovals"]?.GetValue<bool>());
await SetRunStatusAsync(provider, OpenClawRunStates.Completed);
await coordinator.SynchronizeOnceAsync();
Assert.Equal(3, connector.Calls.Count);
Assert.Equal("sessions.messages.unsubscribe", connector.Calls[2].Method);
await SetRunStatusAsync(provider, OpenClawRunStates.Running);
connector.LastConnectedAtValue = connector.LastConnectedAtValue.AddMinutes(1);
await coordinator.SynchronizeOnceAsync();
Assert.Equal(5, connector.Calls.Count);
Assert.Equal("sessions.subscribe", connector.Calls[3].Method);
Assert.Equal("sessions.messages.subscribe", connector.Calls[4].Method);
}
private static async Task SetRunStatusAsync(
ServiceProvider provider,
string status)
{
await using var scope = provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
var run = await db.OpenClawRuns.SingleAsync();
run.Status = status;
await db.SaveChangesAsync();
}
private sealed class SubscriptionConnector : IGatewayConnector
{
public List<SubscriptionCall> Calls { get; } = [];
public DateTimeOffset LastConnectedAtValue { get; set; } = DateTimeOffset.UtcNow;
public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected;
public string? GatewayVersion => "2026.7.0";
public string? RequiredVersion => "2026.7.0";
public DateTimeOffset? LastConnectedAt => LastConnectedAtValue;
public int ReconnectAttempts => 0;
public string? StatusMessage => "Connected";
public string? DeviceId => "nexus-test";
public bool DeviceTokenConfigured => true;
public bool PairingRequired => false;
public string? PairingRequestId => null;
public int? ProtocolVersion => 4;
public IReadOnlySet<string> AdvertisedMethods { get; } = new HashSet<string>(
[
"sessions.subscribe",
"sessions.messages.subscribe",
"sessions.messages.unsubscribe"
],
StringComparer.Ordinal);
public IReadOnlySet<string> AdvertisedEvents { get; } = new HashSet<string>();
public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>(
["operator.read", "operator.approvals"],
StringComparer.Ordinal);
public DateTimeOffset? LastEventAt => null;
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
{
Calls.Add(new SubscriptionCall(
method,
parameters as JsonNode,
invocationContext));
return Task.FromResult<JsonNode?>(new JsonObject { ["ok"] = true });
}
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
}
private sealed record SubscriptionCall(
string Method,
JsonNode? Parameters,
OpenClawInvocationContext? Invocation);
}
@@ -0,0 +1,99 @@
using System.Net;
using System.Text;
using Microsoft.Extensions.Configuration;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawGatewayClientTests
{
[Fact]
public async Task GetSessionHistoryAsync_ProjectsOnlyUserAndAssistantText()
{
var client = CreateClient(
"""
{
"ok": true,
"result": {
"details": {
"messages": [
{
"role": "user",
"content": [{ "type": "text", "text": "Plan the release" }],
"timestamp": "2026-07-30T10:00:00Z"
},
{
"role": "assistant",
"content": [
{ "type": "text", "text": "Release" },
{ "type": "text", "text": "planned" },
{ "type": "toolCall", "name": "ignored" }
],
"timestamp": "2026-07-30T10:01:00Z"
},
{
"role": "tool",
"content": [{ "type": "text", "text": "hidden tool output" }]
}
]
}
}
}
""");
var history = await client.GetSessionHistoryAsync("agent:iris:main");
Assert.Collection(
history,
message =>
{
Assert.Equal("user", message.Role);
Assert.Equal("Plan the release", message.Content);
},
message =>
{
Assert.Equal("assistant", message.Role);
Assert.Equal("Release planned", message.Content);
});
}
[Fact]
public async Task GetSessionHistoryAsync_GatewayFailureReturnsNoFabricatedMessages()
{
var configuration = new ConfigurationBuilder().Build();
var httpClient = new HttpClient(new StubHandler(_ =>
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)))
{
BaseAddress = new Uri("http://gateway.local")
};
var client = new OpenClawGatewayClient(httpClient, configuration);
var history = await client.GetSessionHistoryAsync("agent:iris:main");
Assert.Empty(history);
}
private static OpenClawGatewayClient CreateClient(string responseJson)
{
var configuration = new ConfigurationBuilder().Build();
var httpClient = new HttpClient(new StubHandler(_ =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseJson, Encoding.UTF8, "application/json")
}))
{
BaseAddress = new Uri("http://gateway.local")
};
return new OpenClawGatewayClient(httpClient, configuration);
}
private sealed class StubHandler(
Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
=> Task.FromResult(responder(request));
}
}
@@ -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);
}
}
+205
View File
@@ -0,0 +1,205 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawRunGatewayTests
{
[Fact]
public async Task Start_UsesOfficialChatSendShape_AndForwardsInvocationContext()
{
var connector = new CapturingConnector(
["chat.send"],
new JsonObject
{
["runId"] = "oc-run-42",
["status"] = "started"
});
var gateway = new OpenClawRunGateway(
connector,
new StubOpenClawWriteGate());
var invocation = Invocation();
var result = await gateway.StartAsync(Run(), invocation);
Assert.True(result.Ok);
Assert.Equal("oc-run-42", result.OpenClawRunId);
var call = Assert.Single(connector.Calls);
Assert.Equal("chat.send", call.Method);
Assert.Equal("agent:iris:main", call.Parameters?["sessionKey"]?.GetValue<string>());
Assert.Equal("iris", call.Parameters?["agentId"]?.GetValue<string>());
Assert.Equal("Do the work", call.Parameters?["message"]?.GetValue<string>());
Assert.False(call.Parameters?["deliver"]?.GetValue<bool>());
Assert.Equal("run-idempotency", call.Parameters?["idempotencyKey"]?.GetValue<string>());
Assert.Equal("run-idempotency", call.Invocation?.IdempotencyKey);
Assert.Equal("run-correlation", call.Invocation?.CorrelationId);
Assert.Equal("owner-subject", call.Invocation?.Actor);
Assert.True(call.Invocation?.IncludeIdempotencyParameter);
}
[Fact]
public async Task Start_DoesNotInvokeGateway_WhenWriteBoundaryIsBlocked()
{
var connector = new CapturingConnector(
["chat.send"],
new JsonObject
{
["runId"] = "must-not-be-used",
["status"] = "started"
});
var gate = new StubOpenClawWriteGate(
OpenClawWriteGateDecision.Block(
"endpoint_trust_mismatch",
"The active Gateway endpoint no longer matches the adopted profile.",
"Verify and adopt the connection again."));
var gateway = new OpenClawRunGateway(connector, gate);
var result = await gateway.StartAsync(Run(), Invocation());
Assert.False(result.Ok);
Assert.Equal(OpenClawRunStates.Blocked, result.State);
Assert.Empty(connector.Calls);
Assert.Equal(("chat.send", "operator.write"), Assert.Single(gate.Evaluations));
}
[Fact]
public async Task Stop_RefusesSessionWideAbortWithoutExactOpenClawRunId()
{
var connector = new CapturingConnector(["chat.abort"], new JsonObject());
var gateway = new OpenClawRunGateway(
connector,
new StubOpenClawWriteGate());
var run = Run();
run.OpenClawRunId = null;
var result = await gateway.StopAsync(run, Invocation());
Assert.False(result.Ok);
Assert.Equal(OpenClawRunStates.Blocked, result.State);
Assert.Empty(connector.Calls);
}
[Fact]
public async Task Stop_UsesExactChatAbort_WithoutUnsupportedWireFields()
{
var connector = new CapturingConnector(["chat.abort"], new JsonObject { ["ok"] = true });
var gateway = new OpenClawRunGateway(
connector,
new StubOpenClawWriteGate());
var result = await gateway.StopAsync(Run(), Invocation());
Assert.True(result.Ok);
var call = Assert.Single(connector.Calls);
Assert.Equal("chat.abort", call.Method);
Assert.Equal("oc-run-1", call.Parameters?["runId"]?.GetValue<string>());
Assert.Null(call.Parameters?["idempotencyKey"]);
Assert.False(call.Invocation?.IncludeIdempotencyParameter);
}
[Fact]
public async Task History_IsDisplayNormalizedGatewayData_WithSecretsRedacted()
{
var connector = new CapturingConnector(
["chat.history"],
new JsonObject
{
["messages"] = new JsonArray(new JsonObject { ["text"] = "done" }),
["accessToken"] = "secret-value",
["inputTokens"] = 12
});
var gateway = new OpenClawRunGateway(
connector,
new StubOpenClawWriteGate());
var result = await gateway.GetHistoryAsync(Run(), 50);
Assert.True(result.Ok);
Assert.Equal("[redacted]", result.Data?["accessToken"]?.GetValue<string>());
Assert.Equal(12, result.Data?["inputTokens"]?.GetValue<int>());
var call = Assert.Single(connector.Calls);
Assert.Equal("chat.history", call.Method);
Assert.Equal(50, call.Parameters?["limit"]?.GetValue<int>());
Assert.Null(call.Invocation);
}
private static OpenClawRun Run()
=> new()
{
Title = "Run",
Prompt = "Do the work",
AgentId = "iris",
SessionKey = "agent:iris:main",
Status = OpenClawRunStates.Running,
OpenClawRunId = "oc-run-1",
StartIdempotencyKey = "run-idempotency",
CorrelationId = "run-correlation",
Actor = "owner-subject"
};
private static OpenClawInvocationMetadata Invocation()
=> new(
"run-idempotency",
"run-correlation",
"owner-subject",
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
private sealed class CapturingConnector : IGatewayConnector
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly JsonNode? _response;
public CapturingConnector(IEnumerable<string> methods, JsonNode? response)
{
AdvertisedMethods = new HashSet<string>(methods, StringComparer.Ordinal);
_response = response;
}
public List<InvocationCall> Calls { get; } = [];
public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected;
public string? GatewayVersion => "2026.7.0";
public string? RequiredVersion => "2026.7.0";
public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow;
public int ReconnectAttempts => 0;
public string? StatusMessage => "Connected";
public string? DeviceId => "nexus-tests";
public bool DeviceTokenConfigured => true;
public bool PairingRequired => false;
public string? PairingRequestId => null;
public int? ProtocolVersion => 4;
public IReadOnlySet<string> AdvertisedMethods { get; }
public IReadOnlySet<string> AdvertisedEvents { get; } = new HashSet<string>();
public IReadOnlySet<string> GrantedScopes { get; } = new HashSet<string>();
public DateTimeOffset? LastEventAt => null;
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
{
var node = parameters switch
{
null => null,
JsonNode jsonNode => jsonNode.DeepClone(),
_ => JsonSerializer.SerializeToNode(parameters, JsonOptions)
};
Calls.Add(new InvocationCall(method, node, invocationContext));
return Task.FromResult(_response?.DeepClone());
}
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
}
private sealed record InvocationCall(
string Method,
JsonNode? Parameters,
OpenClawInvocationContext? Invocation);
}
+584
View File
@@ -0,0 +1,584 @@
using System.Security.Claims;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawRunServiceTests
{
[Fact]
public async Task ListCursor_DoesNotSkipOrDuplicateRunsWithTheSameCreatedAt()
{
await using var harness = await RunHarness.CreateAsync();
var timestamp = new DateTimeOffset(
2026,
7,
31,
12,
0,
0,
TimeSpan.Zero);
var runs = Enumerable.Range(0, 3)
.Select(index => new OpenClawRun
{
Id = Guid.NewGuid(),
Title = $"Run {index}",
Prompt = $"Inspect {index}",
AgentId = "iris",
SessionKey = "agent:iris:main",
Status = OpenClawRunStates.Completed,
StartIdempotencyKey = $"list-cursor-{index}",
CorrelationId = $"correlation-{index}",
Actor = "owner",
CreatedAt = timestamp,
UpdatedAt = timestamp
})
.ToList();
harness.Db.OpenClawRuns.AddRange(runs);
await harness.Db.SaveChangesAsync();
harness.Db.ChangeTracker.Clear();
var first = await harness.Service.GetAsync(new OpenClawRunQuery(Limit: 2));
var second = await harness.Service.GetAsync(
new OpenClawRunQuery(Limit: 2, Cursor: first.NextCursor));
Assert.Equal(2, first.Items.Count);
Assert.NotNull(first.NextCursor);
Assert.Matches("^[A-Za-z0-9_-]+$", first.NextCursor);
Assert.True(
OpenClawRunCursorCodec.TryDecode(
first.NextCursor,
out var cursorPosition));
Assert.Equal(first.Items[^1].CreatedAt, cursorPosition.CreatedAt);
Assert.Equal(first.Items[^1].Id, cursorPosition.Id);
Assert.Single(second.Items);
Assert.Null(second.NextCursor);
Assert.Equal(
3,
first.Items
.Concat(second.Items)
.Select(item => item.Id)
.Distinct()
.Count());
}
[Fact]
public async Task ListCursor_AcceptsLegacyUtcTicksCursor()
{
await using var harness = await RunHarness.CreateAsync();
var boundary = new DateTimeOffset(
2026,
7,
31,
12,
0,
0,
TimeSpan.Zero);
harness.Db.OpenClawRuns.AddRange(
CreateListedRun("newer", boundary.AddMinutes(1)),
CreateListedRun("older", boundary.AddMinutes(-1)));
await harness.Db.SaveChangesAsync();
harness.Db.ChangeTracker.Clear();
var page = await harness.Service.GetAsync(
new OpenClawRunQuery(
Limit: 10,
Cursor: boundary.UtcTicks.ToString(
System.Globalization.CultureInfo.InvariantCulture)));
var run = Assert.Single(page.Items);
Assert.Equal("older", run.Title);
}
[Fact]
public async Task Start_PersistsCorrelations_AndReplaysIdempotently()
{
await using var harness = await RunHarness.CreateAsync();
var project = new Project { Name = "Nexus" };
var task = new WorkTask { Title = "Connect OpenClaw", ProjectId = project.Id };
harness.Db.Projects.Add(project);
harness.Db.Tasks.Add(task);
await harness.Db.SaveChangesAsync();
harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult(
true,
true,
OpenClawRunStates.Running,
"started",
"oc-run-1"));
var request = new StartOpenClawRunRequest(
"Inspect the deployment",
"iris",
"agent:iris:main",
"Deployment inspection",
task.Id,
project.Id);
var invocation = Invocation("start-key", "corr-1", "owner-1");
var first = await harness.Service.StartAsync(request, invocation);
var replay = await harness.Service.StartAsync(request, invocation);
Assert.True(first.Ok);
Assert.Equal("oc-run-1", first.Run.OpenClawRunId);
Assert.Equal(task.Id, first.Run.TaskId);
Assert.Equal(project.Id, first.Run.ProjectId);
Assert.Equal("corr-1", first.Run.CorrelationId);
Assert.Equal("owner-1", first.Run.Actor);
Assert.Equal("idempotent_replay", replay.State);
Assert.Single(harness.Gateway.StartCalls);
var persisted = await harness.Db.OpenClawRuns.SingleAsync();
Assert.Equal(OpenClawRunStates.Running, persisted.Status);
Assert.Equal(2, await harness.Db.OpenClawRunHistory.CountAsync());
Assert.All(
await harness.Db.OpenClawRunHistory.ToListAsync(),
item => Assert.Equal("corr-1", item.CorrelationId));
}
[Fact]
public async Task Stop_UsesExactRunAndDoesNotRepeatGatewayMutation()
{
await using var harness = await RunHarness.CreateAsync();
harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult(
true,
true,
OpenClawRunStates.Running,
"started",
"oc-run-stop"));
var started = await harness.Service.StartAsync(
Request(),
Invocation("start-stop", "corr-stop", "owner"));
harness.Gateway.StopResults.Enqueue(new OpenClawRunGatewayResult(
true,
true,
OpenClawRunStates.Stopped,
"stopped",
"oc-run-stop"));
var stopInvocation = Invocation("stop-key", "corr-stop-2", "owner");
var stopped = await harness.Service.StopAsync(
started.Run.Id,
"operator request",
stopInvocation);
var replay = await harness.Service.StopAsync(
started.Run.Id,
"operator request",
stopInvocation);
Assert.NotNull(stopped);
Assert.True(stopped!.Ok);
Assert.Equal(OpenClawRunStates.Stopped, stopped.Run.Status);
Assert.Equal("idempotent_replay", replay?.State);
Assert.Single(harness.Gateway.StopCalls);
Assert.Contains(
await harness.Db.OpenClawRunHistory.ToListAsync(),
item => item.Action == "stop_requested"
&& item.IdempotencyKey == "stop-key"
&& item.Actor == "owner");
}
[Fact]
public async Task Retry_CreatesCorrelatedChild_WhileResumeIsTruthfullyUnsupported()
{
await using var harness = await RunHarness.CreateAsync();
harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult(
true,
false,
OpenClawRunStates.Failed,
"provider failed"));
var source = await harness.Service.StartAsync(
Request(),
Invocation("start-failed", "corr-source", "owner"));
harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult(
true,
true,
OpenClawRunStates.Running,
"retry started",
"oc-run-retry"));
var resume = await harness.Service.ResumeAsync(
source.Run.Id,
null,
Invocation("resume-key", "corr-resume", "owner"));
var retry = await harness.Service.RetryAsync(
source.Run.Id,
"retry after provider recovery",
Invocation("retry-key", "corr-retry", "owner"));
Assert.NotNull(resume);
Assert.False(resume!.Ok);
Assert.Equal(OpenClawRunStates.Unsupported, resume.State);
Assert.False(resume.Run.CanResume);
Assert.NotNull(retry?.ResultRun);
Assert.True(retry!.Ok);
Assert.Equal(source.Run.Id, retry.ResultRun!.RetriedFromRunId);
Assert.Equal(source.Run.SessionKey, retry.ResultRun.SessionKey);
Assert.Equal("corr-retry", retry.ResultRun.CorrelationId);
Assert.Equal(2, harness.Gateway.StartCalls.Count);
}
[Fact]
public async Task Reconcile_ProjectsTerminalState_AndPerRunSequenceGap()
{
await using var harness = await RunHarness.CreateAsync();
harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult(
true,
true,
OpenClawRunStates.Running,
"started",
"oc-run-event"));
var started = await harness.Service.StartAsync(
Request(),
Invocation("start-event", "corr-event", "owner"));
var now = DateTimeOffset.UtcNow;
await harness.Service.ReconcileAsync(ChatEvent("oc-run-event", "delta", 1, now));
await harness.Service.ReconcileAsync(ChatEvent("oc-run-event", "final", 3, now.AddSeconds(1)));
await harness.Service.ReconcileAsync(ChatEvent("oc-run-event", "final", 3, now.AddSeconds(1)));
harness.Db.ChangeTracker.Clear();
var run = await harness.Db.OpenClawRuns.SingleAsync(item => item.Id == started.Run.Id);
Assert.Equal(OpenClawRunStates.Completed, run.Status);
Assert.Equal(3, run.LastGatewaySequence);
Assert.True(run.SequenceGapDetected);
Assert.NotNull(run.FinishedAt);
var gatewayTransitions = await harness.Db.OpenClawRunHistory
.Where(item => item.Action == "gateway_event")
.ToListAsync();
var gatewayTransition = Assert.Single(gatewayTransitions);
Assert.True(gatewayTransition.SequenceGapDetected);
Assert.Equal(OpenClawRunStates.Completed, gatewayTransition.ToStatus);
}
[Fact]
public async Task Start_RejectsUnknownTaskCorrelationBeforeGatewayCall()
{
await using var harness = await RunHarness.CreateAsync();
var request = Request() with { TaskId = Guid.NewGuid() };
var exception = await Assert.ThrowsAsync<OpenClawRunValidationException>(
() => harness.Service.StartAsync(
request,
Invocation("unknown-task", "corr", "owner")));
Assert.Equal("taskId", exception.Field);
Assert.Empty(harness.Gateway.StartCalls);
Assert.Empty(await harness.Db.OpenClawRuns.ToListAsync());
}
[Fact]
public async Task Start_ReplaysUnacknowledgedDispatchWithTheSameIdempotencyKey()
{
await using var harness = await RunHarness.CreateAsync();
var request = Request();
var pending = new OpenClawRun
{
Title = request.Title!,
Prompt = request.Prompt,
AgentId = request.AgentId,
SessionKey = request.SessionKey,
Status = OpenClawRunStates.Dispatching,
StartIdempotencyKey = "recover-key",
CorrelationId = "original-correlation",
Actor = "owner"
};
harness.Db.OpenClawRuns.Add(pending);
await harness.Db.SaveChangesAsync();
harness.Db.ChangeTracker.Clear();
harness.Gateway.StartResults.Enqueue(new OpenClawRunGatewayResult(
true,
true,
OpenClawRunStates.Running,
"in flight",
"oc-recovered"));
var result = await harness.Service.StartAsync(
request,
Invocation("recover-key", "recovery-correlation", "owner"));
Assert.True(result.Ok);
Assert.Equal("oc-recovered", result.Run.OpenClawRunId);
Assert.Single(harness.Gateway.StartCalls);
var recovered = await harness.Db.OpenClawRuns.SingleAsync();
Assert.Equal(OpenClawRunStates.Running, recovered.Status);
Assert.Contains(
await harness.Db.OpenClawRunHistory.ToListAsync(),
item => item.Action == "start_recovery"
&& item.CorrelationId == "recovery-correlation");
}
[Fact]
public async Task Controller_RequiresIdempotencyHeader_AndDerivesActorFromPrincipal()
{
var service = new CapturingRunService();
var controller = new OpenClawRunsController(service);
var context = new DefaultHttpContext();
context.TraceIdentifier = "trace-http";
context.User = new ClaimsPrincipal(
new ClaimsIdentity(
[new Claim("sub", "owner-subject"), new Claim(ClaimTypes.Role, "owner")],
"test"));
controller.ControllerContext = new ControllerContext { HttpContext = context };
var missingHeader = await controller.Start(Request(), CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(missingHeader.Result);
context.Request.Headers["Idempotency-Key"] = "controller-key";
context.Request.Headers["X-Correlation-ID"] = "controller-correlation";
var accepted = await controller.Start(Request(), CancellationToken.None);
Assert.IsType<CreatedAtRouteResult>(accepted.Result);
Assert.Equal("owner-subject", service.Invocation?.Actor);
Assert.Equal("controller-key", service.Invocation?.IdempotencyKey);
Assert.Equal("controller-correlation", service.Invocation?.CorrelationId);
context.Request.Headers["traceparent"] = "not-a-w3c-traceparent";
var invalidTrace = await controller.Start(Request(), CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(invalidTrace.Result);
context.Request.Headers.Remove("traceparent");
service.StartOk = false;
var durablyBlocked = await controller.Start(Request(), CancellationToken.None);
Assert.IsType<AcceptedAtRouteResult>(durablyBlocked.Result);
}
[Fact]
public void Controller_MutationsRequireOwnerRole()
{
var classAuthorization = typeof(OpenClawRunsController)
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>()
.Single(attribute => string.IsNullOrWhiteSpace(attribute.Roles));
var startAuthorization = typeof(OpenClawRunsController)
.GetMethod(nameof(OpenClawRunsController.Start))!
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>()
.Single();
Assert.NotNull(classAuthorization);
Assert.Equal("owner", startAuthorization.Roles);
}
private static StartOpenClawRunRequest Request()
=> new(
"Inspect and report the current state.",
"iris",
"agent:iris:main",
"Inspect state");
private static OpenClawRun CreateListedRun(
string title,
DateTimeOffset createdAt)
=> new()
{
Title = title,
Prompt = $"Inspect {title}",
AgentId = "iris",
SessionKey = "agent:iris:main",
Status = OpenClawRunStates.Completed,
StartIdempotencyKey = $"list-{title}",
CorrelationId = $"correlation-{title}",
Actor = "owner",
CreatedAt = createdAt,
UpdatedAt = createdAt
};
private static OpenClawInvocationMetadata Invocation(
string idempotencyKey,
string correlationId,
string actor)
=> new(
idempotencyKey,
correlationId,
actor,
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
private static GatewayEventEnvelope ChatEvent(
string runId,
string state,
long sequence,
DateTimeOffset receivedAt)
=> new(
"chat",
new JsonObject
{
["runId"] = runId,
["sessionKey"] = "agent:iris:main",
["state"] = state,
["seq"] = sequence
},
sequence,
sequence,
receivedAt);
private sealed class RunHarness : IAsyncDisposable
{
private RunHarness(
NexusDbContext db,
FakeRunGateway gateway,
OpenClawRunService service)
{
Db = db;
Gateway = gateway;
Service = service;
}
public NexusDbContext Db { get; }
public FakeRunGateway Gateway { get; }
public OpenClawRunService Service { get; }
public static async Task<RunHarness> CreateAsync()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase($"openclaw-runs-{Guid.NewGuid():N}")
.Options;
var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var gateway = new FakeRunGateway();
var repository = new OpenClawRunRepository(db);
return new RunHarness(db, gateway, new OpenClawRunService(repository, gateway));
}
public ValueTask DisposeAsync() => Db.DisposeAsync();
}
private sealed class FakeRunGateway : IOpenClawRunGateway
{
public Queue<OpenClawRunGatewayResult> StartResults { get; } = new();
public Queue<OpenClawRunGatewayResult> StopResults { get; } = new();
public List<(Guid RunId, OpenClawInvocationMetadata Invocation)> StartCalls { get; } = [];
public List<(Guid RunId, OpenClawInvocationMetadata Invocation)> StopCalls { get; } = [];
public Task<OpenClawRunGatewayResult> StartAsync(
OpenClawRun run,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
StartCalls.Add((run.Id, invocation));
return Task.FromResult(StartResults.Count > 0
? StartResults.Dequeue()
: new OpenClawRunGatewayResult(
true,
true,
OpenClawRunStates.Running,
"started",
$"oc-{run.Id:N}"));
}
public Task<OpenClawRunGatewayResult> StopAsync(
OpenClawRun run,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
StopCalls.Add((run.Id, invocation));
return Task.FromResult(StopResults.Count > 0
? StopResults.Dequeue()
: new OpenClawRunGatewayResult(
true,
true,
OpenClawRunStates.Stopped,
"stopped",
run.OpenClawRunId));
}
public Task<OpenClawRunGatewayResult> GetHistoryAsync(
OpenClawRun run,
int limit,
CancellationToken cancellationToken = default)
=> Task.FromResult(new OpenClawRunGatewayResult(
true,
true,
"available",
"history",
run.OpenClawRunId,
new JsonObject { ["messages"] = new JsonArray() }));
}
private sealed class CapturingRunService : IOpenClawRunService
{
public OpenClawInvocationMetadata? Invocation { get; private set; }
public bool StartOk { get; set; } = true;
public Task<OpenClawRunOperationDto> StartAsync(
StartOpenClawRunRequest request,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
Invocation = invocation;
var now = DateTimeOffset.UtcNow;
var run = new OpenClawRunDto(
Guid.NewGuid(),
request.Title ?? "Run",
request.Prompt,
request.AgentId,
request.SessionKey,
StartOk ? OpenClawRunStates.Running : OpenClawRunStates.Blocked,
request.TaskId,
request.ProjectId,
"oc-run",
null,
invocation.CorrelationId,
invocation.Actor,
null,
null,
false,
StartOk,
!StartOk,
false,
"unsupported",
now,
now,
now,
null);
return Task.FromResult(new OpenClawRunOperationDto(
StartOk,
StartOk ? OpenClawRunStates.Running : OpenClawRunStates.Blocked,
StartOk ? "started" : "gateway disconnected",
run,
null,
now));
}
public Task<OpenClawRunCollectionDto> GetAsync(
OpenClawRunQuery query,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawRunDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawRunOperationDto?> StopAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawRunOperationDto?> ResumeAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawRunOperationDto?> RetryAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<OpenClawRunHistoryResponse?> GetHistoryAsync(
Guid id,
int gatewayLimit = 200,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task ReconcileAsync(
GatewayEventEnvelope gatewayEvent,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
+552
View File
@@ -0,0 +1,552 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawSetupServiceTests
{
[Fact]
public void SetupController_IsOwnerOnly()
{
var authorize = Assert.Single(
typeof(OpenClawSetupController)
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.OfType<AuthorizeAttribute>());
Assert.Equal("owner", authorize.Roles);
Assert.Empty(
typeof(OpenClawSetupController)
.GetMethods()
.SelectMany(method =>
method.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true)));
}
[Fact]
public async Task Discover_ReturnsOnlyConfiguredAndWellKnownCandidates_WithoutInvokingGateway()
{
var gateway = ConnectedGateway();
var service = CreateService(gateway: gateway);
var result = await service.DiscoverAsync(new OpenClawDiscoveryRequest(IncludeMdns: true));
Assert.Equal("unsupported", result.MdnsState);
Assert.Contains(result.Candidates, candidate =>
candidate.Endpoint == "ws://openclaw-gateway:18789/"
&& candidate.IsValid);
Assert.Contains(result.Candidates, candidate =>
candidate.Endpoint == "ws://127.0.0.1:18789/"
&& candidate.IsCurrentConnectorEndpoint);
Assert.DoesNotContain(result.Candidates, candidate =>
candidate.Endpoint.Contains("0.0.0.0", StringComparison.Ordinal));
Assert.Empty(gateway.Invocations);
}
[Theory]
[InlineData("ws://example.com:18789/", null)]
[InlineData("wss://user:secret@example.com/", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
[InlineData("wss://example.com/?token=secret", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
[InlineData("wss://example.com/", null)]
public async Task Probe_RejectsUnsafeExternalEndpoints(
string endpoint,
string? fingerprint)
{
var service = CreateService();
var result = await service.ProbeAsync(
new ProbeOpenClawRequest(endpoint, fingerprint));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.InvalidEndpoint, result.State);
Assert.Null(result.Data);
}
[Fact]
public async Task Probe_ValidExternalEndpointStaysBlockedWithoutOfficialClientIdentity()
{
var gateway = ConnectedGateway();
var service = CreateService(gateway: gateway);
var result = await service.ProbeAsync(new ProbeOpenClawRequest(
"wss://gateway.example.test/",
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.ExperimentalBlocked, result.State);
Assert.NotNull(result.Data);
Assert.Empty(gateway.Invocations);
Assert.Empty(gateway.ConfiguredEndpoints);
}
[Fact]
public async Task Probe_BlocksUnofficialClientIdentity_EvenWhenLegacyConnectorIsConnected()
{
var service = CreateService(gateway: ConnectedGateway());
var result = await service.ProbeAsync(
new ProbeOpenClawRequest("ws://127.0.0.1:18789/"));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.ExperimentalBlocked, result.State);
Assert.False(result.Data?.CanAttach);
}
[Fact]
public async Task Attach_UsesTransientBootstrapSecret_WithoutPersistingIt()
{
var repository = new FakeOpenClawConnectionProfileRepository();
var gateway = ConnectedGateway();
var service = CreateService(
repository,
gateway,
externalIdentitySupported: true);
var request = new AttachOpenClawRequest(
"ws://127.0.0.1:18789/",
"manual",
BootstrapToken: "one-time-secret");
var result = await service.AttachAsync(request);
Assert.True(result.Ok);
Assert.Equal(OpenClawSetupStates.Attached, result.State);
Assert.NotNull(repository.Current);
Assert.True(gateway.BootstrapTokenSupplied);
Assert.DoesNotContain("one-time-secret", JsonSerializer.Serialize(repository.Current), StringComparison.Ordinal);
Assert.DoesNotContain("one-time-secret", JsonSerializer.Serialize(result), StringComparison.Ordinal);
Assert.DoesNotContain("one-time-secret", request.ToString(), StringComparison.Ordinal);
}
[Fact]
public async Task Attach_PersistsOnlySecretFreeProfile_WhenSupportedReadOnlyConnectorIsReady()
{
var repository = new FakeOpenClawConnectionProfileRepository();
var service = CreateService(
repository,
ConnectedGateway(),
externalIdentitySupported: true);
var result = await service.AttachAsync(new AttachOpenClawRequest(
"ws://127.0.0.1:18789/",
"manual"));
Assert.True(result.Ok);
Assert.Equal(OpenClawSetupStates.Attached, result.State);
Assert.NotNull(repository.Current);
Assert.Equal(OpenClawAdoptionStates.Attached, repository.Current!.AdoptionState);
Assert.False(repository.Current.ManagementEnabled);
Assert.Equal(1, repository.Current.Revision);
var serialized = JsonSerializer.Serialize(repository.Current);
Assert.DoesNotContain("BootstrapToken", serialized, StringComparison.Ordinal);
}
[Fact]
public async Task Attach_RejectsLegacyAdminScopeBeforeAdoption()
{
var repository = new FakeOpenClawConnectionProfileRepository();
var gateway = ConnectedGateway();
gateway.GrantedScopes =
new HashSet<string>(["operator.read", "operator.admin"], StringComparer.Ordinal);
var service = CreateService(
repository,
gateway,
externalIdentitySupported: true);
var result = await service.AttachAsync(new AttachOpenClawRequest(
"ws://127.0.0.1:18789/",
"configured"));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.ExcessiveScope, result.State);
Assert.Null(repository.Current);
}
[Fact]
public async Task Verify_DoesNotAdvanceAttachedProfileWithAdminScope()
{
var repository = new FakeOpenClawConnectionProfileRepository
{
Current = Profile(OpenClawAdoptionStates.Attached, revision: 1)
};
var gateway = ConnectedGateway();
gateway.GrantedScopes =
new HashSet<string>(["operator.read", "operator.admin"], StringComparer.Ordinal);
var service = CreateService(repository, gateway, externalIdentitySupported: true);
var result = await service.VerifyAsync(new VerifyOpenClawRequest(1));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.ExcessiveScope, result.State);
Assert.Equal(OpenClawAdoptionStates.Attached, repository.Current?.AdoptionState);
Assert.Equal(1, repository.Current?.Revision);
}
[Fact]
public async Task Adopt_CollectsLiveInventory_AndDoesNotCopyResourcePayloads()
{
var repository = new FakeOpenClawConnectionProfileRepository
{
Current = Profile(
adoptionState: OpenClawAdoptionStates.Verified,
revision: 3)
};
var gateway = ConnectedGateway(
"agents.list",
"agents.files.list",
"cron.list",
"models.list",
"channels.status",
"nodes.list");
gateway.Handler = (method, parameters) => method switch
{
"agents.list" => JsonNode.Parse(
"""{"agents":[{"id":"iris"},{"id":"programmer"}]}"""),
"agents.files.list" => JsonNode.Parse(
"""{"files":[{"name":"AGENTS.md"},{"name":"SOUL.md"}]}"""),
"cron.list" => JsonNode.Parse(
"""{"jobs":[{},{},{},{},{},{},{}]}"""),
"models.list" => JsonNode.Parse(
"""{"models":[{},{},{}]}"""),
"channels.status" => JsonNode.Parse(
"""{"channels":{"telegram":{},"discord":{}}}"""),
"nodes.list" => JsonNode.Parse(
"""{"nodes":[{}]}"""),
_ => null
};
var service = CreateService(
repository,
gateway,
externalIdentitySupported: true);
var result = await service.AdoptAsync(new AdoptOpenClawRequest(3));
Assert.True(result.Ok);
Assert.Equal(2, result.Data?.AgentCount);
Assert.Equal(4, result.Data?.AgentFileCount);
Assert.Equal(7, result.Data?.CronJobCount);
Assert.Equal(3, result.Data?.ModelCount);
Assert.Equal(2, result.Data?.ChannelCount);
Assert.Equal(1, result.Data?.NodeCount);
Assert.Equal(OpenClawAdoptionStates.Adopted, repository.Current?.AdoptionState);
Assert.False(repository.Current?.ManagementEnabled);
Assert.Equal(4, repository.Current?.Revision);
}
[Fact]
public async Task Management_RequiresExplicitAdminScopeUpgrade()
{
var repository = new FakeOpenClawConnectionProfileRepository
{
Current = Profile(
adoptionState: OpenClawAdoptionStates.Adopted,
revision: 4)
};
var gateway = ConnectedGateway();
var service = CreateService(
repository,
gateway,
externalIdentitySupported: true);
var blocked = await service.SetManagementAsync(
new SetOpenClawManagementRequest(true, true, 4));
Assert.False(blocked.Ok);
Assert.Equal(OpenClawSetupStates.ScopeUpgradeRequired, blocked.State);
Assert.False(repository.Current?.ManagementEnabled);
Assert.Contains(
gateway.RequestedScopeSets,
scopes => scopes.SetEquals(["operator.read", "operator.admin"]));
gateway.GrantedScopes =
new HashSet<string>(["operator.read", "operator.admin"], StringComparer.Ordinal);
var enabled = await service.SetManagementAsync(
new SetOpenClawManagementRequest(true, true, 4));
Assert.True(enabled.Ok);
Assert.True(repository.Current?.ManagementEnabled);
Assert.Equal(5, repository.Current?.Revision);
}
[Fact]
public async Task ProfileMutation_RejectsStaleRevision()
{
var repository = new FakeOpenClawConnectionProfileRepository
{
Current = Profile(
adoptionState: OpenClawAdoptionStates.Adopted,
revision: 7)
};
var service = CreateService(
repository,
ConnectedGateway(),
externalIdentitySupported: true);
var result = await service.SetManagementAsync(
new SetOpenClawManagementRequest(false, true, 6));
Assert.False(result.Ok);
Assert.Equal(OpenClawSetupStates.ConcurrencyConflict, result.State);
Assert.Equal(7, repository.Current?.Revision);
}
[Fact]
public async Task Delete_RequiresExactEndpointAndDeviceConfirmation()
{
var repository = new FakeOpenClawConnectionProfileRepository
{
Current = Profile(
adoptionState: OpenClawAdoptionStates.Adopted,
revision: 2,
deviceId: "device-bao")
};
var gateway = ConnectedGateway();
var service = CreateService(repository, gateway);
var rejected = await service.DeleteAsync(
new DeleteOpenClawConnectionRequest(
"ws://localhost:18789/",
"device-bao",
2));
Assert.False(rejected.Ok);
Assert.Equal(OpenClawSetupStates.InvalidRequest, rejected.State);
Assert.NotNull(repository.Current);
var removed = await service.DeleteAsync(
new DeleteOpenClawConnectionRequest(
"ws://127.0.0.1:18789/",
"device-bao",
2));
Assert.True(removed.Ok);
Assert.Equal(OpenClawSetupStates.Removed, removed.State);
Assert.Null(repository.Current);
Assert.True(gateway.DisconnectRequested);
}
private static OpenClawSetupService CreateService(
FakeOpenClawConnectionProfileRepository? repository = null,
SetupGatewayConnector? gateway = null,
bool externalIdentitySupported = false)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Integrations:OpenClaw:BaseUrl"] = "http://127.0.0.1:18789",
["Integrations:OpenClaw:RequiredVersion"] = "2026.7.1",
["GatewayConnector:WebSocketPath"] = "/",
["OpenClawSetup:ExternalClientIdentitySupported"] =
externalIdentitySupported.ToString()
})
.Build();
return new OpenClawSetupService(
repository ?? new FakeOpenClawConnectionProfileRepository(),
gateway ?? ConnectedGateway(),
configuration);
}
private static SetupGatewayConnector ConnectedGateway(params string[] additionalMethods)
{
var methods = new HashSet<string>(additionalMethods, StringComparer.Ordinal);
return new SetupGatewayConnector
{
ConnectionState = GatewayConnectionState.Connected,
GatewayVersion = "2026.7.1",
RequiredVersion = "2026.7.1",
ProtocolVersion = 4,
DeviceId = "device-bao",
DeviceTokenConfigured = true,
ActiveEndpoint = "ws://127.0.0.1:18789/",
GrantedScopes = new HashSet<string>(["operator.read"], StringComparer.Ordinal),
AdvertisedMethods = methods
};
}
private static OpenClawConnectionProfile Profile(
string adoptionState,
int revision,
string? deviceId = "device-bao")
=> new()
{
Endpoint = "ws://127.0.0.1:18789/",
DiscoverySource = "configured",
RequiredVersion = "2026.7.1",
AdoptionState = adoptionState,
ManagementEnabled = false,
CapabilityHash = new string('a', 64),
DeviceId = deviceId,
Revision = revision,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
UpdatedAt = DateTimeOffset.UtcNow
};
}
internal sealed class FakeOpenClawConnectionProfileRepository
: IOpenClawConnectionProfileRepository
{
public OpenClawConnectionProfile? Current { get; set; }
public Task<OpenClawConnectionProfile?> GetPrimaryAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(Clone(Current));
}
public Task<OpenClawConnectionProfile> SavePrimaryAsync(
OpenClawConnectionProfile profile,
int? expectedRevision,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (Current is null)
{
if (expectedRevision is not null and not 0)
throw new OpenClawConnectionProfileConcurrencyException("Profile missing.");
Current = Clone(profile)!;
Current.Revision = 1;
}
else
{
if (expectedRevision != Current.Revision)
{
throw new OpenClawConnectionProfileConcurrencyException(
"Profile changed.",
Current.Revision);
}
Current = Clone(profile)!;
Current.Revision = expectedRevision.Value + 1;
}
Current.UpdatedAt = DateTimeOffset.UtcNow;
return Task.FromResult(Clone(Current)!);
}
public Task<bool> DeletePrimaryAsync(
int expectedRevision,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (Current is null)
return Task.FromResult(false);
if (Current.Revision != expectedRevision)
{
throw new OpenClawConnectionProfileConcurrencyException(
"Profile changed.",
Current.Revision);
}
Current = null;
return Task.FromResult(true);
}
private static OpenClawConnectionProfile? Clone(OpenClawConnectionProfile? profile)
=> profile is null
? null
: new OpenClawConnectionProfile
{
ProfileId = profile.ProfileId,
Endpoint = profile.Endpoint,
DiscoverySource = profile.DiscoverySource,
RequiredVersion = profile.RequiredVersion,
TlsCertificateFingerprint = profile.TlsCertificateFingerprint,
AdoptionState = profile.AdoptionState,
ManagementEnabled = profile.ManagementEnabled,
CapabilityHash = profile.CapabilityHash,
DeviceId = profile.DeviceId,
Revision = profile.Revision,
CreatedAt = profile.CreatedAt,
UpdatedAt = profile.UpdatedAt,
LastProbedAt = profile.LastProbedAt,
LastVerifiedAt = profile.LastVerifiedAt,
AdoptedAt = profile.AdoptedAt
};
}
internal sealed class SetupGatewayConnector : IGatewayConnector
{
public GatewayConnectionState ConnectionState { get; set; }
public string? GatewayVersion { get; set; }
public string? RequiredVersion { get; set; }
public DateTimeOffset? LastConnectedAt { get; set; } = DateTimeOffset.UtcNow;
public int ReconnectAttempts { get; set; }
public string? StatusMessage { get; set; }
public string? DeviceId { get; set; }
public bool DeviceTokenConfigured { get; set; }
public bool PairingRequired { get; set; }
public string? PairingRequestId { get; set; }
public int? ProtocolVersion { get; set; }
public IReadOnlySet<string> AdvertisedMethods { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> AdvertisedEvents { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> GrantedScopes { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public string? ActiveEndpoint { get; set; } = "ws://127.0.0.1:18789/";
public string? ActiveTlsFingerprint { get; set; }
public DateTimeOffset? LastEventAt { get; set; }
public Func<string, JsonNode?, JsonNode?>? Handler { get; set; }
public List<(string Method, JsonNode? Parameters)> Invocations { get; } = [];
public List<string> ConfiguredEndpoints { get; } = [];
public bool BootstrapTokenSupplied { get; private set; }
public List<HashSet<string>> RequestedScopeSets { get; } = [];
public bool DisconnectRequested { get; private set; }
public Task RequestOperatorScopesAsync(
IReadOnlyCollection<string> scopes,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
RequestedScopeSets.Add(scopes.ToHashSet(StringComparer.Ordinal));
return Task.CompletedTask;
}
public Task ConfigureEndpointAsync(
string endpoint,
string? tlsFingerprint,
string? bootstrapToken,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ActiveEndpoint = endpoint;
ActiveTlsFingerprint = tlsFingerprint;
ConfiguredEndpoints.Add(endpoint);
BootstrapTokenSupplied |= !string.IsNullOrWhiteSpace(bootstrapToken);
return Task.CompletedTask;
}
public Task DisconnectAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
DisconnectRequested = true;
ConnectionState = GatewayConnectionState.Disconnected;
return Task.CompletedTask;
}
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
{
cancellationToken.ThrowIfCancellationRequested();
var node = parameters switch
{
null => null,
JsonNode jsonNode => jsonNode.DeepClone(),
_ => JsonSerializer.SerializeToNode(parameters)
};
Invocations.Add((method, node));
return Task.FromResult(Handler?.Invoke(method, node));
}
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
}
+145
View File
@@ -0,0 +1,145 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawWizardServiceTests
{
private static readonly OpenClawInvocationContext Invocation =
OpenClawInvocationContext.Create(
actor: "owner-1",
idempotencyKey: "wizard-test",
correlationId: "wizard-correlation",
traceParent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
[Fact]
public async Task Start_RequiresExplicitConfirmationAndLocalManagement()
{
var gateway = ReadyGateway();
var state = new OpenClawManagementState();
var service = CreateService(gateway, state);
var unconfirmed = await service.StartAsync(
new StartOpenClawWizardRequest(Confirmed: false),
Invocation);
var disabled = await service.StartAsync(
new StartOpenClawWizardRequest(Confirmed: true),
Invocation);
Assert.Equal("confirmation_required", unconfirmed.State);
Assert.Equal("management_disabled", disabled.State);
Assert.Empty(gateway.Invocations);
}
[Fact]
public async Task Start_UsesOfficialSafeParametersAndProjectsInteractiveStep()
{
var gateway = ReadyGateway();
gateway.Handler = (method, _) => method == "wizard.start"
? JsonNode.Parse(
"""
{
"sessionId": "wizard-1",
"done": false,
"status": "running",
"step": {
"id": "mode",
"type": "select",
"title": "Choose mode",
"options": [
{ "value": "safe", "label": "Safe", "hint": "Recommended" }
]
}
}
""")
: null;
var state = new OpenClawManagementState();
state.SetEnabled(true);
var service = CreateService(gateway, state);
var result = await service.StartAsync(
new StartOpenClawWizardRequest("remote", Confirmed: true),
Invocation);
Assert.True(result.Ok);
Assert.Equal("wizard-1", result.SessionId);
Assert.Equal("select", result.Step?.Type);
var invocation = Assert.Single(gateway.Invocations);
Assert.Equal("wizard.start", invocation.Method);
Assert.Equal("remote", invocation.Parameters?["mode"]?.GetValue<string>());
Assert.False(invocation.Parameters?["installDaemon"]?.GetValue<bool>());
Assert.Equal("setup", invocation.Parameters?["flow"]?.GetValue<string>());
}
[Fact]
public async Task SensitiveStep_IsRedactedAndCannotBeAnsweredFromBrowser()
{
var gateway = ReadyGateway();
gateway.Handler = (method, _) => method == "wizard.start"
? JsonNode.Parse(
"""
{
"sessionId": "wizard-secret",
"done": false,
"step": {
"id": "provider-token",
"type": "text",
"title": "Provider token",
"sensitive": true,
"initialValue": "must-not-leak",
"placeholder": "must-not-leak"
}
}
""")
: null;
var state = new OpenClawManagementState();
state.SetEnabled(true);
var service = CreateService(gateway, state);
var started = await service.StartAsync(
new StartOpenClawWizardRequest(Confirmed: true),
Invocation);
var advanced = await service.NextAsync(
new AdvanceOpenClawWizardRequest(
"wizard-secret",
"provider-token",
JsonValue.Create("browser-secret"),
HasAnswer: true),
Invocation);
Assert.True(started.Step?.Sensitive);
Assert.Null(started.Step?.InitialValue);
Assert.Null(started.Step?.Placeholder);
Assert.False(started.Step?.CanAnswer);
Assert.Equal("server_secret_required", advanced.State);
Assert.Single(gateway.Invocations);
Assert.DoesNotContain("must-not-leak", started.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain("browser-secret", advanced.ToString(), StringComparison.Ordinal);
}
private static OpenClawWizardService CreateService(
SetupGatewayConnector gateway,
IOpenClawManagementState managementState)
=> new(
gateway,
NullLogger<OpenClawWizardService>.Instance,
managementState);
private static SetupGatewayConnector ReadyGateway()
{
var gateway = new SetupGatewayConnector
{
ConnectionState = GatewayConnectionState.Connected,
GrantedScopes = new HashSet<string>(
["operator.read", "operator.admin"],
StringComparer.Ordinal),
AdvertisedMethods = new HashSet<string>(
["wizard.start", "wizard.next", "wizard.status", "wizard.cancel"],
StringComparer.Ordinal)
};
return gateway;
}
}
+159
View File
@@ -0,0 +1,159 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OpenClawWriteGateTests
{
[Fact]
public async Task Adopted_primary_profile_allows_matching_write_boundary()
{
var connector = ConnectedConnector();
await using var fixture = await GateFixture.CreateAsync(connector);
var result = await fixture.Gate.EvaluateAsync("agents.create");
Assert.True(result.Allowed);
}
[Fact]
public async Task Endpoint_change_after_adoption_blocks_write()
{
var connector = ConnectedConnector();
await using var fixture = await GateFixture.CreateAsync(connector);
connector.ActiveEndpoint = "wss://other-openclaw.example.test:18789/";
var result = await fixture.Gate.EvaluateAsync("agents.create");
Assert.False(result.Allowed);
Assert.Equal("endpoint_trust_mismatch", result.State);
}
[Fact]
public async Task Device_change_after_adoption_blocks_write()
{
var connector = ConnectedConnector();
await using var fixture = await GateFixture.CreateAsync(connector);
connector.DeviceId = "unexpected-device";
var result = await fixture.Gate.EvaluateAsync("agents.create");
Assert.False(result.Allowed);
Assert.Equal("device_trust_mismatch", result.State);
}
[Fact]
public async Task Wss_profile_without_tls_fingerprint_blocks_write()
{
var connector = ConnectedConnector();
connector.ActiveTlsFingerprint = null;
await using var fixture = await GateFixture.CreateAsync(connector);
var result = await fixture.Gate.EvaluateAsync("agents.create");
Assert.False(result.Allowed);
Assert.Equal("tls_trust_missing", result.State);
}
[Fact]
public async Task Internal_ws_profile_without_tls_fingerprint_can_write()
{
var connector = ConnectedConnector();
connector.ActiveEndpoint = "ws://openclaw-gateway:18789/";
connector.ActiveTlsFingerprint = null;
await using var fixture = await GateFixture.CreateAsync(connector);
var result = await fixture.Gate.EvaluateAsync("agents.create");
Assert.True(result.Allowed);
}
private static StubOpenClawConnector ConnectedConnector()
=> new()
{
ConnectionState = GatewayConnectionState.Connected,
GatewayVersion = "2026.8.0",
RequiredVersion = "2026.8.0",
ProtocolVersion = 4,
ActiveEndpoint = "wss://openclaw.example.test:18789/",
ActiveTlsFingerprint = new string('A', 64),
DeviceId = "nexus-device",
GrantedScopes = new HashSet<string>(
["operator.read", "operator.admin"],
StringComparer.Ordinal),
AdvertisedMethods = new HashSet<string>(
["agents.create"],
StringComparer.Ordinal),
AdvertisedEvents = new HashSet<string>(
["agent.updated"],
StringComparer.Ordinal)
};
private sealed class GateFixture(
ServiceProvider provider,
OpenClawWriteGate gate) : IAsyncDisposable
{
public OpenClawWriteGate Gate { get; } = gate;
public static async Task<GateFixture> CreateAsync(
StubOpenClawConnector connector)
{
var services = new ServiceCollection();
var databaseName = $"write-gate-{Guid.NewGuid():N}";
services.AddDbContext<NexusDbContext>(options =>
options.UseInMemoryDatabase(databaseName));
var provider = services.BuildServiceProvider();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["OpenClawSetup:ExternalClientIdentitySupported"] = "true"
})
.Build();
var options = Options.Create(new GatewayConnectorOptions
{
ClientId = "nexus",
ClientMode = "backend",
ExternalClientIdentitySupported = true,
AllowReservedInternalClientIdentity = false
});
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider
.GetRequiredService<NexusDbContext>();
db.OpenClawConnectionProfiles.Add(
new OpenClawConnectionProfile
{
Endpoint = connector.ActiveEndpoint!,
DiscoverySource = "test",
RequiredVersion = connector.GatewayVersion,
TlsCertificateFingerprint =
connector.ActiveTlsFingerprint,
AdoptionState = OpenClawAdoptionStates.Adopted,
ManagementEnabled = true,
CapabilityHash =
OpenClawWriteGate.BuildCapabilityHash(connector),
DeviceId = connector.DeviceId,
Revision = 1
});
await db.SaveChangesAsync();
}
return new GateFixture(
provider,
new OpenClawWriteGate(
provider.GetRequiredService<IServiceScopeFactory>(),
connector,
options,
configuration));
}
public ValueTask DisposeAsync() => provider.DisposeAsync();
}
}
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Http;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class OperationResultContractTests
{
[Fact]
public void FromHttpContext_UsesSafeCorrelationAndDeduplicatesAffectedRefs()
{
var context = new DefaultHttpContext
{
TraceIdentifier = "request-trace-1"
};
context.Request.Headers["X-Correlation-ID"] = "operation-42";
var primary = new EntityRefDto("task", "task-1", "Primary task");
var result = OperationResultFactory.FromHttpContext(
context,
"updated",
primary,
revision: 7,
affectedRefs:
[
primary,
new EntityRefDto("agent", "iris", "Iris"),
new EntityRefDto("agent", "iris", "Duplicate")
]);
Assert.Equal("operation-42", result.OperationId);
Assert.Equal("operation-42", context.Response.Headers["X-Correlation-ID"]);
Assert.Equal(7, result.Revision);
var affected = Assert.Single(result.AffectedRefs);
Assert.Equal("agent", affected.Type);
Assert.Equal("iris", affected.Id);
}
[Fact]
public void FromHttpContext_RejectsOversizedCallerCorrelation()
{
var context = new DefaultHttpContext
{
TraceIdentifier = "server-trace"
};
context.Request.Headers["X-Correlation-ID"] = new string('x', 129);
var result = OperationResultFactory.FromHttpContext(
context,
"completed",
new EntityRefDto("project", "project-1"));
Assert.Equal("server-trace", result.OperationId);
Assert.Equal("server-trace", context.Response.Headers["X-Correlation-ID"]);
}
}
+15
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
@@ -80,6 +81,10 @@ internal sealed class GuardedProjectRepository(RepositoryConcurrencyGuard guard)
public Task UpdateAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException();
public Task<List<WorkTask>> GetTasksAsync(
Guid projectId,
CancellationToken ct = default)
=> throw new NotSupportedException();
}
internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository
@@ -101,6 +106,16 @@ internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) :
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<TaskBoardQueryPage> GetBoardPageAsync(
int doneLimit,
DateTimeOffset? doneBeforeUpdatedAt,
Guid? doneBeforeId,
CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<TaskBoardCardDto?> GetBoardCardAsync(
Guid id,
CancellationToken ct = default)
=> throw new NotSupportedException();
}
internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository
@@ -0,0 +1,354 @@
using System.Text.Json.Nodes;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Npgsql;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Testcontainers.PostgreSql;
using Xunit;
namespace Nexus.Api.Tests;
[Collection(DockerIntegrationTestEnvironment.CollectionName)]
public sealed class PostgreSqlAgentProvisioningIntegrationTests
{
private const string Category = "DockerIntegration";
[PostgreSqlIntegrationFact]
[Trait("Category", Category)]
public async Task Migrations_create_postgres_17_schema_and_required_indexes()
{
await using var postgres = BuildPostgreSql();
await postgres.StartAsync();
await using var db = CreateDatabase(postgres.GetConnectionString());
await db.Database.MigrateAsync();
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
Assert.Contains(
"20260730224500_AddAgentProvisioningAndBoardIndexes",
await db.Database.GetAppliedMigrationsAsync());
await using var connection = new NpgsqlConnection(
postgres.GetConnectionString());
await connection.OpenAsync();
await using var versionCommand = new NpgsqlCommand(
"SELECT current_setting('server_version_num')::integer",
connection);
var version = Convert.ToInt32(await versionCommand.ExecuteScalarAsync());
Assert.InRange(version, 170000, 179999);
await using var tableCommand = new NpgsqlCommand(
"""
SELECT count(*)
FROM pg_class
WHERE relnamespace = 'public'::regnamespace
AND relkind = 'r'
AND relname IN (
'AgentProposals',
'AgentProvisionRequests',
'OperationClaims',
'OutboxEvents')
""",
connection);
Assert.Equal(4L, Convert.ToInt64(await tableCommand.ExecuteScalarAsync()));
await using var indexCommand = new NpgsqlCommand(
"""
SELECT count(*)
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname IN (
'IX_AgentProvisionRequests_Status_CreatedAt',
'IX_OperationClaims_Operation_IdempotencyKeyHash',
'IX_OutboxEvents_PublishedAt_Sequence',
'IX_Tasks_State_UpdatedAt_Id_Board',
'IX_Tasks_Done_UpdatedAt_Id',
'IX_Tasks_ParentTaskId_State',
'IX_Activity_TaskId_CreatedAt_Id')
""",
connection);
Assert.Equal(7L, Convert.ToInt64(await indexCommand.ExecuteScalarAsync()));
}
[PostgreSqlIntegrationFact]
[Trait("Category", Category)]
public async Task Concurrent_proposal_creates_share_one_claim_and_one_outbox_event()
{
await using var postgres = BuildPostgreSql();
await postgres.StartAsync();
var connectionString = postgres.GetConnectionString();
var gateway = new ConcurrentApprovalGatewayConnector(
synchronizeAgentLists: false);
await MigrateAndSeedManagementAsync(connectionString, gateway);
const string idempotencyKey = "postgres-concurrent-proposal";
var operations = Enumerable.Range(0, 8)
.Select(_ => CreateProposalAsync(
connectionString,
gateway,
idempotencyKey))
.ToArray();
var results = await Task.WhenAll(operations);
Assert.All(results, result => Assert.True(result.Ok));
Assert.Single(results.Select(result => result.Proposal!.Id).Distinct());
Assert.Contains(
results,
result => result.State == AgentProposalStates.AwaitingApproval);
Assert.Contains(results, result => result.State == "idempotent_replay");
await using var verification = CreateDatabase(connectionString);
var proposal = await verification.AgentProposals.SingleAsync();
var claim = await verification.OperationClaims.SingleAsync();
var outbox = await verification.OutboxEvents.SingleAsync();
Assert.Equal(proposal.Id, claim.ResourceId);
Assert.Equal("agent-proposal.create", claim.Operation);
Assert.Equal("completed", claim.State);
Assert.Equal(proposal.Id.ToString("N"), outbox.AggregateId);
Assert.Equal("agent.proposal.created", outbox.Type);
Assert.DoesNotContain(
idempotencyKey,
claim.IdempotencyKeyHash,
StringComparison.Ordinal);
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var jsonTypeCommand = new NpgsqlCommand(
"""SELECT pg_typeof("PayloadJson")::text FROM "OutboxEvents" LIMIT 1""",
connection);
Assert.Equal("jsonb", await jsonTypeCommand.ExecuteScalarAsync());
}
[PostgreSqlIntegrationFact]
[Trait("Category", Category)]
public async Task Concurrent_owner_approvals_queue_exactly_one_provision_request()
{
await using var postgres = BuildPostgreSql();
await postgres.StartAsync();
var connectionString = postgres.GetConnectionString();
var gateway = new ConcurrentApprovalGatewayConnector(
synchronizeAgentLists: true);
await MigrateAndSeedManagementAsync(connectionString, gateway);
AgentProposalOperationDto created;
await using (var creationDb = CreateDatabase(connectionString))
{
created = await CreateService(creationDb, gateway).CreateAsync(
Proposal(),
"manual",
Invocation("postgres-approval-proposal"));
}
var revision = created.Proposal!.Revision;
var approvals = await Task.WhenAll(
ApproveAsync(
connectionString,
gateway,
created.Proposal.Id,
revision,
"postgres-approval-a"),
ApproveAsync(
connectionString,
gateway,
created.Proposal.Id,
revision,
"postgres-approval-b"));
var succeeded = Assert.Single(approvals, result => result.Ok);
var conflicted = Assert.Single(approvals, result => !result.Ok);
Assert.Equal(AgentProposalStates.Provisioning, succeeded.State);
Assert.Equal("concurrency_conflict", conflicted.State);
await using var verification = CreateDatabase(connectionString);
var proposal = await verification.AgentProposals.SingleAsync();
var request = await verification.AgentProvisionRequests.SingleAsync();
var claims = await verification.OperationClaims
.OrderBy(item => item.CreatedAt)
.ToArrayAsync();
var events = await verification.OutboxEvents
.OrderBy(item => item.Sequence)
.ToArrayAsync();
Assert.Equal(AgentProposalStates.Provisioning, proposal.Status);
Assert.Equal(AgentProvisionRequestStates.Queued, request.Status);
Assert.Equal(1, request.Attempt);
Assert.Equal(3, claims.Length);
Assert.Single(
claims,
item => item.Operation == "agent-proposal.approve"
&& item.State == "completed");
Assert.Single(
claims,
item => item.Operation == "agent-proposal.approve"
&& item.ResultCode == "concurrency_conflict");
Assert.Single(
events,
item => item.Type == "agent.provision.requested");
}
private static PostgreSqlContainer BuildPostgreSql()
=> new PostgreSqlBuilder("postgres:17-alpine")
.WithDatabase("nexus_integration")
.WithUsername("nexus_test")
.WithPassword("nexus_test_password")
.Build();
private static NexusDbContext CreateDatabase(string connectionString)
=> new(new DbContextOptionsBuilder<NexusDbContext>()
.UseNpgsql(connectionString)
.EnableDetailedErrors()
.Options);
private static async Task MigrateAndSeedManagementAsync(
string connectionString,
IGatewayConnector gateway)
{
await using var db = CreateDatabase(connectionString);
await db.Database.MigrateAsync();
db.OpenClawConnectionProfiles.Add(new OpenClawConnectionProfile
{
Endpoint = "ws://127.0.0.1:18789/",
DiscoverySource = "testcontainers",
RequiredVersion = gateway.RequiredVersion,
AdoptionState = OpenClawAdoptionStates.Adopted,
ManagementEnabled = true,
CapabilityHash = AgentProposalService.BuildCapabilityHash(gateway),
Revision = 1
});
await db.SaveChangesAsync();
}
private static async Task<AgentProposalOperationDto> CreateProposalAsync(
string connectionString,
IGatewayConnector gateway,
string idempotencyKey)
{
await using var db = CreateDatabase(connectionString);
return await CreateService(db, gateway).CreateAsync(
Proposal(),
"manual",
Invocation(idempotencyKey));
}
private static async Task<AgentProposalOperationDto> ApproveAsync(
string connectionString,
IGatewayConnector gateway,
Guid proposalId,
int expectedRevision,
string idempotencyKey)
{
await using var db = CreateDatabase(connectionString);
return await CreateService(db, gateway).ApproveAsync(
proposalId,
new AgentProposalActionRequest(expectedRevision),
Invocation(idempotencyKey));
}
internal static AgentProposalService CreateService(
NexusDbContext db,
IGatewayConnector gateway,
IOpenClawAgentConfigurationService? agentFiles = null)
{
var management = new OpenClawManagementState();
management.SetEnabled(true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["OpenClawSetup:ExternalClientIdentitySupported"] = "true"
})
.Build();
return new AgentProposalService(
db,
gateway,
agentFiles ?? new ProposalAgentConfigurationService(),
new StubOpenClawWriteGate(),
Options.Create(new AgentProvisioningOptions()),
new AgentProvisioningSignal(),
NullLogger<AgentProposalService>.Instance);
}
internal static CreateAgentProposalRequest Proposal()
=> new(
"Release Analyst",
Role: "Release quality",
Description: "Verify releases and report evidence.",
Model: "openai/gpt-5.5",
ClientRequestId: "postgres-proposal");
internal static OpenClawInvocationMetadata Invocation(string key)
=> new(key, $"correlation-{key}", "bao", null);
}
internal sealed class ConcurrentApprovalGatewayConnector(
bool synchronizeAgentLists) : IGatewayConnector
{
private readonly TaskCompletionSource<bool> bothInventoryReads =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private int inventoryReads;
public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected;
public string? GatewayVersion => "2026.7.1";
public string? RequiredVersion => "2026.7.1";
public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow;
public int ReconnectAttempts => 0;
public string? StatusMessage => "ready";
public string? DeviceId => "testcontainers-device";
public bool DeviceTokenConfigured => true;
public bool PairingRequired => false;
public string? PairingRequestId => null;
public int? ProtocolVersion => 4;
public IReadOnlySet<string> AdvertisedMethods { get; } =
new HashSet<string>(
[
"agents.list",
"agents.create",
"agents.files.get",
"agents.files.set",
"config.get"
],
StringComparer.Ordinal);
public IReadOnlySet<string> AdvertisedEvents { get; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> GrantedScopes { get; } =
new HashSet<string>(
["operator.read", "operator.admin"],
StringComparer.Ordinal);
public DateTimeOffset? LastEventAt => null;
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public async Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
{
if (method != "agents.list")
{
throw new OpenClawGatewayRpcException(
"METHOD_NOT_FOUND",
$"Unexpected integration-test method {method}.");
}
if (synchronizeAgentLists)
{
if (Interlocked.Increment(ref inventoryReads) == 2)
bothInventoryReads.TrySetResult(true);
await bothInventoryReads.Task.WaitAsync(
TimeSpan.FromSeconds(15),
cancellationToken);
}
return new JsonObject { ["agents"] = new JsonArray() };
}
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100)
=> [];
}
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Nexus.Api.Data;
using Nexus.Api.Services;
using Testcontainers.PostgreSql;
using Xunit;
namespace Nexus.Api.Tests;
[Collection(DockerIntegrationTestEnvironment.CollectionName)]
public sealed class PostgresOpenClawOperationAuditStoreIntegrationTests
{
[PostgreSqlIntegrationFact]
[Trait("Category", "DockerIntegration")]
public async Task Concurrent_store_instances_create_one_claim_and_one_in_doubt_result()
{
await using var postgres = new PostgreSqlBuilder("postgres:17-alpine")
.WithDatabase("nexus_claims")
.WithUsername("nexus_test")
.WithPassword("nexus_test_password")
.Build();
await postgres.StartAsync();
var connectionString = postgres.GetConnectionString();
await using (var db = CreateDatabase(connectionString))
await db.Database.MigrateAsync();
await using var firstProvider = CreateProvider(connectionString);
await using var secondProvider = CreateProvider(connectionString);
var first = CreateStore(firstProvider);
var second = CreateStore(secondProvider);
var context = OpenClawInvocationContext.Create(
"owner",
"postgres-race-key",
"postgres-race-correlation");
var operation = new OpenClawOperationDescriptor(
"cron.run",
"cron-job",
"job-1",
OpenClawInvocationContextFactory.Hash("same-intent"));
var results = await Task.WhenAll(
first.ClaimAsync(context, operation),
second.ClaimAsync(context, operation));
Assert.Single(
results,
item => item.Disposition ==
OpenClawOperationClaimDisposition.Started);
Assert.Single(
results,
item => item.Disposition ==
OpenClawOperationClaimDisposition.InDoubt);
await using var verification = CreateDatabase(connectionString);
Assert.Single(await verification.OperationClaims.ToArrayAsync());
}
private static NexusDbContext CreateDatabase(string connectionString)
=> new(new DbContextOptionsBuilder<NexusDbContext>()
.UseNpgsql(connectionString)
.Options);
private static ServiceProvider CreateProvider(string connectionString)
{
var services = new ServiceCollection();
services.AddDbContext<NexusDbContext>(options =>
options.UseNpgsql(connectionString));
return services.BuildServiceProvider();
}
private static PostgresOpenClawOperationAuditStore CreateStore(
ServiceProvider provider)
=> new(
provider.GetRequiredService<IServiceScopeFactory>(),
Options.Create(new GatewayConnectorOptions()));
}
@@ -0,0 +1,175 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Nexus.Api.Data;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class PostgresOpenClawOperationAuditStoreTests
{
[Fact]
public async Task Completed_claim_replays_from_database_without_writing_jsonl()
{
await using var fixture = Fixture.Create();
var context = Context("database-replay");
var operation = Operation("cron.run", "job-1", "intent-a");
var started = await fixture.Store.ClaimAsync(context, operation);
await fixture.Store.CompleteAsync(
context,
operation,
true,
"enqueued",
"This message is deliberately not persisted.");
var replay = await fixture.Store.ClaimAsync(context, operation);
Assert.Equal(OpenClawOperationClaimDisposition.Started, started.Disposition);
Assert.Equal(OpenClawOperationClaimDisposition.Replayed, replay.Disposition);
Assert.True(replay.PreviousOk);
Assert.Equal("enqueued", replay.PreviousState);
Assert.False(File.Exists(fixture.AuditPath));
await using var scope = fixture.Provider.CreateAsyncScope();
var claim = await scope.ServiceProvider
.GetRequiredService<NexusDbContext>()
.OperationClaims
.SingleAsync();
Assert.Equal("openclaw.mutation", claim.Operation);
Assert.Equal("completed", claim.State);
Assert.Equal("enqueued", claim.ResultCode);
Assert.DoesNotContain("database-replay", claim.IdempotencyKeyHash);
Assert.DoesNotContain("This message", claim.ResultCode);
}
[Fact]
public async Task Same_key_with_different_intent_conflicts()
{
await using var fixture = Fixture.Create();
var context = Context("conflict-key");
await fixture.Store.ClaimAsync(
context,
Operation("cron.run", "job-1", "intent-a"));
var conflict = await fixture.Store.ClaimAsync(
context,
Operation("cron.run", "job-2", "intent-b"));
Assert.Equal(
OpenClawOperationClaimDisposition.Conflict,
conflict.Disposition);
}
[Fact]
public async Task Expired_incomplete_claim_remains_in_doubt()
{
await using var fixture = Fixture.Create();
var context = Context("in-doubt-key");
var operation = Operation("agents.files.set", "agent/SOUL.md", "intent");
await fixture.Store.ClaimAsync(context, operation);
await fixture.ExpireClaimAsync();
var replay = await fixture.Store.ClaimAsync(context, operation);
Assert.Equal(
OpenClawOperationClaimDisposition.InDoubt,
replay.Disposition);
}
[Fact]
public async Task Expired_terminal_claim_can_be_reclaimed()
{
await using var fixture = Fixture.Create();
var context = Context("expired-terminal-key");
var operation = Operation("config.patch", "primary", "intent");
await fixture.Store.ClaimAsync(context, operation);
await fixture.Store.CompleteAsync(
context,
operation,
false,
"conflict",
"Not persisted.");
await fixture.ExpireClaimAsync();
var reclaimed = await fixture.Store.ClaimAsync(context, operation);
Assert.Equal(
OpenClawOperationClaimDisposition.Started,
reclaimed.Disposition);
await using var scope = fixture.Provider.CreateAsyncScope();
Assert.Single(await scope.ServiceProvider
.GetRequiredService<NexusDbContext>()
.OperationClaims
.ToArrayAsync());
}
[Fact]
public async Task Database_failure_is_not_treated_as_a_started_claim()
{
var fixture = Fixture.Create();
var store = fixture.Store;
await fixture.DisposeAsync();
await Assert.ThrowsAnyAsync<ObjectDisposedException>(() =>
store.ClaimAsync(
Context("database-down"),
Operation("cron.run", "job-1", "intent")));
}
private static OpenClawInvocationContext Context(string key)
=> OpenClawInvocationContext.Create(
actor: "owner",
idempotencyKey: key,
correlationId: $"correlation-{key}");
private static OpenClawOperationDescriptor Operation(
string method,
string targetId,
string intent)
=> new(
method,
"test-resource",
targetId,
OpenClawInvocationContextFactory.Hash(intent));
private sealed class Fixture(
ServiceProvider provider,
PostgresOpenClawOperationAuditStore store,
string auditPath) : IAsyncDisposable
{
public ServiceProvider Provider { get; } = provider;
public PostgresOpenClawOperationAuditStore Store { get; } = store;
public string AuditPath { get; } = auditPath;
public static Fixture Create()
{
var services = new ServiceCollection();
var databaseName = $"operation-claims-{Guid.NewGuid():N}";
services.AddDbContext<NexusDbContext>(options =>
options.UseInMemoryDatabase(databaseName));
var provider = services.BuildServiceProvider();
var auditPath = Path.Combine(
Path.GetTempPath(),
$"nexus-operation-archive-{Guid.NewGuid():N}.jsonl");
var store = new PostgresOpenClawOperationAuditStore(
provider.GetRequiredService<IServiceScopeFactory>(),
Options.Create(new GatewayConnectorOptions
{
OperationAuditPath = auditPath
}));
return new Fixture(provider, store, auditPath);
}
public async Task ExpireClaimAsync()
{
await using var scope = Provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
var claim = await db.OperationClaims.SingleAsync();
claim.ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1);
await db.SaveChangesAsync();
}
public ValueTask DisposeAsync() => Provider.DisposeAsync();
}
}
+77
View File
@@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class ProjectRelationsTests
{
[Fact]
public async Task GetTasks_ProjectsExistingAgentCorrelationFields()
{
var project = new Project
{
Id = Guid.NewGuid(),
Name = "Release readiness"
};
var task = new WorkTask
{
Id = Guid.NewGuid(),
Title = "Verify release",
State = "In progress",
Priority = "High",
ProjectId = project.Id,
AssignedTo = "iris",
ExpectedFrom = "iris",
IsAgentTask = true
};
var controller = new ProjectsController(
new ProjectRelationsService(project, task));
var response = await controller.GetTasks(project.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(response.Result);
var items = Assert.IsAssignableFrom<IReadOnlyList<ProjectTaskDto>>(ok.Value);
var projected = Assert.Single(items);
Assert.Equal(project.Id, projected.ProjectId);
Assert.Equal("iris", projected.AssignedTo);
Assert.Equal("iris", projected.ExpectedFrom);
Assert.True(projected.IsAgentTask);
}
private sealed class ProjectRelationsService(Project project, WorkTask task)
: IProjectService
{
public Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Project>>([project]);
public Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> Task.FromResult<Project?>(id == project.Id ? project : null);
public Task<IReadOnlyList<WorkTask>> GetTasksAsync(
Guid id,
CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WorkTask>>(
id == project.Id ? [task] : []);
public Task<Project> CreateAsync(
CreateProjectRequest request,
CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<Project?> UpdateAsync(
Guid id,
UpdateProjectRequest request,
CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<ProjectDeleteResult> DeleteAsync(
Guid id,
CancellationToken ct = default)
=> throw new NotSupportedException();
}
}
+32
View File
@@ -0,0 +1,32 @@
# Backend tests
The normal test command remains Docker-free:
```powershell
dotnet test Nexus.Api.Tests.csproj --configuration Release
```
Docker-backed tests are discovered but skipped unless explicitly enabled.
They use `postgres:17-alpine`; the optional network-fault contract additionally
uses Testcontainers Toxiproxy and a pinned lightweight OpenClaw HTTP stub.
```powershell
$env:NEXUS_RUN_DOCKER_INTEGRATION_TESTS = "true"
dotnet test Nexus.Api.Tests.csproj --configuration Release `
--filter "Category=DockerIntegration"
```
Enable the slower Toxiproxy case as well:
```powershell
$env:NEXUS_RUN_DOCKER_INTEGRATION_TESTS = "true"
$env:NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS = "true"
dotnet test Nexus.Api.Tests.csproj --configuration Release `
--filter "Category=DockerIntegration"
```
Once opted in, an unavailable Docker daemon is a test failure rather than a
silent skip. In Gitea Actions, set the repository variable
`NEXUS_RUN_DOCKER_INTEGRATION_TESTS=true`; optionally set
`NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS=true` for the timeout contract. The
selected runner must expose a working Docker endpoint to Testcontainers.
+260
View File
@@ -0,0 +1,260 @@
using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Nexus.Api.Controllers;
using Nexus.Api.Extensions;
using Nexus.Api.Middleware;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class SecurityBoundaryTests
{
[Fact]
public void Authorization_UsesAuthenticatedFallbackPolicy()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Key"] = new string('k', 48),
["Jwt:Issuer"] = "nexus-test",
["Jwt:Audience"] = "nexus-test"
})
.Build();
var services = new ServiceCollection();
services.AddNexusAuth(configuration);
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<AuthorizationOptions>>().Value;
Assert.NotNull(options.FallbackPolicy);
Assert.Contains(
options.FallbackPolicy!.Requirements,
requirement => requirement is DenyAnonymousAuthorizationRequirement);
}
[Fact]
public void DomainControllers_DoNotOptOutOfAuthentication()
{
Type[] domainControllers =
[
typeof(ActivityController),
typeof(AgentsController),
typeof(CalendarController),
typeof(DocsController),
typeof(GatewayBridgeController),
typeof(IncidentsController),
typeof(MemoryController),
typeof(RoutingController),
typeof(TeamController)
];
foreach (var controller in domainControllers)
{
Assert.Empty(controller.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
Assert.DoesNotContain(
controller.GetMethods(),
method => method.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true).Length > 0);
}
}
[Fact]
public void TaskAutomationEndpoints_DoNotOptOutOfAuthentication()
{
var board = typeof(TasksController).GetMethod(nameof(TasksController.GetBoard));
var reset = typeof(TasksController).GetMethod(nameof(TasksController.ResetStale));
Assert.NotNull(board);
Assert.NotNull(reset);
Assert.Empty(board!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
Assert.Empty(reset!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
}
[Fact]
public void AgentCommand_RequiresOwnerRole()
{
var command = typeof(AgentsController).GetMethod(nameof(AgentsController.SendCommand));
Assert.NotNull(command);
var authorize = Assert.Single(
command!.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.OfType<AuthorizeAttribute>());
Assert.Equal("owner", authorize.Roles);
}
[Theory]
[InlineData(nameof(AuthController.GetCsrfToken))]
[InlineData(nameof(AuthController.Login))]
[InlineData(nameof(AuthController.Refresh))]
[InlineData(nameof(AuthController.Logout))]
[InlineData(nameof(AuthController.AdminResetPassword))]
public void PublicAuthBootstrapEndpoints_AreExplicitlyAnonymous(string methodName)
{
var method = typeof(AuthController).GetMethod(methodName);
Assert.NotNull(method);
Assert.NotEmpty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
}
[Theory]
[InlineData(nameof(HealthController.Live))]
[InlineData(nameof(HealthController.Get))]
public void PublicHealthEndpoints_AreExplicitlyAnonymous(string methodName)
{
var method = typeof(HealthController).GetMethod(methodName);
Assert.NotNull(method);
Assert.NotEmpty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
}
[Theory]
[InlineData(nameof(AuthController.GetMe))]
[InlineData(nameof(AuthController.UpdateProfile))]
[InlineData(nameof(AuthController.ChangePassword))]
public void AccountEndpoints_InheritAuthenticatedFallback(string methodName)
{
var method = typeof(AuthController).GetMethod(methodName);
Assert.NotNull(method);
Assert.Empty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
}
[Fact]
public async Task AgentIdentityHeader_IsRejectedWithoutVerifiedAuthentication()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var context = TaskWorkflowFixture.CreateHttpContext(agentId: "iris");
var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
context,
fixture.AgentService,
fixture.Configuration,
CancellationToken.None);
Assert.Null(resolution.AgentId);
Assert.True(resolution.HeaderProvided);
Assert.True(resolution.IsRecognized);
Assert.False(resolution.CredentialVerified);
Assert.False(resolution.IdentityHintAuthorized);
}
[Fact]
public async Task AgentIdentityHeader_IsAcceptedAsHintAfterVerifiedServiceKey()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var context = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "iris",
["X-Nexus-Api-Key"] = "test-service-key"
});
var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
context,
fixture.AgentService,
fixture.Configuration,
CancellationToken.None);
Assert.Equal("iris", resolution.AgentId);
Assert.True(resolution.CredentialVerified);
Assert.True(resolution.IdentityHintAuthorized);
}
[Fact]
public async Task AgentIdentityHeader_CannotEscalateAnOrdinaryJwtUser()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var context = TaskWorkflowFixture.CreateHttpContext(
agentId: "iris",
user: TaskWorkflowFixture.CreateUser("ordinary-user", "user"));
var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
context,
fixture.AgentService,
fixture.Configuration,
CancellationToken.None);
Assert.Null(resolution.AgentId);
Assert.True(resolution.CredentialVerified);
Assert.False(resolution.IdentityHintAuthorized);
}
[Fact]
public async Task ApiKeyMiddleware_AuthenticatesMcpRequests()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["NexusApiKey"] = "service-secret"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
using var provider = services.BuildServiceProvider();
var context = new DefaultHttpContext { RequestServices = provider };
context.Request.Path = "/mcp";
context.Request.Headers["X-Nexus-Api-Key"] = "service-secret";
var nextWasCalled = false;
var middleware = new ApiKeyMiddleware(nextContext =>
{
nextWasCalled = true;
Assert.True(nextContext.User.Identity?.IsAuthenticated);
Assert.True(nextContext.User.IsInRole("Service"));
return Task.CompletedTask;
});
await middleware.InvokeAsync(context);
Assert.True(nextWasCalled);
}
[Fact]
public async Task ApiKeyMiddleware_DoesNotAuthenticateMcpFromAgentHeaderAlone()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["NexusApiKey"] = "service-secret"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
using var provider = services.BuildServiceProvider();
var context = new DefaultHttpContext { RequestServices = provider };
context.Request.Path = "/mcp";
context.Request.Headers["X-Agent-Id"] = "iris";
var middleware = new ApiKeyMiddleware(nextContext =>
{
Assert.False(nextContext.User.Identity?.IsAuthenticated);
return Task.CompletedTask;
});
await middleware.InvokeAsync(context);
}
[Fact]
public void ForwardedHeaders_TrustOnlyDefaultsAndConfiguredProxyRanges()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ForwardedHeaders:ForwardLimit"] = "2",
["ForwardedHeaders:KnownProxies:0"] = "10.10.0.12",
["ForwardedHeaders:KnownNetworks:0"] = "10.20.0.0/24"
})
.Build();
var services = new ServiceCollection();
services.AddNexusForwardedHeaders(configuration);
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<ForwardedHeadersOptions>>().Value;
Assert.Equal(2, options.ForwardLimit);
Assert.Contains(IPAddress.Parse("10.10.0.12"), options.KnownProxies);
Assert.Contains(options.KnownIPNetworks, network => network.ToString() == "10.20.0.0/24");
Assert.DoesNotContain(options.KnownIPNetworks, network => network.ToString() == "0.0.0.0/0");
}
}
+12
View File
@@ -278,6 +278,18 @@ file sealed class FakeTaskRepository(WorkTask staleCandidate, WorkTask currentTa
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default)
=> Task.FromResult<WorkTask?>(null);
public Task<TaskBoardQueryPage> GetBoardPageAsync(
int doneLimit,
DateTimeOffset? doneBeforeUpdatedAt,
Guid? doneBeforeId,
CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<TaskBoardCardDto?> GetBoardCardAsync(
Guid id,
CancellationToken ct = default)
=> throw new NotSupportedException();
}
file sealed class FakeActivityRepository : IActivityRepository
+194
View File
@@ -0,0 +1,194 @@
using System.Text.Json.Nodes;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Tests;
/// <summary>
/// Minimal live-RPC test double for consumers that only need the OpenClaw
/// agent/session inventory. Unsupported control-plane calls fail loudly so a
/// test cannot accidentally fall back to a fabricated legacy source.
/// </summary>
internal sealed class StubOpenClawControlService : IOpenClawControlService
{
public StubOpenClawControlService(
IReadOnlyList<OpenClawAgentDto>? agents = null,
IReadOnlyList<OpenClawSessionDto>? sessions = null,
bool connected = true)
{
Agents = agents ??
[
Agent("iris", "Iris", "openai/gpt-5.5", "/workspace/iris"),
Agent("product-owner", "Product Owner", "openai/gpt-5.5", "/workspace-po"),
Agent("programmer", "Programmer", "openai/gpt-5.4", "/workspace/programmer"),
Agent("programmer-fast", "Programmer Fast", "openai/gpt-5.3-codex-spark", "/workspace/programmer-fast"),
Agent("reviewer", "Reviewer", "openai/gpt-5.5", "/workspace/reviewer"),
Agent("architekt", "Architekt", "openai/gpt-5.5", "/workspace/architekt")
];
Sessions = sessions ?? [];
Connected = connected;
}
public IReadOnlyList<OpenClawAgentDto> Agents { get; }
public IReadOnlyList<OpenClawSessionDto> Sessions { get; }
public bool Connected { get; }
public OpenClawConnectionDto GetConnection()
=> new(
State: Connected ? "connected" : "disconnected",
Configured: true,
CredentialConfigured: true,
Connected: Connected,
Endpoint: "ws://openclaw-gateway:18789",
GatewayVersion: "2026.7.1",
RequiredVersion: "2026.7.1",
VersionPinned: true,
VersionMatches: true,
ProtocolVersion: 4,
GrantedScopes: ["operator.read"],
AdvertisedEvents: [],
LastConnectedAt: Connected ? DateTimeOffset.UtcNow : null,
LastEventAt: Connected ? DateTimeOffset.UtcNow : null,
ReconnectAttempts: 0,
Message: null,
Recovery: null,
CheckedAt: DateTimeOffset.UtcNow);
public Task<OpenClawCollectionDto<OpenClawAgentDto>> GetAgentsAsync(
CancellationToken cancellationToken = default)
=> Task.FromResult(Collection(Agents));
public Task<OpenClawCollectionDto<OpenClawSessionDto>> GetSessionsAsync(
int limit = 100,
CancellationToken cancellationToken = default)
=> Task.FromResult(Collection<OpenClawSessionDto>(
Sessions.Take(limit).ToArray()));
public IReadOnlyList<OpenClawCapabilityDto> GetCapabilities() => [];
public Task<OpenClawOverviewDto> GetOverviewAsync(CancellationToken cancellationToken = default)
=> Unsupported<OpenClawOverviewDto>();
public Task<OpenClawCollectionDto<OpenClawTaskDto>> GetTasksAsync(
int limit = 100,
string? cursor = null,
CancellationToken cancellationToken = default)
=> Unsupported<OpenClawCollectionDto<OpenClawTaskDto>>();
public Task<OpenClawCollectionDto<OpenClawCronJobDto>> GetCronJobsAsync(
int limit = 100,
CancellationToken cancellationToken = default)
=> Unsupported<OpenClawCollectionDto<OpenClawCronJobDto>>();
public Task<OpenClawCollectionDto<OpenClawCronJobDto>> GetCronJobsAsync(
bool includeDisabled,
int limit = 100,
string? cursor = null,
CancellationToken cancellationToken = default)
=> Unsupported<OpenClawCollectionDto<OpenClawCronJobDto>>();
public Task<OpenClawOperationDto<OpenClawCronJobDetailDto>> GetCronJobAsync(
string jobId,
CancellationToken cancellationToken = default)
=> Unsupported<OpenClawOperationDto<OpenClawCronJobDetailDto>>();
public Task<OpenClawCollectionDto<OpenClawCronRunDto>> GetCronRunsAsync(
string jobId,
int limit = 100,
string? cursor = null,
string? runId = null,
CancellationToken cancellationToken = default)
=> Unsupported<OpenClawCollectionDto<OpenClawCronRunDto>>();
public Task<OpenClawCollectionDto<OpenClawApprovalDto>> GetApprovalsAsync(
int limit = 100,
CancellationToken cancellationToken = default)
=> Unsupported<OpenClawCollectionDto<OpenClawApprovalDto>>();
public Task<OpenClawCollectionDto<OpenClawActivityDto>> GetActivityAsync(
int limit = 100,
string? cursor = null,
CancellationToken cancellationToken = default)
=> Unsupported<OpenClawCollectionDto<OpenClawActivityDto>>();
public Task<OpenClawCollectionDto<OpenClawModelDto>> GetModelsAsync(
CancellationToken cancellationToken = default)
=> Unsupported<OpenClawCollectionDto<OpenClawModelDto>>();
public Task<OpenClawCollectionDto<OpenClawModelAuthProviderDto>> GetModelAuthStatusAsync(
bool refresh = false,
CancellationToken cancellationToken = default)
=> Unsupported<OpenClawCollectionDto<OpenClawModelAuthProviderDto>>();
public Task<OpenClawOperationDto<OpenClawTaskDto>> CancelTaskAsync(
string taskId,
string? reason,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Unsupported<OpenClawOperationDto<OpenClawTaskDto>>();
public Task<OpenClawOperationDto<object>> AbortSessionAsync(
string sessionKey,
string? runId,
bool clearQueued,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Unsupported<OpenClawOperationDto<object>>();
public Task<OpenClawOperationDto<object>> PatchSessionModelAsync(
string sessionKey,
string model,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Unsupported<OpenClawOperationDto<object>>();
public Task<OpenClawOperationDto<object>> RunCronJobAsync(
string jobId,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Unsupported<OpenClawOperationDto<object>>();
public Task<OpenClawOperationDto<object>> RunCronJobAsync(
string jobId,
string? expectedHash,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Unsupported<OpenClawOperationDto<object>>();
public Task<OpenClawOperationDto<OpenClawCronJobDetailDto>> CreateCronJobAsync(
CreateOpenClawCronJobRequest request,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Unsupported<OpenClawOperationDto<OpenClawCronJobDetailDto>>();
public Task<OpenClawOperationDto<OpenClawCronJobDetailDto>> PatchCronJobAsync(
string jobId,
JsonObject patch,
string? expectedHash,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Unsupported<OpenClawOperationDto<OpenClawCronJobDetailDto>>();
public Task<OpenClawOperationDto<object>> DeleteCronJobAsync(
string jobId,
string? expectedHash = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Unsupported<OpenClawOperationDto<object>>();
public Task<OpenClawOperationDto<OpenClawApprovalDto>> ResolveApprovalAsync(
string approvalId,
string kind,
string decision,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
=> Unsupported<OpenClawOperationDto<OpenClawApprovalDto>>();
private static OpenClawAgentDto Agent(
string id,
string name,
string model,
string workspace)
=> new(
Id: id,
Name: name,
Description: null,
Model: model,
Provider: "openai",
Workspace: workspace,
Status: "ready");
private static OpenClawCollectionDto<T> Collection<T>(IReadOnlyList<T> items)
=> new(
State: "ready",
Items: items,
NextCursor: null,
Message: null,
Recovery: null,
CheckedAt: DateTimeOffset.UtcNow);
private static Task<T> Unsupported<T>()
=> Task.FromException<T>(new NotSupportedException(
"This test double only supports live agent and session inventory."));
}
+21
View File
@@ -0,0 +1,21 @@
using Nexus.Api.Services;
namespace Nexus.Api.Tests;
internal sealed class StubOpenClawWriteGate(
OpenClawWriteGateDecision? decision = null) : IOpenClawWriteGate
{
public OpenClawWriteGateDecision Decision { get; set; } =
decision ?? OpenClawWriteGateDecision.Permit();
public List<(string Method, string Scope)> Evaluations { get; } = [];
public Task<OpenClawWriteGateDecision> EvaluateAsync(
string method,
string requiredScope = "operator.admin",
CancellationToken cancellationToken = default)
{
Evaluations.Add((method, requiredScope));
return Task.FromResult(Decision);
}
}
+198
View File
@@ -0,0 +1,198 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class TaskBoardV2Tests
{
[Fact]
public async Task GetBoardPage_ReturnsAllActiveGroups_AndPaginatesDoneByStableKeyset()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var timestamp = new DateTimeOffset(2026, 7, 30, 12, 0, 0, TimeSpan.Zero);
var parentId = Guid.Parse("00000000-0000-0000-0000-000000000100");
await fixture.TaskRepository.AddAsync(new WorkTask
{
Id = parentId,
Title = "Parent",
State = "Backlog",
Priority = "High",
UpdatedAt = timestamp.AddMinutes(-10),
CreatedAt = timestamp.AddHours(-1)
});
await fixture.TaskRepository.AddAsync(new WorkTask
{
Id = Guid.Parse("00000000-0000-0000-0000-000000000101"),
Title = "Child",
State = "In progress",
Priority = "Medium",
ParentTaskId = parentId,
UpdatedAt = timestamp.AddMinutes(-9),
CreatedAt = timestamp.AddMinutes(-50)
});
await fixture.TaskRepository.AddAsync(new WorkTask
{
Id = Guid.Parse("00000000-0000-0000-0000-000000000102"),
Title = "Review",
State = "Review",
UpdatedAt = timestamp.AddMinutes(-8)
});
await fixture.TaskRepository.AddAsync(new WorkTask
{
Id = Guid.Parse("00000000-0000-0000-0000-000000000103"),
Title = "Blocked",
State = "Blocked",
UpdatedAt = timestamp.AddMinutes(-7)
});
var newestDoneId = Guid.Parse("00000000-0000-0000-0000-000000000203");
var middleDoneId = Guid.Parse("00000000-0000-0000-0000-000000000202");
var oldestDoneId = Guid.Parse("00000000-0000-0000-0000-000000000201");
await AddDoneAsync(fixture, oldestDoneId, "Done 1", timestamp.AddMinutes(-3));
await AddDoneAsync(fixture, middleDoneId, "Done 2", timestamp.AddMinutes(-2));
await AddDoneAsync(fixture, newestDoneId, "Done 3", timestamp.AddMinutes(-1));
await fixture.ActivityRepository.AddAsync(new ActivityEvent
{
Type = "task",
Message = "Parent updated",
TaskId = parentId,
CreatedAt = timestamp
});
var first = await fixture.TaskService.GetBoardPageAsync(2);
Assert.Single(first.Offen);
Assert.Single(first.InProgress);
Assert.Single(first.Review);
Assert.Single(first.Blocked);
Assert.Equal([newestDoneId, middleDoneId], first.Done.Select(task => task.Id));
Assert.True(first.HasMoreDone);
Assert.NotNull(first.NextDoneCursor);
Assert.Equal(1, first.Offen[0].ChildTaskCount);
Assert.Equal(1, first.Offen[0].OpenChildTaskCount);
Assert.Equal("Parent updated", first.Offen[0].LastActivityMessage);
var second = await fixture.TaskService.GetBoardPageAsync(2, first.NextDoneCursor);
Assert.Equal(first.Revision, second.Revision);
Assert.Empty(second.Offen);
Assert.Empty(second.InProgress);
Assert.Empty(second.Review);
Assert.Empty(second.Blocked);
Assert.Equal([oldestDoneId], second.Done.Select(task => task.Id));
Assert.False(second.HasMoreDone);
Assert.Null(second.NextDoneCursor);
Assert.DoesNotContain(second.Done, task => first.Done.Any(firstTask => firstTask.Id == task.Id));
}
[Fact]
public async Task GetBoardPage_UsesIdAsTieBreaker_WhenDoneTimestampsMatch()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var timestamp = new DateTimeOffset(2026, 7, 30, 12, 0, 0, TimeSpan.Zero);
var firstId = Guid.Parse("00000000-0000-0000-0000-000000000003");
var secondId = Guid.Parse("00000000-0000-0000-0000-000000000002");
var thirdId = Guid.Parse("00000000-0000-0000-0000-000000000001");
await AddDoneAsync(fixture, thirdId, "Done 1", timestamp);
await AddDoneAsync(fixture, firstId, "Done 3", timestamp);
await AddDoneAsync(fixture, secondId, "Done 2", timestamp);
var first = await fixture.TaskService.GetBoardPageAsync(2);
var second = await fixture.TaskService.GetBoardPageAsync(2, first.NextDoneCursor);
Assert.Equal([firstId, secondId], first.Done.Select(task => task.Id));
Assert.Equal([thirdId], second.Done.Select(task => task.Id));
}
[Fact]
public async Task GetBoardPage_RejectsMalformedCursor_AsValidationProblem()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = CreateController(fixture);
var result = await controller.GetBoard(
CancellationToken.None,
doneLimit: 50,
doneCursor: "not-a-valid-cursor");
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode);
}
[Theory]
[InlineData(0)]
[InlineData(101)]
public async Task GetBoardPage_RejectsOutOfRangeDoneLimit(int doneLimit)
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = CreateController(fixture);
var result = await controller.GetBoard(CancellationToken.None, doneLimit);
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode);
}
[Fact]
public void DoneKeysetPredicate_IsTranslatableByNpgsql()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseNpgsql("Host=unused;Database=unused;Username=unused;Password=unused")
.Options;
using var db = new NexusDbContext(options);
var cursorUpdatedAt = new DateTimeOffset(2026, 7, 30, 12, 0, 0, TimeSpan.Zero);
var cursorId = Guid.Parse("00000000-0000-0000-0000-000000000002");
var sql = db.Tasks
.AsNoTracking()
.Where(task => task.State == "Done")
.Where(task =>
task.UpdatedAt < cursorUpdatedAt
|| (task.UpdatedAt == cursorUpdatedAt && task.Id.CompareTo(cursorId) < 0))
.OrderByDescending(task => task.UpdatedAt)
.ThenByDescending(task => task.Id)
.Take(51)
.ToQueryString();
Assert.Contains("\"UpdatedAt\"", sql, StringComparison.Ordinal);
Assert.Contains("\"Id\"", sql, StringComparison.Ordinal);
}
private static TasksController CreateController(TaskWorkflowFixture fixture)
=> new(
fixture.TaskService,
fixture.AgentService,
fixture.Configuration,
fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(
user: TaskWorkflowFixture.CreateUser("bao", "owner"))
}
};
private static async Task AddDoneAsync(
TaskWorkflowFixture fixture,
Guid id,
string title,
DateTimeOffset updatedAt)
{
await fixture.TaskRepository.AddAsync(new WorkTask
{
Id = id,
Title = title,
State = "Done",
UpdatedAt = updatedAt,
CreatedAt = updatedAt.AddHours(-1)
});
}
}
+138 -40
View File
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -15,6 +16,31 @@ namespace Nexus.Api.Tests;
public sealed class TaskWorkflowTests
{
[Fact]
public async Task TaskMutation_PublishesContentMinimizedLegacyInvalidation()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
using var subscriptionLifetime = new CancellationTokenSource();
var subscription = await fixture.LiveUpdateService.SubscribeAsync(
ct: subscriptionLifetime.Token);
var task = await fixture.TaskService.CreateAsync(
new Nexus.Api.DTOs.CreateTaskRequest("Mutation delta", "Normal", null),
CancellationToken.None);
var update = await subscription.Reader.ReadAsync(CancellationToken.None);
if (update.Type != "tasks.board.snapshot")
update = await subscription.Reader.ReadAsync(CancellationToken.None);
subscriptionLifetime.Cancel();
Assert.Equal("tasks.board.snapshot", update.Type);
Assert.Equal("board", update.Channel);
Assert.IsNotType<BoardResponse>(update.Payload);
var payload = JsonSerializer.Serialize(update.Payload);
Assert.Contains(task.Id.ToString(), payload, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Mutation delta", payload, StringComparison.Ordinal);
}
[Fact]
public async Task CreateAgentTaskAsync_PreservesConfiguredAssigneeAndBacklogState_WhenPlannedChildTask()
{
@@ -98,7 +124,7 @@ public sealed class TaskWorkflowTests
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AcceptsProgrammerFastHeader()
public async Task GatewayBridgeController_GetBoard_RejectsAgentHeaderWithoutAuthentication()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
@@ -117,6 +143,31 @@ public sealed class TaskWorkflowTests
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result.Result);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AcceptsAgentHintAfterServiceAuthentication()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "programmer-fast",
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
@@ -174,7 +225,7 @@ public sealed class TaskWorkflowTests
}
[Fact]
public async Task TasksController_GetBoard_AcceptsProgrammerFastHeader()
public async Task TasksController_GetBoard_RejectsAgentHeaderWithoutAuthentication()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
@@ -191,7 +242,7 @@ public sealed class TaskWorkflowTests
var result = await controller.GetBoard(CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
}
[Fact]
@@ -213,7 +264,7 @@ public sealed class TaskWorkflowTests
}
[Fact]
public async Task TasksController_ResetStale_UnknownAgentHeader_IsForbidden()
public async Task TasksController_ResetStale_UnknownAgentHeaderWithoutAuthentication_IsUnauthorized()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
@@ -230,7 +281,7 @@ public sealed class TaskWorkflowTests
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status403Forbidden);
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
}
[Fact]
@@ -251,6 +302,26 @@ public sealed class TaskWorkflowTests
AssertStatusCode(result, StatusCodes.Status403Forbidden);
}
[Fact]
public async Task TasksController_ResetStale_OrdinaryJwtCannotEscalateWithIrisHeader()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(
agentId: "iris",
user: TaskWorkflowFixture.CreateUser("user-1", "user"))
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status403Forbidden);
}
[Fact]
public async Task TasksController_ResetStale_ServiceKey_IsAllowed()
{
@@ -273,7 +344,7 @@ public sealed class TaskWorkflowTests
}
[Fact]
public async Task TasksController_ResetStale_IrisHeader_IsAllowed()
public async Task TasksController_ResetStale_IrisHeaderWithoutAuthentication_IsUnauthorized()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
@@ -287,11 +358,33 @@ public sealed class TaskWorkflowTests
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
}
[Fact]
public async Task TasksController_ResetStale_IrisHintWithServiceKey_IsAllowed()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "iris",
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsUnauthorized()
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsForbidden()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
@@ -308,7 +401,32 @@ public sealed class TaskWorkflowTests
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result.Result);
var forbidden = Assert.IsType<ObjectResult>(result.Result);
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtCannotEscalateWithAgentHeader()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(
agentId: "iris",
user: TaskWorkflowFixture.CreateUser("user-1", "user"))
}
};
var result = await controller.GetBoard(CancellationToken.None);
var forbidden = Assert.IsType<ObjectResult>(result.Result);
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
}
[Fact]
@@ -422,21 +540,24 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var configPath = CreateAgentConfigFile();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = configPath,
["NexusApiKey"] = "test-service-key"
})
.Build();
var agentService = new AgentService(configuration, new FakeRuntime());
var agentService = new AgentService(new StubOpenClawControlService());
var liveUpdateService = new LiveUpdateService();
var activityRepository = new ActivityRepository(db, liveUpdateService);
var taskRepository = new TaskRepository(db);
var notificationService = new NotificationService(db, liveUpdateService);
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
var httpContextAccessor = new HttpContextAccessor
{
HttpContext = CreateHttpContext(
agentId: "iris",
user: CreateUser("service", "Service"))
};
var staleTaskRecoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
@@ -504,7 +625,9 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
public void SetCallerAgent(string agentId)
{
HttpContextAccessor.HttpContext = CreateHttpContext(agentId: agentId);
HttpContextAccessor.HttpContext = CreateHttpContext(
agentId: agentId,
user: CreateUser("service", "Service"));
}
public async ValueTask DisposeAsync()
@@ -512,32 +635,6 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
await _db.DisposeAsync();
}
private static string CreateAgentConfigFile()
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path,
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": {
"primary": "deepseek/deepseek-v4-flash"
}
},
"list": [
{ "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } },
{ "id": "product-owner", "name": "product-owner", "model": { "primary": "openai/gpt-5.5" } },
{ "id": "programmer", "name": "programmer", "model": { "primary": "openai/gpt-5.4" } },
{ "id": "programmer-fast", "name": "programmer-fast", "model": { "primary": "openai/gpt-5.3-codex-spark" } },
{ "id": "reviewer", "name": "reviewer", "model": { "primary": "openai/gpt-5.5" } }
]
}
}
""");
return path;
}
}
file sealed class FakeDashboardService : IDashboardService
@@ -554,5 +651,6 @@ file sealed class FakeDashboardService : IDashboardService
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
public List<ModelOption> GetAvailableModels() => [];
public Task<List<ModelOption>> GetAvailableModelsAsync(CancellationToken ct)
=> Task.FromResult(new List<ModelOption>());
}
@@ -0,0 +1,327 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using DotNet.Testcontainers.Builders;
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Testcontainers.Toxiproxy;
using Xunit;
namespace Nexus.Api.Tests;
[Collection(DockerIntegrationTestEnvironment.CollectionName)]
public sealed class ToxiproxyAgentProvisioningIntegrationTests
{
private const ushort ToxiproxyAdminPort = 8474;
private const ushort OpenClawProxyPort = 8666;
[ToxiproxyIntegrationFact]
[Trait("Category", "DockerIntegration")]
[Trait("Category", "ToxiproxyIntegration")]
public async Task Timeout_after_create_dispatch_becomes_in_doubt_without_duplicate_create()
{
await using var network = new NetworkBuilder().Build();
await using var openClawStub = new ContainerBuilder(
"python:3.13.7-alpine3.22")
.WithNetwork(network)
.WithNetworkAliases("openclaw-stub")
.WithExposedPort(8080)
.WithEntrypoint("python", "-u", "-c")
.WithCommand(OpenClawStubScript)
.WithWaitStrategy(
Wait.ForUnixContainer()
.UntilInternalTcpPortIsAvailable(8080))
.Build();
await using var toxiproxy = new ToxiproxyBuilder(
"ghcr.io/shopify/toxiproxy:2.12.0")
.WithNetwork(network)
.Build();
await network.CreateAsync();
await Task.WhenAll(
openClawStub.StartAsync(),
toxiproxy.StartAsync());
using var toxiproxyAdmin = new HttpClient
{
BaseAddress = new Uri(
$"http://{toxiproxy.Hostname}:"
+ toxiproxy.GetMappedPublicPort(ToxiproxyAdminPort))
};
using (var proxyResponse = await toxiproxyAdmin.PostAsJsonAsync(
"/proxies",
new
{
name = "openclaw",
listen = $"0.0.0.0:{OpenClawProxyPort}",
upstream = "openclaw-stub:8080",
enabled = true
}))
{
proxyResponse.EnsureSuccessStatusCode();
}
var gatewayEndpoint = new Uri(
$"http://{toxiproxy.Hostname}:"
+ toxiproxy.GetMappedPublicPort(OpenClawProxyPort));
using var gateway = new ToxiproxyHttpGatewayConnector(gatewayEndpoint);
await using var db = new NexusDbContext(
new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase($"toxiproxy-agent-{Guid.NewGuid():N}")
.Options);
await db.Database.EnsureCreatedAsync();
var management = new OpenClawConnectionProfile
{
Endpoint = gatewayEndpoint.ToString(),
DiscoverySource = "testcontainers-toxiproxy",
RequiredVersion = gateway.RequiredVersion,
AdoptionState = OpenClawAdoptionStates.Adopted,
ManagementEnabled = true,
CapabilityHash = AgentProposalService.BuildCapabilityHash(gateway),
Revision = 1
};
db.OpenClawConnectionProfiles.Add(management);
await db.SaveChangesAsync();
var service = PostgreSqlAgentProvisioningIntegrationTests.CreateService(
db,
gateway);
var created = await service.CreateAsync(
PostgreSqlAgentProvisioningIntegrationTests.Proposal(),
"manual",
PostgreSqlAgentProvisioningIntegrationTests.Invocation(
"toxiproxy-proposal"));
var approved = await service.ApproveAsync(
created.Proposal!.Id,
new(created.Proposal.Revision),
PostgreSqlAgentProvisioningIntegrationTests.Invocation(
"toxiproxy-approval"));
Assert.True(approved.Ok);
gateway.BeforeCreateAsync = async cancellationToken =>
{
using var toxicResponse = await toxiproxyAdmin.PostAsJsonAsync(
"/proxies/openclaw/toxics",
new
{
name = "drop-all-create-responses",
type = "timeout",
stream = "downstream",
toxicity = 1.0,
attributes = new { timeout = 0 }
},
cancellationToken);
toxicResponse.EnsureSuccessStatusCode();
};
Assert.True(await service.ProcessNextAsync());
Assert.False(await service.ProcessNextAsync());
var uncertain = await service.GetByIdAsync(created.Proposal.Id);
Assert.NotNull(uncertain);
Assert.Equal(AgentProposalStates.InDoubt, uncertain!.Status);
Assert.Equal(1, gateway.CreateCalls);
using (var removeToxic = await toxiproxyAdmin.DeleteAsync(
"/proxies/openclaw/toxics/drop-all-create-responses"))
{
removeToxic.EnsureSuccessStatusCode();
}
var retry = await service.RetryAsync(
created.Proposal.Id,
new(uncertain.Revision),
PostgreSqlAgentProvisioningIntegrationTests.Invocation(
"toxiproxy-reconcile"));
Assert.True(retry.Ok);
Assert.True(await service.ProcessNextAsync());
var reconciled = await service.GetByIdAsync(created.Proposal.Id);
Assert.NotNull(reconciled);
Assert.Equal(AgentProposalStates.Ready, reconciled!.Status);
Assert.Equal("release-analyst", reconciled.OpenClawAgentId);
Assert.Equal(1, gateway.CreateCalls);
}
private const string OpenClawStubScript =
"""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import re
import threading
import time
agents = {}
lock = threading.Lock()
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass
def send_json(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def do_GET(self):
if self.path != "/agents":
self.send_json(404, {"error": "not_found"})
return
with lock:
snapshot = list(agents.values())
self.send_json(200, {"agents": snapshot})
def do_POST(self):
if self.path != "/agents/create":
self.send_json(404, {"error": "not_found"})
return
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
name = payload.get("name", "")
agent_id = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower())
agent_id = re.sub(r"^-+|-+$", "", agent_id)[:64] or "main"
workspace = payload.get("workspace")
created = {
"id": agent_id,
"agentId": agent_id,
"name": name,
"workspace": workspace
}
with lock:
agents[agent_id] = created
# The mutation is committed before the response. Toxiproxy
# blocks downstream bytes, recreating an uncertain dispatch.
time.sleep(1.5)
self.send_json(200, {
"ok": True,
"agentId": agent_id,
"name": name,
"workspace": workspace
})
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
""";
}
internal sealed class ToxiproxyHttpGatewayConnector(Uri endpoint)
: IGatewayConnector, IDisposable
{
private readonly HttpClient client = new()
{
BaseAddress = endpoint
};
private int createCalls;
public int CreateCalls => Volatile.Read(ref createCalls);
public Func<CancellationToken, Task>? BeforeCreateAsync { get; set; }
public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected;
public string? GatewayVersion => "2026.7.1";
public string? RequiredVersion => "2026.7.1";
public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow;
public int ReconnectAttempts => 0;
public string? StatusMessage => "testcontainers-toxiproxy";
public string? DeviceId => "toxiproxy-device";
public bool DeviceTokenConfigured => true;
public bool PairingRequired => false;
public string? PairingRequestId => null;
public int? ProtocolVersion => 4;
public IReadOnlySet<string> AdvertisedMethods { get; } =
new HashSet<string>(
[
"agents.list",
"agents.create",
"agents.files.get",
"agents.files.set",
"config.get"
],
StringComparer.Ordinal);
public IReadOnlySet<string> AdvertisedEvents { get; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> GrantedScopes { get; } =
new HashSet<string>(
["operator.read", "operator.admin"],
StringComparer.Ordinal);
public DateTimeOffset? LastEventAt => null;
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public async Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null)
{
using var timeoutSource =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var budget = timeout is null
? TimeSpan.FromSeconds(1)
: TimeSpan.FromMilliseconds(Math.Min(
timeout.Value.TotalMilliseconds,
1000));
timeoutSource.CancelAfter(budget);
try
{
HttpResponseMessage response;
switch (method)
{
case "agents.list":
response = await client.GetAsync(
"/agents",
timeoutSource.Token);
break;
case "agents.create":
Interlocked.Increment(ref createCalls);
if (BeforeCreateAsync is not null)
await BeforeCreateAsync(timeoutSource.Token);
response = await client.PostAsJsonAsync(
"/agents/create",
JsonSerializer.SerializeToNode(parameters),
timeoutSource.Token);
break;
default:
throw new OpenClawGatewayRpcException(
"METHOD_NOT_FOUND",
$"Unexpected integration-test method {method}.");
}
using (response)
{
response.EnsureSuccessStatusCode();
return JsonNode.Parse(
await response.Content.ReadAsStringAsync(
timeoutSource.Token));
}
}
catch (OperationCanceledException)
when (!cancellationToken.IsCancellationRequested)
{
throw new OpenClawGatewayRpcException(
"GATEWAY_TIMEOUT",
"Toxiproxy blocked the OpenClaw response.",
retryable: true);
}
catch (HttpRequestException)
{
throw new OpenClawGatewayRpcException(
"GATEWAY_DISCONNECTED",
"Toxiproxy interrupted the OpenClaw connection.",
retryable: true);
}
}
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100)
=> [];
public void Dispose() => client.Dispose();
}