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 proxy for sensitive OpenClaw agent files, workspace previews, /// and schema-validated configuration changes. /// [Authorize(Roles = "owner")] [ApiController] [Route("api/v1/openclaw")] public sealed class OpenClawAgentConfigurationController( IOpenClawAgentConfigurationService configuration) : ControllerBase { [HttpGet("agents/{agentId}/files")] public Task> GetAgentFiles( string agentId, CancellationToken cancellationToken) => ExecuteAsync(() => configuration.GetAgentFilesAsync(agentId, cancellationToken)); [HttpGet("agents/{agentId}/files/{fileName}")] public Task> GetAgentFile( string agentId, string fileName, CancellationToken cancellationToken) => ExecuteAsync(() => configuration.GetAgentFileAsync( agentId, fileName, cancellationToken)); [HttpPut("agents/{agentId}/files/{fileName}")] [EnableRateLimiting("agents")] public async Task> 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> 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> GetWorkspaceFile( string agentId, [FromQuery] string path, CancellationToken cancellationToken) => ExecuteAsync(() => configuration.GetWorkspaceFileAsync( agentId, path, cancellationToken)); [HttpGet("config/schema")] public Task> GetConfigSchema( [FromQuery] string path, CancellationToken cancellationToken) => ExecuteAsync(() => configuration.GetConfigSchemaAsync(path, cancellationToken)); [HttpGet("config")] public Task> GetConfig( CancellationToken cancellationToken) => ExecuteAsync(() => configuration.GetConfigAsync(cancellationToken)); [HttpPatch("config")] [EnableRateLimiting("agents")] public async Task> 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> ExecuteAsync(Func> action) { try { return Ok(await action()); } catch (OpenClawAgentConfigurationValidationException exception) { return new BadRequestObjectResult(new ValidationProblemDetails( new Dictionary { [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 { ["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 { ["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 { ["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." }; }