257 lines
9.8 KiB
C#
257 lines
9.8 KiB
C#
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 proxy for sensitive OpenClaw agent files, workspace previews,
|
|
/// and schema-validated configuration changes.
|
|
/// </summary>
|
|
[Authorize(Roles = "owner")]
|
|
[ApiController]
|
|
[Route("api/v1/openclaw")]
|
|
public sealed class OpenClawAgentConfigurationController(
|
|
IOpenClawAgentConfigurationService configuration) : ControllerBase
|
|
{
|
|
[HttpGet("agents/{agentId}/files")]
|
|
public Task<ActionResult<OpenClawAgentFileCollectionDto>> GetAgentFiles(
|
|
string agentId,
|
|
CancellationToken cancellationToken)
|
|
=> ExecuteAsync(() => configuration.GetAgentFilesAsync(agentId, cancellationToken));
|
|
|
|
[HttpGet("agents/{agentId}/files/{fileName}")]
|
|
public Task<ActionResult<OpenClawAgentFileDto>> GetAgentFile(
|
|
string agentId,
|
|
string fileName,
|
|
CancellationToken cancellationToken)
|
|
=> ExecuteAsync(() => configuration.GetAgentFileAsync(
|
|
agentId,
|
|
fileName,
|
|
cancellationToken));
|
|
|
|
[HttpPut("agents/{agentId}/files/{fileName}")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawAgentFileWriteDto>> SetAgentFile(
|
|
string agentId,
|
|
string fileName,
|
|
[FromBody] UpdateOpenClawAgentFileRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!TryBuildInvocation(out var invocation, out var validationError))
|
|
return validationError!;
|
|
|
|
return await ExecuteAsync(() => configuration.SetAgentFileAsync(
|
|
agentId,
|
|
fileName,
|
|
request,
|
|
invocation!,
|
|
cancellationToken));
|
|
}
|
|
|
|
[HttpGet("agents/{agentId}/workspace")]
|
|
public Task<ActionResult<OpenClawWorkspaceCollectionDto>> GetWorkspace(
|
|
string agentId,
|
|
[FromQuery] string? path = null,
|
|
[FromQuery] int offset = 0,
|
|
[FromQuery] int limit = 250,
|
|
CancellationToken cancellationToken = default)
|
|
=> ExecuteAsync(() => configuration.GetWorkspaceAsync(
|
|
agentId,
|
|
path,
|
|
offset,
|
|
limit,
|
|
cancellationToken));
|
|
|
|
[HttpGet("agents/{agentId}/workspace/file")]
|
|
public Task<ActionResult<OpenClawWorkspaceFileDto>> GetWorkspaceFile(
|
|
string agentId,
|
|
[FromQuery] string path,
|
|
CancellationToken cancellationToken)
|
|
=> ExecuteAsync(() => configuration.GetWorkspaceFileAsync(
|
|
agentId,
|
|
path,
|
|
cancellationToken));
|
|
|
|
[HttpGet("config/schema")]
|
|
public Task<ActionResult<OpenClawConfigSchemaLookupDto>> GetConfigSchema(
|
|
[FromQuery] string path,
|
|
CancellationToken cancellationToken)
|
|
=> ExecuteAsync(() => configuration.GetConfigSchemaAsync(path, cancellationToken));
|
|
|
|
[HttpGet("config")]
|
|
public Task<ActionResult<OpenClawConfigSnapshotDto>> GetConfig(
|
|
CancellationToken cancellationToken)
|
|
=> ExecuteAsync(() => configuration.GetConfigAsync(cancellationToken));
|
|
|
|
[HttpPatch("config")]
|
|
[EnableRateLimiting("agents")]
|
|
public async Task<ActionResult<OpenClawConfigPatchDto>> PatchConfig(
|
|
[FromBody] PatchOpenClawConfigRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!TryBuildInvocation(out var invocation, out var validationError))
|
|
return validationError!;
|
|
|
|
return await ExecuteAsync(() => configuration.PatchConfigAsync(
|
|
request,
|
|
invocation!,
|
|
cancellationToken));
|
|
}
|
|
|
|
private async Task<ActionResult<T>> ExecuteAsync<T>(Func<Task<T>> action)
|
|
{
|
|
try
|
|
{
|
|
return Ok(await action());
|
|
}
|
|
catch (OpenClawAgentConfigurationValidationException exception)
|
|
{
|
|
return new BadRequestObjectResult(new ValidationProblemDetails(
|
|
new Dictionary<string, string[]>
|
|
{
|
|
[exception.Field] = [exception.Message]
|
|
}));
|
|
}
|
|
catch (OpenClawAgentConfigurationConflictException exception)
|
|
{
|
|
return StatusCode(
|
|
StatusCodes.Status409Conflict,
|
|
new
|
|
{
|
|
code = exception.Code,
|
|
message = exception.Message,
|
|
expectedHash = exception.ExpectedHash,
|
|
currentHash = exception.CurrentHash
|
|
});
|
|
}
|
|
catch (OpenClawAgentConfigurationUnavailableException exception)
|
|
{
|
|
var status = exception.State switch
|
|
{
|
|
"forbidden" or "management_disabled" => StatusCodes.Status403Forbidden,
|
|
"disconnected" => StatusCodes.Status503ServiceUnavailable,
|
|
_ => StatusCodes.Status409Conflict
|
|
};
|
|
return StatusCode(
|
|
status,
|
|
new OpenClawAgentConfigurationErrorDto(
|
|
exception.State,
|
|
exception.Message,
|
|
exception.Method,
|
|
exception.RequiredScope));
|
|
}
|
|
catch (OpenClawAgentConfigurationVerificationException)
|
|
{
|
|
return StatusCode(
|
|
StatusCodes.Status502BadGateway,
|
|
new OpenClawAgentConfigurationErrorDto(
|
|
"verification_failed",
|
|
"OpenClaw-Antwort konnte nicht sicher verifiziert werden."));
|
|
}
|
|
catch (OpenClawGatewayRpcException exception)
|
|
{
|
|
var code = exception.Code.ToUpperInvariant();
|
|
var status = code switch
|
|
{
|
|
"FORBIDDEN" or "AUTH_SCOPE_MISMATCH" => StatusCodes.Status403Forbidden,
|
|
"INVALID_REQUEST" or "BAD_REQUEST" => StatusCodes.Status400BadRequest,
|
|
"METHOD_UNAVAILABLE" or "METHOD_NOT_FOUND" or "NOT_IMPLEMENTED" =>
|
|
StatusCodes.Status409Conflict,
|
|
"GATEWAY_DISCONNECTED" or "UNAVAILABLE" => StatusCodes.Status503ServiceUnavailable,
|
|
"GATEWAY_TIMEOUT" or "TIMEOUT" => StatusCodes.Status504GatewayTimeout,
|
|
_ when code.StartsWith("AUTH_", StringComparison.Ordinal) ||
|
|
code.StartsWith("DEVICE_AUTH_", StringComparison.Ordinal) =>
|
|
StatusCodes.Status403Forbidden,
|
|
_ => StatusCodes.Status502BadGateway
|
|
};
|
|
return StatusCode(
|
|
status,
|
|
new OpenClawAgentConfigurationErrorDto(
|
|
code.ToLowerInvariant(),
|
|
SafeGatewayMessage(status)));
|
|
}
|
|
}
|
|
|
|
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 = new BadRequestObjectResult(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 correlationId = Request.Headers["X-Correlation-ID"].FirstOrDefault()?.Trim();
|
|
if (!string.IsNullOrEmpty(correlationId) &&
|
|
(correlationId.Length > 128 || correlationId.Any(char.IsControl)))
|
|
{
|
|
validationError = new BadRequestObjectResult(new ValidationProblemDetails(
|
|
new Dictionary<string, string[]>
|
|
{
|
|
["X-Correlation-ID"] =
|
|
[
|
|
"X-Correlation-ID must contain at most 128 non-control characters."
|
|
]
|
|
}));
|
|
return false;
|
|
}
|
|
|
|
var actor = User.FindFirst("sub")?.Value
|
|
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
|
?? User.FindFirst(ClaimTypes.Email)?.Value
|
|
?? User.Identity?.Name
|
|
?? "authenticated-owner";
|
|
var traceParent = Request.Headers["traceparent"].FirstOrDefault()?.Trim();
|
|
try
|
|
{
|
|
invocation = OpenClawInvocationContext.Create(
|
|
actor,
|
|
idempotencyKey,
|
|
correlationId,
|
|
traceParent,
|
|
includeIdempotencyParameter: false);
|
|
}
|
|
catch (ArgumentException exception)
|
|
{
|
|
validationError = new BadRequestObjectResult(new ValidationProblemDetails(
|
|
new Dictionary<string, string[]>
|
|
{
|
|
["traceparent"] = [exception.Message]
|
|
}));
|
|
return false;
|
|
}
|
|
|
|
Response.Headers["Idempotency-Key"] = invocation.IdempotencyKey;
|
|
Response.Headers["X-Correlation-ID"] = invocation.CorrelationId;
|
|
return true;
|
|
}
|
|
|
|
private static string SafeGatewayMessage(int status)
|
|
=> status switch
|
|
{
|
|
StatusCodes.Status400BadRequest => "OpenClaw hat die Anfrage als ungültig abgelehnt.",
|
|
StatusCodes.Status403Forbidden => "OpenClaw hat Nexus nicht die erforderliche Berechtigung gewährt.",
|
|
StatusCodes.Status409Conflict => "Die verbundene OpenClaw-Version unterstützt diese Aktion nicht.",
|
|
StatusCodes.Status503ServiceUnavailable => "OpenClaw Gateway ist nicht verfügbar.",
|
|
StatusCodes.Status504GatewayTimeout => "OpenClaw hat nicht rechtzeitig geantwortet.",
|
|
_ => "OpenClaw-Anfrage ist fehlgeschlagen."
|
|
};
|
|
}
|