From 841183014511b8f9385e42d9e452c6bf6ab82d3d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:05:24 +0000 Subject: [PATCH 1/2] fix(setup): surface actionable error when gateway device-pair plugin is not loaded When the gateway CLI lacks the device-pair plugin (versions prior to 2026.6.0), 'openclaw nodes list --json' and 'openclaw nodes approve' exit non-zero with output containing 'plugin not found: device-pair'. Previously, AutoApproveNodePairing and AutoApprovePairing returned StepResult.Fail with the raw CLI output and then retried 3 times before reporting a vague failure. After retries the error surfaced as: 'Could not list pending node pairing requests (exit 1): ...' This change: - Adds ApprovalRequestHelper.IsPluginNotFoundError to detect this pattern - Returns StepResult.Terminal (non-retriable) with an actionable message pointing the user to upgrade their gateway to 2026.6.0+ - Covers both the 'list pending' and 'approve' command paths in AutoApproveNodePairing, and the 'approve' command path in AutoApprovePairing - Adds tests for IsPluginNotFoundError in ApprovalRequestHelperTests Relates to #677. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApprovalRequestHelper.cs | 10 ++++++++++ src/OpenClaw.SetupEngine/SetupSteps.cs | 18 +++++++++++++++--- .../ApprovalRequestHelperTests.cs | 19 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index f8ce94a38..a46f34265 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -20,6 +20,16 @@ internal static bool IsSafeRequestId(string? requestId) internal static string ApprovalCommand(ApprovalRequestKind kind) => $"openclaw {Noun(kind)} approve \"${RequestIdEnvironmentVariable}\" --json"; + // "plugins.entries.device-pair: plugin not found: device-pair" is emitted by older gateway + // versions that ship without the device-pair plugin bundle or don't load it. Detecting this + // lets callers return a Terminal (non-retriable) failure with actionable upgrade guidance. + internal static bool IsPluginNotFoundError(string output) + => output.Contains("plugin not found", StringComparison.OrdinalIgnoreCase); + + internal const string PluginNotFoundMessage = + "The gateway device-pair plugin is not loaded. " + + "Upgrade your gateway to version 2026.6.0 or later and re-run setup."; + internal static Dictionary AddRequestIdEnvironment( IReadOnlyDictionary environment, string requestId) diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index ef5b3e0f2..3795f4bbe 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -1919,7 +1919,12 @@ internal static async Task AutoApprovePairing(SetupContext ctx, stri ctx.Logger.Info($"Approve result: exit={approve.ExitCode}"); if (approve.ExitCode != 0) - return StepResult.Fail($"Device approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}"); + { + var approveOutput = approve.Stdout.Trim(); + if (ApprovalRequestHelper.IsPluginNotFoundError(approveOutput)) + return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage); + return StepResult.Fail($"Device approval failed (exit {approve.ExitCode}): {approveOutput}"); + } return StepResult.Ok($"Approved request {requestId}"); } @@ -2343,7 +2348,12 @@ internal static async Task AutoApproveNodePairing(SetupContext ctx, ctx.Logger.Info($"Node pending list: exit={pending.ExitCode}"); if (pending.ExitCode != 0) - return StepResult.Fail($"Could not list pending node pairing requests (exit {pending.ExitCode}): {pending.Stdout.Trim()}"); + { + var pendingOutput = pending.Stdout.Trim(); + if (ApprovalRequestHelper.IsPluginNotFoundError(pendingOutput)) + return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage); + return StepResult.Fail($"Could not list pending node pairing requests (exit {pending.ExitCode}): {pendingOutput}"); + } var parsed = ApprovalRequestHelper.TryReadSinglePendingRequestId(pending.Stdout.Trim()); if (!parsed.Success) @@ -2370,7 +2380,9 @@ internal static async Task AutoApproveNodePairing(SetupContext ctx, return approve.ExitCode == 0 ? StepResult.Ok($"Node approved: {requestId}") - : StepResult.Fail($"Node approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}"); + : ApprovalRequestHelper.IsPluginNotFoundError(approve.Stdout.Trim()) + ? StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage) + : StepResult.Fail($"Node approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}"); } private static void RegisterCapabilitiesFromConfig(WindowsNodeClient client, SetupContext ctx) diff --git a/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs index bdff1990e..254ca22c6 100644 --- a/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs @@ -94,4 +94,23 @@ public void TryReadApprovedRequestId_ReadsApproveSuccessShape() Assert.True(result.Success); Assert.Equal("device-req-2", result.RequestId); } + + [Theory] + [InlineData("plugin not found: device-pair")] + [InlineData("plugins.entries.device-pair: plugin not found: device-pair")] + [InlineData("error: Plugin not found")] + public void IsPluginNotFoundError_ReturnsTrueForPluginNotFoundOutput(string output) + { + Assert.True(ApprovalRequestHelper.IsPluginNotFoundError(output)); + } + + [Theory] + [InlineData("")] + [InlineData("{}")] + [InlineData("approval failed: unknown error")] + [InlineData("gateway connection refused")] + public void IsPluginNotFoundError_ReturnsFalseForOtherOutput(string output) + { + Assert.False(ApprovalRequestHelper.IsPluginNotFoundError(output)); + } } From a87442e79e45e5d582628c0d35d204b49be70fb9 Mon Sep 17 00:00:00 2001 From: ranjeshj Date: Thu, 18 Jun 2026 00:35:56 -0700 Subject: [PATCH 2/2] fix(setup): narrow device-pair plugin error detection Require the missing-plugin classifier to identify device-pair before returning terminal setup guidance, and cover the pairing approval paths so unrelated plugin failures remain retriable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApprovalRequestHelper.cs | 3 +- .../ApprovalRequestHelperTests.cs | 4 +- .../SetupStepsTests.cs | 85 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index a46f34265..cdcbb908b 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -24,7 +24,8 @@ internal static string ApprovalCommand(ApprovalRequestKind kind) // versions that ship without the device-pair plugin bundle or don't load it. Detecting this // lets callers return a Terminal (non-retriable) failure with actionable upgrade guidance. internal static bool IsPluginNotFoundError(string output) - => output.Contains("plugin not found", StringComparison.OrdinalIgnoreCase); + => output.Contains("plugin not found", StringComparison.OrdinalIgnoreCase) + && output.Contains("device-pair", StringComparison.OrdinalIgnoreCase); internal const string PluginNotFoundMessage = "The gateway device-pair plugin is not loaded. " + diff --git a/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs index 254ca22c6..35804ecd9 100644 --- a/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs @@ -98,7 +98,7 @@ public void TryReadApprovedRequestId_ReadsApproveSuccessShape() [Theory] [InlineData("plugin not found: device-pair")] [InlineData("plugins.entries.device-pair: plugin not found: device-pair")] - [InlineData("error: Plugin not found")] + [InlineData("error: Plugin Not Found: Device-Pair")] public void IsPluginNotFoundError_ReturnsTrueForPluginNotFoundOutput(string output) { Assert.True(ApprovalRequestHelper.IsPluginNotFoundError(output)); @@ -109,6 +109,8 @@ public void IsPluginNotFoundError_ReturnsTrueForPluginNotFoundOutput(string outp [InlineData("{}")] [InlineData("approval failed: unknown error")] [InlineData("gateway connection refused")] + [InlineData("error: Plugin not found")] + [InlineData("plugins.entries.other-plugin: plugin not found: other-plugin")] public void IsPluginNotFoundError_ReturnsFalseForOtherOutput(string output) { Assert.False(ApprovalRequestHelper.IsPluginNotFoundError(output)); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index a5b877893..216b6fac2 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -12,6 +12,8 @@ public class SetupStepsTests : IDisposable private readonly string _localTempDir; private readonly string? _prevDataDir; private readonly string? _prevLocalDataDir; + 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"; public SetupStepsTests() { @@ -1180,6 +1182,75 @@ public void IsKeepaliveCommandLine_RequiresDistroAndSleepInfinity() "OpenClawGateway")); } + [Fact] + public async Task AutoApprovePairing_ReturnsTerminalForDevicePairPluginNotFound() + { + var ctx = CreatePairingContext(DevicePairPluginNotFoundOutput); + + var result = await PairOperatorStep.AutoApprovePairing(ctx, "device-req-1", CancellationToken.None); + + Assert.Equal(StepOutcome.FailedTerminal, result.Outcome); + Assert.Equal(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); + } + + [Fact] + public async Task AutoApprovePairing_KeepsOtherMissingPluginRetriable() + { + var ctx = CreatePairingContext(OtherPluginNotFoundOutput); + + var result = await PairOperatorStep.AutoApprovePairing(ctx, "device-req-1", CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("Device approval failed", result.Message); + Assert.DoesNotContain(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); + } + + [Fact] + public async Task AutoApproveNodePairing_ReturnsTerminalWhenPendingListReportsDevicePairPluginNotFound() + { + var ctx = CreatePairingContext(DevicePairPluginNotFoundOutput); + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + + Assert.Equal(StepOutcome.FailedTerminal, result.Outcome); + Assert.Equal(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); + } + + [Fact] + public async Task AutoApproveNodePairing_KeepsOtherPendingListMissingPluginRetriable() + { + var ctx = CreatePairingContext(OtherPluginNotFoundOutput); + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("Could not list pending node pairing requests", result.Message); + Assert.DoesNotContain(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); + } + + [Fact] + public async Task AutoApproveNodePairing_ReturnsTerminalWhenApproveReportsDevicePairPluginNotFound() + { + var ctx = CreatePairingContext(DevicePairPluginNotFoundOutput); + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, "node-req-1", CancellationToken.None); + + Assert.Equal(StepOutcome.FailedTerminal, result.Outcome); + Assert.Equal(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); + } + + [Fact] + public async Task AutoApproveNodePairing_KeepsOtherApproveMissingPluginRetriable() + { + var ctx = CreatePairingContext(OtherPluginNotFoundOutput); + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, "node-req-1", CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("Node approval failed", result.Message); + Assert.DoesNotContain(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); + } + // ─── Bind validation ─── [Fact] @@ -1279,9 +1350,23 @@ private static CommandResult Ok(string stdout = "", string stderr = "") private static CommandResult Fail(string stderr = "") => new(1, "", stderr, TimeSpan.Zero, TimedOut: false); + private static CommandResult FailWithStdout(string stdout) + => new(1, stdout, "", TimeSpan.Zero, TimedOut: false); + private static CommandResult TimedOut() => new(-1, "", "", TimeSpan.FromSeconds(30), TimedOut: true); + private SetupContext CreatePairingContext(string failureStdout) + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, _, _) => FailWithStdout(failureStdout)); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "shared-token"; + return ctx; + } + private sealed class FakeCommandRunner( Func run, Func? runInWsl = null) : ICommandRunner