diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index f8ce94a38..cdcbb908b 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -20,6 +20,17 @@ 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) + && output.Contains("device-pair", 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..35804ecd9 100644 --- a/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs @@ -94,4 +94,25 @@ 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: Device-Pair")] + public void IsPluginNotFoundError_ReturnsTrueForPluginNotFoundOutput(string output) + { + Assert.True(ApprovalRequestHelper.IsPluginNotFoundError(output)); + } + + [Theory] + [InlineData("")] + [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