273 lines
9.0 KiB
C#
273 lines
9.0 KiB
C#
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
|
|
};
|
|
}
|