391 lines
16 KiB
C#
391 lines
16 KiB
C#
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>
|
|
/// Authenticated, browser-safe OpenClaw control-plane facade.
|
|
/// Gateway credentials and raw protocol payloads remain inside the backend.
|
|
/// </summary>
|
|
[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<OpenClawCapabilityDto>), StatusCodes.Status200OK)]
|
|
public IResult GetCapabilities()
|
|
=> Results.Ok(openClaw.GetCapabilities());
|
|
|
|
[HttpGet("overview")]
|
|
[ProducesResponseType(typeof(OpenClawOverviewDto), StatusCodes.Status200OK)]
|
|
public async Task<IResult> GetOverview(CancellationToken cancellationToken)
|
|
=> Results.Ok(await openClaw.GetOverviewAsync(cancellationToken));
|
|
|
|
[HttpGet("tasks")]
|
|
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawTaskDto>), StatusCodes.Status200OK)]
|
|
public async Task<IResult> 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<OpenClawTaskDto>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
|
public async Task<IResult> CancelTask(
|
|
string taskId,
|
|
[FromBody] CancelOpenClawTaskRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(taskId))
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["taskId"] = ["Task id is required."]
|
|
});
|
|
|
|
return Results.Ok(await openClaw.CancelTaskAsync(
|
|
taskId,
|
|
request.Reason,
|
|
cancellationToken));
|
|
}
|
|
|
|
[HttpGet("sessions")]
|
|
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawSessionDto>), StatusCodes.Status200OK)]
|
|
public async Task<IResult> 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<object>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
|
public async Task<IResult> AbortSession(
|
|
[FromBody] AbortOpenClawSessionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.SessionKey))
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["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<object>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
|
public async Task<IResult> PatchSessionModel(
|
|
[FromBody] PatchOpenClawSessionModelRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var errors = new Dictionary<string, string[]>();
|
|
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<OpenClawCronJobDto>), StatusCodes.Status200OK)]
|
|
public async Task<IResult> 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<OpenClawCronJobDetailDto>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
|
public async Task<IResult> 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<OpenClawCronRunDto>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
|
public async Task<IResult> 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<OpenClawCronJobDetailDto>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status403Forbidden)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status409Conflict)]
|
|
public async Task<IResult> 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<OpenClawCronJobDetailDto>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status403Forbidden)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status409Conflict)]
|
|
public async Task<IResult> PatchCronJob(
|
|
string jobId,
|
|
[FromBody] PatchOpenClawCronJobRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(jobId))
|
|
return MissingCronJobId();
|
|
if (request.Patch is null)
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["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<object>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status403Forbidden)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status409Conflict)]
|
|
public async Task<IResult> 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<object>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status403Forbidden)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status409Conflict)]
|
|
public async Task<IResult> 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<OpenClawApprovalDto>), StatusCodes.Status200OK)]
|
|
public async Task<IResult> 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<OpenClawApprovalDto>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
|
public async Task<IResult> ResolveApproval(
|
|
string approvalId,
|
|
[FromBody] ResolveOpenClawApprovalRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(approvalId))
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["approvalId"] = ["Approval id is required."]
|
|
});
|
|
if (string.IsNullOrWhiteSpace(request.Kind))
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["kind"] = ["Approval kind is required."]
|
|
});
|
|
|
|
return Results.Ok(await openClaw.ResolveApprovalAsync(
|
|
approvalId,
|
|
request.Kind,
|
|
request.Decision,
|
|
cancellationToken));
|
|
}
|
|
|
|
[HttpGet("activity")]
|
|
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawActivityDto>), StatusCodes.Status200OK)]
|
|
public async Task<IResult> 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<OpenClawModelDto>), StatusCodes.Status200OK)]
|
|
public async Task<IResult> GetModels(CancellationToken cancellationToken)
|
|
=> Results.Ok(await openClaw.GetModelsAsync(cancellationToken));
|
|
|
|
[HttpGet("models/auth-status")]
|
|
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawModelAuthProviderDto>), StatusCodes.Status200OK)]
|
|
public async Task<IResult> GetModelAuthStatus(
|
|
[FromQuery] bool refresh = false,
|
|
CancellationToken cancellationToken = default)
|
|
=> Results.Ok(await openClaw.GetModelAuthStatusAsync(refresh, cancellationToken));
|
|
|
|
[HttpGet("agents")]
|
|
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawAgentDto>), StatusCodes.Status200OK)]
|
|
public async Task<IResult> 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<string, string[]>
|
|
{
|
|
["jobId"] = ["Cron job id is required."]
|
|
});
|
|
|
|
private static IResult MissingExpectedHash()
|
|
=> Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["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<string, string[]>
|
|
{
|
|
["Idempotency-Key"] =
|
|
[
|
|
"A non-empty Idempotency-Key header with at most 128 non-control characters is required."
|
|
]
|
|
});
|
|
}
|
|
|
|
private static IResult CronMutationResult<T>(OpenClawOperationDto<T> 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)
|
|
};
|
|
}
|
|
}
|