Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 184 additions & 96 deletions src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs

Large diffs are not rendered by default.

186 changes: 131 additions & 55 deletions src/OpenClaw.SetupEngine/SetupWizardRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ public sealed class SetupWizardRunner
private const int MaxWizardSteps = 50;
private const int MaxSameStepVisits = 3;
private static readonly Regex s_normalizeKeyRegex = new("[^a-z0-9]+", RegexOptions.Compiled);

// Progress steps can repeat while background work runs; keep bounded caps
// so setup fails with a diagnostic instead of hanging.

private readonly SetupContext _ctx;

public SetupWizardRunner(SetupContext ctx)
Expand Down Expand Up @@ -84,7 +88,45 @@ public async Task<StepResult> RunAsync(CancellationToken ct)

var visits = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var restartAttempts = 0;
for (var i = 0; i < MaxWizardSteps; i++)
var progressPolls = 0;
var totalProgressPolls = 0;
var lastProgressStepId = "";
var interactiveSteps = 0;

// A reconnect restarts the wizard session, so reset replay-scoped
// counters before processing the replacement start payload.
async Task<JsonElement> SendWizardNextAsync(object parameters, int timeoutMs)
{
try
{
return await client!.SendWizardRequestAsync("wizard.next", parameters, timeoutMs);
}
catch (Exception ex) when (!ct.IsCancellationRequested && IsRestartLikeWizardDisconnect(ex) && restartAttempts < 2)
{
restartAttempts++;
_ctx.Logger.Warn($"Gateway restarted during wizard; reconnecting and replaying answers (attempt {restartAttempts}/2): {ex.Message}");

try { await client!.DisconnectAsync(); } catch { }
client!.Dispose();

await Task.Delay(TimeSpan.FromSeconds(3), ct);
client = CreateWizardClient(credential, identityPath, wsLogger);
var reconnect = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(30), ct);
if (reconnect != PairOperatorStep.ConnectionOutcome.Connected)
throw new WizardFatalException($"Gateway wizard reconnect failed after restart: {reconnect}");

sessionId = "";
visits.Clear();
discoveredSteps.Clear();
interactiveSteps = 0;
progressPolls = 0;
totalProgressPolls = 0;
lastProgressStepId = "";
return await client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000);
}
}

while (true)
{
ct.ThrowIfCancellationRequested();

Expand Down Expand Up @@ -123,6 +165,39 @@ public async Task<StepResult> RunAsync(CancellationToken ct)
if (string.IsNullOrWhiteSpace(parsed.StepId))
return StepResult.Fail("Gateway wizard step is missing an id.");

var category = WizardStepClassifier.Categorize(parsed.StepType, parsed.Options.Count > 0);

// Progress carries no answer; poll until the gateway emits the
// next interactive step or reaches a bounded failure.
if (category == WizardStepCategory.Progress)
{
if (!string.Equals(parsed.StepId, lastProgressStepId, StringComparison.Ordinal))
{
lastProgressStepId = parsed.StepId;
progressPolls = 0;
}

progressPolls++;
totalProgressPolls++;
if (progressPolls > WizardTimeouts.MaxProgressPollsPerStep)
return StepResult.Fail($"Gateway wizard progress step '{parsed.StepId}' did not complete after {WizardTimeouts.MaxProgressPollsPerStep} polls.");
if (totalProgressPolls > WizardTimeouts.MaxTotalProgressPolls)
return StepResult.Fail($"Gateway wizard did not finish after {WizardTimeouts.MaxTotalProgressPolls} progress updates.");

var progressText = $"{parsed.Title} {parsed.Message}".Trim();
_ctx.Logger.Info(string.IsNullOrWhiteSpace(progressText)
? $"Wizard progress step '{parsed.StepId}' — polling for next step"
: $"Wizard progress: {progressText}");

await Task.Delay(WizardTimeouts.ProgressPollDelay, ct);
payload = await SendWizardNextAsync(WizardNextPayload.Acknowledge(sessionId, parsed.StepId), TimeoutFor(parsed));
continue;
}

interactiveSteps++;
if (interactiveSteps > MaxWizardSteps)
return StepResult.Fail($"Gateway wizard exceeded {MaxWizardSteps} steps.");

var visitKey = $"{parsed.StepId}:{parsed.StepIndex}";
visits.TryGetValue(visitKey, out var visitCount);
visits[visitKey] = visitCount + 1;
Expand All @@ -142,9 +217,9 @@ public async Task<StepResult> RunAsync(CancellationToken ct)

_ctx.Logger.Info(answerResult.HasAnswer
? $"Wizard step '{parsed.StepId}' ({parsed.StepType}, key={StableAnswerKey(parsed.Title, parsed.Message, parsed.StepId)}) answered with {(parsed.Sensitive ? "[sensitive]" : $"'{answerResult.Answer}'")}"
: $"Wizard step '{parsed.StepId}' ({parsed.StepType}) continuing without explicit answer");
: $"Wizard step '{parsed.StepId}' ({parsed.StepType}, {category}) continuing without explicit answer");

var parameters = answerResult.HasAnswer
object parameters = answerResult.HasAnswer
? new
{
sessionId,
Expand All @@ -154,42 +229,21 @@ public async Task<StepResult> RunAsync(CancellationToken ct)
value = AnswerValueForWire(parsed, answerResult.Answer)
}
}
: (object)new { sessionId };
: WizardNextPayload.Acknowledge(sessionId, parsed.StepId);

try
{
payload = await client.SendWizardRequestAsync("wizard.next", parameters, timeoutMs: TimeoutFor(parsed));
}
catch (Exception ex) when (!ct.IsCancellationRequested && IsRestartLikeWizardDisconnect(ex) && restartAttempts < 2)
{
restartAttempts++;
_ctx.Logger.Warn($"Gateway restarted during wizard; reconnecting and replaying answers (attempt {restartAttempts}/2): {ex.Message}");

// slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved.
try { await client.DisconnectAsync(); } catch { }
client.Dispose();

await Task.Delay(TimeSpan.FromSeconds(3), ct);
client = CreateWizardClient(credential, identityPath, wsLogger);
var reconnect = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(30), ct);
if (reconnect != PairOperatorStep.ConnectionOutcome.Connected)
return StepResult.Fail($"Gateway wizard reconnect failed after restart: {reconnect}");

sessionId = "";
visits.Clear();
discoveredSteps.Clear();
payload = await client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000);
}
payload = await SendWizardNextAsync(parameters, TimeoutFor(parsed));
}

return StepResult.Fail($"Gateway wizard exceeded {MaxWizardSteps} steps.");
}
catch (OperationCanceledException)
{
if (client is not null && wizardStarted && !string.IsNullOrWhiteSpace(sessionId))
await TryCancelWizardAsync(client, sessionId);
throw;
}
catch (WizardFatalException ex)
{
return StepResult.Fail(ex.Message, ex);
}
catch (Exception ex)
{
return StepResult.Fail($"Gateway wizard failed: {ex.Message}", ex);
Expand Down Expand Up @@ -225,6 +279,7 @@ private async Task TryCancelWizardAsync(OpenClawGatewayClient client, string ses
_ctx.Logger.Warn("Cancelling gateway wizard session");
await client.SendWizardRequestAsync("wizard.cancel", new { sessionId }, timeoutMs: 10_000);
}

catch (Exception ex)
{
_ctx.Logger.Warn($"Failed to cancel gateway wizard session: {ex.Message}");
Expand Down Expand Up @@ -291,19 +346,34 @@ private static AnswerResolution ResolveAnswer(WizardPayload step, Dictionary<str
if (TryGetConfiguredAnswer(step, configuredAnswers, out var configured))
return ValidateAnswer(step, configured, configuredAnswer: true);

var inferred = step.StepType switch
var category = WizardStepClassifier.Categorize(step.StepType, step.Options.Count > 0);
if (WizardStepClassifier.ContinuesWithoutAnswer(category))
{
"note" => "true",
"confirm" => InferConfirmAnswer(step),
"select" => InferOptionAnswer(step),
"multiselect" => InferOptionAnswer(step),
"text" => InferTextAnswer(step),
_ => !string.IsNullOrWhiteSpace(step.InitialValue) ? step.InitialValue : null
};
return AnswerResolution.Continue();
}

switch (category)
{
case WizardStepCategory.Acknowledge:
return AnswerResolution.Ok("true");

case WizardStepCategory.Confirm:
return ValidateAnswer(step, InferConfirmAnswer(step), configuredAnswer: false);
}

// Unknown types with options are choice prompts for wire-shaping purposes.
var inferred = step.Options.Count > 0 && step.StepType != "text"
? InferOptionAnswer(step)
: step.StepType switch
{
"select" or "multiselect" => InferOptionAnswer(step),
"text" => InferTextAnswer(step),
_ => !string.IsNullOrWhiteSpace(step.InitialValue) ? step.InitialValue : null
};

if (inferred == null)
{
return AnswerResolution.Fail($"Gateway wizard step '{step.StepId}' ({step.StepType}) requires a text answer.");
return AnswerResolution.Fail($"Gateway wizard step '{step.StepId}' ({step.StepType}) requires a value that was not provided.");
}

return ValidateAnswer(step, inferred, configuredAnswer: false);
Expand Down Expand Up @@ -337,7 +407,11 @@ private static string InferConfirmAnswer(WizardPayload step)

private static object AnswerValueForWire(WizardPayload step, string answer)
{
return WizardAnswerBuilder.BuildWireValue(step.StepType, answer, step.Options);
// Preserve the selected option's raw JSON value for unknown choice-style steps.
var effectiveType = step.Options.Count > 0 && step.StepType is not ("select" or "multiselect" or "text")
? "select"
: step.StepType;
return WizardAnswerBuilder.BuildWireValue(effectiveType, answer, step.Options);
}

private static string? InferTextAnswer(WizardPayload step)
Expand Down Expand Up @@ -404,17 +478,7 @@ private static bool TryGetConfiguredAnswer(WizardPayload step, Dictionary<string
return false;
}

private static int TimeoutFor(WizardPayload step)
{
var text = $"{step.Title} {step.Message}";
return text.Contains("device", StringComparison.OrdinalIgnoreCase)
|| text.Contains("authorize", StringComparison.OrdinalIgnoreCase)
|| text.Contains("login", StringComparison.OrdinalIgnoreCase)
|| text.Contains("sign in", StringComparison.OrdinalIgnoreCase)
|| text.Contains("oauth", StringComparison.OrdinalIgnoreCase)
? 300_000
: 30_000;
}
private static int TimeoutFor(WizardPayload step) => WizardTimeouts.ForStep(step.Title, step.Message);

private static bool IsRestartLikeWizardDisconnect(Exception ex)
{
Expand All @@ -434,7 +498,11 @@ private static string AnswerPlaceholderFor(WizardTemplateStep step)
{
"select" => step.Options.FirstOrDefault()?.Value ?? "<select one option value>",
"multiselect" => "<comma-separated option values>",
"text" => step.Sensitive ? "<sensitive value>" : "<text value>",
"text" => step.Sensitive
? "<sensitive value>"
: step.AuthUrls.Count > 0
? $"<value obtained from: {step.AuthUrls[0]}>"
: "<text value>",
_ => step.SuggestedAnswer ?? "true"
};
}
Expand All @@ -461,8 +529,12 @@ private sealed record AnswerResolution(bool Success, bool HasAnswer, string Answ
{
public static AnswerResolution Ok(string answer) => new(true, true, answer, null);
public static AnswerResolution Fail(string error) => new(false, false, "", error);

public static AnswerResolution Continue() => new(true, false, "", null);
}

private sealed class WizardFatalException(string message) : Exception(message);

private sealed record WizardPayload(
bool IsDone,
string? SessionId,
Expand Down Expand Up @@ -499,7 +571,8 @@ public static WizardPayload Parse(JsonElement payload)
if (!payload.TryGetProperty("step", out var step) || step.ValueKind != JsonValueKind.Object)
return ErrorPayload("Gateway wizard response is missing a step object.");

var type = step.TryGetProperty("type", out var typeProperty) ? typeProperty.ToString() : "note";
var rawType = step.TryGetProperty("type", out var typeProperty) ? typeProperty.ToString() : "note";
var type = string.IsNullOrWhiteSpace(rawType) ? "note" : rawType.Trim().ToLowerInvariant();
var title = step.TryGetProperty("title", out var titleProperty) ? titleProperty.ToString() : "";
var message = step.TryGetProperty("message", out var messageProperty) ? messageProperty.ToString() : "";
var stepId = step.TryGetProperty("id", out var idProperty) ? idProperty.ToString() : "";
Expand Down Expand Up @@ -530,7 +603,8 @@ private sealed record WizardTemplateStep(
string Message,
bool Sensitive,
string? SuggestedAnswer,
IReadOnlyList<WizardOptionValue> Options)
IReadOnlyList<WizardOptionValue> Options,
IReadOnlyList<string> AuthUrls)
{
public static WizardTemplateStep From(WizardPayload payload)
{
Expand All @@ -549,7 +623,9 @@ public static WizardTemplateStep From(WizardPayload payload)
if (payload.Sensitive && !string.IsNullOrWhiteSpace(suggested))
suggested = "<sensitive value>";

return new(payload.StepId, payload.StepType, payload.Title, payload.Message, payload.Sensitive, suggested, payload.Options);
var authUrls = WizardMessageFormatting.ExtractUrls($"{payload.Title}\n{payload.Message}");

return new(payload.StepId, payload.StepType, payload.Title, payload.Message, payload.Sensitive, suggested, payload.Options, authUrls);
}
}
}
90 changes: 90 additions & 0 deletions src/OpenClaw.SetupEngine/WizardMessageFormatting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Text.RegularExpressions;

namespace OpenClaw.SetupEngine;

/// <summary>The kind of content a single wizard message line carries.</summary>
public enum WizardLineKind
{
/// <summary>Plain text.</summary>
Text,

/// <summary>Contains an http(s) URL (e.g. an OAuth/device-authorization link).</summary>
Url,

/// <summary>A "Code: XXXX" style device/pairing code the user must enter elsewhere.</summary>
Code,
}

/// <summary>A classified wizard message line with an optional highlighted URL or code.</summary>
public sealed record WizardLineSegment(
WizardLineKind Kind,
string Text,
string Prefix,
string Highlight,
string Suffix);

/// <summary>Detects actionable URLs and manual entry codes in wizard messages.</summary>
public static class WizardMessageFormatting
{
// "Code: ABCD-EFGH" / "user_code = ABC123" style manual entry codes.
private static readonly Regex s_codeRegex = new(
@"^((?:Code|code|user_code|USER_CODE|User Code)\s*[:=]\s*)([A-Z0-9]{2,8}(?:-[A-Z0-9]{2,8})+|[A-Z0-9]{4,12})\b",
RegexOptions.Compiled);

private static readonly Regex s_urlRegex = new(
@"https?://[^\s\)\""]+",
RegexOptions.Compiled | RegexOptions.IgnoreCase);

/// <summary>Classifies a line as plain text, URL, or device-code text.</summary>
public static WizardLineSegment ClassifyLine(string? line)
{
var trimmed = (line ?? string.Empty).TrimEnd('\r');

var codeMatch = s_codeRegex.Match(trimmed);
if (codeMatch.Success)
{
return new WizardLineSegment(
WizardLineKind.Code,
trimmed,
Prefix: codeMatch.Groups[1].Value,
Highlight: codeMatch.Groups[2].Value,
Suffix: string.Empty);
}

var urlMatch = s_urlRegex.Match(trimmed);
if (urlMatch.Success)
{
var url = urlMatch.Value.TrimEnd('.', ',');
if (Uri.TryCreate(url, UriKind.Absolute, out _))
{
var index = trimmed.IndexOf(urlMatch.Value, StringComparison.Ordinal);
var prefix = index > 0 ? trimmed[..index] : string.Empty;
var suffix = trimmed[(index + urlMatch.Value.Length)..];
return new WizardLineSegment(WizardLineKind.Url, trimmed, prefix, url, suffix);
}
}

return new WizardLineSegment(WizardLineKind.Text, trimmed, trimmed, string.Empty, string.Empty);
}

/// <summary>Returns all distinct absolute http(s) URLs found in a message.</summary>
public static IReadOnlyList<string> ExtractUrls(string? message)
{
if (string.IsNullOrWhiteSpace(message))
return Array.Empty<string>();

var urls = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in s_urlRegex.Matches(message))
{
var url = match.Value.TrimEnd('.', ',');
if (Uri.TryCreate(url, UriKind.Absolute, out _) && seen.Add(url))
urls.Add(url);
}

return urls;
}

/// <summary>True when the message contains at least one actionable auth URL.</summary>
public static bool ContainsAuthUrl(string? message) => ExtractUrls(message).Count > 0;
}
Loading
Loading