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