586 lines
21 KiB
C#
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);
|
|
}
|