Skip to content
Closed
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
6 changes: 6 additions & 0 deletions docs/SETUP_ENGINE_REDESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ 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.
If the node socket omits a pairing request ID, setup selects exactly one pending
node request matching that full identity, not the shortened display ID or the
only request in the queue. Missing identity, no match, or multiple matches fail
closed. 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
104 changes: 104 additions & 0 deletions src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,110 @@ internal static RequestIdParseResult TryReadApprovedRequestId(string json)
}
}

internal static RequestIdParseResult TrySelectPendingRequestForDevice(string json, string? deviceId)
=> TrySelectPendingRequestForDevice(json, deviceId, matchNodeId: false);

internal static RequestIdParseResult TrySelectPendingRequestForDevice(
string json,
string? deviceId,
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 (match is not null)
return RequestIdParseResult.NotFound("Multiple 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 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 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
6 changes: 5 additions & 1 deletion src/OpenClaw.SetupEngine/PairNodeStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,11 @@ 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,
matchNodeId: true);
if (!parsed.Success)
{
ctx.Logger.Warn($"Could not select node pairing request: {parsed.Error}");
Expand Down
20 changes: 15 additions & 5 deletions src/OpenClaw.SetupEngine/PairOperatorStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -344,18 +344,28 @@ internal static async Task<StepResult> AutoApprovePairing(SetupContext ctx, stri

if (string.IsNullOrWhiteSpace(requestId))
{
var preview = await ctx.Commands.RunInWslAsync(
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);

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

var parsed = ApprovalRequestHelper.TryReadSelectedRequestId(preview.Stdout.Trim());
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.TrySelectPendingRequestForDevice(
pending.Stdout.Trim(),
ctx.OperatorDeviceId);
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
32 changes: 29 additions & 3 deletions src/OpenClaw.SetupEngine/SetupWizardRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ internal async Task<StepResult> RunWithReloadRestorationAsync(

internal void MarkReloadSuspended() => _reloadSuspended = true;

internal static bool ShouldReplaceOperatorDeviceId(
bool usingWizardIdentity,
string? currentOperatorDeviceId)
{
if (usingWizardIdentity)
return true;

return string.IsNullOrWhiteSpace(currentOperatorDeviceId);
}

internal async Task<StepResult> SuspendReloadModeAsync()
{
try
Expand Down Expand Up @@ -173,9 +183,10 @@ private async Task<StepResult> RunCoreAsync(CancellationToken ct)
_ctx.SharedGatewayToken ??= record.SharedGatewayToken;
_ctx.BootstrapToken ??= record.BootstrapToken;

if (string.IsNullOrWhiteSpace(storedDeviceToken)
var usingWizardIdentity = string.IsNullOrWhiteSpace(storedDeviceToken)
&& !string.IsNullOrWhiteSpace(record.SharedGatewayToken)
&& string.Equals(credential, record.SharedGatewayToken, StringComparison.Ordinal))
&& string.Equals(credential, record.SharedGatewayToken, StringComparison.Ordinal);
if (usingWizardIdentity)
identityPath = Path.Combine(identityPath, "setup-wizard");

var wsLogger = new SetupOpenClawLogger(_ctx.Logger);
Expand All @@ -201,10 +212,25 @@ private async Task<StepResult> RunCoreAsync(CancellationToken ct)
if (connection == PairOperatorStep.ConnectionOutcome.PairingRequired && _ctx.Config.AutoApprovePairing)
{
_ctx.Logger.Info("Wizard operator pairing required — auto-approving");
var requestId = client.PairingRequiredRequestId;
if (ShouldReplaceOperatorDeviceId(usingWizardIdentity, _ctx.OperatorDeviceId))
{
try
{
var identity = new DeviceIdentity(identityPath);
identity.Initialize();
_ctx.OperatorDeviceId = identity.DeviceId;
}
catch (DeviceIdentityLoadException ex)
{
return SetupIdentityFailure.Terminal(_ctx, "wizard operator pairing", ex);
}
}

await client.DisconnectAsync();
client.Dispose();

var approval = await PairOperatorStep.AutoApprovePairing(_ctx, ct);
var approval = await PairOperatorStep.AutoApprovePairing(_ctx, requestId, ct);
if (!approval.IsSuccess)
return approval;

Expand Down
Loading
Loading