229 lines
8.6 KiB
C#
229 lines
8.6 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Nexus.Api.Models;
|
|
using Nexus.Api.Services;
|
|
using System.Security.Claims;
|
|
|
|
namespace Nexus.Api.Controllers;
|
|
|
|
/// <summary>
|
|
/// Owner-only orchestration for the single OpenClaw Attach & Adopt profile.
|
|
/// The browser never receives Gateway, bootstrap or provider credentials.
|
|
/// </summary>
|
|
[Authorize(Roles = "owner")]
|
|
[ApiController]
|
|
[Route("api/v1/openclaw/setup")]
|
|
public sealed class OpenClawSetupController(
|
|
IOpenClawSetupService setup,
|
|
IOpenClawWizardService wizard) : ControllerBase
|
|
{
|
|
[HttpGet]
|
|
public async Task<ActionResult<OpenClawSetupStatusDto>> GetStatus(
|
|
CancellationToken cancellationToken)
|
|
=> Ok(await setup.GetStatusAsync(cancellationToken));
|
|
|
|
[HttpPost("discover")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawDiscoveryDto>> Discover(
|
|
[FromBody] OpenClawDiscoveryRequest? request,
|
|
CancellationToken cancellationToken)
|
|
=> Ok(await setup.DiscoverAsync(
|
|
request ?? new OpenClawDiscoveryRequest(),
|
|
cancellationToken));
|
|
|
|
[HttpPost("probe")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawProbeDto>>> Probe(
|
|
[FromBody] ProbeOpenClawRequest request,
|
|
CancellationToken cancellationToken)
|
|
=> Map(await setup.ProbeAsync(request, cancellationToken));
|
|
|
|
[HttpPost("attach")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> Attach(
|
|
[FromBody] AttachOpenClawRequest request,
|
|
CancellationToken cancellationToken)
|
|
=> Map(await setup.AttachAsync(request, cancellationToken));
|
|
|
|
[HttpPost("verify")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> Verify(
|
|
[FromBody] VerifyOpenClawRequest request,
|
|
CancellationToken cancellationToken)
|
|
=> Map(await setup.VerifyAsync(request, cancellationToken));
|
|
|
|
[HttpPost("adopt")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawAdoptionInventoryDto>>> Adopt(
|
|
[FromBody] AdoptOpenClawRequest request,
|
|
CancellationToken cancellationToken)
|
|
=> Map(await setup.AdoptAsync(request, cancellationToken));
|
|
|
|
[HttpPost("management")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> SetManagement(
|
|
[FromBody] SetOpenClawManagementRequest request,
|
|
CancellationToken cancellationToken)
|
|
=> Map(await setup.SetManagementAsync(request, cancellationToken));
|
|
|
|
[HttpDelete("connection")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> DeleteConnection(
|
|
[FromBody] DeleteOpenClawConnectionRequest request,
|
|
CancellationToken cancellationToken)
|
|
=> Map(await setup.DeleteAsync(request, cancellationToken));
|
|
|
|
[HttpPost("wizard/start")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawWizardResultDto>> StartWizard(
|
|
[FromBody] StartOpenClawWizardRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!TryBuildInvocation(out var invocation, out var validationError))
|
|
return validationError!;
|
|
return MapWizard(await wizard.StartAsync(
|
|
request,
|
|
invocation!,
|
|
cancellationToken));
|
|
}
|
|
|
|
[HttpPost("wizard/next")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawWizardResultDto>> AdvanceWizard(
|
|
[FromBody] AdvanceOpenClawWizardRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!TryBuildInvocation(out var invocation, out var validationError))
|
|
return validationError!;
|
|
return MapWizard(await wizard.NextAsync(
|
|
request,
|
|
invocation!,
|
|
cancellationToken));
|
|
}
|
|
|
|
[HttpGet("wizard/{sessionId}")]
|
|
public async Task<ActionResult<OpenClawWizardResultDto>> GetWizardStatus(
|
|
string sessionId,
|
|
CancellationToken cancellationToken)
|
|
=> MapWizard(await wizard.GetStatusAsync(sessionId, cancellationToken));
|
|
|
|
[HttpPost("wizard/{sessionId}/cancel")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawWizardResultDto>> CancelWizard(
|
|
string sessionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!TryBuildInvocation(out var invocation, out var validationError))
|
|
return validationError!;
|
|
return MapWizard(await wizard.CancelAsync(
|
|
sessionId,
|
|
invocation!,
|
|
cancellationToken));
|
|
}
|
|
|
|
private ActionResult<OpenClawSetupOperationDto<T>> Map<T>(
|
|
OpenClawSetupOperationDto<T> result)
|
|
{
|
|
if (result.Ok)
|
|
return Ok(result);
|
|
|
|
return StatusCode(StatusFor(result.State), result);
|
|
}
|
|
|
|
private ActionResult<OpenClawWizardResultDto> MapWizard(
|
|
OpenClawWizardResultDto result)
|
|
{
|
|
if (result.Ok)
|
|
return Ok(result);
|
|
|
|
var status = result.State switch
|
|
{
|
|
"invalid" or "confirmation_required"
|
|
=> StatusCodes.Status400BadRequest,
|
|
"not_found"
|
|
=> StatusCodes.Status404NotFound,
|
|
"management_disabled" or "scope_upgrade_required"
|
|
=> StatusCodes.Status403Forbidden,
|
|
"conflict" or "server_secret_required" or "unsupported"
|
|
=> StatusCodes.Status409Conflict,
|
|
"disconnected" or "gateway_error"
|
|
=> StatusCodes.Status503ServiceUnavailable,
|
|
_ => StatusCodes.Status502BadGateway
|
|
};
|
|
return StatusCode(status, result);
|
|
}
|
|
|
|
private bool TryBuildInvocation(
|
|
out OpenClawInvocationContext? invocation,
|
|
out BadRequestObjectResult? validationError)
|
|
{
|
|
invocation = null;
|
|
validationError = null;
|
|
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
|
|
if (string.IsNullOrWhiteSpace(idempotencyKey) ||
|
|
idempotencyKey.Length > 128 ||
|
|
idempotencyKey.Any(char.IsControl))
|
|
{
|
|
validationError = BadRequest(new ValidationProblemDetails(
|
|
new Dictionary<string, string[]>
|
|
{
|
|
["Idempotency-Key"] =
|
|
[
|
|
"A non-empty Idempotency-Key header with at most 128 non-control characters is required."
|
|
]
|
|
}));
|
|
return false;
|
|
}
|
|
|
|
var actor = User.FindFirst("sub")?.Value
|
|
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
|
?? User.FindFirst(ClaimTypes.Email)?.Value
|
|
?? User.Identity?.Name
|
|
?? "authenticated-owner";
|
|
try
|
|
{
|
|
invocation = OpenClawInvocationContext.Create(
|
|
actor,
|
|
idempotencyKey,
|
|
Request.Headers["X-Correlation-ID"].FirstOrDefault(),
|
|
Request.Headers["traceparent"].FirstOrDefault(),
|
|
includeIdempotencyParameter: false);
|
|
}
|
|
catch (ArgumentException exception)
|
|
{
|
|
validationError = BadRequest(new ValidationProblemDetails(
|
|
new Dictionary<string, string[]>
|
|
{
|
|
["request"] = [exception.Message]
|
|
}));
|
|
return false;
|
|
}
|
|
|
|
Response.Headers["Idempotency-Key"] = invocation.IdempotencyKey;
|
|
Response.Headers["X-Correlation-ID"] = invocation.CorrelationId;
|
|
return true;
|
|
}
|
|
|
|
private static int StatusFor(string state)
|
|
=> state switch
|
|
{
|
|
OpenClawSetupStates.InvalidEndpoint
|
|
or OpenClawSetupStates.InvalidRequest
|
|
=> StatusCodes.Status400BadRequest,
|
|
OpenClawSetupStates.NotFound
|
|
=> StatusCodes.Status404NotFound,
|
|
OpenClawSetupStates.ConcurrencyConflict
|
|
or OpenClawSetupStates.DynamicEndpointUnsupported
|
|
or OpenClawSetupStates.ExperimentalBlocked
|
|
or OpenClawSetupStates.ExcessiveScope
|
|
or OpenClawSetupStates.PairingRequired
|
|
or OpenClawSetupStates.ScopeUpgradeRequired
|
|
=> StatusCodes.Status409Conflict,
|
|
OpenClawSetupStates.Disconnected
|
|
or OpenClawSetupStates.GatewayUnavailable
|
|
=> StatusCodes.Status503ServiceUnavailable,
|
|
_ => StatusCodes.Status502BadGateway
|
|
};
|
|
}
|