412 lines
15 KiB
C#
412 lines
15 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Nexus.Api.Models;
|
|
|
|
namespace Nexus.Api.Services;
|
|
|
|
/// <summary>
|
|
/// Secret-safe projection of OpenClaw's official gateway-driven onboarding
|
|
/// wizard. Nexus renders the protocol; it does not reimplement onboarding,
|
|
/// install packages, or accept provider credentials through the browser.
|
|
/// </summary>
|
|
public sealed class OpenClawWizardService(
|
|
IGatewayConnector connector,
|
|
ILogger<OpenClawWizardService> logger,
|
|
IOpenClawManagementState? managementState = null) : IOpenClawWizardService
|
|
{
|
|
private const int MaxAnswerBytes = 64 * 1024;
|
|
private readonly ConcurrentDictionary<string, WizardSessionState> sessions =
|
|
new(StringComparer.Ordinal);
|
|
|
|
public async Task<OpenClawWizardResultDto> StartAsync(
|
|
StartOpenClawWizardRequest request,
|
|
OpenClawInvocationContext invocation,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!request.Confirmed)
|
|
{
|
|
return Failure(
|
|
"confirmation_required",
|
|
"Der OpenClaw-Assistent wurde nicht gestartet.",
|
|
"Die schreibende OpenClaw-Einrichtung muss ausdrücklich bestätigt werden.");
|
|
}
|
|
|
|
var mode = request.Mode.Trim().ToLowerInvariant();
|
|
if (mode is not ("local" or "remote"))
|
|
{
|
|
return Failure(
|
|
"invalid",
|
|
"Der OpenClaw-Assistent wurde nicht gestartet.",
|
|
"mode muss local oder remote sein.");
|
|
}
|
|
|
|
var unavailable = CheckAvailability("wizard.start");
|
|
if (unavailable is not null)
|
|
return unavailable;
|
|
|
|
try
|
|
{
|
|
var response = await connector.InvokeAsync(
|
|
"wizard.start",
|
|
new JsonObject
|
|
{
|
|
["mode"] = mode,
|
|
["installDaemon"] = false,
|
|
["flow"] = "setup"
|
|
},
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: invocation with { IncludeIdempotencyParameter = false });
|
|
return MapResult(response, sessionId: null);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw wizard start failed");
|
|
return GatewayFailure(exception);
|
|
}
|
|
}
|
|
|
|
public async Task<OpenClawWizardResultDto> NextAsync(
|
|
AdvanceOpenClawWizardRequest request,
|
|
OpenClawInvocationContext invocation,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var sessionId = NormalizeSessionId(request.SessionId);
|
|
if (sessionId is null || !sessions.TryGetValue(sessionId, out var current))
|
|
{
|
|
return Failure(
|
|
"not_found",
|
|
"Die OpenClaw-Assistentensitzung ist nicht mehr verfügbar.",
|
|
"Assistent neu starten; abgeschlossene Sitzungen werden von OpenClaw entfernt.",
|
|
sessionId);
|
|
}
|
|
|
|
var unavailable = CheckAvailability("wizard.next");
|
|
if (unavailable is not null)
|
|
return unavailable with { SessionId = sessionId };
|
|
|
|
JsonObject? answer = null;
|
|
if (request.HasAnswer)
|
|
{
|
|
var stepId = request.StepId?.Trim();
|
|
if (string.IsNullOrWhiteSpace(stepId) ||
|
|
!string.Equals(stepId, current.StepId, StringComparison.Ordinal))
|
|
{
|
|
return Failure(
|
|
"conflict",
|
|
"Die Antwort gehört nicht zum aktuellen OpenClaw-Schritt.",
|
|
"Aktuellen Schritt neu laden und erneut antworten.",
|
|
sessionId);
|
|
}
|
|
if (current.Sensitive)
|
|
{
|
|
return Failure(
|
|
"server_secret_required",
|
|
"Dieser OpenClaw-Schritt erwartet ein Geheimnis.",
|
|
"Provider-Secret serverseitig als SecretRef bereitstellen und den offiziellen OpenClaw-Flow dort fortsetzen. Nexus nimmt keine Provider-Secrets aus dem Browser an.",
|
|
sessionId);
|
|
}
|
|
|
|
if (Encoding.UTF8.GetByteCount(request.Value?.ToJsonString() ?? "null") > MaxAnswerBytes)
|
|
{
|
|
return Failure(
|
|
"invalid",
|
|
"Die OpenClaw-Antwort ist zu groß.",
|
|
"Antwort auf höchstens 64 KiB reduzieren.",
|
|
sessionId);
|
|
}
|
|
|
|
answer = new JsonObject
|
|
{
|
|
["stepId"] = stepId,
|
|
["value"] = request.Value?.DeepClone()
|
|
};
|
|
}
|
|
|
|
try
|
|
{
|
|
var parameters = new JsonObject { ["sessionId"] = sessionId };
|
|
if (answer is not null)
|
|
parameters["answer"] = answer;
|
|
var response = await connector.InvokeAsync(
|
|
"wizard.next",
|
|
parameters,
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: invocation with { IncludeIdempotencyParameter = false });
|
|
return MapResult(response, sessionId);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw wizard next failed");
|
|
return GatewayFailure(exception, sessionId);
|
|
}
|
|
}
|
|
|
|
public async Task<OpenClawWizardResultDto> GetStatusAsync(
|
|
string sessionId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var normalized = NormalizeSessionId(sessionId);
|
|
if (normalized is null || !sessions.ContainsKey(normalized))
|
|
{
|
|
return Failure(
|
|
"not_found",
|
|
"Die OpenClaw-Assistentensitzung ist nicht mehr verfügbar.",
|
|
"Assistent neu starten.",
|
|
normalized);
|
|
}
|
|
|
|
var unavailable = CheckAvailability("wizard.status");
|
|
if (unavailable is not null)
|
|
return unavailable with { SessionId = normalized };
|
|
|
|
try
|
|
{
|
|
var response = await connector.InvokeAsync(
|
|
"wizard.status",
|
|
new JsonObject { ["sessionId"] = normalized },
|
|
cancellationToken: cancellationToken);
|
|
var status = SafeString(response?["status"], 40);
|
|
var error = SafeString(response?["error"], 1000);
|
|
if (status is not "running")
|
|
sessions.TryRemove(normalized, out _);
|
|
return new OpenClawWizardResultDto(
|
|
true,
|
|
status ?? "running",
|
|
status == "running"
|
|
? "Der OpenClaw-Assistent läuft."
|
|
: "Der OpenClaw-Assistent ist beendet.",
|
|
normalized,
|
|
status is "done" or "cancelled" or "error",
|
|
status,
|
|
error,
|
|
null,
|
|
error,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw wizard status failed");
|
|
return GatewayFailure(exception, normalized);
|
|
}
|
|
}
|
|
|
|
public async Task<OpenClawWizardResultDto> CancelAsync(
|
|
string sessionId,
|
|
OpenClawInvocationContext invocation,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var normalized = NormalizeSessionId(sessionId);
|
|
if (normalized is null || !sessions.ContainsKey(normalized))
|
|
{
|
|
return Failure(
|
|
"not_found",
|
|
"Die OpenClaw-Assistentensitzung ist nicht mehr verfügbar.",
|
|
"Es wurde nichts abgebrochen.",
|
|
normalized);
|
|
}
|
|
|
|
var unavailable = CheckAvailability("wizard.cancel");
|
|
if (unavailable is not null)
|
|
return unavailable with { SessionId = normalized };
|
|
|
|
try
|
|
{
|
|
var response = await connector.InvokeAsync(
|
|
"wizard.cancel",
|
|
new JsonObject { ["sessionId"] = normalized },
|
|
cancellationToken: cancellationToken,
|
|
invocationContext: invocation with { IncludeIdempotencyParameter = false });
|
|
sessions.TryRemove(normalized, out _);
|
|
return new OpenClawWizardResultDto(
|
|
true,
|
|
"cancelled",
|
|
"Der OpenClaw-Assistent wurde abgebrochen.",
|
|
normalized,
|
|
true,
|
|
SafeString(response?["status"], 40) ?? "cancelled",
|
|
SafeString(response?["error"], 1000),
|
|
null,
|
|
null,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "OpenClaw wizard cancel failed");
|
|
return GatewayFailure(exception, normalized);
|
|
}
|
|
}
|
|
|
|
private OpenClawWizardResultDto MapResult(JsonNode? raw, string? sessionId)
|
|
{
|
|
var response = OpenClawPayloadSanitizer.Redact(raw) as JsonObject ?? new JsonObject();
|
|
var resolvedSessionId = NormalizeSessionId(SafeString(response["sessionId"], 128))
|
|
?? NormalizeSessionId(sessionId);
|
|
var done = ReadBool(response["done"]) ?? false;
|
|
var status = SafeString(response["status"], 40) ?? (done ? "done" : "running");
|
|
var error = SafeString(response["error"], 1000);
|
|
var step = MapStep(response["step"]);
|
|
|
|
if (!done && resolvedSessionId is not null && step is not null)
|
|
sessions[resolvedSessionId] = new WizardSessionState(step.Id, step.Sensitive);
|
|
else if (resolvedSessionId is not null)
|
|
sessions.TryRemove(resolvedSessionId, out _);
|
|
|
|
var ok = error is null && status != "error";
|
|
return new OpenClawWizardResultDto(
|
|
ok,
|
|
done ? status : "waiting_for_input",
|
|
done
|
|
? "Der offizielle OpenClaw-Assistent ist beendet."
|
|
: step?.Sensitive == true
|
|
? "OpenClaw fordert einen serverseitigen Secret-Schritt an."
|
|
: "OpenClaw wartet auf den nächsten Schritt.",
|
|
resolvedSessionId,
|
|
done,
|
|
status,
|
|
error,
|
|
step,
|
|
step?.Sensitive == true
|
|
? "Provider-Secrets ausschließlich in OpenClaw oder als serverseitigen SecretRef konfigurieren."
|
|
: error,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private static OpenClawWizardStepDto? MapStep(JsonNode? value)
|
|
{
|
|
if (value is not JsonObject step)
|
|
return null;
|
|
var id = SafeString(step["id"], 256);
|
|
var type = SafeString(step["type"], 40);
|
|
if (string.IsNullOrWhiteSpace(id) ||
|
|
type is not ("note" or "select" or "text" or "confirm" or "multiselect" or "progress" or "action"))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var sensitive = ReadBool(step["sensitive"]) ?? false;
|
|
var options = step["options"] is JsonArray optionArray
|
|
? optionArray
|
|
.OfType<JsonObject>()
|
|
.Select(option => new OpenClawWizardOptionDto(
|
|
option["value"]?.DeepClone(),
|
|
SafeString(option["label"], 500) ?? "Option",
|
|
SafeString(option["hint"], 1000)))
|
|
.Take(200)
|
|
.ToArray()
|
|
: [];
|
|
var externalUrl = SafeHttpUrl(SafeString(step["externalUrl"], 2048));
|
|
var deviceCode = step["deviceCode"] is JsonObject device
|
|
? new OpenClawWizardDeviceCodeDto(
|
|
SafeString(device["code"], 256) ?? string.Empty,
|
|
ReadInt(device["expiresInMinutes"]),
|
|
SafeString(device["message"], 1000))
|
|
: null;
|
|
|
|
return new OpenClawWizardStepDto(
|
|
id,
|
|
type,
|
|
SafeString(step["title"], 500),
|
|
SafeString(step["message"], 4000),
|
|
options,
|
|
sensitive ? null : step["initialValue"]?.DeepClone(),
|
|
sensitive ? null : SafeString(step["placeholder"], 500),
|
|
sensitive,
|
|
SafeString(step["executor"], 40),
|
|
externalUrl,
|
|
deviceCode,
|
|
!sensitive,
|
|
sensitive
|
|
? "Nexus übernimmt keine Provider-Secrets aus dem Browser."
|
|
: null);
|
|
}
|
|
|
|
private OpenClawWizardResultDto? CheckAvailability(string method)
|
|
{
|
|
if (managementState is not null && !managementState.Enabled)
|
|
return Failure("management_disabled", "OpenClaw-Verwaltung ist in Nexus nicht freigegeben.", "Read-only-Adoption abschließen und Verwaltungsrechte bewusst aktivieren.");
|
|
if (connector.ConnectionState != GatewayConnectionState.Connected)
|
|
return Failure("disconnected", "OpenClaw Gateway ist nicht verbunden.", "Verbindung zuerst prüfen.");
|
|
if (!connector.Supports(method))
|
|
return Failure("unsupported", $"OpenClaw unterstützt {method} nicht.", "Gepinnte OpenClaw-Version prüfen.");
|
|
if (!connector.GrantedScopes.Contains("operator.admin"))
|
|
return Failure("scope_upgrade_required", "OpenClaw hat operator.admin nicht gewährt.", "Scope-Upgrade ausdrücklich pairen und erneut versuchen.");
|
|
return null;
|
|
}
|
|
|
|
private static OpenClawWizardResultDto GatewayFailure(Exception exception, string? sessionId = null)
|
|
{
|
|
var state = exception is OpenClawGatewayRpcException gateway
|
|
? gateway.Code.ToLowerInvariant()
|
|
: "gateway_error";
|
|
return Failure(
|
|
state,
|
|
"Der offizielle OpenClaw-Assistent konnte nicht fortgesetzt werden.",
|
|
"OpenClaw-Verbindung, Scope und Wizard-Status prüfen.",
|
|
sessionId);
|
|
}
|
|
|
|
private static OpenClawWizardResultDto Failure(
|
|
string state,
|
|
string message,
|
|
string recovery,
|
|
string? sessionId = null)
|
|
=> new(
|
|
false,
|
|
state,
|
|
message,
|
|
sessionId,
|
|
false,
|
|
null,
|
|
null,
|
|
null,
|
|
recovery,
|
|
DateTimeOffset.UtcNow);
|
|
|
|
private static string? NormalizeSessionId(string? value)
|
|
{
|
|
var normalized = value?.Trim();
|
|
return string.IsNullOrWhiteSpace(normalized) ||
|
|
normalized.Length > 128 ||
|
|
normalized.Any(char.IsControl)
|
|
? null
|
|
: normalized;
|
|
}
|
|
|
|
private static string? SafeString(JsonNode? value, int maxLength)
|
|
{
|
|
try
|
|
{
|
|
var text = value?.GetValue<string>()?.Trim();
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return null;
|
|
return text.Length <= maxLength ? text : $"{text[..(maxLength - 1)]}…";
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static string? SafeHttpUrl(string? value)
|
|
=> Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
|
|
uri.Scheme is "https" or "http"
|
|
? uri.ToString()
|
|
: null;
|
|
|
|
private static bool? ReadBool(JsonNode? value)
|
|
{
|
|
try { return value?.GetValue<bool>(); }
|
|
catch { return null; }
|
|
}
|
|
|
|
private static int? ReadInt(JsonNode? value)
|
|
{
|
|
try { return value?.GetValue<int>(); }
|
|
catch { return null; }
|
|
}
|
|
|
|
private sealed record WizardSessionState(string StepId, bool Sensitive);
|
|
}
|