using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
///
/// 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.
///
public sealed class OpenClawWizardService(
IGatewayConnector connector,
ILogger logger,
IOpenClawManagementState? managementState = null) : IOpenClawWizardService
{
private const int MaxAnswerBytes = 64 * 1024;
private readonly ConcurrentDictionary sessions =
new(StringComparer.Ordinal);
public async Task 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 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 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 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()
.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()?.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(); }
catch { return null; }
}
private static int? ReadInt(JsonNode? value)
{
try { return value?.GetValue(); }
catch { return null; }
}
private sealed record WizardSessionState(string StepId, bool Sensitive);
}