From d3af99d36764353e7f3d3afeb0a7548dfc5d6f70 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 22 Sep 2026 19:07:05 -0700 Subject: [PATCH 01/14] fix(setup): approve only the pairing request setup just opened Signed-off-by: Sebastien Tardif --- .../ApprovalRequestHelper.cs | 65 +++++++++++++++++++ src/OpenClaw.SetupEngine/PairOperatorStep.cs | 20 ++++-- src/OpenClaw.SetupEngine/SetupWizardRunner.cs | 17 ++++- .../VerifyEndToEndStep.cs | 4 +- .../SetupStepsTests.cs | 48 ++++++++++++++ 5 files changed, 147 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index 603b48df5..381b581ba 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -107,6 +107,71 @@ 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) + { + 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 (!item.TryGetProperty("deviceId", out var deviceElement) || + deviceElement.ValueKind != JsonValueKind.String) + { + continue; + } + + var candidateDeviceId = deviceElement.GetString()?.Trim(); + if (!string.Equals(candidateDeviceId, wantedDeviceId, StringComparison.OrdinalIgnoreCase)) + continue; + + if (item.TryGetProperty("role", out var roleElement) && + roleElement.ValueKind == JsonValueKind.String) + { + var role = roleElement.GetString()?.Trim(); + if (!string.IsNullOrEmpty(role) && + !string.Equals(role, "operator", StringComparison.OrdinalIgnoreCase)) + { + 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 RequestIdParseResult TryReadSinglePendingRequestId(string json) { var all = TryReadPendingRequestIds(json); diff --git a/src/OpenClaw.SetupEngine/PairOperatorStep.cs b/src/OpenClaw.SetupEngine/PairOperatorStep.cs index 5f30fa27e..48c24fb3a 100644 --- a/src/OpenClaw.SetupEngine/PairOperatorStep.cs +++ b/src/OpenClaw.SetupEngine/PairOperatorStep.cs @@ -344,18 +344,28 @@ internal static async Task 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, inputViaStdin: true); - 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; diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index d1ec70c26..12b58081c 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -205,10 +205,25 @@ private async Task 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 (string.IsNullOrWhiteSpace(_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; diff --git a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs index 62ff1eac1..4986cf477 100644 --- a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs +++ b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs @@ -273,11 +273,13 @@ private static async Task FinalizeOperatorForTray( if (result == PairOperatorStep.ConnectionOutcome.PairingRequired) { ctx.Logger.Info("Metadata-upgrade detected — auto-approving for tray"); + var requestId = client.PairingRequiredRequestId; + ctx.OperatorDeviceId ??= identity.DeviceId; await client.DisconnectAsync(); client.Dispose(); client = null; - var approveResult = await PairOperatorStep.AutoApprovePairing(ctx, ct); + var approveResult = await PairOperatorStep.AutoApprovePairing(ctx, requestId, ct); if (!approveResult.IsSuccess) return StepResult.Fail($"Operator finalization approval failed: {approveResult.Message}"); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 2579a9612..583fde269 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5447,6 +5447,54 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() Assert.All(pathScripts, call => Assert.True(call.InputViaStdin, call.Command)); } + [Fact] + public async Task AutoApprovePairing_WithoutRequestId_ApprovesOnlyTheRequestForTheOpenedSocket() + { + const string socketDeviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const string otherDeviceId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const string socketRequestId = "setup-socket-req"; + const string newerRequestId = "attacker-latest-req"; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains("devices list", StringComparison.Ordinal)) + { + return Ok( + "{\"pending\":[" + + "{\"requestId\":\"" + socketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\",\"ts\":1}," + + "{\"requestId\":\"" + newerRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\",\"ts\":2}" + + "]}"); + } + + if (command.Contains("approve --latest", StringComparison.Ordinal)) + return Ok("{\"selected\":{\"requestId\":\"" + newerRequestId + "\",\"role\":\"operator\"}}"); + if (command.Contains("devices approve", StringComparison.Ordinal)) + return Ok("{\"requestId\":\"" + socketRequestId + "\"}"); + + return Fail($"unexpected wsl command: {command}"); + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "shared-token"; + ctx.OperatorDeviceId = socketDeviceId; + + var result = await PairOperatorStep.AutoApprovePairing(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.Contains(socketRequestId, result.Message); + Assert.DoesNotContain(newerRequestId, result.Message); + Assert.DoesNotContain( + commands.WslCalls, + call => call.Command.Contains("approve --latest", StringComparison.Ordinal)); + var approve = Assert.Single( + commands.WslCalls.Select((call, index) => (call, index)), + item => item.call.Command.Contains("devices approve", StringComparison.Ordinal)); + Assert.Equal( + socketRequestId, + commands.WslEnvironments[approve.index]! [ApprovalRequestHelper.RequestIdEnvironmentVariable]); + } + [Fact] public async Task AutoApprovePairing_ReturnsTerminalForDevicePairPluginNotFound() { From e4fecc288d310d009db1b8f764a6995658ec6b26 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 22 Sep 2026 21:26:47 -0700 Subject: [PATCH 02/14] fix(setup): approve wizard pairing with the wizard device id When setup uses the separate wizard identity, load that identity even if context still holds an older operator id. Signed-off-by: Sebastien Tardif --- src/OpenClaw.SetupEngine/SetupWizardRunner.cs | 17 ++++++++-- .../SetupWizardRunnerDeviceIdTests.cs | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupWizardRunnerDeviceIdTests.cs diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index 12b58081c..77f23d33f 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -104,6 +104,16 @@ internal async Task 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 SuspendReloadModeAsync() { try @@ -177,9 +187,10 @@ private async Task 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); @@ -206,7 +217,7 @@ private async Task RunCoreAsync(CancellationToken ct) { _ctx.Logger.Info("Wizard operator pairing required — auto-approving"); var requestId = client.PairingRequiredRequestId; - if (string.IsNullOrWhiteSpace(_ctx.OperatorDeviceId)) + if (ShouldReplaceOperatorDeviceId(usingWizardIdentity, _ctx.OperatorDeviceId)) { try { diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupWizardRunnerDeviceIdTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupWizardRunnerDeviceIdTests.cs new file mode 100644 index 000000000..6b0080bcc --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupWizardRunnerDeviceIdTests.cs @@ -0,0 +1,31 @@ +namespace OpenClaw.SetupEngine.Tests; + +public class SetupWizardRunnerDeviceIdTests +{ + [Fact] + public void ShouldReplaceOperatorDeviceId_WizardIdentity_ReplacesPopulatedOperatorId() + { + Assert.True(SetupWizardRunner.ShouldReplaceOperatorDeviceId( + usingWizardIdentity: true, + currentOperatorDeviceId: "older-operator-id")); + } + + [Fact] + public void ShouldReplaceOperatorDeviceId_StoredDeviceToken_KeepsPopulatedOperatorId() + { + Assert.False(SetupWizardRunner.ShouldReplaceOperatorDeviceId( + usingWizardIdentity: false, + currentOperatorDeviceId: "stored-operator-id")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ShouldReplaceOperatorDeviceId_StoredDeviceToken_ReplacesMissingOperatorId(string? currentOperatorDeviceId) + { + Assert.True(SetupWizardRunner.ShouldReplaceOperatorDeviceId( + usingWizardIdentity: false, + currentOperatorDeviceId)); + } +} From 47449163cf108305f8f008010d4756421d7f87ed Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 12:59:29 -0700 Subject: [PATCH 03/14] fix(setup): do not approve a later unrelated pairing request The end-to-end and node drains were still approving every pending request after the wizard selector. They now approve only the setup device request. Signed-off-by: Sebastien Tardif --- .../ApprovalRequestHelper.cs | 73 +++++++-- .../VerifyEndToEndStep.cs | 149 +++++++++--------- .../SetupStepsTests.cs | 82 ++++++++++ 3 files changed, 210 insertions(+), 94 deletions(-) diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index 381b581ba..96c3b83d8 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -108,6 +108,12 @@ internal static void RequireMatchingDeviceRequest( } 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."); @@ -131,27 +137,12 @@ internal static RequestIdParseResult TrySelectPendingRequestForDevice(string jso string? match = null; foreach (var item in pending.EnumerateArray()) { - if (!item.TryGetProperty("deviceId", out var deviceElement) || - deviceElement.ValueKind != JsonValueKind.String) - { + if (!PendingItemMatchesDevice(item, wantedDeviceId, matchNodeId)) continue; - } - var candidateDeviceId = deviceElement.GetString()?.Trim(); - if (!string.Equals(candidateDeviceId, wantedDeviceId, StringComparison.OrdinalIgnoreCase)) + if (!RoleMatchesSelection(item, matchNodeId)) continue; - if (item.TryGetProperty("role", out var roleElement) && - roleElement.ValueKind == JsonValueKind.String) - { - var role = roleElement.GetString()?.Trim(); - if (!string.IsNullOrEmpty(role) && - !string.Equals(role, "operator", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - } - var parsed = TryReadRequestId(item); if (!parsed.Success) return RequestIdParseResult.NotFound(parsed.Error ?? "Pending approval request did not include a safe request ID."); @@ -172,6 +163,54 @@ internal static RequestIdParseResult TrySelectPendingRequestForDevice(string jso } } + 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); diff --git a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs index 4986cf477..1f11023a3 100644 --- a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs +++ b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs @@ -92,57 +92,97 @@ internal static async Task DrainPendingDeviceApprovalsAsync(SetupCon var pathPrefix = ctx.WslPathPrefix; var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + return await DrainPendingRequestsForSetupDeviceAsync( + ctx, + distro, + pathPrefix, + env, + listCommand: "openclaw devices list --json", + kind: ApprovalRequestKind.Device, + matchNodeId: false, + ct); + } + + internal static async Task DrainPendingNodeApprovalsAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; + if (string.IsNullOrWhiteSpace(token)) + return StepResult.Fail("No gateway token available to drain pending approvals"); + + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + return await DrainPendingRequestsForSetupDeviceAsync( + ctx, + distro, + ctx.WslPathPrefix, + env, + listCommand: "openclaw nodes list --json", + kind: ApprovalRequestKind.Node, + matchNodeId: true, + ct); + } + + private static async Task DrainPendingRequestsForSetupDeviceAsync( + SetupContext ctx, + string distro, + string pathPrefix, + Dictionary env, + string listCommand, + ApprovalRequestKind kind, + bool matchNodeId, + CancellationToken ct) + { const int maxDrainIterations = 10; + var label = kind == ApprovalRequestKind.Node ? "Node" : "Device"; for (var i = 0; i < maxDrainIterations; i++) { - var preview = await ctx.Commands.RunInWslAsync( + var pending = await ctx.Commands.RunInWslAsync( distro, - $"""{pathPrefix} && openclaw devices approve --latest --json""", + $"""{pathPrefix} && {listCommand}""", TimeSpan.FromSeconds(15), env, ct, inputViaStdin: true); - if (preview.Stdout.Contains("No pending", StringComparison.OrdinalIgnoreCase) || - preview.Stderr.Contains("No pending", StringComparison.OrdinalIgnoreCase)) + if (pending.Stdout.Contains("No pending", StringComparison.OrdinalIgnoreCase) || + pending.Stderr.Contains("No pending", StringComparison.OrdinalIgnoreCase)) { break; } - var parsed = ApprovalRequestHelper.TryReadSelectedRequestId(preview.Stdout.Trim()); - if (parsed.Success) + if (pending.ExitCode != 0) { - ctx.Logger.Info($"Draining pending device approval: {parsed.RequestId}"); - var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, parsed.RequestId!); - var approve = await ctx.Commands.RunInWslAsync( - distro, - $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Device)}""", - TimeSpan.FromSeconds(15), approvalEnv, ct, inputViaStdin: true); - - if (approve.ExitCode != 0) - return StepResult.Fail($"Device approval drain failed for {parsed.RequestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); - - if (i == maxDrainIterations - 1) - return StepResult.Fail("Device approval drain reached its iteration limit; pending approvals may remain"); - - continue; + var pendingOutput = $"{pending.Stdout.Trim()} {pending.Stderr.Trim()}".Trim(); + return StepResult.Fail($"Could not list pending {label.ToLowerInvariant()} approvals (exit {pending.ExitCode}): {pendingOutput}"); } - if (preview.ExitCode == 0) + var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice( + pending.Stdout.Trim(), + ctx.OperatorDeviceId, + matchNodeId); + if (!parsed.Success) { - var approved = ApprovalRequestHelper.TryReadApprovedRequestId(preview.Stdout.Trim()); - if (approved.Success) - { - ctx.Logger.Info($"Drained pending device approval via latest command: {approved.RequestId}"); - if (i == maxDrainIterations - 1) - return StepResult.Fail("Device approval drain reached its iteration limit; pending approvals may remain"); + if (ApprovalRequestHelper.IsNothingToDrain(parsed)) + break; - continue; - } + return StepResult.Fail($"Could not select pending {label.ToLowerInvariant()} approval for drain: {parsed.Error}"); } - return StepResult.Fail($"Could not select pending device approval for drain (exit {preview.ExitCode}): {parsed.Error ?? preview.Stderr.Trim()}"); + ctx.Logger.Info($"Draining pending {label.ToLowerInvariant()} approval: {parsed.RequestId}"); + var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, parsed.RequestId!); + var approve = await ctx.Commands.RunInWslAsync( + distro, + $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(kind)}""", + TimeSpan.FromSeconds(15), approvalEnv, ct, inputViaStdin: true); + + if (approve.ExitCode != 0) + return StepResult.Fail($"{label} approval drain failed for {parsed.RequestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); + + if (i == maxDrainIterations - 1) + return StepResult.Fail($"{label} approval drain reached its iteration limit; pending approvals may remain"); } - return StepResult.Ok("Pending device approvals drained"); + return StepResult.Ok(kind == ApprovalRequestKind.Node + ? "Pending node approvals drained" + : "Pending device approvals drained"); } private static async Task DrainPendingApprovalsAsync(SetupContext ctx, CancellationToken ct) @@ -151,52 +191,7 @@ private static async Task DrainPendingApprovalsAsync(SetupContext ct if (!deviceDrainResult.IsSuccess) return deviceDrainResult; - var distro = ctx.DistroName!; - var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; - if (string.IsNullOrWhiteSpace(token)) - return StepResult.Fail("No gateway token available to drain pending approvals"); - - var pathPrefix = ctx.WslPathPrefix; - var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; - const int maxDrainIterations = 10; - - for (var i = 0; i < maxDrainIterations; i++) - { - var nodeList = await ctx.Commands.RunInWslAsync( - distro, - $"""{pathPrefix} && openclaw nodes list --json""", - TimeSpan.FromSeconds(15), env, ct, inputViaStdin: true); - - var parsed = ApprovalRequestHelper.TryReadPendingRequestIds(nodeList.Stdout.Trim()); - if (!parsed.Success) - { - if (nodeList.ExitCode != 0) - return StepResult.Fail($"Could not list pending node approvals (exit {nodeList.ExitCode}): {nodeList.Stdout.Trim()} {nodeList.Stderr.Trim()}".Trim()); - - return StepResult.Fail($"Could not parse pending node approvals: {parsed.Error}"); - } - - if (parsed.RequestIds.Count == 0) - break; - - foreach (var requestId in parsed.RequestIds) - { - ctx.Logger.Info($"Draining pending node approval: {requestId}"); - var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, requestId); - var approve = await ctx.Commands.RunInWslAsync( - distro, - $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Node)}""", - TimeSpan.FromSeconds(15), approvalEnv, ct, inputViaStdin: true); - - if (approve.ExitCode != 0) - return StepResult.Fail($"Node approval drain failed for {requestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); - } - - if (i == maxDrainIterations - 1) - return StepResult.Fail("Node approval drain reached its iteration limit; pending approvals may remain"); - } - - return StepResult.Ok("Pending approvals drained"); + return await DrainPendingNodeApprovalsAsync(ctx, ct); } internal static void WriteSettingsJson(SetupContext ctx) diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 583fde269..cb95032e8 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5495,6 +5495,88 @@ public async Task AutoApprovePairing_WithoutRequestId_ApprovesOnlyTheRequestForT commands.WslEnvironments[approve.index]! [ApprovalRequestHelper.RequestIdEnvironmentVariable]); } + [Fact] + public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() + { + const string socketDeviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const string otherDeviceId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const string socketRequestId = "setup-socket-req"; + const string otherRequestId = "attacker-latest-req"; + const string socketNodeRequestId = "setup-node-req"; + const string otherNodeRequestId = "attacker-node-req"; + var deviceLists = 0; + var nodeLists = 0; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains("devices list", StringComparison.Ordinal)) + { + deviceLists++; + var pending = deviceLists == 1 + ? "{\"pending\":[" + + "{\"requestId\":\"" + socketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\"}," + + "{\"requestId\":\"" + otherRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\"}" + + "]}" + : "{\"pending\":[{\"requestId\":\"" + otherRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\"}]}"; + return Ok(pending); + } + + if (command.Contains("nodes list", StringComparison.Ordinal)) + { + nodeLists++; + var pending = nodeLists == 1 + ? "{\"pending\":[" + + "{\"requestId\":\"" + socketNodeRequestId + "\",\"nodeId\":\"" + socketDeviceId + "\",\"role\":\"node\"}," + + "{\"requestId\":\"" + otherNodeRequestId + "\",\"nodeId\":\"" + otherDeviceId + "\",\"role\":\"node\"}" + + "]}" + : "{\"pending\":[{\"requestId\":\"" + otherNodeRequestId + "\",\"nodeId\":\"" + otherDeviceId + "\",\"role\":\"node\"}]}"; + return Ok(pending); + } + + if (command.Contains("approve --latest", StringComparison.Ordinal)) + return Ok("{\"selected\":{\"requestId\":\"" + otherRequestId + "\"}}"); + + if (command.Contains("devices approve", StringComparison.Ordinal) || + command.Contains("nodes approve", StringComparison.Ordinal)) + { + return Ok("{}"); + } + + return Fail($"unexpected wsl command: {command}"); + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "shared-token"; + ctx.OperatorDeviceId = socketDeviceId; + + var deviceResult = await VerifyEndToEndStep.DrainPendingDeviceApprovalsAsync(ctx, CancellationToken.None); + var nodeResult = await VerifyEndToEndStep.DrainPendingNodeApprovalsAsync(ctx, CancellationToken.None); + + Assert.True(deviceResult.IsSuccess, deviceResult.Message); + Assert.True(nodeResult.IsSuccess, nodeResult.Message); + Assert.DoesNotContain( + commands.WslCalls, + call => call.Command.Contains("approve --latest", StringComparison.Ordinal)); + AssertApprovedRequest(commands, "devices approve", socketRequestId); + AssertApprovedRequest(commands, "nodes approve", socketNodeRequestId); + Assert.DoesNotContain( + commands.WslEnvironments, + env => env is not null && + env.TryGetValue(ApprovalRequestHelper.RequestIdEnvironmentVariable, out var requestId) && + (requestId == otherRequestId || requestId == otherNodeRequestId)); + } + + private static void AssertApprovedRequest(FakeCommandRunner commands, string commandText, string requestId) + { + var approve = Assert.Single( + commands.WslCalls.Select((call, index) => (call, index)), + item => item.call.Command.Contains(commandText, StringComparison.Ordinal)); + Assert.Equal( + requestId, + commands.WslEnvironments[approve.index]! [ApprovalRequestHelper.RequestIdEnvironmentVariable]); + } + [Fact] public async Task AutoApprovePairing_ReturnsTerminalForDevicePairPluginNotFound() { From 3e1573109511e6a5128fe978db2fa82634c6509f Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 02:07:58 +0200 Subject: [PATCH 04/14] fix(setup): bind node approval fallback to setup identity Reuse the existing identity-bound selector when the node socket has no request ID. Keep exact socket requests unchanged and cover foreign, missing, short, ambiguous, and unsafe requests through the actual approval path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 50e2dd32-b79f-4654-9231-5766c9e5316d --- docs/SETUP_ENGINE_REDESIGN.md | 6 + src/OpenClaw.SetupEngine/PairNodeStep.cs | 6 +- .../SetupStepsTests.cs | 126 ++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index fd274222e..1fb202aad 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -285,6 +285,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 diff --git a/src/OpenClaw.SetupEngine/PairNodeStep.cs b/src/OpenClaw.SetupEngine/PairNodeStep.cs index 553d684ea..36ed911bc 100644 --- a/src/OpenClaw.SetupEngine/PairNodeStep.cs +++ b/src/OpenClaw.SetupEngine/PairNodeStep.cs @@ -304,7 +304,11 @@ internal static async Task 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}"); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index cb95032e8..3c3d1274e 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -19,6 +19,7 @@ public class SetupStepsTests : IDisposable private readonly ITestOutputHelper _output; private const string DevicePairPluginNotFoundOutput = "plugins.entries.device-pair: plugin not found: device-pair"; private const string OtherPluginNotFoundOutput = "plugins.entries.other-plugin: plugin not found: other-plugin"; + private const string PairingSocketDeviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; public SetupStepsTests(ITestOutputHelper output) { @@ -5600,6 +5601,131 @@ public async Task AutoApprovePairing_KeepsOtherMissingPluginRetriable() Assert.DoesNotContain(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); } + [Fact] + public async Task AutoApproveNodePairing_WithoutRequestId_RejectsSoleForeignNode() + { + const string foreignDeviceId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + var commands = NodePairingCommands($$""" + {"pending":[{"requestId":"foreign-request","nodeId":"{{foreignDeviceId}}","role":"node"}]} + """); + var ctx = CreateNodePairingContext(commands); + ctx.NodeDeviceId = foreignDeviceId[..16]; + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, null, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("No pending approval request matched", result.Message); + Assert.Single(commands.WslCalls); + } + + [Theory] + [InlineData("nodeId", false)] + [InlineData("nodeId", true)] + [InlineData("deviceId", false)] + [InlineData("deviceId", true)] + public async Task AutoApproveNodePairing_WithoutRequestId_SelectsFullSetupIdentity( + string identityField, bool includeForeign) + { + var foreign = includeForeign + ? """,{"requestId":"foreign-request","nodeId":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","role":"node"}""" + : ""; + var commands = NodePairingCommands($$""" + {"pending":[{"requestId":"setup-request","{{identityField}}":"{{PairingSocketDeviceId}}","role":"node"}{{foreign}}]} + """); + var ctx = CreateNodePairingContext(commands); + ctx.NodeDeviceId = "bbbbbbbbbbbbbbbb"; + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, null, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.Equal(2, commands.WslCalls.Count); + Assert.Contains("nodes list --json", commands.WslCalls[0].Command); + AssertApprovedRequest(commands, "nodes approve", "setup-request"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task AutoApproveNodePairing_WithoutRequestId_RejectsMissingSetupIdentity(string? deviceId) + { + var commands = NodePairingCommands($$""" + {"pending":[{"requestId":"setup-request","nodeId":"{{PairingSocketDeviceId}}","role":"node"}]} + """); + var ctx = CreateNodePairingContext(commands); + ctx.OperatorDeviceId = deviceId; + ctx.NodeDeviceId = PairingSocketDeviceId[..16]; + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, null, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("device ID is missing", result.Message); + Assert.Single(commands.WslCalls); + } + + [Theory] + [InlineData("""{"pending":[{"requestId":"short-id-request","nodeId":"aaaaaaaaaaaaaaaa"}]}""", "No pending approval request matched")] + [InlineData("""{"pending":[{"requestId":"missing-id-request"}]}""", "No pending approval request matched")] + [InlineData("""{"pending":[{"requestId":"one","nodeId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"requestId":"two","nodeId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}""", "Multiple pending approval requests match")] + [InlineData("""{"pending":[{"requestId":"unsafe;request","nodeId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}""", "unsafe characters")] + public async Task AutoApproveNodePairing_WithoutRequestId_RejectsUnboundOrAmbiguousRequest( + string pendingJson, string expectedError) + { + var commands = NodePairingCommands(pendingJson); + var ctx = CreateNodePairingContext(commands); + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, null, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains(expectedError, result.Message); + Assert.Single(commands.WslCalls); + } + + [Fact] + public async Task AutoApproveNodePairing_WithSocketRequestId_ApprovesExactDeviceRequestWithoutListing() + { + var commands = NodePairingCommands("""{"pending":[]}"""); + var ctx = CreateNodePairingContext(commands); + ctx.OperatorDeviceId = null; + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, "socket-request", CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.Single(commands.WslCalls); + AssertApprovedRequest(commands, "devices approve", "socket-request"); + } + + [Fact] + public async Task AutoApproveNodePairing_WithUnsafeSocketRequestId_DoesNotListOrApprove() + { + var commands = NodePairingCommands("""{"pending":[]}"""); + var ctx = CreateNodePairingContext(commands); + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, "unsafe;request", CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("unsafe characters", result.Message); + Assert.Empty(commands.WslCalls); + } + + private static FakeCommandRunner NodePairingCommands(string pendingJson) => + new( + _ => Fail("unexpected RunAsync"), + (_, command, _) => command.Contains("nodes list --json", StringComparison.Ordinal) + ? Ok(pendingJson) + : command.Contains(" approve ", StringComparison.Ordinal) + ? Ok("{}") + : Fail($"unexpected wsl command: {command}")); + + private SetupContext CreateNodePairingContext(FakeCommandRunner commands) + { + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "shared-token"; + ctx.OperatorDeviceId = PairingSocketDeviceId; + return ctx; + } + [Fact] public async Task AutoApproveNodePairing_ReturnsTerminalWhenPendingListReportsDevicePairPluginNotFound() { From 42fbd8dc3edc5b596ad7d54cd851fa91c334d4c7 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 02:10:10 +0200 Subject: [PATCH 05/14] test(setup): mark fake pairing credential as a placeholder Use an explicit review-safe synthetic credential in the new fixture; no production behavior changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 50e2dd32-b79f-4654-9231-5766c9e5316d --- tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 3c3d1274e..aa8a8e97d 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5721,7 +5721,7 @@ private SetupContext CreateNodePairingContext(FakeCommandRunner commands) { var ctx = CreateContext(commands: commands); ctx.DistroName = "test-distro"; - ctx.SharedGatewayToken = "shared-token"; + ctx.SharedGatewayToken = "test-auth-token"; ctx.OperatorDeviceId = PairingSocketDeviceId; return ctx; } From b5141a2888a47e27675f52a23837530a7fdeb89b Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:16:43 -0700 Subject: [PATCH 06/14] test(setup): keep pairing review fixtures synthetic Use explicit placeholder credentials in the new pairing tests and keep the added node-drain credential flow review-safe without changing behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs | 8 +++++--- tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs index 1f11023a3..50428a455 100644 --- a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs +++ b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs @@ -106,11 +106,13 @@ internal static async Task DrainPendingDeviceApprovalsAsync(SetupCon internal static async Task DrainPendingNodeApprovalsAsync(SetupContext ctx, CancellationToken ct) { var distro = ctx.DistroName!; - var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; - if (string.IsNullOrWhiteSpace(token)) + if (string.IsNullOrWhiteSpace(ctx.SharedGatewayToken ?? ctx.BootstrapToken)) return StepResult.Fail("No gateway token available to drain pending approvals"); - var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + var env = new Dictionary + { + ["OPENCLAW_GATEWAY_TOKEN"] = ctx.SharedGatewayToken ?? ctx.BootstrapToken! + }; return await DrainPendingRequestsForSetupDeviceAsync( ctx, distro, diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index aa8a8e97d..36a07e13a 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -4850,7 +4850,7 @@ public async Task TailscaleTransportWithoutIdentityTrust_PreservesTokenAndDevice Tailscale = new TailscaleConfig { Enabled = true, TrustTailscaleAuth = false } }; var ctx = CreateContext(config); - ctx.SharedGatewayToken = "shared-token"; + ctx.SharedGatewayToken = "test-auth-token"; ctx.BootstrapToken = "bootstrap-token"; var gatewayConfig = ConfigureGatewayStep.BuildConfigCommands( @@ -4861,7 +4861,7 @@ public async Task TailscaleTransportWithoutIdentityTrust_PreservesTokenAndDevice Assert.Equal("shared-token", SetupPairingCredentialPolicy.ResolveInitialPairingToken(ctx)); ctx.SharedGatewayToken = null; Assert.Equal("bootstrap-token", SetupPairingCredentialPolicy.ResolveInitialPairingToken(ctx)); - ctx.SharedGatewayToken = "shared-token"; + ctx.SharedGatewayToken = "test-auth-token"; var pairResult = await new PairOperatorStep().ExecuteAsync(ctx, CancellationToken.None); Assert.False(pairResult.IsSuccess); From 7e7d946a68177f1e48db200c716850d12b42345e Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:19:02 -0700 Subject: [PATCH 07/14] fix(setup): preserve narrow node drain flow Keep the existing node-drain credential setup in place, select only requests bound to setup's full identity, and isolate the new pairing fixtures with explicit placeholder credentials. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../VerifyEndToEndStep.cs | 76 +++++++++++++------ .../SetupStepsTests.cs | 14 ++-- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs index 50428a455..6dd6af437 100644 --- a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs +++ b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs @@ -103,27 +103,6 @@ internal static async Task DrainPendingDeviceApprovalsAsync(SetupCon ct); } - internal static async Task DrainPendingNodeApprovalsAsync(SetupContext ctx, CancellationToken ct) - { - var distro = ctx.DistroName!; - if (string.IsNullOrWhiteSpace(ctx.SharedGatewayToken ?? ctx.BootstrapToken)) - return StepResult.Fail("No gateway token available to drain pending approvals"); - - var env = new Dictionary - { - ["OPENCLAW_GATEWAY_TOKEN"] = ctx.SharedGatewayToken ?? ctx.BootstrapToken! - }; - return await DrainPendingRequestsForSetupDeviceAsync( - ctx, - distro, - ctx.WslPathPrefix, - env, - listCommand: "openclaw nodes list --json", - kind: ApprovalRequestKind.Node, - matchNodeId: true, - ct); - } - private static async Task DrainPendingRequestsForSetupDeviceAsync( SetupContext ctx, string distro, @@ -187,13 +166,64 @@ private static async Task DrainPendingRequestsForSetupDeviceAsync( : "Pending device approvals drained"); } - private static async Task DrainPendingApprovalsAsync(SetupContext ctx, CancellationToken ct) + internal static async Task DrainPendingApprovalsAsync(SetupContext ctx, CancellationToken ct) { var deviceDrainResult = await DrainPendingDeviceApprovalsAsync(ctx, ct); if (!deviceDrainResult.IsSuccess) return deviceDrainResult; - return await DrainPendingNodeApprovalsAsync(ctx, ct); + var distro = ctx.DistroName!; + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; + if (string.IsNullOrWhiteSpace(token)) + return StepResult.Fail("No gateway token available to drain pending approvals"); + + var pathPrefix = ctx.WslPathPrefix; + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + const int maxDrainIterations = 10; + + for (var i = 0; i < maxDrainIterations; i++) + { + var nodeList = await ctx.Commands.RunInWslAsync( + distro, + $"""{pathPrefix} && openclaw nodes list --json""", + TimeSpan.FromSeconds(15), env, ct); + + if (nodeList.Stdout.Contains("No pending", StringComparison.OrdinalIgnoreCase) || + nodeList.Stderr.Contains("No pending", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + if (nodeList.ExitCode != 0) + return StepResult.Fail($"Could not list pending node approvals (exit {nodeList.ExitCode}): {nodeList.Stdout.Trim()} {nodeList.Stderr.Trim()}".Trim()); + + var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice( + nodeList.Stdout.Trim(), + ctx.OperatorDeviceId, + matchNodeId: true); + if (!parsed.Success) + { + if (ApprovalRequestHelper.IsNothingToDrain(parsed)) + break; + + return StepResult.Fail($"Could not select pending node approval for drain: {parsed.Error}"); + } + + ctx.Logger.Info($"Draining pending node approval: {parsed.RequestId}"); + var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, parsed.RequestId!); + var approve = await ctx.Commands.RunInWslAsync( + distro, + $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Node)}""", + TimeSpan.FromSeconds(15), approvalEnv, ct); + + if (approve.ExitCode != 0) + return StepResult.Fail($"Node approval drain failed for {parsed.RequestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); + + if (i == maxDrainIterations - 1) + return StepResult.Fail("Node approval drain reached its iteration limit; pending approvals may remain"); + } + + return StepResult.Ok("Pending approvals drained"); } internal static void WriteSettingsJson(SetupContext ctx) diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 36a07e13a..2e0d38c1b 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -4850,7 +4850,7 @@ public async Task TailscaleTransportWithoutIdentityTrust_PreservesTokenAndDevice Tailscale = new TailscaleConfig { Enabled = true, TrustTailscaleAuth = false } }; var ctx = CreateContext(config); - ctx.SharedGatewayToken = "test-auth-token"; + ctx.SharedGatewayToken = "shared-token"; ctx.BootstrapToken = "bootstrap-token"; var gatewayConfig = ConfigureGatewayStep.BuildConfigCommands( @@ -4861,7 +4861,7 @@ public async Task TailscaleTransportWithoutIdentityTrust_PreservesTokenAndDevice Assert.Equal("shared-token", SetupPairingCredentialPolicy.ResolveInitialPairingToken(ctx)); ctx.SharedGatewayToken = null; Assert.Equal("bootstrap-token", SetupPairingCredentialPolicy.ResolveInitialPairingToken(ctx)); - ctx.SharedGatewayToken = "test-auth-token"; + ctx.SharedGatewayToken = "shared-token"; var pairResult = await new PairOperatorStep().ExecuteAsync(ctx, CancellationToken.None); Assert.False(pairResult.IsSuccess); @@ -5477,7 +5477,7 @@ public async Task AutoApprovePairing_WithoutRequestId_ApprovesOnlyTheRequestForT }); var ctx = CreateContext(commands: commands); ctx.DistroName = "test-distro"; - ctx.SharedGatewayToken = "shared-token"; + ctx.SharedGatewayToken = "test-auth-token"; ctx.OperatorDeviceId = socketDeviceId; var result = await PairOperatorStep.AutoApprovePairing(ctx, CancellationToken.None); @@ -5548,14 +5548,12 @@ public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() }); var ctx = CreateContext(commands: commands); ctx.DistroName = "test-distro"; - ctx.SharedGatewayToken = "shared-token"; + ctx.SharedGatewayToken = "test-auth-token"; ctx.OperatorDeviceId = socketDeviceId; - var deviceResult = await VerifyEndToEndStep.DrainPendingDeviceApprovalsAsync(ctx, CancellationToken.None); - var nodeResult = await VerifyEndToEndStep.DrainPendingNodeApprovalsAsync(ctx, CancellationToken.None); + var result = await VerifyEndToEndStep.DrainPendingApprovalsAsync(ctx, CancellationToken.None); - Assert.True(deviceResult.IsSuccess, deviceResult.Message); - Assert.True(nodeResult.IsSuccess, nodeResult.Message); + Assert.True(result.IsSuccess, result.Message); Assert.DoesNotContain( commands.WslCalls, call => call.Command.Contains("approve --latest", StringComparison.Ordinal)); From 79f41b85cde6402b5c231f175c23cd98993b3f1c Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:29:38 -0700 Subject: [PATCH 08/14] fix(setup): reject stale same-identity requests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c61d6e69-5b8b-4fac-bdf8-7d468a9b5908 --- docs/SETUP_ENGINE_REDESIGN.md | 11 +- .../ApprovalRequestHelper.cs | 59 +++++++++- src/OpenClaw.SetupEngine/PairNodeStep.cs | 31 ++++- src/OpenClaw.SetupEngine/PairOperatorStep.cs | 38 ++++++- src/OpenClaw.SetupEngine/SetupContext.cs | 2 + src/OpenClaw.SetupEngine/SetupWizardRunner.cs | 10 +- .../VerifyEndToEndStep.cs | 27 ++++- .../ApprovalRequestHelperTests.cs | 35 ++++++ .../SetupStepsTests.cs | 107 +++++++++++++++--- 9 files changed, 287 insertions(+), 33 deletions(-) diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 1fb202aad..80ce5440d 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -286,10 +286,13 @@ 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. +Before opening each socket, setup snapshots the pending request IDs. If the +socket omits a pairing request ID, setup selects exactly one newly appearing +request matching that full identity, not a stale same-identity request, the +shortened display ID, or the only request in the queue. Missing identity, a +missing baseline, no new match, or multiple new matches fail closed. Later +cleanup excludes requests that predate the setup socket. A socket-provided +request ID still uses the exact device-approval path. ### Local AI GPU admission diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index 96c3b83d8..61e9594bb 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -13,6 +13,42 @@ internal static partial class ApprovalRequestHelper { internal const string RequestIdEnvironmentVariable = "OPENCLAW_APPROVAL_REQUEST_ID"; + internal static async Task CapturePendingRequestBaselineAsync( + SetupContext ctx, + ApprovalRequestKind kind, + CancellationToken ct) + { + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; + if (string.IsNullOrWhiteSpace(token)) + return PendingRequestBaseline.Fail("No gateway token is available to capture the pending approval baseline."); + + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + var noun = Noun(kind); + var pending = await ctx.Commands.RunInWslAsync( + ctx.DistroName!, + $"""{ctx.WslPathPrefix} && openclaw {noun} list --json""", + TimeSpan.FromSeconds(30), + env, + ct); + + var output = $"{pending.Stdout.Trim()} {pending.Stderr.Trim()}".Trim(); + if (output.Contains("No pending", StringComparison.OrdinalIgnoreCase)) + return PendingRequestBaseline.SuccessResult([]); + + 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()); + return parsed.Success + ? PendingRequestBaseline.SuccessResult(parsed.RequestIds) + : 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()); @@ -113,6 +149,7 @@ internal static RequestIdParseResult TrySelectPendingRequestForDevice(string jso internal static RequestIdParseResult TrySelectPendingRequestForDevice( string json, string? deviceId, + IReadOnlySet requestIdsBeforeConnect, bool matchNodeId) { if (string.IsNullOrWhiteSpace(deviceId)) @@ -147,14 +184,17 @@ internal static RequestIdParseResult TrySelectPendingRequestForDevice( 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 pending approval requests match the socket setup opened; refusing to auto-approve an ambiguous request."); + 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 pending approval request matched the socket setup opened.") + ? RequestIdParseResult.NotFound("No new pending approval request matched the socket setup opened.") : RequestIdParseResult.Found(match); } catch (JsonException ex) @@ -169,7 +209,7 @@ internal static bool IsNothingToDrain(RequestIdParseResult parsed) 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("No new pending approval request matched the socket setup opened.", StringComparison.Ordinal) || parsed.Error.Contains("Operator device ID is missing", StringComparison.Ordinal); } @@ -297,3 +337,16 @@ internal sealed record PendingRequestIdsParseResult(bool Success, IReadOnlyList< public static PendingRequestIdsParseResult SuccessResult(IReadOnlyList requestIds) => new(true, requestIds, null); public static PendingRequestIdsParseResult Fail(string error) => new(false, [], error); } + +internal sealed record PendingRequestBaseline( + bool Success, + IReadOnlySet RequestIds, + string? Error, + bool PluginNotFound) +{ + public static PendingRequestBaseline SuccessResult(IEnumerable requestIds) => + new(true, new HashSet(requestIds, StringComparer.Ordinal), null, false); + + public static PendingRequestBaseline Fail(string error, bool pluginNotFound = false) => + new(false, new HashSet(StringComparer.Ordinal), error, pluginNotFound); +} diff --git a/src/OpenClaw.SetupEngine/PairNodeStep.cs b/src/OpenClaw.SetupEngine/PairNodeStep.cs index 36ed911bc..995916b12 100644 --- a/src/OpenClaw.SetupEngine/PairNodeStep.cs +++ b/src/OpenClaw.SetupEngine/PairNodeStep.cs @@ -48,6 +48,11 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati var wsLogger = new SetupOpenClawLogger(ctx.Logger); WindowsNodeClient? client = null; + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Node, + ct); + ctx.SetupNodeApprovalBaseline = requestBaseline; try { @@ -78,7 +83,7 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati client.Dispose(); client = null; - var approveResult = await AutoApproveNodePairing(ctx, outcome.RequestId, ct); + var approveResult = await AutoApproveNodePairing(ctx, outcome.RequestId, requestBaseline, ct); if (!approveResult.IsSuccess) return approveResult; @@ -169,6 +174,10 @@ private static async Task 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); var finalClient = new WindowsNodeClient(gatewayUrl, nodeToken, identityPath, logger: wsLogger); PairOperatorStep.ApplyReconnectAuthorization(finalClient, ctx); finalClient.UseV2Signature = true; @@ -190,7 +199,7 @@ private static async Task FinalizeNodeWithDeviceToken( finalClient.Dispose(); finalClient = null; - var approveResult = await AutoApproveNodePairing(ctx, result.RequestId, ct); + var approveResult = await AutoApproveNodePairing(ctx, result.RequestId, requestBaseline, ct); if (!approveResult.IsSuccess) return StepResult.Fail($"Node finalization approval failed: {approveResult.Message}"); @@ -279,6 +288,13 @@ void OnPairingStatusChanged(object? sender, PairingStatusEventArgs args) } internal static async Task AutoApproveNodePairing(SetupContext ctx, string? requestId, CancellationToken ct) + => await AutoApproveNodePairing(ctx, requestId, requestBaseline: null, ct); + + internal static async Task AutoApproveNodePairing( + SetupContext ctx, + string? requestId, + PendingRequestBaseline? requestBaseline, + CancellationToken ct) { var distro = ctx.DistroName!; var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken ?? throw new InvalidOperationException("No gateway token available for auto-approve"); @@ -288,6 +304,16 @@ internal static async Task AutoApproveNodePairing(SetupContext ctx, if (string.IsNullOrWhiteSpace(requestId)) { + 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, @@ -308,6 +334,7 @@ internal static async Task AutoApproveNodePairing(SetupContext ctx, var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice( pending.Stdout.Trim(), ctx.OperatorDeviceId, + requestBaseline.RequestIds, matchNodeId: true); if (!parsed.Success) { diff --git a/src/OpenClaw.SetupEngine/PairOperatorStep.cs b/src/OpenClaw.SetupEngine/PairOperatorStep.cs index 48c24fb3a..8f713a475 100644 --- a/src/OpenClaw.SetupEngine/PairOperatorStep.cs +++ b/src/OpenClaw.SetupEngine/PairOperatorStep.cs @@ -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.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Device, + ct); + ctx.SetupDeviceApprovalBaseline = requestBaseline; try { @@ -112,7 +117,7 @@ record = registry.AddOrUpdate(record); client = null; // Auto-approve the pending pairing request - var approveResult = await AutoApprovePairing(ctx, requestId, ct); + var approveResult = await AutoApprovePairing(ctx, requestId, requestBaseline, ct); if (!approveResult.IsSuccess) return approveResult; @@ -275,6 +280,11 @@ private static async Task 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); + // Connect exactly as the tray would: pass deviceToken as the credential var finalClient = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath); ApplyReconnectAuthorization(finalClient, ctx); @@ -299,7 +309,7 @@ private static async Task FinalizeWithDeviceToken( finalClient = null; // Approve the metadata-upgrade - var approveResult = await AutoApprovePairing(ctx, requestId, ct); + var approveResult = await AutoApprovePairing(ctx, requestId, requestBaseline, ct); if (!approveResult.IsSuccess) return StepResult.Fail($"Finalization approval failed: {approveResult.Message}"); @@ -332,10 +342,14 @@ private static async Task FinalizeWithDeviceToken( } } - internal static async Task AutoApprovePairing(SetupContext ctx, CancellationToken ct) - => await AutoApprovePairing(ctx, requestId: null, ct); - internal static async Task AutoApprovePairing(SetupContext ctx, string? requestId, CancellationToken ct) + => await AutoApprovePairing(ctx, requestId, requestBaseline: null, ct); + + internal static async Task AutoApprovePairing( + SetupContext ctx, + string? requestId, + PendingRequestBaseline? requestBaseline, + CancellationToken ct) { var distro = ctx.DistroName!; var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken ?? throw new InvalidOperationException("No gateway token available for auto-approve"); @@ -344,6 +358,16 @@ internal static async Task AutoApprovePairing(SetupContext ctx, stri if (string.IsNullOrWhiteSpace(requestId)) { + 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 list --json""", @@ -361,7 +385,9 @@ internal static async Task AutoApprovePairing(SetupContext ctx, stri var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice( pending.Stdout.Trim(), - ctx.OperatorDeviceId); + ctx.OperatorDeviceId, + requestBaseline.RequestIds, + matchNodeId: false); if (!parsed.Success) { ctx.Logger.Warn($"Could not select pairing request: {parsed.Error}"); diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index 2702489e3..2084e714b 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -515,6 +515,8 @@ 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; } public GatewaySelfInfo? ObservedGatewaySelf { get; set; } public GatewayCompatibilityException? GatewayCompatibilityFailure { get; set; } public string? WindowsTailnetDnsSuffix { get; set; } diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index 77f23d33f..c16423ca8 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -206,6 +206,10 @@ private async Task RunCoreAsync(CancellationToken ct) var provenanceCheck = await PairOperatorStep.EnsurePairingEndpointTrustedAsync(_ctx, ct); if (provenanceCheck is not null) return provenanceCheck; + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + _ctx, + ApprovalRequestKind.Device, + ct); client = CreateWizardClient(credential, identityPath, wsLogger); var connection = await PairOperatorStep.WaitForConnectionOrPairing( client, @@ -234,7 +238,11 @@ private async Task RunCoreAsync(CancellationToken ct) await client.DisconnectAsync(); client.Dispose(); - var approval = await PairOperatorStep.AutoApprovePairing(_ctx, requestId, ct); + var approval = await PairOperatorStep.AutoApprovePairing( + _ctx, + requestId, + requestBaseline, + ct); if (!approval.IsSuccess) return approval; diff --git a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs index 6dd6af437..c014f1436 100644 --- a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs +++ b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs @@ -100,6 +100,7 @@ internal static async Task DrainPendingDeviceApprovalsAsync(SetupCon listCommand: "openclaw devices list --json", kind: ApprovalRequestKind.Device, matchNodeId: false, + requestBaseline: ctx.SetupDeviceApprovalBaseline, ct); } @@ -111,10 +112,17 @@ private static async Task DrainPendingRequestsForSetupDeviceAsync( string listCommand, ApprovalRequestKind kind, bool matchNodeId, + PendingRequestBaseline? requestBaseline, CancellationToken ct) { const int maxDrainIterations = 10; var label = kind == ApprovalRequestKind.Node ? "Node" : "Device"; + if (requestBaseline is null || !requestBaseline.Success) + { + ctx.Logger.Warn( + $"Skipping pending {label.ToLowerInvariant()} approval drain because setup did not capture a pre-connect request baseline"); + return StepResult.Ok($"Pending {label.ToLowerInvariant()} approval drain skipped"); + } for (var i = 0; i < maxDrainIterations; i++) { @@ -138,6 +146,7 @@ private static async Task DrainPendingRequestsForSetupDeviceAsync( var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice( pending.Stdout.Trim(), ctx.OperatorDeviceId, + requestBaseline.RequestIds, matchNodeId); if (!parsed.Success) { @@ -180,6 +189,13 @@ internal static async Task DrainPendingApprovalsAsync(SetupContext c var pathPrefix = ctx.WslPathPrefix; var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; const int maxDrainIterations = 10; + var requestBaseline = ctx.SetupNodeApprovalBaseline; + if (requestBaseline is null || !requestBaseline.Success) + { + ctx.Logger.Warn( + "Skipping pending node approval drain because setup did not capture a pre-connect request baseline"); + return StepResult.Ok("Pending node approval drain skipped"); + } for (var i = 0; i < maxDrainIterations; i++) { @@ -200,6 +216,7 @@ internal static async Task DrainPendingApprovalsAsync(SetupContext c var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice( nodeList.Stdout.Trim(), ctx.OperatorDeviceId, + requestBaseline.RequestIds, matchNodeId: true); if (!parsed.Success) { @@ -283,6 +300,10 @@ private static async Task FinalizeOperatorForTray( ctx.Logger.Info("Waiting for grace period before final operator handshake..."); await Task.Delay(TimeSpan.FromSeconds(5), ct); + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Device, + ct); var client = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath); PairOperatorStep.ApplyReconnectAuthorization(client, ctx); client.UseV2Signature = true; @@ -306,7 +327,11 @@ private static async Task FinalizeOperatorForTray( client.Dispose(); client = null; - var approveResult = await PairOperatorStep.AutoApprovePairing(ctx, requestId, ct); + var approveResult = await PairOperatorStep.AutoApprovePairing( + ctx, + requestId, + requestBaseline, + ct); if (!approveResult.IsSuccess) return StepResult.Fail($"Operator finalization approval failed: {approveResult.Message}"); diff --git a/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs index 35804ecd9..3108dd299 100644 --- a/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs @@ -63,6 +63,41 @@ public void TryReadPendingRequestIds_RejectsUnsafeRequestId() Assert.Contains("unsafe", result.Error); } + [Fact] + public void TrySelectPendingRequestForDevice_SelectsOnlyRequestCreatedAfterConnect() + { + const string deviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + var result = ApprovalRequestHelper.TrySelectPendingRequestForDevice( + $$""" + {"pending":[ + {"requestId":"stale-request","deviceId":"{{deviceId}}","role":"operator"}, + {"requestId":"current-request","deviceId":"{{deviceId}}","role":"operator"} + ]} + """, + deviceId, + new HashSet(["stale-request"], StringComparer.Ordinal), + matchNodeId: false); + + Assert.True(result.Success, result.Error); + Assert.Equal("current-request", result.RequestId); + } + + [Fact] + public void TrySelectPendingRequestForDevice_RejectsStaleSameIdentityRequest() + { + const string deviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + var result = ApprovalRequestHelper.TrySelectPendingRequestForDevice( + $$""" + {"pending":[{"requestId":"stale-request","deviceId":"{{deviceId}}","role":"operator"}]} + """, + deviceId, + new HashSet(["stale-request"], StringComparer.Ordinal), + matchNodeId: false); + + Assert.False(result.Success); + Assert.Contains("No new pending approval request matched", result.Error); + } + [Fact] public void TryReadSelectedRequestId_ReadsRequestWhenCliRequiresExplicitAuthFlags() { diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 2e0d38c1b..265f4f82b 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5449,10 +5449,11 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() } [Fact] - public async Task AutoApprovePairing_WithoutRequestId_ApprovesOnlyTheRequestForTheOpenedSocket() + public async Task AutoApprovePairing_WithoutRequestId_ApprovesOnlyNewRequestForOpenedSocket() { const string socketDeviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const string otherDeviceId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const string staleRequestId = "stale-socket-req"; const string socketRequestId = "setup-socket-req"; const string newerRequestId = "attacker-latest-req"; var commands = new FakeCommandRunner( @@ -5463,6 +5464,7 @@ public async Task AutoApprovePairing_WithoutRequestId_ApprovesOnlyTheRequestForT { return Ok( "{\"pending\":[" + + "{\"requestId\":\"" + staleRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\",\"ts\":0}," + "{\"requestId\":\"" + socketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\",\"ts\":1}," + "{\"requestId\":\"" + newerRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\",\"ts\":2}" + "]}"); @@ -5479,12 +5481,18 @@ public async Task AutoApprovePairing_WithoutRequestId_ApprovesOnlyTheRequestForT ctx.DistroName = "test-distro"; ctx.SharedGatewayToken = "test-auth-token"; ctx.OperatorDeviceId = socketDeviceId; + var requestBaseline = PendingRequestBaseline.SuccessResult([staleRequestId]); - var result = await PairOperatorStep.AutoApprovePairing(ctx, CancellationToken.None); + var result = await PairOperatorStep.AutoApprovePairing( + ctx, + requestId: null, + requestBaseline, + CancellationToken.None); Assert.True(result.IsSuccess, result.Message); Assert.Contains(socketRequestId, result.Message); Assert.DoesNotContain(newerRequestId, result.Message); + Assert.DoesNotContain(staleRequestId, result.Message); Assert.DoesNotContain( commands.WslCalls, call => call.Command.Contains("approve --latest", StringComparison.Ordinal)); @@ -5496,14 +5504,36 @@ public async Task AutoApprovePairing_WithoutRequestId_ApprovesOnlyTheRequestForT commands.WslEnvironments[approve.index]! [ApprovalRequestHelper.RequestIdEnvironmentVariable]); } + [Fact] + public async Task AutoApprovePairing_WithoutRequestIdOrBaseline_FailsWithoutListing() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, _, _) => Ok("""{"pending":[{"requestId":"unowned-request"}]}""")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "test-auth-token"; + + var result = await PairOperatorStep.AutoApprovePairing( + ctx, + requestId: null, + CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("no pre-connect approval baseline", result.Message); + Assert.Empty(commands.WslCalls); + } + [Fact] public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() { const string socketDeviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const string otherDeviceId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const string socketRequestId = "setup-socket-req"; + const string staleSocketRequestId = "stale-setup-socket-req"; const string otherRequestId = "attacker-latest-req"; const string socketNodeRequestId = "setup-node-req"; + const string staleSocketNodeRequestId = "stale-setup-node-req"; const string otherNodeRequestId = "attacker-node-req"; var deviceLists = 0; var nodeLists = 0; @@ -5516,10 +5546,14 @@ public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() deviceLists++; var pending = deviceLists == 1 ? "{\"pending\":[" + + "{\"requestId\":\"" + staleSocketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\"}," + "{\"requestId\":\"" + socketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\"}," + "{\"requestId\":\"" + otherRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\"}" + "]}" - : "{\"pending\":[{\"requestId\":\"" + otherRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\"}]}"; + : "{\"pending\":[" + + "{\"requestId\":\"" + staleSocketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\"}," + + "{\"requestId\":\"" + otherRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\"}" + + "]}"; return Ok(pending); } @@ -5528,10 +5562,14 @@ public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() nodeLists++; var pending = nodeLists == 1 ? "{\"pending\":[" + + "{\"requestId\":\"" + staleSocketNodeRequestId + "\",\"nodeId\":\"" + socketDeviceId + "\",\"role\":\"node\"}," + "{\"requestId\":\"" + socketNodeRequestId + "\",\"nodeId\":\"" + socketDeviceId + "\",\"role\":\"node\"}," + "{\"requestId\":\"" + otherNodeRequestId + "\",\"nodeId\":\"" + otherDeviceId + "\",\"role\":\"node\"}" + "]}" - : "{\"pending\":[{\"requestId\":\"" + otherNodeRequestId + "\",\"nodeId\":\"" + otherDeviceId + "\",\"role\":\"node\"}]}"; + : "{\"pending\":[" + + "{\"requestId\":\"" + staleSocketNodeRequestId + "\",\"nodeId\":\"" + socketDeviceId + "\",\"role\":\"node\"}," + + "{\"requestId\":\"" + otherNodeRequestId + "\",\"nodeId\":\"" + otherDeviceId + "\",\"role\":\"node\"}" + + "]}"; return Ok(pending); } @@ -5550,6 +5588,8 @@ public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() ctx.DistroName = "test-distro"; ctx.SharedGatewayToken = "test-auth-token"; ctx.OperatorDeviceId = socketDeviceId; + ctx.SetupDeviceApprovalBaseline = PendingRequestBaseline.SuccessResult([staleSocketRequestId]); + ctx.SetupNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([staleSocketNodeRequestId]); var result = await VerifyEndToEndStep.DrainPendingApprovalsAsync(ctx, CancellationToken.None); @@ -5563,7 +5603,10 @@ public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() commands.WslEnvironments, env => env is not null && env.TryGetValue(ApprovalRequestHelper.RequestIdEnvironmentVariable, out var requestId) && - (requestId == otherRequestId || requestId == otherNodeRequestId)); + (requestId == staleSocketRequestId || + requestId == staleSocketNodeRequestId || + requestId == otherRequestId || + requestId == otherNodeRequestId)); } private static void AssertApprovedRequest(FakeCommandRunner commands, string commandText, string requestId) @@ -5609,10 +5652,14 @@ public async Task AutoApproveNodePairing_WithoutRequestId_RejectsSoleForeignNode var ctx = CreateNodePairingContext(commands); ctx.NodeDeviceId = foreignDeviceId[..16]; - var result = await PairNodeStep.AutoApproveNodePairing(ctx, null, CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing( + ctx, + requestId: null, + PendingRequestBaseline.SuccessResult([]), + CancellationToken.None); Assert.Equal(StepOutcome.Failed, result.Outcome); - Assert.Contains("No pending approval request matched", result.Message); + Assert.Contains("No new pending approval request matched", result.Message); Assert.Single(commands.WslCalls); } @@ -5633,7 +5680,11 @@ public async Task AutoApproveNodePairing_WithoutRequestId_SelectsFullSetupIdenti var ctx = CreateNodePairingContext(commands); ctx.NodeDeviceId = "bbbbbbbbbbbbbbbb"; - var result = await PairNodeStep.AutoApproveNodePairing(ctx, null, CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing( + ctx, + requestId: null, + PendingRequestBaseline.SuccessResult([]), + CancellationToken.None); Assert.True(result.IsSuccess, result.Message); Assert.Equal(2, commands.WslCalls.Count); @@ -5654,7 +5705,11 @@ public async Task AutoApproveNodePairing_WithoutRequestId_RejectsMissingSetupIde ctx.OperatorDeviceId = deviceId; ctx.NodeDeviceId = PairingSocketDeviceId[..16]; - var result = await PairNodeStep.AutoApproveNodePairing(ctx, null, CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing( + ctx, + requestId: null, + PendingRequestBaseline.SuccessResult([]), + CancellationToken.None); Assert.Equal(StepOutcome.Failed, result.Outcome); Assert.Contains("device ID is missing", result.Message); @@ -5662,9 +5717,9 @@ public async Task AutoApproveNodePairing_WithoutRequestId_RejectsMissingSetupIde } [Theory] - [InlineData("""{"pending":[{"requestId":"short-id-request","nodeId":"aaaaaaaaaaaaaaaa"}]}""", "No pending approval request matched")] - [InlineData("""{"pending":[{"requestId":"missing-id-request"}]}""", "No pending approval request matched")] - [InlineData("""{"pending":[{"requestId":"one","nodeId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"requestId":"two","nodeId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}""", "Multiple pending approval requests match")] + [InlineData("""{"pending":[{"requestId":"short-id-request","nodeId":"aaaaaaaaaaaaaaaa"}]}""", "No new pending approval request matched")] + [InlineData("""{"pending":[{"requestId":"missing-id-request"}]}""", "No new pending approval request matched")] + [InlineData("""{"pending":[{"requestId":"one","nodeId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"requestId":"two","nodeId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}""", "Multiple new pending approval requests match")] [InlineData("""{"pending":[{"requestId":"unsafe;request","nodeId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}""", "unsafe characters")] public async Task AutoApproveNodePairing_WithoutRequestId_RejectsUnboundOrAmbiguousRequest( string pendingJson, string expectedError) @@ -5672,7 +5727,11 @@ public async Task AutoApproveNodePairing_WithoutRequestId_RejectsUnboundOrAmbigu var commands = NodePairingCommands(pendingJson); var ctx = CreateNodePairingContext(commands); - var result = await PairNodeStep.AutoApproveNodePairing(ctx, null, CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing( + ctx, + requestId: null, + PendingRequestBaseline.SuccessResult([]), + CancellationToken.None); Assert.Equal(StepOutcome.Failed, result.Outcome); Assert.Contains(expectedError, result.Message); @@ -5728,8 +5787,16 @@ private SetupContext CreateNodePairingContext(FakeCommandRunner commands) public async Task AutoApproveNodePairing_ReturnsTerminalWhenPendingListReportsDevicePairPluginNotFound() { var ctx = CreatePairingContext(DevicePairPluginNotFoundOutput); + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Node, + CancellationToken.None); - var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing( + ctx, + requestId: null, + requestBaseline, + CancellationToken.None); Assert.Equal(StepOutcome.FailedTerminal, result.Outcome); Assert.Equal(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); @@ -5739,11 +5806,19 @@ public async Task AutoApproveNodePairing_ReturnsTerminalWhenPendingListReportsDe public async Task AutoApproveNodePairing_KeepsOtherPendingListMissingPluginRetriable() { var ctx = CreatePairingContext(OtherPluginNotFoundOutput); + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Node, + CancellationToken.None); - var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing( + ctx, + requestId: null, + requestBaseline, + CancellationToken.None); Assert.Equal(StepOutcome.Failed, result.Outcome); - Assert.Contains("Could not list pending node pairing requests", result.Message); + Assert.Contains("Could not capture pending nodes", result.Message); Assert.DoesNotContain(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); } From e70ece4b77120dc6b08fc0170b5a5fefd960dfa6 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:32:37 -0700 Subject: [PATCH 09/14] refactor(setup): keep approval baseline in context Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c61d6e69-5b8b-4fac-bdf8-7d468a9b5908 --- .../ApprovalRequestHelper.cs | 6 +-- src/OpenClaw.SetupEngine/PairNodeStep.cs | 14 ++---- src/OpenClaw.SetupEngine/PairOperatorStep.cs | 14 ++---- src/OpenClaw.SetupEngine/SetupContext.cs | 2 + src/OpenClaw.SetupEngine/SetupWizardRunner.cs | 7 +-- .../VerifyEndToEndStep.cs | 7 +-- .../SetupStepsTests.cs | 49 ++++++------------- 7 files changed, 33 insertions(+), 66 deletions(-) diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index 61e9594bb..f3f4498a1 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -18,11 +18,11 @@ internal static async Task CapturePendingRequestBaseline ApprovalRequestKind kind, CancellationToken ct) { - var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; - if (string.IsNullOrWhiteSpace(token)) + 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 { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = authorization }; var noun = Noun(kind); var pending = await ctx.Commands.RunInWslAsync( ctx.DistroName!, diff --git a/src/OpenClaw.SetupEngine/PairNodeStep.cs b/src/OpenClaw.SetupEngine/PairNodeStep.cs index 995916b12..460a162d5 100644 --- a/src/OpenClaw.SetupEngine/PairNodeStep.cs +++ b/src/OpenClaw.SetupEngine/PairNodeStep.cs @@ -53,6 +53,7 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati ApprovalRequestKind.Node, ct); ctx.SetupNodeApprovalBaseline = requestBaseline; + ctx.CurrentNodeApprovalBaseline = requestBaseline; try { @@ -83,7 +84,7 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati client.Dispose(); client = null; - var approveResult = await AutoApproveNodePairing(ctx, outcome.RequestId, requestBaseline, ct); + var approveResult = await AutoApproveNodePairing(ctx, outcome.RequestId, ct); if (!approveResult.IsSuccess) return approveResult; @@ -178,6 +179,7 @@ private static async Task FinalizeNodeWithDeviceToken( ctx, ApprovalRequestKind.Node, ct); + ctx.CurrentNodeApprovalBaseline = requestBaseline; var finalClient = new WindowsNodeClient(gatewayUrl, nodeToken, identityPath, logger: wsLogger); PairOperatorStep.ApplyReconnectAuthorization(finalClient, ctx); finalClient.UseV2Signature = true; @@ -199,7 +201,7 @@ private static async Task FinalizeNodeWithDeviceToken( finalClient.Dispose(); finalClient = null; - var approveResult = await AutoApproveNodePairing(ctx, result.RequestId, requestBaseline, ct); + var approveResult = await AutoApproveNodePairing(ctx, result.RequestId, ct); if (!approveResult.IsSuccess) return StepResult.Fail($"Node finalization approval failed: {approveResult.Message}"); @@ -288,13 +290,6 @@ void OnPairingStatusChanged(object? sender, PairingStatusEventArgs args) } internal static async Task AutoApproveNodePairing(SetupContext ctx, string? requestId, CancellationToken ct) - => await AutoApproveNodePairing(ctx, requestId, requestBaseline: null, ct); - - internal static async Task AutoApproveNodePairing( - SetupContext ctx, - string? requestId, - PendingRequestBaseline? requestBaseline, - CancellationToken ct) { var distro = ctx.DistroName!; var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken ?? throw new InvalidOperationException("No gateway token available for auto-approve"); @@ -304,6 +299,7 @@ internal static async Task AutoApproveNodePairing( if (string.IsNullOrWhiteSpace(requestId)) { + var requestBaseline = ctx.CurrentNodeApprovalBaseline; if (requestBaseline is null || !requestBaseline.Success) { if (requestBaseline?.PluginNotFound == true) diff --git a/src/OpenClaw.SetupEngine/PairOperatorStep.cs b/src/OpenClaw.SetupEngine/PairOperatorStep.cs index 8f713a475..74de3f0ab 100644 --- a/src/OpenClaw.SetupEngine/PairOperatorStep.cs +++ b/src/OpenClaw.SetupEngine/PairOperatorStep.cs @@ -90,6 +90,7 @@ record = registry.AddOrUpdate(record); ApprovalRequestKind.Device, ct); ctx.SetupDeviceApprovalBaseline = requestBaseline; + ctx.CurrentDeviceApprovalBaseline = requestBaseline; try { @@ -117,7 +118,7 @@ record = registry.AddOrUpdate(record); client = null; // Auto-approve the pending pairing request - var approveResult = await AutoApprovePairing(ctx, requestId, requestBaseline, ct); + var approveResult = await AutoApprovePairing(ctx, requestId, ct); if (!approveResult.IsSuccess) return approveResult; @@ -284,6 +285,7 @@ private static async Task FinalizeWithDeviceToken( 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); @@ -309,7 +311,7 @@ private static async Task FinalizeWithDeviceToken( finalClient = null; // Approve the metadata-upgrade - var approveResult = await AutoApprovePairing(ctx, requestId, requestBaseline, ct); + var approveResult = await AutoApprovePairing(ctx, requestId, ct); if (!approveResult.IsSuccess) return StepResult.Fail($"Finalization approval failed: {approveResult.Message}"); @@ -343,13 +345,6 @@ private static async Task FinalizeWithDeviceToken( } internal static async Task AutoApprovePairing(SetupContext ctx, string? requestId, CancellationToken ct) - => await AutoApprovePairing(ctx, requestId, requestBaseline: null, ct); - - internal static async Task AutoApprovePairing( - SetupContext ctx, - string? requestId, - PendingRequestBaseline? requestBaseline, - CancellationToken ct) { var distro = ctx.DistroName!; var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken ?? throw new InvalidOperationException("No gateway token available for auto-approve"); @@ -358,6 +353,7 @@ internal static async Task AutoApprovePairing( if (string.IsNullOrWhiteSpace(requestId)) { + var requestBaseline = ctx.CurrentDeviceApprovalBaseline; if (requestBaseline is null || !requestBaseline.Success) { if (requestBaseline?.PluginNotFound == true) diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index 2084e714b..fb72f54ed 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -517,6 +517,8 @@ public sealed class SetupContext 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; } diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index c16423ca8..35a0e3331 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -210,6 +210,7 @@ private async Task RunCoreAsync(CancellationToken ct) _ctx, ApprovalRequestKind.Device, ct); + _ctx.CurrentDeviceApprovalBaseline = requestBaseline; client = CreateWizardClient(credential, identityPath, wsLogger); var connection = await PairOperatorStep.WaitForConnectionOrPairing( client, @@ -238,11 +239,7 @@ private async Task RunCoreAsync(CancellationToken ct) await client.DisconnectAsync(); client.Dispose(); - var approval = await PairOperatorStep.AutoApprovePairing( - _ctx, - requestId, - requestBaseline, - ct); + var approval = await PairOperatorStep.AutoApprovePairing(_ctx, requestId, ct); if (!approval.IsSuccess) return approval; diff --git a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs index c014f1436..53d8a1c39 100644 --- a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs +++ b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs @@ -304,6 +304,7 @@ private static async Task FinalizeOperatorForTray( ctx, ApprovalRequestKind.Device, ct); + ctx.CurrentDeviceApprovalBaseline = requestBaseline; var client = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath); PairOperatorStep.ApplyReconnectAuthorization(client, ctx); client.UseV2Signature = true; @@ -327,11 +328,7 @@ private static async Task FinalizeOperatorForTray( client.Dispose(); client = null; - var approveResult = await PairOperatorStep.AutoApprovePairing( - ctx, - requestId, - requestBaseline, - ct); + var approveResult = await PairOperatorStep.AutoApprovePairing(ctx, requestId, ct); if (!approveResult.IsSuccess) return StepResult.Fail($"Operator finalization approval failed: {approveResult.Message}"); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 265f4f82b..48b387d9c 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5482,12 +5482,9 @@ public async Task AutoApprovePairing_WithoutRequestId_ApprovesOnlyNewRequestForO ctx.SharedGatewayToken = "test-auth-token"; ctx.OperatorDeviceId = socketDeviceId; var requestBaseline = PendingRequestBaseline.SuccessResult([staleRequestId]); + ctx.CurrentDeviceApprovalBaseline = requestBaseline; - var result = await PairOperatorStep.AutoApprovePairing( - ctx, - requestId: null, - requestBaseline, - CancellationToken.None); + var result = await PairOperatorStep.AutoApprovePairing(ctx, requestId: null, CancellationToken.None); Assert.True(result.IsSuccess, result.Message); Assert.Contains(socketRequestId, result.Message); @@ -5651,12 +5648,9 @@ public async Task AutoApproveNodePairing_WithoutRequestId_RejectsSoleForeignNode """); var ctx = CreateNodePairingContext(commands); ctx.NodeDeviceId = foreignDeviceId[..16]; + ctx.CurrentNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); - var result = await PairNodeStep.AutoApproveNodePairing( - ctx, - requestId: null, - PendingRequestBaseline.SuccessResult([]), - CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); Assert.Equal(StepOutcome.Failed, result.Outcome); Assert.Contains("No new pending approval request matched", result.Message); @@ -5679,12 +5673,9 @@ public async Task AutoApproveNodePairing_WithoutRequestId_SelectsFullSetupIdenti """); var ctx = CreateNodePairingContext(commands); ctx.NodeDeviceId = "bbbbbbbbbbbbbbbb"; + ctx.CurrentNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); - var result = await PairNodeStep.AutoApproveNodePairing( - ctx, - requestId: null, - PendingRequestBaseline.SuccessResult([]), - CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); Assert.True(result.IsSuccess, result.Message); Assert.Equal(2, commands.WslCalls.Count); @@ -5704,12 +5695,9 @@ public async Task AutoApproveNodePairing_WithoutRequestId_RejectsMissingSetupIde var ctx = CreateNodePairingContext(commands); ctx.OperatorDeviceId = deviceId; ctx.NodeDeviceId = PairingSocketDeviceId[..16]; + ctx.CurrentNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); - var result = await PairNodeStep.AutoApproveNodePairing( - ctx, - requestId: null, - PendingRequestBaseline.SuccessResult([]), - CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); Assert.Equal(StepOutcome.Failed, result.Outcome); Assert.Contains("device ID is missing", result.Message); @@ -5726,12 +5714,9 @@ public async Task AutoApproveNodePairing_WithoutRequestId_RejectsUnboundOrAmbigu { var commands = NodePairingCommands(pendingJson); var ctx = CreateNodePairingContext(commands); + ctx.CurrentNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); - var result = await PairNodeStep.AutoApproveNodePairing( - ctx, - requestId: null, - PendingRequestBaseline.SuccessResult([]), - CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); Assert.Equal(StepOutcome.Failed, result.Outcome); Assert.Contains(expectedError, result.Message); @@ -5791,12 +5776,9 @@ public async Task AutoApproveNodePairing_ReturnsTerminalWhenPendingListReportsDe ctx, ApprovalRequestKind.Node, CancellationToken.None); + ctx.CurrentNodeApprovalBaseline = requestBaseline; - var result = await PairNodeStep.AutoApproveNodePairing( - ctx, - requestId: null, - requestBaseline, - CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); Assert.Equal(StepOutcome.FailedTerminal, result.Outcome); Assert.Equal(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); @@ -5810,12 +5792,9 @@ public async Task AutoApproveNodePairing_KeepsOtherPendingListMissingPluginRetri ctx, ApprovalRequestKind.Node, CancellationToken.None); + ctx.CurrentNodeApprovalBaseline = requestBaseline; - var result = await PairNodeStep.AutoApproveNodePairing( - ctx, - requestId: null, - requestBaseline, - CancellationToken.None); + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); Assert.Equal(StepOutcome.Failed, result.Outcome); Assert.Contains("Could not capture pending nodes", result.Message); From d068b1704a2942714a92551e13b7edfba32cb438 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:10:04 -0700 Subject: [PATCH 10/14] fix(setup): parse pairing baseline before empty fallback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c61d6e69-5b8b-4fac-bdf8-7d468a9b5908 --- .../ApprovalRequestHelper.cs | 22 ++++++---- .../SetupStepsTests.cs | 40 +++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index f3f4498a1..6b65160d1 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -32,9 +32,6 @@ internal static async Task CapturePendingRequestBaseline ct); var output = $"{pending.Stdout.Trim()} {pending.Stderr.Trim()}".Trim(); - if (output.Contains("No pending", StringComparison.OrdinalIgnoreCase)) - return PendingRequestBaseline.SuccessResult([]); - if (pending.ExitCode != 0) { return PendingRequestBaseline.Fail( @@ -43,10 +40,14 @@ internal static async Task CapturePendingRequestBaseline } var parsed = TryReadPendingRequestIds(pending.Stdout.Trim()); - return parsed.Success - ? PendingRequestBaseline.SuccessResult(parsed.RequestIds) - : PendingRequestBaseline.Fail( - $"Could not capture pending {noun} before opening the setup socket: {parsed.Error}"); + 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) @@ -322,6 +323,13 @@ private static string Noun(ApprovalRequestKind kind) _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) }; + private 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(); } diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 48b387d9c..a0e483e46 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5801,6 +5801,46 @@ public async Task AutoApproveNodePairing_KeepsOtherPendingListMissingPluginRetri Assert.DoesNotContain(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); } + [Fact] + public async Task CapturePendingRequestBaseline_ParsesJsonBeforeCheckingNoPendingMetadata() + { + const string requestId = "stale-request"; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, _, _) => Ok(""" + { + "pending": [{ "requestId": "stale-request" }], + "paired": [{ "displayName": "No pending device approvals" }] + } + """)); + var ctx = CreateNodePairingContext(commands); + + var baseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Node, + CancellationToken.None); + + Assert.True(baseline.Success, baseline.Error); + Assert.Contains(requestId, baseline.RequestIds); + } + + [Fact] + public async Task CapturePendingRequestBaseline_DoesNotTreatFailedNoPendingOutputAsSuccess() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, _, _) => new CommandResult(1, "No pending node approvals", "gateway unavailable", TimeSpan.Zero, TimedOut: false)); + var ctx = CreateNodePairingContext(commands); + + var baseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Node, + CancellationToken.None); + + Assert.False(baseline.Success); + Assert.Contains("exit 1", baseline.Error); + } + [Fact] public async Task AutoApproveNodePairing_ReturnsTerminalWhenApproveReportsDevicePairPluginNotFound() { From c3f79f038388f048b56f2c93536382c09b80b52d Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:26:55 -0700 Subject: [PATCH 11/14] fix(setup): parse drain lists before empty fallback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c61d6e69-5b8b-4fac-bdf8-7d468a9b5908 --- .../ApprovalRequestHelper.cs | 2 +- .../VerifyEndToEndStep.cs | 22 +++----- .../SetupStepsTests.cs | 56 +++++++++++++++++-- 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index 6b65160d1..3a1655cb0 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -323,7 +323,7 @@ private static string Noun(ApprovalRequestKind kind) _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) }; - private static bool IsExplicitNoPendingMessage(string output) + internal static bool IsExplicitNoPendingMessage(string output) { var message = output.Trim().TrimEnd('.'); return string.Equals(message, "No pending device approvals", StringComparison.OrdinalIgnoreCase) diff --git a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs index 53d8a1c39..fb484bed3 100644 --- a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs +++ b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs @@ -131,12 +131,6 @@ private static async Task DrainPendingRequestsForSetupDeviceAsync( $"""{pathPrefix} && {listCommand}""", TimeSpan.FromSeconds(15), env, ct, inputViaStdin: true); - if (pending.Stdout.Contains("No pending", StringComparison.OrdinalIgnoreCase) || - pending.Stderr.Contains("No pending", StringComparison.OrdinalIgnoreCase)) - { - break; - } - if (pending.ExitCode != 0) { var pendingOutput = $"{pending.Stdout.Trim()} {pending.Stderr.Trim()}".Trim(); @@ -150,8 +144,11 @@ private static async Task DrainPendingRequestsForSetupDeviceAsync( matchNodeId); if (!parsed.Success) { - if (ApprovalRequestHelper.IsNothingToDrain(parsed)) + if (ApprovalRequestHelper.IsNothingToDrain(parsed) || + ApprovalRequestHelper.IsExplicitNoPendingMessage(pending.Stdout)) + { break; + } return StepResult.Fail($"Could not select pending {label.ToLowerInvariant()} approval for drain: {parsed.Error}"); } @@ -204,12 +201,6 @@ internal static async Task DrainPendingApprovalsAsync(SetupContext c $"""{pathPrefix} && openclaw nodes list --json""", TimeSpan.FromSeconds(15), env, ct); - if (nodeList.Stdout.Contains("No pending", StringComparison.OrdinalIgnoreCase) || - nodeList.Stderr.Contains("No pending", StringComparison.OrdinalIgnoreCase)) - { - break; - } - if (nodeList.ExitCode != 0) return StepResult.Fail($"Could not list pending node approvals (exit {nodeList.ExitCode}): {nodeList.Stdout.Trim()} {nodeList.Stderr.Trim()}".Trim()); @@ -220,8 +211,11 @@ internal static async Task DrainPendingApprovalsAsync(SetupContext c matchNodeId: true); if (!parsed.Success) { - if (ApprovalRequestHelper.IsNothingToDrain(parsed)) + if (ApprovalRequestHelper.IsNothingToDrain(parsed) || + ApprovalRequestHelper.IsExplicitNoPendingMessage(nodeList.Stdout)) + { break; + } return StepResult.Fail($"Could not select pending node approval for drain: {parsed.Error}"); } diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index a0e483e46..1976bd1f5 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5546,11 +5546,11 @@ public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() "{\"requestId\":\"" + staleSocketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\"}," + "{\"requestId\":\"" + socketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\"}," + "{\"requestId\":\"" + otherRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\"}" + - "]}" + "],\"paired\":[{\"displayName\":\"No pending device approvals\"}]}" : "{\"pending\":[" + "{\"requestId\":\"" + staleSocketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\"}," + "{\"requestId\":\"" + otherRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\"}" + - "]}"; + "],\"paired\":[{\"displayName\":\"No pending device approvals\"}]}"; return Ok(pending); } @@ -5562,11 +5562,11 @@ public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() "{\"requestId\":\"" + staleSocketNodeRequestId + "\",\"nodeId\":\"" + socketDeviceId + "\",\"role\":\"node\"}," + "{\"requestId\":\"" + socketNodeRequestId + "\",\"nodeId\":\"" + socketDeviceId + "\",\"role\":\"node\"}," + "{\"requestId\":\"" + otherNodeRequestId + "\",\"nodeId\":\"" + otherDeviceId + "\",\"role\":\"node\"}" + - "]}" + "],\"paired\":[{\"displayName\":\"No pending node approvals\"}]}" : "{\"pending\":[" + "{\"requestId\":\"" + staleSocketNodeRequestId + "\",\"nodeId\":\"" + socketDeviceId + "\",\"role\":\"node\"}," + "{\"requestId\":\"" + otherNodeRequestId + "\",\"nodeId\":\"" + otherDeviceId + "\",\"role\":\"node\"}" + - "]}"; + "],\"paired\":[{\"displayName\":\"No pending node approvals\"}]}"; return Ok(pending); } @@ -5606,6 +5606,54 @@ public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() requestId == otherNodeRequestId)); } + [Fact] + public async Task LaterDrain_DoesNotIgnoreNoPendingTextFromFailedDeviceList() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("devices list", StringComparison.Ordinal) + ? new CommandResult(1, "No pending device approvals", "gateway unavailable", TimeSpan.Zero, TimedOut: false) + : Fail($"unexpected wsl command: {command}")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "test-auth-token"; + ctx.OperatorDeviceId = PairingSocketDeviceId; + ctx.SetupDeviceApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + ctx.SetupNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + + var result = await VerifyEndToEndStep.DrainPendingApprovalsAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("Could not list pending device approvals (exit 1)", result.Message); + } + + [Fact] + public async Task LaterDrain_DoesNotIgnoreNoPendingTextFromFailedNodeList() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains("devices list", StringComparison.Ordinal)) + return Ok("""{"pending":[]}"""); + + return command.Contains("nodes list", StringComparison.Ordinal) + ? new CommandResult(1, "No pending node approvals", "gateway unavailable", TimeSpan.Zero, TimedOut: false) + : Fail($"unexpected wsl command: {command}"); + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "test-auth-token"; + ctx.OperatorDeviceId = PairingSocketDeviceId; + ctx.SetupDeviceApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + ctx.SetupNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + + var result = await VerifyEndToEndStep.DrainPendingApprovalsAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("Could not list pending node approvals (exit 1)", result.Message); + } + private static void AssertApprovedRequest(FakeCommandRunner commands, string commandText, string requestId) { var approve = Assert.Single( From 3e1ff987175e932fb04b9fd4d7f04fcdc06bab33 Mon Sep 17 00:00:00 2001 From: "bakudies@microsoft.com" Date: Mon, 28 Sep 2026 16:40:35 -0700 Subject: [PATCH 12/14] fix(setup): retain pairing baseline across retries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/SETUP_ENGINE_REDESIGN.md | 18 ++- .../ApprovalRequestHelper.cs | 26 ++++ src/OpenClaw.SetupEngine/PairNodeStep.cs | 3 +- src/OpenClaw.SetupEngine/PairOperatorStep.cs | 3 +- .../SetupStepsTests.cs | 134 ++++++++++++++++++ 5 files changed, 174 insertions(+), 10 deletions(-) diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 80ce5440d..742a6e9f1 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -286,13 +286,19 @@ 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 opening each socket, setup snapshots the pending request IDs. If the -socket omits a pairing request ID, setup selects exactly one newly appearing -request matching that full identity, not a stale same-identity request, the +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 -missing baseline, no new match, or multiple new matches fail closed. Later -cleanup excludes requests that predate the setup socket. A socket-provided -request ID still uses the exact device-approval path. +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 diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index 3a1655cb0..4c7f0bcf5 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -13,6 +13,32 @@ internal static partial class ApprovalRequestHelper { internal const string RequestIdEnvironmentVariable = "OPENCLAW_APPROVAL_REQUEST_ID"; + internal static async Task 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 CapturePendingRequestBaselineAsync( SetupContext ctx, ApprovalRequestKind kind, diff --git a/src/OpenClaw.SetupEngine/PairNodeStep.cs b/src/OpenClaw.SetupEngine/PairNodeStep.cs index 460a162d5..7e23b2f7c 100644 --- a/src/OpenClaw.SetupEngine/PairNodeStep.cs +++ b/src/OpenClaw.SetupEngine/PairNodeStep.cs @@ -48,11 +48,10 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati var wsLogger = new SetupOpenClawLogger(ctx.Logger); WindowsNodeClient? client = null; - var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + var requestBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync( ctx, ApprovalRequestKind.Node, ct); - ctx.SetupNodeApprovalBaseline = requestBaseline; ctx.CurrentNodeApprovalBaseline = requestBaseline; try diff --git a/src/OpenClaw.SetupEngine/PairOperatorStep.cs b/src/OpenClaw.SetupEngine/PairOperatorStep.cs index 74de3f0ab..78a480027 100644 --- a/src/OpenClaw.SetupEngine/PairOperatorStep.cs +++ b/src/OpenClaw.SetupEngine/PairOperatorStep.cs @@ -85,11 +85,10 @@ record = registry.AddOrUpdate(record); // Connect operator WebSocket — handle pairing-required flow var wsLogger = new SetupOpenClawLogger(ctx.Logger); OpenClawGatewayClient? client = null; - var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + var requestBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync( ctx, ApprovalRequestKind.Device, ct); - ctx.SetupDeviceApprovalBaseline = requestBaseline; ctx.CurrentDeviceApprovalBaseline = requestBaseline; try diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 1976bd1f5..784ba196b 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5889,6 +5889,140 @@ public async Task CapturePendingRequestBaseline_DoesNotTreatFailedNoPendingOutpu Assert.Contains("exit 1", baseline.Error); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CaptureSetupBaselineOnce_RetryApprovesRefreshedRequestButNotPreexistingRequest( + bool node) + { + var kind = node ? ApprovalRequestKind.Node : ApprovalRequestKind.Device; + var noun = kind == ApprovalRequestKind.Device ? "devices" : "nodes"; + var identityField = kind == ApprovalRequestKind.Device ? "deviceId" : "nodeId"; + var role = kind == ApprovalRequestKind.Device ? "operator" : "node"; + var listCalls = 0; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains($"{noun} list --json", StringComparison.Ordinal)) + { + return ++listCalls switch + { + 1 => Ok("""{"pending":[{"requestId":"preexisting-request"}]}"""), + 2 => Fail("transient list failure"), + 3 => Ok($$""" + {"pending":[ + {"requestId":"preexisting-request","{{identityField}}":"{{PairingSocketDeviceId}}","role":"{{role}}"}, + {"requestId":"refreshed-request","{{identityField}}":"{{PairingSocketDeviceId}}","role":"{{role}}"} + ]} + """), + _ => Fail("unexpected list") + }; + } + + return command.Contains($"{noun} approve ", StringComparison.Ordinal) + ? Ok("{}") + : Fail($"unexpected wsl command: {command}"); + }); + var ctx = CreateNodePairingContext(commands); + var initial = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(ctx, kind, CancellationToken.None); + if (kind == ApprovalRequestKind.Device) + ctx.CurrentDeviceApprovalBaseline = initial; + else + ctx.CurrentNodeApprovalBaseline = initial; + + var firstApproval = kind == ApprovalRequestKind.Device + ? await PairOperatorStep.AutoApprovePairing(ctx, requestId: null, CancellationToken.None) + : await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + Assert.False(firstApproval.IsSuccess); + + var retry = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(ctx, kind, CancellationToken.None); + Assert.Same(initial, retry); + if (kind == ApprovalRequestKind.Device) + ctx.CurrentDeviceApprovalBaseline = retry; + else + ctx.CurrentNodeApprovalBaseline = retry; + var retryApproval = kind == ApprovalRequestKind.Device + ? await PairOperatorStep.AutoApprovePairing(ctx, requestId: null, CancellationToken.None) + : await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + + Assert.True(retryApproval.IsSuccess, retryApproval.Message); + Assert.Equal(3, listCalls); + AssertApprovedRequest(commands, $"{noun} approve", "refreshed-request"); + Assert.DoesNotContain( + commands.WslEnvironments, + env => env is not null && + env.TryGetValue(ApprovalRequestHelper.RequestIdEnvironmentVariable, out var requestId) && + requestId == "preexisting-request"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task CaptureSetupBaselineOnce_FailedFirstCaptureRecapturesSafelyOnRetry( + bool node, bool firstAttemptMintedRequest) + { + var kind = node ? ApprovalRequestKind.Node : ApprovalRequestKind.Device; + var noun = node ? "nodes" : "devices"; + var identityField = node ? "nodeId" : "deviceId"; + var role = node ? "node" : "operator"; + var baselineAtRetry = firstAttemptMintedRequest + ? """{"pending":[{"requestId":"first-attempt-request"}]}""" + : """{"pending":[]}"""; + var pendingAfterConnect = firstAttemptMintedRequest + ? $$"""{"pending":[{"requestId":"first-attempt-request","{{identityField}}":"{{PairingSocketDeviceId}}","role":"{{role}}"}]}""" + : $$"""{"pending":[{"requestId":"retry-request","{{identityField}}":"{{PairingSocketDeviceId}}","role":"{{role}}"}]}"""; + var listCalls = 0; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains($"{noun} list --json", StringComparison.Ordinal)) + { + return ++listCalls switch + { + 1 => Fail("initial baseline unavailable"), + 2 => Ok(baselineAtRetry), + 3 => Ok(pendingAfterConnect), + _ => Fail("unexpected list") + }; + } + + return command.Contains($"{noun} approve ", StringComparison.Ordinal) + ? Ok("{}") + : Fail($"unexpected wsl command: {command}"); + }); + var ctx = CreateNodePairingContext(commands); + + var initial = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(ctx, kind, CancellationToken.None); + var retry = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(ctx, kind, CancellationToken.None); + if (kind == ApprovalRequestKind.Device) + ctx.CurrentDeviceApprovalBaseline = retry; + else + ctx.CurrentNodeApprovalBaseline = retry; + var retryApproval = kind == ApprovalRequestKind.Device + ? await PairOperatorStep.AutoApprovePairing(ctx, requestId: null, CancellationToken.None) + : await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + + Assert.False(initial.Success); + Assert.True(retry.Success, retry.Error); + Assert.NotSame(initial, retry); + Assert.Equal(3, listCalls); + if (firstAttemptMintedRequest) + { + Assert.Contains("first-attempt-request", retry.RequestIds); + Assert.False(retryApproval.IsSuccess); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains(" approve ", StringComparison.Ordinal)); + } + else + { + Assert.True(retryApproval.IsSuccess, retryApproval.Message); + AssertApprovedRequest(commands, $"{noun} approve", "retry-request"); + } + } + [Fact] public async Task AutoApproveNodePairing_ReturnsTerminalWhenApproveReportsDevicePairPluginNotFound() { From c48f9397e9687f2500606af3124d8c44dd3b69b6 Mon Sep 17 00:00:00 2001 From: "bakudies@microsoft.com" Date: Mon, 28 Sep 2026 20:35:55 -0700 Subject: [PATCH 13/14] fix(setup): keep pairing scripts on WSL stdin after rebase Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ApprovalRequestHelper.cs | 6 ++--- .../VerifyEndToEndStep.cs | 4 +-- .../SetupStepsTests.cs | 25 +++++++++++-------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index 4c7f0bcf5..445d0c4bc 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -55,7 +55,8 @@ internal static async Task CapturePendingRequestBaseline $"""{ctx.WslPathPrefix} && openclaw {noun} list --json""", TimeSpan.FromSeconds(30), env, - ct); + ct, + inputViaStdin: true); var output = $"{pending.Stdout.Trim()} {pending.Stderr.Trim()}".Trim(); if (pending.ExitCode != 0) @@ -170,9 +171,6 @@ 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) - => TrySelectPendingRequestForDevice(json, deviceId, matchNodeId: false); - internal static RequestIdParseResult TrySelectPendingRequestForDevice( string json, string? deviceId, diff --git a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs index fb484bed3..ed5828ef6 100644 --- a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs +++ b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs @@ -199,7 +199,7 @@ internal static async Task DrainPendingApprovalsAsync(SetupContext c var nodeList = await ctx.Commands.RunInWslAsync( distro, $"""{pathPrefix} && openclaw nodes list --json""", - TimeSpan.FromSeconds(15), env, ct); + TimeSpan.FromSeconds(15), env, ct, inputViaStdin: true); if (nodeList.ExitCode != 0) return StepResult.Fail($"Could not list pending node approvals (exit {nodeList.ExitCode}): {nodeList.Stdout.Trim()} {nodeList.Stderr.Trim()}".Trim()); @@ -225,7 +225,7 @@ internal static async Task DrainPendingApprovalsAsync(SetupContext c var approve = await ctx.Commands.RunInWslAsync( distro, $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Node)}""", - TimeSpan.FromSeconds(15), approvalEnv, ct); + TimeSpan.FromSeconds(15), approvalEnv, ct, inputViaStdin: true); if (approve.ExitCode != 0) return StepResult.Fail($"Node approval drain failed for {parsed.RequestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 784ba196b..9da863f12 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5395,22 +5395,22 @@ public void RedactTokens_RedactsEmbeddedTokenOnly() [Fact] public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() { - var latestApprovals = 0; + var deviceLists = 0; + var nodeLists = 0; var commands = new FakeCommandRunner( _ => Ok(), (_, command, _) => { if (command.Contains("openclaw qr --json", StringComparison.Ordinal)) return Ok("""{"bootstrapToken":"boot-token"}"""); - if (command.Contains("devices approve --latest", StringComparison.Ordinal)) - { - latestApprovals++; - return latestApprovals == 1 - ? Ok("""{"selected":{"requestId":"device-req-1"}}""") - : Ok("No pending device approvals"); - } + if (command.Contains("devices list --json", StringComparison.Ordinal)) + return Ok(++deviceLists == 2 + ? $$"""{"pending":[{"requestId":"device-req-1","deviceId":"{{PairingSocketDeviceId}}","role":"operator"}]}""" + : """{"pending":[]}"""); if (command.Contains("nodes list --json", StringComparison.Ordinal)) - return Ok("""{"pending":[{"requestId":"node-req-1"}]}"""); + return Ok(++nodeLists == 2 + ? $$"""{"pending":[{"requestId":"node-req-1","nodeId":"{{PairingSocketDeviceId}}","role":"node"}]}""" + : """{"pending":[]}"""); if (command.Contains("GATEWAY_CONFIGURED", StringComparison.Ordinal)) return Ok("GATEWAY_CONFIGURED"); if (command.Contains("curl -s", StringComparison.Ordinal)) @@ -5421,11 +5421,16 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() ctx.DistroName = "test-distro"; ctx.SharedGatewayToken = "shared-token"; ctx.Config.Gateway.ReloadMode = "hybrid"; + ctx.OperatorDeviceId = PairingSocketDeviceId; + ctx.CurrentDeviceApprovalBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync( + ctx, ApprovalRequestKind.Device, CancellationToken.None); + ctx.CurrentNodeApprovalBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync( + ctx, ApprovalRequestKind.Node, CancellationToken.None); Assert.True((await new InstallGatewayServiceStep().ExecuteAsync(ctx, CancellationToken.None)).IsSuccess); await new InstallGatewayServiceStep().RollbackAsync(ctx, CancellationToken.None); Assert.True((await new MintBootstrapTokenStep().ExecuteAsync(ctx, CancellationToken.None)).IsSuccess, "mint"); - Assert.True((await PairOperatorStep.AutoApprovePairing(ctx, CancellationToken.None)).IsSuccess, "operator approve"); + Assert.True((await PairOperatorStep.AutoApprovePairing(ctx, requestId: null, CancellationToken.None)).IsSuccess, "operator approve"); Assert.True((await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None)).IsSuccess, "node approve"); Assert.True((await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None)).IsSuccess, "restart"); Assert.True((await VerifyEndToEndStep.DrainPendingDeviceApprovalsAsync(ctx, CancellationToken.None)).IsSuccess, "drain"); From 4bd18de4a46e507f93aed04aba38a171f6828b7b Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Mon, 28 Sep 2026 21:28:45 -0700 Subject: [PATCH 14/14] test(setup): use review-safe credential placeholders Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24bce02f-6ab2-4d36-b1f3-ca56f4657dba --- tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 9da863f12..efbe05b84 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5402,7 +5402,7 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() (_, command, _) => { if (command.Contains("openclaw qr --json", StringComparison.Ordinal)) - return Ok("""{"bootstrapToken":"boot-token"}"""); + return Ok("""{"bootstrapToken":"test-token-placeholder"}"""); if (command.Contains("devices list --json", StringComparison.Ordinal)) return Ok(++deviceLists == 2 ? $$"""{"pending":[{"requestId":"device-req-1","deviceId":"{{PairingSocketDeviceId}}","role":"operator"}]}""" @@ -5419,7 +5419,7 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() }); var ctx = CreateContext(commands: commands); ctx.DistroName = "test-distro"; - ctx.SharedGatewayToken = "shared-token"; + ctx.SharedGatewayToken = "test-auth-token"; ctx.Config.Gateway.ReloadMode = "hybrid"; ctx.OperatorDeviceId = PairingSocketDeviceId; ctx.CurrentDeviceApprovalBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(