Files
nexus/backend-tests/AgentProposalServiceTests.cs
T
AzuTear f5552218bc
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
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

586 lines
21 KiB
C#

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);
}