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; /// /// 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. /// [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> GetCreateOptions( CancellationToken cancellationToken) => Ok(await proposals.GetCreateOptionsAsync(cancellationToken)); [HttpGet] public async Task> 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> 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> 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> Approve( Guid id, [FromBody] AgentProposalActionRequest request, CancellationToken cancellationToken) => Mutate( id, request, proposals.ApproveAsync, acceptedWhenProvisioning: true, cancellationToken); [HttpPost("{id:guid}/reject")] [EnableRateLimiting("agents")] public Task> Reject( Guid id, [FromBody] AgentProposalActionRequest request, CancellationToken cancellationToken) => Mutate( id, request, proposals.RejectAsync, acceptedWhenProvisioning: false, cancellationToken); [HttpPost("{id:guid}/retry")] [EnableRateLimiting("agents")] public Task> Retry( Guid id, [FromBody] AgentProposalActionRequest request, CancellationToken cancellationToken) => Mutate( id, request, proposals.RetryAsync, acceptedWhenProvisioning: true, cancellationToken); private async Task> Mutate( Guid id, AgentProposalActionRequest request, Func< Guid, AgentProposalActionRequest, OpenClawInvocationMetadata, CancellationToken, Task> 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? 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 { ["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 { ["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 { ["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 { [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 }; }