diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 11b055d0c..f52a3cd40 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -209,6 +209,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/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index cdcbb908b..42f79c5e4 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -83,6 +83,110 @@ internal static RequestIdParseResult TryReadApprovedRequestId(string json) } } + 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."); + + 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 (!PendingItemMatchesDevice(item, wantedDeviceId, matchNodeId)) + continue; + + if (!RoleMatchesSelection(item, matchNodeId)) + 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 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/PairNodeStep.cs b/src/OpenClaw.SetupEngine/PairNodeStep.cs index 4a996da8b..b9eb59d22 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/src/OpenClaw.SetupEngine/PairOperatorStep.cs b/src/OpenClaw.SetupEngine/PairOperatorStep.cs index 371b5906d..43c8a4c82 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); - 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 6c70ec18e..91170b44a 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -102,6 +102,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 @@ -173,9 +183,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); @@ -201,10 +212,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 (ShouldReplaceOperatorDeviceId(usingWizardIdentity, _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 966a8d794..c75f9822f 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); - 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); - - 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); + + 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); - - 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); - - 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) @@ -273,11 +268,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 3a568a07f..ca00e401a 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) { @@ -5216,6 +5217,139 @@ public void RedactTokens_RedactsEmbeddedTokenOnly() // lives in KeepaliveProcessManager, not StartKeepaliveStep (see setup-keepalive-process-manager // in docs/ARCHITECTURE.md). + [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 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() { @@ -5239,6 +5373,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 = "test-auth-token"; + ctx.OperatorDeviceId = PairingSocketDeviceId; + return ctx; + } + [Fact] public async Task AutoApproveNodePairing_ReturnsTerminalWhenPendingListReportsDevicePairPluginNotFound() { 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)); + } +}