Skip to content

Commit 2153c7e

Browse files
Surface missing gateway device-pair plugin during setup
Validated with GitHub CI, .\build.ps1, OpenClaw.SetupEngine.Tests, OpenClaw.Shared.Tests, and OpenClaw.Tray.Tests.
1 parent 80029fe commit 2153c7e

4 files changed

Lines changed: 132 additions & 3 deletions

File tree

src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ internal static bool IsSafeRequestId(string? requestId)
2020
internal static string ApprovalCommand(ApprovalRequestKind kind)
2121
=> $"openclaw {Noun(kind)} approve \"${RequestIdEnvironmentVariable}\" --json";
2222

23+
// "plugins.entries.device-pair: plugin not found: device-pair" is emitted by older gateway
24+
// versions that ship without the device-pair plugin bundle or don't load it. Detecting this
25+
// lets callers return a Terminal (non-retriable) failure with actionable upgrade guidance.
26+
internal static bool IsPluginNotFoundError(string output)
27+
=> output.Contains("plugin not found", StringComparison.OrdinalIgnoreCase)
28+
&& output.Contains("device-pair", StringComparison.OrdinalIgnoreCase);
29+
30+
internal const string PluginNotFoundMessage =
31+
"The gateway device-pair plugin is not loaded. " +
32+
"Upgrade your gateway to version 2026.6.0 or later and re-run setup.";
33+
2334
internal static Dictionary<string, string> AddRequestIdEnvironment(
2435
IReadOnlyDictionary<string, string> environment,
2536
string requestId)

src/OpenClaw.SetupEngine/SetupSteps.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,7 +1945,12 @@ internal static async Task<StepResult> AutoApprovePairing(SetupContext ctx, stri
19451945
ctx.Logger.Info($"Approve result: exit={approve.ExitCode}");
19461946

19471947
if (approve.ExitCode != 0)
1948-
return StepResult.Fail($"Device approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}");
1948+
{
1949+
var approveOutput = approve.Stdout.Trim();
1950+
if (ApprovalRequestHelper.IsPluginNotFoundError(approveOutput))
1951+
return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage);
1952+
return StepResult.Fail($"Device approval failed (exit {approve.ExitCode}): {approveOutput}");
1953+
}
19491954

19501955
return StepResult.Ok($"Approved request {requestId}");
19511956
}
@@ -2361,7 +2366,12 @@ internal static async Task<StepResult> AutoApproveNodePairing(SetupContext ctx,
23612366
ctx.Logger.Info($"Node pending list: exit={pending.ExitCode}");
23622367

23632368
if (pending.ExitCode != 0)
2364-
return StepResult.Fail($"Could not list pending node pairing requests (exit {pending.ExitCode}): {pending.Stdout.Trim()}");
2369+
{
2370+
var pendingOutput = pending.Stdout.Trim();
2371+
if (ApprovalRequestHelper.IsPluginNotFoundError(pendingOutput))
2372+
return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage);
2373+
return StepResult.Fail($"Could not list pending node pairing requests (exit {pending.ExitCode}): {pendingOutput}");
2374+
}
23652375

23662376
var parsed = ApprovalRequestHelper.TryReadSinglePendingRequestId(pending.Stdout.Trim());
23672377
if (!parsed.Success)
@@ -2388,7 +2398,9 @@ internal static async Task<StepResult> AutoApproveNodePairing(SetupContext ctx,
23882398

23892399
return approve.ExitCode == 0
23902400
? StepResult.Ok($"Node approved: {requestId}")
2391-
: StepResult.Fail($"Node approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}");
2401+
: ApprovalRequestHelper.IsPluginNotFoundError(approve.Stdout.Trim())
2402+
? StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage)
2403+
: StepResult.Fail($"Node approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}");
23922404
}
23932405

23942406
private static void RegisterCapabilitiesFromConfig(WindowsNodeClient client, SetupContext ctx)

tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,25 @@ public void TryReadApprovedRequestId_ReadsApproveSuccessShape()
9494
Assert.True(result.Success);
9595
Assert.Equal("device-req-2", result.RequestId);
9696
}
97+
98+
[Theory]
99+
[InlineData("plugin not found: device-pair")]
100+
[InlineData("plugins.entries.device-pair: plugin not found: device-pair")]
101+
[InlineData("error: Plugin Not Found: Device-Pair")]
102+
public void IsPluginNotFoundError_ReturnsTrueForPluginNotFoundOutput(string output)
103+
{
104+
Assert.True(ApprovalRequestHelper.IsPluginNotFoundError(output));
105+
}
106+
107+
[Theory]
108+
[InlineData("")]
109+
[InlineData("{}")]
110+
[InlineData("approval failed: unknown error")]
111+
[InlineData("gateway connection refused")]
112+
[InlineData("error: Plugin not found")]
113+
[InlineData("plugins.entries.other-plugin: plugin not found: other-plugin")]
114+
public void IsPluginNotFoundError_ReturnsFalseForOtherOutput(string output)
115+
{
116+
Assert.False(ApprovalRequestHelper.IsPluginNotFoundError(output));
117+
}
97118
}

tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ public class SetupStepsTests : IDisposable
1212
private readonly string _localTempDir;
1313
private readonly string? _prevDataDir;
1414
private readonly string? _prevLocalDataDir;
15+
private const string DevicePairPluginNotFoundOutput = "plugins.entries.device-pair: plugin not found: device-pair";
16+
private const string OtherPluginNotFoundOutput = "plugins.entries.other-plugin: plugin not found: other-plugin";
1517

1618
public SetupStepsTests()
1719
{
@@ -1180,6 +1182,75 @@ public void IsKeepaliveCommandLine_RequiresDistroAndSleepInfinity()
11801182
"OpenClawGateway"));
11811183
}
11821184

1185+
[Fact]
1186+
public async Task AutoApprovePairing_ReturnsTerminalForDevicePairPluginNotFound()
1187+
{
1188+
var ctx = CreatePairingContext(DevicePairPluginNotFoundOutput);
1189+
1190+
var result = await PairOperatorStep.AutoApprovePairing(ctx, "device-req-1", CancellationToken.None);
1191+
1192+
Assert.Equal(StepOutcome.FailedTerminal, result.Outcome);
1193+
Assert.Equal(ApprovalRequestHelper.PluginNotFoundMessage, result.Message);
1194+
}
1195+
1196+
[Fact]
1197+
public async Task AutoApprovePairing_KeepsOtherMissingPluginRetriable()
1198+
{
1199+
var ctx = CreatePairingContext(OtherPluginNotFoundOutput);
1200+
1201+
var result = await PairOperatorStep.AutoApprovePairing(ctx, "device-req-1", CancellationToken.None);
1202+
1203+
Assert.Equal(StepOutcome.Failed, result.Outcome);
1204+
Assert.Contains("Device approval failed", result.Message);
1205+
Assert.DoesNotContain(ApprovalRequestHelper.PluginNotFoundMessage, result.Message);
1206+
}
1207+
1208+
[Fact]
1209+
public async Task AutoApproveNodePairing_ReturnsTerminalWhenPendingListReportsDevicePairPluginNotFound()
1210+
{
1211+
var ctx = CreatePairingContext(DevicePairPluginNotFoundOutput);
1212+
1213+
var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None);
1214+
1215+
Assert.Equal(StepOutcome.FailedTerminal, result.Outcome);
1216+
Assert.Equal(ApprovalRequestHelper.PluginNotFoundMessage, result.Message);
1217+
}
1218+
1219+
[Fact]
1220+
public async Task AutoApproveNodePairing_KeepsOtherPendingListMissingPluginRetriable()
1221+
{
1222+
var ctx = CreatePairingContext(OtherPluginNotFoundOutput);
1223+
1224+
var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None);
1225+
1226+
Assert.Equal(StepOutcome.Failed, result.Outcome);
1227+
Assert.Contains("Could not list pending node pairing requests", result.Message);
1228+
Assert.DoesNotContain(ApprovalRequestHelper.PluginNotFoundMessage, result.Message);
1229+
}
1230+
1231+
[Fact]
1232+
public async Task AutoApproveNodePairing_ReturnsTerminalWhenApproveReportsDevicePairPluginNotFound()
1233+
{
1234+
var ctx = CreatePairingContext(DevicePairPluginNotFoundOutput);
1235+
1236+
var result = await PairNodeStep.AutoApproveNodePairing(ctx, "node-req-1", CancellationToken.None);
1237+
1238+
Assert.Equal(StepOutcome.FailedTerminal, result.Outcome);
1239+
Assert.Equal(ApprovalRequestHelper.PluginNotFoundMessage, result.Message);
1240+
}
1241+
1242+
[Fact]
1243+
public async Task AutoApproveNodePairing_KeepsOtherApproveMissingPluginRetriable()
1244+
{
1245+
var ctx = CreatePairingContext(OtherPluginNotFoundOutput);
1246+
1247+
var result = await PairNodeStep.AutoApproveNodePairing(ctx, "node-req-1", CancellationToken.None);
1248+
1249+
Assert.Equal(StepOutcome.Failed, result.Outcome);
1250+
Assert.Contains("Node approval failed", result.Message);
1251+
Assert.DoesNotContain(ApprovalRequestHelper.PluginNotFoundMessage, result.Message);
1252+
}
1253+
11831254
// ─── Bind validation ───
11841255

11851256
[Fact]
@@ -1298,9 +1369,23 @@ private static CommandResult Ok(string stdout = "", string stderr = "")
12981369
private static CommandResult Fail(string stderr = "")
12991370
=> new(1, "", stderr, TimeSpan.Zero, TimedOut: false);
13001371

1372+
private static CommandResult FailWithStdout(string stdout)
1373+
=> new(1, stdout, "", TimeSpan.Zero, TimedOut: false);
1374+
13011375
private static CommandResult TimedOut()
13021376
=> new(-1, "", "", TimeSpan.FromSeconds(30), TimedOut: true);
13031377

1378+
private SetupContext CreatePairingContext(string failureStdout)
1379+
{
1380+
var commands = new FakeCommandRunner(
1381+
_ => Ok(),
1382+
(_, _, _) => FailWithStdout(failureStdout));
1383+
var ctx = CreateContext(commands: commands);
1384+
ctx.DistroName = "test-distro";
1385+
ctx.SharedGatewayToken = "shared-token";
1386+
return ctx;
1387+
}
1388+
13041389
private sealed class FakeCommandRunner(
13051390
Func<string[], CommandResult> run,
13061391
Func<string, string, TimeSpan, CommandResult>? runInWsl = null) : ICommandRunner

0 commit comments

Comments
 (0)