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
@@ -0,0 +1,272 @@
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
/// <summary>
/// Owner-only approval boundary for OpenClaw agent creation. Proposal creation
/// never mutates OpenClaw; approval queues a durable request only after every
/// live management gate passes.
/// </summary>
[Authorize(Roles = "owner")]
[ApiController]
[Route("api/v1/openclaw/agent-proposals")]
public sealed class OpenClawAgentProposalsController(
IAgentProposalService proposals) : ControllerBase
{
[HttpGet("~/api/v1/openclaw/agents/create-options")]
public async Task<ActionResult<AgentCreateOptionsDto>> GetCreateOptions(
CancellationToken cancellationToken)
=> Ok(await proposals.GetCreateOptionsAsync(cancellationToken));
[HttpGet]
public async Task<ActionResult<AgentProposalCollectionDto>> Get(
[FromQuery] int limit = 50,
[FromQuery] string? cursor = null,
[FromQuery] string? status = null,
CancellationToken cancellationToken = default)
{
try
{
return Ok(await proposals.GetAsync(
limit,
cursor,
status,
cancellationToken));
}
catch (AgentProposalValidationException exception)
{
return Validation(exception);
}
}
[HttpGet("{id:guid}", Name = "GetAgentProposal")]
public async Task<ActionResult<AgentProposalDto>> GetById(
Guid id,
CancellationToken cancellationToken)
{
var proposal = await proposals.GetByIdAsync(
id,
includeFileContent: true,
cancellationToken);
return proposal is null ? NotFound() : Ok(proposal);
}
[HttpPost]
[EnableRateLimiting("agents")]
public async Task<ActionResult<AgentProposalOperationDto>> Create(
[FromBody] CreateAgentProposalRequest request,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var error))
return error!;
try
{
var result = await proposals.CreateAsync(
request,
"manual",
invocation!,
cancellationToken);
return result.Ok
? CreatedAtRoute(
"GetAgentProposal",
new { id = result.Proposal!.Id },
result)
: StatusCode(StatusFor(result.State), result);
}
catch (AgentProposalValidationException exception)
{
return Validation(exception);
}
}
[HttpPost("{id:guid}/approve")]
[EnableRateLimiting("agents")]
public Task<ActionResult<AgentProposalOperationDto>> Approve(
Guid id,
[FromBody] AgentProposalActionRequest request,
CancellationToken cancellationToken)
=> Mutate(
id,
request,
proposals.ApproveAsync,
acceptedWhenProvisioning: true,
cancellationToken);
[HttpPost("{id:guid}/reject")]
[EnableRateLimiting("agents")]
public Task<ActionResult<AgentProposalOperationDto>> Reject(
Guid id,
[FromBody] AgentProposalActionRequest request,
CancellationToken cancellationToken)
=> Mutate(
id,
request,
proposals.RejectAsync,
acceptedWhenProvisioning: false,
cancellationToken);
[HttpPost("{id:guid}/retry")]
[EnableRateLimiting("agents")]
public Task<ActionResult<AgentProposalOperationDto>> Retry(
Guid id,
[FromBody] AgentProposalActionRequest request,
CancellationToken cancellationToken)
=> Mutate(
id,
request,
proposals.RetryAsync,
acceptedWhenProvisioning: true,
cancellationToken);
private async Task<ActionResult<AgentProposalOperationDto>> Mutate(
Guid id,
AgentProposalActionRequest request,
Func<
Guid,
AgentProposalActionRequest,
OpenClawInvocationMetadata,
CancellationToken,
Task<AgentProposalOperationDto>> operation,
bool acceptedWhenProvisioning,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var error))
return error!;
try
{
var result = await operation(
id,
request,
invocation!,
cancellationToken);
if (!result.Ok)
return StatusCode(StatusFor(result.State), result);
if (acceptedWhenProvisioning && result.State == "provisioning")
{
return AcceptedAtRoute(
"GetAgentProposal",
new { id = result.Proposal!.Id },
result);
}
return Ok(result);
}
catch (AgentProposalValidationException exception)
{
return Validation(exception);
}
}
private bool TryBuildInvocation(
out OpenClawInvocationMetadata? invocation,
out ActionResult<AgentProposalOperationDto>? error)
{
invocation = null;
error = null;
var idempotencyKey = Request.Headers["Idempotency-Key"]
.FirstOrDefault()
?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey)
|| idempotencyKey.Length > 200)
{
error = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["Idempotency-Key"] =
[
"A non-empty Idempotency-Key header with at most 200 characters is required."
]
}));
return false;
}
var correlationId = Request.Headers["X-Correlation-ID"]
.FirstOrDefault()
?.Trim();
if (string.IsNullOrWhiteSpace(correlationId))
correlationId = HttpContext.TraceIdentifier;
if (correlationId.Length > 200)
{
error = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["X-Correlation-ID"] =
[
"X-Correlation-ID must contain at most 200 characters."
]
}));
return false;
}
var traceParent = Request.Headers["traceparent"].FirstOrDefault()?.Trim()
?? Activity.Current?.Id;
if (traceParent?.Length > 128
|| (!string.IsNullOrWhiteSpace(traceParent)
&& !ActivityContext.TryParse(traceParent, null, out _)))
{
error = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["traceparent"] =
[
"traceparent must be a valid W3C trace context with at most 128 characters."
]
}));
return false;
}
var actor = User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst(ClaimTypes.Email)?.Value
?? User.Identity?.Name
?? "authenticated-owner";
Response.Headers["X-Correlation-ID"] = correlationId;
invocation = new OpenClawInvocationMetadata(
idempotencyKey,
correlationId,
actor,
traceParent);
return true;
}
private BadRequestObjectResult Validation(
AgentProposalValidationException exception)
=> new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
[exception.Field] = [exception.Message]
}));
private static int StatusFor(string state)
=> state switch
{
"invalid"
or "invalid_state"
or "invalid_stage"
=> StatusCodes.Status400BadRequest,
"not_found"
=> StatusCodes.Status404NotFound,
"concurrency_conflict"
or "idempotency_conflict"
or "agent_exists"
=> StatusCodes.Status409Conflict,
"experimental_blocked"
or "management_disabled"
or "scope_upgrade_required"
or "capability_missing"
or "capability_drift"
or "version_mismatch"
or "workspace_root_invalid"
=> StatusCodes.Status403Forbidden,
"gateway_unavailable"
or "inventory_unavailable"
=> StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status502BadGateway
};
}