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