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() .Single(); Assert.Equal("owner", authorize.Roles); var propose = typeof(NexusMcpTools).GetMethod( nameof(NexusMcpTools.ProposeAgent))!; var proposeTool = propose.GetCustomAttributes( typeof(McpServerToolAttribute), inherit: false) .OfType() .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() .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 CreateAsync( bool externalIdentitySupported) { var options = new DbContextOptionsBuilder() .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 { ["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.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 AdvertisedMethods { get; } = new HashSet( [ "agents.list", "agents.create", "agents.files.get", "agents.files.set", "config.get", "models.list" ], StringComparer.Ordinal); public IReadOnlySet AdvertisedEvents { get; } = new HashSet(StringComparer.Ordinal); public IReadOnlySet GrantedScopes { get; } = new HashSet( ["operator.read", "operator.admin"], StringComparer.Ordinal); public DateTimeOffset? LastEventAt => null; public bool Supports(string method) => AdvertisedMethods.Contains(method); public Task InvokeAsync( string method, object? parameters = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { if (method == "agents.list") { return Task.FromResult(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(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(); var id = name.Trim().ToLowerInvariant().Replace(' ', '-'); var workspace = json["workspace"]!.GetValue(); agents.Add((id, workspace)); return Task.FromResult(new JsonObject { ["ok"] = true, ["agentId"] = id, ["name"] = name, ["workspace"] = workspace }); } throw new OpenClawGatewayRpcException( "METHOD_NOT_FOUND", $"Unexpected method {method}."); } public IReadOnlyList GetRecentEvents(int limit = 100) => []; } internal sealed class ProposalAgentConfigurationService : IOpenClawAgentConfigurationService { public Dictionary Writes { get; } = new(StringComparer.Ordinal); public string? DefaultWorkspace { get; set; } public Task 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 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 GetAgentFilesAsync( string agentId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetWorkspaceAsync( string agentId, string? path, int offset, int limit, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetWorkspaceFileAsync( string agentId, string path, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetConfigSchemaAsync( string path, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task 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 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); }