Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> AddRequestIdEnvironment(
IReadOnlyDictionary<string, string> environment,
string requestId)
Expand Down
18 changes: 15 additions & 3 deletions src/OpenClaw.SetupEngine/SetupSteps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1919,7 +1919,12 @@ internal static async Task<StepResult> 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}");
}
Expand Down Expand Up @@ -2343,7 +2348,12 @@ internal static async Task<StepResult> 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)
Expand All @@ -2370,7 +2380,9 @@ internal static async Task<StepResult> 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)
Expand Down
21 changes: 21 additions & 0 deletions tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
85 changes: 85 additions & 0 deletions tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<string[], CommandResult> run,
Func<string, string, TimeSpan, CommandResult>? runInWsl = null) : ICommandRunner
Expand Down