feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user