Files
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

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)
};
}
}