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
15 changes: 15 additions & 0 deletions docs/SETUP_ENGINE_REDESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,21 @@ name such as `node` or `openclaw` alone is insufficient. Conflicts retain the
port-in-use error and include owning process names when available. Missing
listener ownership or a failed listener inspection does not bypass the check.

Setup operator and node sockets share the gateway record's full device identity.
Before the first operator or node pairing attempt, setup snapshots the pending
request IDs and retains that baseline across step retries. A gateway may refresh
the same request ID when the socket reconnects, so a fresh baseline on retry
would incorrectly classify setup's request as pre-existing. Finalization and
wizard sockets still take their own pre-connect snapshots. If a socket omits a
pairing request ID, setup selects exactly one request absent from its baseline
and matching the full identity, not a request already pending before setup, the
shortened display ID, or the only request in the queue. Missing identity, a
failed baseline on that attempt, no new match, or multiple new matches fail
closed. If the first baseline capture fails, retries take a new snapshot that
excludes any requests already pending, including requests from earlier setup
attempts. Later cleanup excludes requests that predate the retained successful
baseline. A socket-provided request ID still uses the exact device-approval path.

### Local AI GPU admission

Local AI uses the CUDA driver's `cuMemGetInfo` total and free memory directly
Expand Down
189 changes: 189 additions & 0 deletions src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,70 @@ internal static partial class ApprovalRequestHelper
{
internal const string RequestIdEnvironmentVariable = "OPENCLAW_APPROVAL_REQUEST_ID";

internal static async Task<PendingRequestBaseline> CaptureSetupBaselineOnceAsync(
SetupContext ctx,
ApprovalRequestKind kind,
CancellationToken ct)
{
var baseline = kind switch
{
ApprovalRequestKind.Device => ctx.SetupDeviceApprovalBaseline,
ApprovalRequestKind.Node => ctx.SetupNodeApprovalBaseline,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
if (baseline is not null)
return baseline;

baseline = await CapturePendingRequestBaselineAsync(ctx, kind, ct);
if (baseline.Success || baseline.PluginNotFound)
{
if (kind == ApprovalRequestKind.Device)
ctx.SetupDeviceApprovalBaseline = baseline;
else
ctx.SetupNodeApprovalBaseline = baseline;
}

return baseline;
}

internal static async Task<PendingRequestBaseline> CapturePendingRequestBaselineAsync(
SetupContext ctx,
ApprovalRequestKind kind,
CancellationToken ct)
{
var authorization = ctx.SharedGatewayToken ?? ctx.BootstrapToken;
if (string.IsNullOrWhiteSpace(authorization))
return PendingRequestBaseline.Fail("No gateway token is available to capture the pending approval baseline.");

var env = new Dictionary<string, string> { ["OPENCLAW_GATEWAY_TOKEN"] = authorization };
var noun = Noun(kind);
var pending = await ctx.Commands.RunInWslAsync(
ctx.DistroName!,
$"""{ctx.WslPathPrefix} && openclaw {noun} list --json""",
TimeSpan.FromSeconds(30),
env,
ct,
inputViaStdin: true);

var output = $"{pending.Stdout.Trim()} {pending.Stderr.Trim()}".Trim();
if (pending.ExitCode != 0)
{
return PendingRequestBaseline.Fail(
$"Could not capture pending {noun} before opening the setup socket (exit {pending.ExitCode}): {output}",
IsPluginNotFoundError(output));
}

var parsed = TryReadPendingRequestIds(pending.Stdout.Trim());
if (parsed.Success)
return PendingRequestBaseline.SuccessResult(parsed.RequestIds);

if (IsExplicitNoPendingMessage(pending.Stdout))
return PendingRequestBaseline.SuccessResult([]);

return PendingRequestBaseline.Fail(
$"Could not capture pending {noun} before opening the setup socket: {parsed.Error}");
}

internal static bool IsSafeRequestId(string? requestId)
=> !string.IsNullOrWhiteSpace(requestId)
&& SafeRequestIdPattern().IsMatch(requestId.Trim());
Expand Down Expand Up @@ -107,6 +171,111 @@ internal static void RequireMatchingDeviceRequest(
"The pending pairing request does not uniquely match this setup's Companion identity.");
}

internal static RequestIdParseResult TrySelectPendingRequestForDevice(
string json,
string? deviceId,
IReadOnlySet<string> requestIdsBeforeConnect,
bool matchNodeId)
{
if (string.IsNullOrWhiteSpace(deviceId))
return RequestIdParseResult.NotFound("Operator device ID is missing, so no pending request can be bound to the socket setup opened.");

if (string.IsNullOrWhiteSpace(json))
return RequestIdParseResult.NotFound("Pending approval output was empty.");

var wantedDeviceId = deviceId.Trim();
try
{
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("pending", out var pending) ||
pending.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return RequestIdParseResult.NotFound("No pending approval request was found.");
}

if (pending.ValueKind != JsonValueKind.Array)
return RequestIdParseResult.NotFound("Pending approval output did not contain an array.");

string? match = null;
foreach (var item in pending.EnumerateArray())
{
if (!PendingItemMatchesDevice(item, wantedDeviceId, matchNodeId))
continue;

if (!RoleMatchesSelection(item, matchNodeId))
continue;

var parsed = TryReadRequestId(item);
if (!parsed.Success)
return RequestIdParseResult.NotFound(parsed.Error ?? "Pending approval request did not include a safe request ID.");

if (requestIdsBeforeConnect.Contains(parsed.RequestId!))
continue;

if (match is not null)
return RequestIdParseResult.NotFound("Multiple new pending approval requests match the socket setup opened; refusing to auto-approve an ambiguous request.");

match = parsed.RequestId;
}

return match is null
? RequestIdParseResult.NotFound("No new pending approval request matched the socket setup opened.")
: RequestIdParseResult.Found(match);
}
catch (JsonException ex)
{
return RequestIdParseResult.NotFound($"Pending approval output was not valid JSON: {ex.Message}");
}
}

internal static bool IsNothingToDrain(RequestIdParseResult parsed)
{
if (parsed.Success || string.IsNullOrWhiteSpace(parsed.Error))
return false;

return parsed.Error.Contains("No pending approval request was found.", StringComparison.Ordinal)
|| parsed.Error.Contains("No new pending approval request matched the socket setup opened.", StringComparison.Ordinal)
|| parsed.Error.Contains("Operator device ID is missing", StringComparison.Ordinal);
}

private static bool PendingItemMatchesDevice(JsonElement item, string wantedDeviceId, bool matchNodeId)
{
var matched = false;
if (item.TryGetProperty("deviceId", out var deviceElement) &&
deviceElement.ValueKind == JsonValueKind.String &&
string.Equals(deviceElement.GetString()?.Trim(), wantedDeviceId, StringComparison.OrdinalIgnoreCase))
{
matched = true;
}

if (matchNodeId &&
item.TryGetProperty("nodeId", out var nodeElement) &&
nodeElement.ValueKind == JsonValueKind.String &&
string.Equals(nodeElement.GetString()?.Trim(), wantedDeviceId, StringComparison.OrdinalIgnoreCase))
{
matched = true;
}

return matched;
}

private static bool RoleMatchesSelection(JsonElement item, bool matchNodeId)
{
if (!item.TryGetProperty("role", out var roleElement) ||
roleElement.ValueKind != JsonValueKind.String)
{
return true;
}

var role = roleElement.GetString()?.Trim();
if (string.IsNullOrEmpty(role))
return true;

return matchNodeId
? !string.Equals(role, "operator", StringComparison.OrdinalIgnoreCase)
: string.Equals(role, "operator", StringComparison.OrdinalIgnoreCase);
}

internal static RequestIdParseResult TryReadSinglePendingRequestId(string json)
{
var all = TryReadPendingRequestIds(json);
Expand Down Expand Up @@ -178,6 +347,13 @@ private static string Noun(ApprovalRequestKind kind)
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};

internal static bool IsExplicitNoPendingMessage(string output)
{
var message = output.Trim().TrimEnd('.');
return string.Equals(message, "No pending device approvals", StringComparison.OrdinalIgnoreCase)
|| string.Equals(message, "No pending node approvals", StringComparison.OrdinalIgnoreCase);
}

[GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", RegexOptions.Compiled)]
private static partial Regex SafeRequestIdPattern();
}
Expand All @@ -193,3 +369,16 @@ internal sealed record PendingRequestIdsParseResult(bool Success, IReadOnlyList<
public static PendingRequestIdsParseResult SuccessResult(IReadOnlyList<string> requestIds) => new(true, requestIds, null);
public static PendingRequestIdsParseResult Fail(string error) => new(false, [], error);
}

internal sealed record PendingRequestBaseline(
bool Success,
IReadOnlySet<string> RequestIds,
string? Error,
bool PluginNotFound)
{
public static PendingRequestBaseline SuccessResult(IEnumerable<string> requestIds) =>
new(true, new HashSet<string>(requestIds, StringComparer.Ordinal), null, false);

public static PendingRequestBaseline Fail(string error, bool pluginNotFound = false) =>
new(false, new HashSet<string>(StringComparer.Ordinal), error, pluginNotFound);
}
28 changes: 27 additions & 1 deletion src/OpenClaw.SetupEngine/PairNodeStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ public override async Task<StepResult> ExecuteAsync(SetupContext ctx, Cancellati

var wsLogger = new SetupOpenClawLogger(ctx.Logger);
WindowsNodeClient? client = null;
var requestBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(
ctx,
ApprovalRequestKind.Node,
ct);
ctx.CurrentNodeApprovalBaseline = requestBaseline;

try
{
Expand Down Expand Up @@ -169,6 +174,11 @@ private static async Task<StepResult> FinalizeNodeWithDeviceToken(
ctx.Logger.Info("Waiting for gateway grace period before node finalization...");
await Task.Delay(TimeSpan.FromSeconds(5), ct);

var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync(
ctx,
ApprovalRequestKind.Node,
ct);
ctx.CurrentNodeApprovalBaseline = requestBaseline;
var finalClient = new WindowsNodeClient(gatewayUrl, nodeToken, identityPath, logger: wsLogger);
PairOperatorStep.ApplyReconnectAuthorization(finalClient, ctx);
finalClient.UseV2Signature = true;
Expand Down Expand Up @@ -288,6 +298,17 @@ internal static async Task<StepResult> AutoApproveNodePairing(SetupContext ctx,

if (string.IsNullOrWhiteSpace(requestId))
{
var requestBaseline = ctx.CurrentNodeApprovalBaseline;
if (requestBaseline is null || !requestBaseline.Success)
{
if (requestBaseline?.PluginNotFound == true)
return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage);

return StepResult.Fail(
requestBaseline?.Error ??
"The setup socket did not provide a node pairing request ID, and no pre-connect approval baseline is available.");
}

approvalKind = ApprovalRequestKind.Node;
var pending = await ctx.Commands.RunInWslAsync(
distro,
Expand All @@ -304,7 +325,12 @@ internal static async Task<StepResult> AutoApproveNodePairing(SetupContext ctx,
return StepResult.Fail($"Could not list pending node pairing requests (exit {pending.ExitCode}): {pendingOutput}");
}

var parsed = ApprovalRequestHelper.TryReadSinglePendingRequestId(pending.Stdout.Trim());
// Both setup sockets use the same per-gateway identity. NodeDeviceId is display-only.
var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice(
pending.Stdout.Trim(),
ctx.OperatorDeviceId,
requestBaseline.RequestIds,
matchNodeId: true);
if (!parsed.Success)
{
ctx.Logger.Warn($"Could not select node pairing request: {parsed.Error}");
Expand Down
47 changes: 39 additions & 8 deletions src/OpenClaw.SetupEngine/PairOperatorStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ record = registry.AddOrUpdate(record);
// Connect operator WebSocket — handle pairing-required flow
var wsLogger = new SetupOpenClawLogger(ctx.Logger);
OpenClawGatewayClient? client = null;
var requestBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(
ctx,
ApprovalRequestKind.Device,
ct);
ctx.CurrentDeviceApprovalBaseline = requestBaseline;

try
{
Expand Down Expand Up @@ -275,6 +280,12 @@ private static async Task<StepResult> FinalizeWithDeviceToken(
ctx.Logger.Info("Waiting for gateway grace period to expire before finalization...");
await Task.Delay(TimeSpan.FromSeconds(5), ct);

var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync(
ctx,
ApprovalRequestKind.Device,
ct);
ctx.CurrentDeviceApprovalBaseline = requestBaseline;

// Connect exactly as the tray would: pass deviceToken as the credential
var finalClient = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath);
ApplyReconnectAuthorization(finalClient, ctx);
Expand Down Expand Up @@ -332,9 +343,6 @@ private static async Task<StepResult> FinalizeWithDeviceToken(
}
}

internal static async Task<StepResult> AutoApprovePairing(SetupContext ctx, CancellationToken ct)
=> await AutoApprovePairing(ctx, requestId: null, ct);

internal static async Task<StepResult> AutoApprovePairing(SetupContext ctx, string? requestId, CancellationToken ct)
{
var distro = ctx.DistroName!;
Expand All @@ -344,18 +352,41 @@ internal static async Task<StepResult> AutoApprovePairing(SetupContext ctx, stri

if (string.IsNullOrWhiteSpace(requestId))
{
var preview = await ctx.Commands.RunInWslAsync(
var requestBaseline = ctx.CurrentDeviceApprovalBaseline;
if (requestBaseline is null || !requestBaseline.Success)
{
if (requestBaseline?.PluginNotFound == true)
return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage);

return StepResult.Fail(
requestBaseline?.Error ??
"The setup socket did not provide a pairing request ID, and no pre-connect approval baseline is available.");
}

var pending = await ctx.Commands.RunInWslAsync(
distro,
$"""{ctx.WslPathPrefix} && openclaw devices approve --latest --json""",
$"""{ctx.WslPathPrefix} && openclaw devices list --json""",
TimeSpan.FromSeconds(30), env, ct, inputViaStdin: true);

ctx.Logger.Info($"Approve preview: exit={preview.ExitCode}");
ctx.Logger.Info($"Device pending list: exit={pending.ExitCode}");

if (pending.ExitCode != 0)
{
var pendingOutput = pending.Stdout.Trim();
if (ApprovalRequestHelper.IsPluginNotFoundError(pendingOutput))
return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage);
return StepResult.Fail($"Could not list pending pairing requests (exit {pending.ExitCode}): {pendingOutput}");
}

var parsed = ApprovalRequestHelper.TryReadSelectedRequestId(preview.Stdout.Trim());
var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice(
pending.Stdout.Trim(),
ctx.OperatorDeviceId,
requestBaseline.RequestIds,
matchNodeId: false);
if (!parsed.Success)
{
ctx.Logger.Warn($"Could not select pairing request: {parsed.Error}");
return StepResult.Fail("Could not find a safe pending pairing request to approve");
return StepResult.Fail(parsed.Error ?? "Could not find a safe pending pairing request to approve");
}

requestId = parsed.RequestId;
Expand Down
4 changes: 4 additions & 0 deletions src/OpenClaw.SetupEngine/SetupContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,10 @@ public sealed class SetupContext
public string? GatewayRecordId { get; set; }
public string? OperatorDeviceId { get; set; }
public string? NodeDeviceId { get; set; }
internal PendingRequestBaseline? SetupDeviceApprovalBaseline { get; set; }
internal PendingRequestBaseline? SetupNodeApprovalBaseline { get; set; }
internal PendingRequestBaseline? CurrentDeviceApprovalBaseline { get; set; }
internal PendingRequestBaseline? CurrentNodeApprovalBaseline { get; set; }
public GatewaySelfInfo? ObservedGatewaySelf { get; set; }
public GatewayCompatibilityException? GatewayCompatibilityFailure { get; set; }
public string? WindowsTailnetDnsSuffix { get; set; }
Expand Down
Loading
Loading