feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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."
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user