using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Nexus.Api.Models; using Nexus.Api.Services; namespace Nexus.Api.Controllers; /// /// Authenticated, browser-safe OpenClaw control-plane facade. /// Gateway credentials and raw protocol payloads remain inside the backend. /// [Authorize] [ApiController] [Route("api/v1/openclaw")] public sealed class OpenClawController(IOpenClawControlService openClaw) : ControllerBase { [HttpGet("connection")] [ProducesResponseType(typeof(OpenClawConnectionDto), StatusCodes.Status200OK)] public IResult GetConnection() => Results.Ok(openClaw.GetConnection()); [HttpGet("capabilities")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] public IResult GetCapabilities() => Results.Ok(openClaw.GetCapabilities()); [HttpGet("overview")] [ProducesResponseType(typeof(OpenClawOverviewDto), StatusCodes.Status200OK)] public async Task GetOverview(CancellationToken cancellationToken) => Results.Ok(await openClaw.GetOverviewAsync(cancellationToken)); [HttpGet("tasks")] [ProducesResponseType(typeof(OpenClawCollectionDto), StatusCodes.Status200OK)] public async Task GetTasks( [FromQuery] int limit = 100, [FromQuery] string? cursor = null, CancellationToken cancellationToken = default) => Results.Ok(await openClaw.GetTasksAsync(limit, cursor, cancellationToken)); [HttpPost("tasks/{taskId}/cancel")] [Authorize(Roles = "owner")] [EnableRateLimiting("agents")] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] public async Task CancelTask( string taskId, [FromBody] CancelOpenClawTaskRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(taskId)) return Results.ValidationProblem(new Dictionary { ["taskId"] = ["Task id is required."] }); return Results.Ok(await openClaw.CancelTaskAsync( taskId, request.Reason, cancellationToken)); } [HttpGet("sessions")] [ProducesResponseType(typeof(OpenClawCollectionDto), StatusCodes.Status200OK)] public async Task GetSessions( [FromQuery] int limit = 100, CancellationToken cancellationToken = default) => Results.Ok(await openClaw.GetSessionsAsync(limit, cancellationToken)); [HttpPost("sessions/abort")] [Authorize(Roles = "owner")] [EnableRateLimiting("agents")] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] public async Task AbortSession( [FromBody] AbortOpenClawSessionRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.SessionKey)) return Results.ValidationProblem(new Dictionary { ["sessionKey"] = ["Session key is required."] }); return Results.Ok(await openClaw.AbortSessionAsync( request.SessionKey, request.RunId, request.ClearQueued, cancellationToken)); } [HttpPost("sessions/model")] [Authorize(Roles = "owner")] [EnableRateLimiting("agents")] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] public async Task PatchSessionModel( [FromBody] PatchOpenClawSessionModelRequest request, CancellationToken cancellationToken) { var errors = new Dictionary(); if (string.IsNullOrWhiteSpace(request.SessionKey)) errors["sessionKey"] = ["Session key is required."]; if (string.IsNullOrWhiteSpace(request.Model)) errors["model"] = ["Model is required."]; if (errors.Count > 0) return Results.ValidationProblem(errors); return Results.Ok(await openClaw.PatchSessionModelAsync( request.SessionKey, request.Model, cancellationToken)); } [HttpGet("cron")] [ProducesResponseType(typeof(OpenClawCollectionDto), StatusCodes.Status200OK)] public async Task GetCronJobs( [FromQuery] bool includeDisabled = true, [FromQuery] int limit = 100, [FromQuery] string? cursor = null, CancellationToken cancellationToken = default) => Results.Ok(await openClaw.GetCronJobsAsync( includeDisabled, limit, cursor, cancellationToken)); [HttpGet("cron/{jobId}")] [Authorize(Roles = "owner")] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] public async Task GetCronJob( string jobId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(jobId)) return MissingCronJobId(); var result = await openClaw.GetCronJobAsync(jobId, cancellationToken); return result.State == "not_found" ? Results.NotFound(result) : Results.Ok(result); } [HttpGet("cron/{jobId}/runs")] [ProducesResponseType(typeof(OpenClawCollectionDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] public async Task GetCronRuns( string jobId, [FromQuery] int limit = 100, [FromQuery] string? cursor = null, [FromQuery] string? runId = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(jobId)) return MissingCronJobId(); return Results.Ok(await openClaw.GetCronRunsAsync( jobId, limit, cursor, runId, cancellationToken)); } [HttpPost("cron")] [Authorize(Roles = "owner")] [EnableRateLimiting("agents")] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status403Forbidden)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status409Conflict)] public async Task CreateCronJob( [FromBody] CreateOpenClawCronJobRequest request, CancellationToken cancellationToken) { var headerError = RequireIdempotencyKey(); if (headerError is not null) return headerError; return CronMutationResult(await openClaw.CreateCronJobAsync( request, cancellationToken)); } [HttpPatch("cron/{jobId}")] [Authorize(Roles = "owner")] [EnableRateLimiting("agents")] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status403Forbidden)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status409Conflict)] public async Task PatchCronJob( string jobId, [FromBody] PatchOpenClawCronJobRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(jobId)) return MissingCronJobId(); if (request.Patch is null) return Results.ValidationProblem(new Dictionary { ["patch"] = ["Cron patch is required."] }); var headerError = RequireIdempotencyKey(); if (headerError is not null) return headerError; var expectedHash = ResolveExpectedHash(request.ExpectedHash); if (string.IsNullOrWhiteSpace(expectedHash)) return MissingExpectedHash(); return CronMutationResult(await openClaw.PatchCronJobAsync( jobId, request.Patch, expectedHash, cancellationToken)); } [HttpDelete("cron/{jobId}")] [Authorize(Roles = "owner")] [EnableRateLimiting("agents")] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status403Forbidden)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status409Conflict)] public async Task DeleteCronJob( string jobId, [FromQuery] string? expectedHash = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(jobId)) return MissingCronJobId(); var headerError = RequireIdempotencyKey(); if (headerError is not null) return headerError; expectedHash = ResolveExpectedHash(expectedHash); if (string.IsNullOrWhiteSpace(expectedHash)) return MissingExpectedHash(); return CronMutationResult(await openClaw.DeleteCronJobAsync( jobId, expectedHash, cancellationToken)); } [HttpPost("cron/{jobId}/run")] [Authorize(Roles = "owner")] [EnableRateLimiting("agents")] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status403Forbidden)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status409Conflict)] public async Task RunCronJob( string jobId, [FromQuery] string? expectedHash = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(jobId)) return MissingCronJobId(); var headerError = RequireIdempotencyKey(); if (headerError is not null) return headerError; expectedHash = ResolveExpectedHash(expectedHash); if (string.IsNullOrWhiteSpace(expectedHash)) return MissingExpectedHash(); return CronMutationResult(await openClaw.RunCronJobAsync( jobId, expectedHash, cancellationToken)); } [HttpGet("approvals")] [ProducesResponseType(typeof(OpenClawCollectionDto), StatusCodes.Status200OK)] public async Task GetApprovals( [FromQuery] int limit = 100, CancellationToken cancellationToken = default) => Results.Ok(await openClaw.GetApprovalsAsync(limit, cancellationToken)); [HttpPost("approvals/{approvalId}/resolve")] [Authorize(Roles = "owner")] [EnableRateLimiting("agents")] [ProducesResponseType(typeof(OpenClawOperationDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] public async Task ResolveApproval( string approvalId, [FromBody] ResolveOpenClawApprovalRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(approvalId)) return Results.ValidationProblem(new Dictionary { ["approvalId"] = ["Approval id is required."] }); if (string.IsNullOrWhiteSpace(request.Kind)) return Results.ValidationProblem(new Dictionary { ["kind"] = ["Approval kind is required."] }); return Results.Ok(await openClaw.ResolveApprovalAsync( approvalId, request.Kind, request.Decision, cancellationToken)); } [HttpGet("activity")] [ProducesResponseType(typeof(OpenClawCollectionDto), StatusCodes.Status200OK)] public async Task GetActivity( [FromQuery] int limit = 100, [FromQuery] string? cursor = null, CancellationToken cancellationToken = default) => Results.Ok(await openClaw.GetActivityAsync(limit, cursor, cancellationToken)); [HttpGet("models")] [ProducesResponseType(typeof(OpenClawCollectionDto), StatusCodes.Status200OK)] public async Task GetModels(CancellationToken cancellationToken) => Results.Ok(await openClaw.GetModelsAsync(cancellationToken)); [HttpGet("models/auth-status")] [ProducesResponseType(typeof(OpenClawCollectionDto), StatusCodes.Status200OK)] public async Task GetModelAuthStatus( [FromQuery] bool refresh = false, CancellationToken cancellationToken = default) => Results.Ok(await openClaw.GetModelAuthStatusAsync(refresh, cancellationToken)); [HttpGet("agents")] [ProducesResponseType(typeof(OpenClawCollectionDto), StatusCodes.Status200OK)] public async Task GetAgents(CancellationToken cancellationToken) => Results.Ok(await openClaw.GetAgentsAsync(cancellationToken)); private string? ResolveExpectedHash(string? value) { if (!string.IsNullOrWhiteSpace(value)) return value; return Request.Headers.IfMatch.FirstOrDefault(); } private static IResult MissingCronJobId() => Results.ValidationProblem(new Dictionary { ["jobId"] = ["Cron job id is required."] }); private static IResult MissingExpectedHash() => Results.ValidationProblem(new Dictionary { ["expectedHash"] = [ "A current OpenClaw resource hash is required for this cron mutation." ] }); private IResult? RequireIdempotencyKey() { var value = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim(); if (!string.IsNullOrWhiteSpace(value) && value.Length <= 128 && !value.Any(char.IsControl)) { return null; } return Results.ValidationProblem(new Dictionary { ["Idempotency-Key"] = [ "A non-empty Idempotency-Key header with at most 128 non-control characters is required." ] }); } private static IResult CronMutationResult(OpenClawOperationDto result) { return result.State switch { "conflict" => Results.Json(result, statusCode: StatusCodes.Status409Conflict), "not_found" => Results.Json(result, statusCode: StatusCodes.Status404NotFound), "invalid" => Results.Json(result, statusCode: StatusCodes.Status400BadRequest), "restricted" or "management_disabled" or "forbidden" => Results.Json(result, statusCode: StatusCodes.Status403Forbidden), _ => Results.Ok(result) }; } }