diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index fd274222e..742a6e9f1 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -285,6 +285,21 @@ 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. +Before the first operator or node pairing attempt, setup snapshots the pending +request IDs and retains that baseline across step retries. A gateway may refresh +the same request ID when the socket reconnects, so a fresh baseline on retry +would incorrectly classify setup's request as pre-existing. Finalization and +wizard sockets still take their own pre-connect snapshots. If a socket omits a +pairing request ID, setup selects exactly one request absent from its baseline +and matching the full identity, not a request already pending before setup, the +shortened display ID, or the only request in the queue. Missing identity, a +failed baseline on that attempt, no new match, or multiple new matches fail +closed. If the first baseline capture fails, retries take a new snapshot that +excludes any requests already pending, including requests from earlier setup +attempts. Later cleanup excludes requests that predate the retained successful +baseline. 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 603b48df5..445d0c4bc 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -13,6 +13,70 @@ internal static partial class ApprovalRequestHelper { internal const string RequestIdEnvironmentVariable = "OPENCLAW_APPROVAL_REQUEST_ID"; + internal static async Task CaptureSetupBaselineOnceAsync( + SetupContext ctx, + ApprovalRequestKind kind, + CancellationToken ct) + { + var baseline = kind switch + { + ApprovalRequestKind.Device => ctx.SetupDeviceApprovalBaseline, + ApprovalRequestKind.Node => ctx.SetupNodeApprovalBaseline, + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; + if (baseline is not null) + return baseline; + + baseline = await CapturePendingRequestBaselineAsync(ctx, kind, ct); + if (baseline.Success || baseline.PluginNotFound) + { + if (kind == ApprovalRequestKind.Device) + ctx.SetupDeviceApprovalBaseline = baseline; + else + ctx.SetupNodeApprovalBaseline = baseline; + } + + return baseline; + } + + internal static async Task CapturePendingRequestBaselineAsync( + SetupContext ctx, + ApprovalRequestKind kind, + CancellationToken ct) + { + var authorization = ctx.SharedGatewayToken ?? ctx.BootstrapToken; + if (string.IsNullOrWhiteSpace(authorization)) + return PendingRequestBaseline.Fail("No gateway token is available to capture the pending approval baseline."); + + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = authorization }; + var noun = Noun(kind); + var pending = await ctx.Commands.RunInWslAsync( + ctx.DistroName!, + $"""{ctx.WslPathPrefix} && openclaw {noun} list --json""", + TimeSpan.FromSeconds(30), + env, + ct, + inputViaStdin: true); + + var output = $"{pending.Stdout.Trim()} {pending.Stderr.Trim()}".Trim(); + if (pending.ExitCode != 0) + { + return PendingRequestBaseline.Fail( + $"Could not capture pending {noun} before opening the setup socket (exit {pending.ExitCode}): {output}", + IsPluginNotFoundError(output)); + } + + var parsed = TryReadPendingRequestIds(pending.Stdout.Trim()); + if (parsed.Success) + return PendingRequestBaseline.SuccessResult(parsed.RequestIds); + + if (IsExplicitNoPendingMessage(pending.Stdout)) + return PendingRequestBaseline.SuccessResult([]); + + return PendingRequestBaseline.Fail( + $"Could not capture pending {noun} before opening the setup socket: {parsed.Error}"); + } + internal static bool IsSafeRequestId(string? requestId) => !string.IsNullOrWhiteSpace(requestId) && SafeRequestIdPattern().IsMatch(requestId.Trim()); @@ -107,6 +171,111 @@ internal static void RequireMatchingDeviceRequest( "The pending pairing request does not uniquely match this setup's Companion identity."); } + internal static RequestIdParseResult TrySelectPendingRequestForDevice( + string json, + string? deviceId, + IReadOnlySet requestIdsBeforeConnect, + 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 (requestIdsBeforeConnect.Contains(parsed.RequestId!)) + continue; + + if (match is not null) + return RequestIdParseResult.NotFound("Multiple new 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 new 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 new 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); @@ -178,6 +347,13 @@ private static string Noun(ApprovalRequestKind kind) _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) }; + internal static bool IsExplicitNoPendingMessage(string output) + { + var message = output.Trim().TrimEnd('.'); + return string.Equals(message, "No pending device approvals", StringComparison.OrdinalIgnoreCase) + || string.Equals(message, "No pending node approvals", StringComparison.OrdinalIgnoreCase); + } + [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", RegexOptions.Compiled)] private static partial Regex SafeRequestIdPattern(); } @@ -193,3 +369,16 @@ internal sealed record PendingRequestIdsParseResult(bool Success, IReadOnlyList< public static PendingRequestIdsParseResult SuccessResult(IReadOnlyList requestIds) => new(true, requestIds, null); public static PendingRequestIdsParseResult Fail(string error) => new(false, [], error); } + +internal sealed record PendingRequestBaseline( + bool Success, + IReadOnlySet RequestIds, + string? Error, + bool PluginNotFound) +{ + public static PendingRequestBaseline SuccessResult(IEnumerable requestIds) => + new(true, new HashSet(requestIds, StringComparer.Ordinal), null, false); + + public static PendingRequestBaseline Fail(string error, bool pluginNotFound = false) => + new(false, new HashSet(StringComparer.Ordinal), error, pluginNotFound); +} diff --git a/src/OpenClaw.SetupEngine/PairNodeStep.cs b/src/OpenClaw.SetupEngine/PairNodeStep.cs index 553d684ea..7e23b2f7c 100644 --- a/src/OpenClaw.SetupEngine/PairNodeStep.cs +++ b/src/OpenClaw.SetupEngine/PairNodeStep.cs @@ -48,6 +48,11 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati var wsLogger = new SetupOpenClawLogger(ctx.Logger); WindowsNodeClient? client = null; + var requestBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync( + ctx, + ApprovalRequestKind.Node, + ct); + ctx.CurrentNodeApprovalBaseline = requestBaseline; try { @@ -169,6 +174,11 @@ private static async Task FinalizeNodeWithDeviceToken( ctx.Logger.Info("Waiting for gateway grace period before node finalization..."); await Task.Delay(TimeSpan.FromSeconds(5), ct); + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Node, + ct); + ctx.CurrentNodeApprovalBaseline = requestBaseline; var finalClient = new WindowsNodeClient(gatewayUrl, nodeToken, identityPath, logger: wsLogger); PairOperatorStep.ApplyReconnectAuthorization(finalClient, ctx); finalClient.UseV2Signature = true; @@ -288,6 +298,17 @@ internal static async Task AutoApproveNodePairing(SetupContext ctx, if (string.IsNullOrWhiteSpace(requestId)) { + var requestBaseline = ctx.CurrentNodeApprovalBaseline; + if (requestBaseline is null || !requestBaseline.Success) + { + if (requestBaseline?.PluginNotFound == true) + return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage); + + return StepResult.Fail( + requestBaseline?.Error ?? + "The setup socket did not provide a node pairing request ID, and no pre-connect approval baseline is available."); + } + approvalKind = ApprovalRequestKind.Node; var pending = await ctx.Commands.RunInWslAsync( distro, @@ -304,7 +325,12 @@ 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, + requestBaseline.RequestIds, + 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 5f30fa27e..78a480027 100644 --- a/src/OpenClaw.SetupEngine/PairOperatorStep.cs +++ b/src/OpenClaw.SetupEngine/PairOperatorStep.cs @@ -85,6 +85,11 @@ record = registry.AddOrUpdate(record); // Connect operator WebSocket — handle pairing-required flow var wsLogger = new SetupOpenClawLogger(ctx.Logger); OpenClawGatewayClient? client = null; + var requestBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync( + ctx, + ApprovalRequestKind.Device, + ct); + ctx.CurrentDeviceApprovalBaseline = requestBaseline; try { @@ -275,6 +280,12 @@ private static async Task FinalizeWithDeviceToken( ctx.Logger.Info("Waiting for gateway grace period to expire before finalization..."); await Task.Delay(TimeSpan.FromSeconds(5), ct); + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Device, + ct); + ctx.CurrentDeviceApprovalBaseline = requestBaseline; + // Connect exactly as the tray would: pass deviceToken as the credential var finalClient = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath); ApplyReconnectAuthorization(finalClient, ctx); @@ -332,9 +343,6 @@ private static async Task FinalizeWithDeviceToken( } } - internal static async Task AutoApprovePairing(SetupContext ctx, CancellationToken ct) - => await AutoApprovePairing(ctx, requestId: null, ct); - internal static async Task AutoApprovePairing(SetupContext ctx, string? requestId, CancellationToken ct) { var distro = ctx.DistroName!; @@ -344,18 +352,41 @@ internal static async Task AutoApprovePairing(SetupContext ctx, stri if (string.IsNullOrWhiteSpace(requestId)) { - var preview = await ctx.Commands.RunInWslAsync( + var requestBaseline = ctx.CurrentDeviceApprovalBaseline; + if (requestBaseline is null || !requestBaseline.Success) + { + if (requestBaseline?.PluginNotFound == true) + return StepResult.Terminal(ApprovalRequestHelper.PluginNotFoundMessage); + + return StepResult.Fail( + requestBaseline?.Error ?? + "The setup socket did not provide a pairing request ID, and no pre-connect approval baseline is available."); + } + + 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, inputViaStdin: true); - ctx.Logger.Info($"Approve preview: exit={preview.ExitCode}"); + ctx.Logger.Info($"Device pending list: exit={pending.ExitCode}"); + + 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.TryReadSelectedRequestId(preview.Stdout.Trim()); + var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice( + pending.Stdout.Trim(), + ctx.OperatorDeviceId, + requestBaseline.RequestIds, + matchNodeId: false); 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/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index 2702489e3..fb72f54ed 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -515,6 +515,10 @@ public sealed class SetupContext public string? GatewayRecordId { get; set; } public string? OperatorDeviceId { get; set; } public string? NodeDeviceId { get; set; } + internal PendingRequestBaseline? SetupDeviceApprovalBaseline { get; set; } + internal PendingRequestBaseline? SetupNodeApprovalBaseline { get; set; } + internal PendingRequestBaseline? CurrentDeviceApprovalBaseline { get; set; } + internal PendingRequestBaseline? CurrentNodeApprovalBaseline { get; set; } public GatewaySelfInfo? ObservedGatewaySelf { get; set; } public GatewayCompatibilityException? GatewayCompatibilityFailure { get; set; } public string? WindowsTailnetDnsSuffix { get; set; } diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index d1ec70c26..35a0e3331 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -104,6 +104,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 @@ -177,9 +187,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); @@ -195,6 +206,11 @@ private async Task RunCoreAsync(CancellationToken ct) var provenanceCheck = await PairOperatorStep.EnsurePairingEndpointTrustedAsync(_ctx, ct); if (provenanceCheck is not null) return provenanceCheck; + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + _ctx, + ApprovalRequestKind.Device, + ct); + _ctx.CurrentDeviceApprovalBaseline = requestBaseline; client = CreateWizardClient(credential, identityPath, wsLogger); var connection = await PairOperatorStep.WaitForConnectionOrPairing( client, @@ -205,10 +221,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 62ff1eac1..ed5828ef6 100644 --- a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs +++ b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs @@ -92,60 +92,87 @@ 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, + requestBaseline: ctx.SetupDeviceApprovalBaseline, + ct); + } + + private static async Task DrainPendingRequestsForSetupDeviceAsync( + SetupContext ctx, + string distro, + string pathPrefix, + Dictionary env, + string listCommand, + ApprovalRequestKind kind, + bool matchNodeId, + PendingRequestBaseline? requestBaseline, + CancellationToken ct) + { const int maxDrainIterations = 10; + var label = kind == ApprovalRequestKind.Node ? "Node" : "Device"; + if (requestBaseline is null || !requestBaseline.Success) + { + ctx.Logger.Warn( + $"Skipping pending {label.ToLowerInvariant()} approval drain because setup did not capture a pre-connect request baseline"); + return StepResult.Ok($"Pending {label.ToLowerInvariant()} approval drain skipped"); + } 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, inputViaStdin: true); - if (preview.Stdout.Contains("No pending", StringComparison.OrdinalIgnoreCase) || - preview.Stderr.Contains("No pending", StringComparison.OrdinalIgnoreCase)) + if (pending.ExitCode != 0) { - break; + var pendingOutput = $"{pending.Stdout.Trim()} {pending.Stderr.Trim()}".Trim(); + return StepResult.Fail($"Could not list pending {label.ToLowerInvariant()} approvals (exit {pending.ExitCode}): {pendingOutput}"); } - var parsed = ApprovalRequestHelper.TryReadSelectedRequestId(preview.Stdout.Trim()); - if (parsed.Success) + var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice( + pending.Stdout.Trim(), + ctx.OperatorDeviceId, + requestBaseline.RequestIds, + matchNodeId); + if (!parsed.Success) { - 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, inputViaStdin: true); - - 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"); + if (ApprovalRequestHelper.IsNothingToDrain(parsed) || + ApprovalRequestHelper.IsExplicitNoPendingMessage(pending.Stdout)) + { + break; + } - continue; + return StepResult.Fail($"Could not select pending {label.ToLowerInvariant()} approval for drain: {parsed.Error}"); } - if (preview.ExitCode == 0) - { - 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"); + 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, inputViaStdin: true); - continue; - } - } + if (approve.ExitCode != 0) + return StepResult.Fail($"{label} approval drain failed for {parsed.RequestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); - return StepResult.Fail($"Could not select pending device approval for drain (exit {preview.ExitCode}): {parsed.Error ?? preview.Stderr.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) + internal static async Task DrainPendingApprovalsAsync(SetupContext ctx, CancellationToken ct) { var deviceDrainResult = await DrainPendingDeviceApprovalsAsync(ctx, ct); if (!deviceDrainResult.IsSuccess) @@ -159,6 +186,13 @@ private static async Task DrainPendingApprovalsAsync(SetupContext ct var pathPrefix = ctx.WslPathPrefix; var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; const int maxDrainIterations = 10; + var requestBaseline = ctx.SetupNodeApprovalBaseline; + if (requestBaseline is null || !requestBaseline.Success) + { + ctx.Logger.Warn( + "Skipping pending node approval drain because setup did not capture a pre-connect request baseline"); + return StepResult.Ok("Pending node approval drain skipped"); + } for (var i = 0; i < maxDrainIterations; i++) { @@ -167,30 +201,34 @@ private static async Task DrainPendingApprovalsAsync(SetupContext ct $"""{pathPrefix} && openclaw nodes list --json""", TimeSpan.FromSeconds(15), env, ct, inputViaStdin: true); - var parsed = ApprovalRequestHelper.TryReadPendingRequestIds(nodeList.Stdout.Trim()); + if (nodeList.ExitCode != 0) + return StepResult.Fail($"Could not list pending node approvals (exit {nodeList.ExitCode}): {nodeList.Stdout.Trim()} {nodeList.Stderr.Trim()}".Trim()); + + var parsed = ApprovalRequestHelper.TrySelectPendingRequestForDevice( + nodeList.Stdout.Trim(), + ctx.OperatorDeviceId, + requestBaseline.RequestIds, + matchNodeId: true); 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()); + if (ApprovalRequestHelper.IsNothingToDrain(parsed) || + ApprovalRequestHelper.IsExplicitNoPendingMessage(nodeList.Stdout)) + { + break; + } - return StepResult.Fail($"Could not parse pending node approvals: {parsed.Error}"); + return StepResult.Fail($"Could not select pending node approval for drain: {parsed.Error}"); } - if (parsed.RequestIds.Count == 0) - break; + ctx.Logger.Info($"Draining pending node approval: {parsed.RequestId}"); + var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, parsed.RequestId!); + var approve = await ctx.Commands.RunInWslAsync( + distro, + $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Node)}""", + TimeSpan.FromSeconds(15), approvalEnv, ct, inputViaStdin: true); - 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, inputViaStdin: true); - - if (approve.ExitCode != 0) - return StepResult.Fail($"Node approval drain failed for {requestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); - } + if (approve.ExitCode != 0) + return StepResult.Fail($"Node approval drain failed for {parsed.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"); @@ -256,6 +294,11 @@ private static async Task FinalizeOperatorForTray( ctx.Logger.Info("Waiting for grace period before final operator handshake..."); await Task.Delay(TimeSpan.FromSeconds(5), ct); + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Device, + ct); + ctx.CurrentDeviceApprovalBaseline = requestBaseline; var client = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath); PairOperatorStep.ApplyReconnectAuthorization(client, ctx); client.UseV2Signature = true; @@ -273,11 +316,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/ApprovalRequestHelperTests.cs b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs index 35804ecd9..3108dd299 100644 --- a/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs @@ -63,6 +63,41 @@ public void TryReadPendingRequestIds_RejectsUnsafeRequestId() Assert.Contains("unsafe", result.Error); } + [Fact] + public void TrySelectPendingRequestForDevice_SelectsOnlyRequestCreatedAfterConnect() + { + const string deviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + var result = ApprovalRequestHelper.TrySelectPendingRequestForDevice( + $$""" + {"pending":[ + {"requestId":"stale-request","deviceId":"{{deviceId}}","role":"operator"}, + {"requestId":"current-request","deviceId":"{{deviceId}}","role":"operator"} + ]} + """, + deviceId, + new HashSet(["stale-request"], StringComparer.Ordinal), + matchNodeId: false); + + Assert.True(result.Success, result.Error); + Assert.Equal("current-request", result.RequestId); + } + + [Fact] + public void TrySelectPendingRequestForDevice_RejectsStaleSameIdentityRequest() + { + const string deviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + var result = ApprovalRequestHelper.TrySelectPendingRequestForDevice( + $$""" + {"pending":[{"requestId":"stale-request","deviceId":"{{deviceId}}","role":"operator"}]} + """, + deviceId, + new HashSet(["stale-request"], StringComparer.Ordinal), + matchNodeId: false); + + Assert.False(result.Success); + Assert.Contains("No new pending approval request matched", result.Error); + } + [Fact] public void TryReadSelectedRequestId_ReadsRequestWhenCliRequiresExplicitAuthFlags() { diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 2579a9612..efbe05b84 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) { @@ -5394,22 +5395,22 @@ public void RedactTokens_RedactsEmbeddedTokenOnly() [Fact] public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() { - var latestApprovals = 0; + var deviceLists = 0; + var nodeLists = 0; var commands = new FakeCommandRunner( _ => Ok(), (_, command, _) => { if (command.Contains("openclaw qr --json", StringComparison.Ordinal)) - return Ok("""{"bootstrapToken":"boot-token"}"""); - if (command.Contains("devices approve --latest", StringComparison.Ordinal)) - { - latestApprovals++; - return latestApprovals == 1 - ? Ok("""{"selected":{"requestId":"device-req-1"}}""") - : Ok("No pending device approvals"); - } + return Ok("""{"bootstrapToken":"test-token-placeholder"}"""); + if (command.Contains("devices list --json", StringComparison.Ordinal)) + return Ok(++deviceLists == 2 + ? $$"""{"pending":[{"requestId":"device-req-1","deviceId":"{{PairingSocketDeviceId}}","role":"operator"}]}""" + : """{"pending":[]}"""); if (command.Contains("nodes list --json", StringComparison.Ordinal)) - return Ok("""{"pending":[{"requestId":"node-req-1"}]}"""); + return Ok(++nodeLists == 2 + ? $$"""{"pending":[{"requestId":"node-req-1","nodeId":"{{PairingSocketDeviceId}}","role":"node"}]}""" + : """{"pending":[]}"""); if (command.Contains("GATEWAY_CONFIGURED", StringComparison.Ordinal)) return Ok("GATEWAY_CONFIGURED"); if (command.Contains("curl -s", StringComparison.Ordinal)) @@ -5418,13 +5419,18 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() }); var ctx = CreateContext(commands: commands); ctx.DistroName = "test-distro"; - ctx.SharedGatewayToken = "shared-token"; + ctx.SharedGatewayToken = "test-auth-token"; ctx.Config.Gateway.ReloadMode = "hybrid"; + ctx.OperatorDeviceId = PairingSocketDeviceId; + ctx.CurrentDeviceApprovalBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync( + ctx, ApprovalRequestKind.Device, CancellationToken.None); + ctx.CurrentNodeApprovalBaseline = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync( + ctx, ApprovalRequestKind.Node, CancellationToken.None); Assert.True((await new InstallGatewayServiceStep().ExecuteAsync(ctx, CancellationToken.None)).IsSuccess); await new InstallGatewayServiceStep().RollbackAsync(ctx, CancellationToken.None); Assert.True((await new MintBootstrapTokenStep().ExecuteAsync(ctx, CancellationToken.None)).IsSuccess, "mint"); - Assert.True((await PairOperatorStep.AutoApprovePairing(ctx, CancellationToken.None)).IsSuccess, "operator approve"); + Assert.True((await PairOperatorStep.AutoApprovePairing(ctx, requestId: null, CancellationToken.None)).IsSuccess, "operator approve"); Assert.True((await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None)).IsSuccess, "node approve"); Assert.True((await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None)).IsSuccess, "restart"); Assert.True((await VerifyEndToEndStep.DrainPendingDeviceApprovalsAsync(ctx, CancellationToken.None)).IsSuccess, "drain"); @@ -5447,6 +5453,222 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() Assert.All(pathScripts, call => Assert.True(call.InputViaStdin, call.Command)); } + [Fact] + public async Task AutoApprovePairing_WithoutRequestId_ApprovesOnlyNewRequestForOpenedSocket() + { + const string socketDeviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const string otherDeviceId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const string staleRequestId = "stale-socket-req"; + 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\":\"" + staleRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\",\"ts\":0}," + + "{\"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 = "test-auth-token"; + ctx.OperatorDeviceId = socketDeviceId; + var requestBaseline = PendingRequestBaseline.SuccessResult([staleRequestId]); + ctx.CurrentDeviceApprovalBaseline = requestBaseline; + + var result = await PairOperatorStep.AutoApprovePairing(ctx, requestId: null, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.Contains(socketRequestId, result.Message); + Assert.DoesNotContain(newerRequestId, result.Message); + Assert.DoesNotContain(staleRequestId, 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 AutoApprovePairing_WithoutRequestIdOrBaseline_FailsWithoutListing() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, _, _) => Ok("""{"pending":[{"requestId":"unowned-request"}]}""")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "test-auth-token"; + + var result = await PairOperatorStep.AutoApprovePairing( + ctx, + requestId: null, + CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("no pre-connect approval baseline", result.Message); + Assert.Empty(commands.WslCalls); + } + + [Fact] + public async Task LaterDrain_DoesNotApproveADifferentPendingRequest() + { + const string socketDeviceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const string otherDeviceId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const string socketRequestId = "setup-socket-req"; + const string staleSocketRequestId = "stale-setup-socket-req"; + const string otherRequestId = "attacker-latest-req"; + const string socketNodeRequestId = "setup-node-req"; + const string staleSocketNodeRequestId = "stale-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\":\"" + staleSocketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\"}," + + "{\"requestId\":\"" + socketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\"}," + + "{\"requestId\":\"" + otherRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\"}" + + "],\"paired\":[{\"displayName\":\"No pending device approvals\"}]}" + : "{\"pending\":[" + + "{\"requestId\":\"" + staleSocketRequestId + "\",\"deviceId\":\"" + socketDeviceId + "\",\"role\":\"operator\"}," + + "{\"requestId\":\"" + otherRequestId + "\",\"deviceId\":\"" + otherDeviceId + "\",\"role\":\"operator\"}" + + "],\"paired\":[{\"displayName\":\"No pending device approvals\"}]}"; + return Ok(pending); + } + + if (command.Contains("nodes list", StringComparison.Ordinal)) + { + nodeLists++; + var pending = nodeLists == 1 + ? "{\"pending\":[" + + "{\"requestId\":\"" + staleSocketNodeRequestId + "\",\"nodeId\":\"" + socketDeviceId + "\",\"role\":\"node\"}," + + "{\"requestId\":\"" + socketNodeRequestId + "\",\"nodeId\":\"" + socketDeviceId + "\",\"role\":\"node\"}," + + "{\"requestId\":\"" + otherNodeRequestId + "\",\"nodeId\":\"" + otherDeviceId + "\",\"role\":\"node\"}" + + "],\"paired\":[{\"displayName\":\"No pending node approvals\"}]}" + : "{\"pending\":[" + + "{\"requestId\":\"" + staleSocketNodeRequestId + "\",\"nodeId\":\"" + socketDeviceId + "\",\"role\":\"node\"}," + + "{\"requestId\":\"" + otherNodeRequestId + "\",\"nodeId\":\"" + otherDeviceId + "\",\"role\":\"node\"}" + + "],\"paired\":[{\"displayName\":\"No pending node approvals\"}]}"; + 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 = "test-auth-token"; + ctx.OperatorDeviceId = socketDeviceId; + ctx.SetupDeviceApprovalBaseline = PendingRequestBaseline.SuccessResult([staleSocketRequestId]); + ctx.SetupNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([staleSocketNodeRequestId]); + + var result = await VerifyEndToEndStep.DrainPendingApprovalsAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.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 == staleSocketRequestId || + requestId == staleSocketNodeRequestId || + requestId == otherRequestId || + requestId == otherNodeRequestId)); + } + + [Fact] + public async Task LaterDrain_DoesNotIgnoreNoPendingTextFromFailedDeviceList() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("devices list", StringComparison.Ordinal) + ? new CommandResult(1, "No pending device approvals", "gateway unavailable", TimeSpan.Zero, TimedOut: false) + : Fail($"unexpected wsl command: {command}")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "test-auth-token"; + ctx.OperatorDeviceId = PairingSocketDeviceId; + ctx.SetupDeviceApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + ctx.SetupNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + + var result = await VerifyEndToEndStep.DrainPendingApprovalsAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("Could not list pending device approvals (exit 1)", result.Message); + } + + [Fact] + public async Task LaterDrain_DoesNotIgnoreNoPendingTextFromFailedNodeList() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains("devices list", StringComparison.Ordinal)) + return Ok("""{"pending":[]}"""); + + return command.Contains("nodes list", StringComparison.Ordinal) + ? new CommandResult(1, "No pending node approvals", "gateway unavailable", TimeSpan.Zero, TimedOut: false) + : Fail($"unexpected wsl command: {command}"); + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "test-auth-token"; + ctx.OperatorDeviceId = PairingSocketDeviceId; + ctx.SetupDeviceApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + ctx.SetupNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + + var result = await VerifyEndToEndStep.DrainPendingApprovalsAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("Could not list pending node approvals (exit 1)", result.Message); + } + + 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() { @@ -5470,10 +5692,144 @@ 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]; + ctx.CurrentNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("No new 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"; + ctx.CurrentNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: 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]; + ctx.CurrentNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: 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 new pending approval request matched")] + [InlineData("""{"pending":[{"requestId":"missing-id-request"}]}""", "No new pending approval request matched")] + [InlineData("""{"pending":[{"requestId":"one","nodeId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"requestId":"two","nodeId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}""", "Multiple new 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); + ctx.CurrentNodeApprovalBaseline = PendingRequestBaseline.SuccessResult([]); + + var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: 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() { var ctx = CreatePairingContext(DevicePairPluginNotFoundOutput); + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Node, + CancellationToken.None); + ctx.CurrentNodeApprovalBaseline = requestBaseline; var result = await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); @@ -5485,14 +5841,193 @@ public async Task AutoApproveNodePairing_ReturnsTerminalWhenPendingListReportsDe public async Task AutoApproveNodePairing_KeepsOtherPendingListMissingPluginRetriable() { var ctx = CreatePairingContext(OtherPluginNotFoundOutput); + var requestBaseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Node, + CancellationToken.None); + ctx.CurrentNodeApprovalBaseline = requestBaseline; 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.Contains("Could not capture pending nodes", result.Message); Assert.DoesNotContain(ApprovalRequestHelper.PluginNotFoundMessage, result.Message); } + [Fact] + public async Task CapturePendingRequestBaseline_ParsesJsonBeforeCheckingNoPendingMetadata() + { + const string requestId = "stale-request"; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, _, _) => Ok(""" + { + "pending": [{ "requestId": "stale-request" }], + "paired": [{ "displayName": "No pending device approvals" }] + } + """)); + var ctx = CreateNodePairingContext(commands); + + var baseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Node, + CancellationToken.None); + + Assert.True(baseline.Success, baseline.Error); + Assert.Contains(requestId, baseline.RequestIds); + } + + [Fact] + public async Task CapturePendingRequestBaseline_DoesNotTreatFailedNoPendingOutputAsSuccess() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, _, _) => new CommandResult(1, "No pending node approvals", "gateway unavailable", TimeSpan.Zero, TimedOut: false)); + var ctx = CreateNodePairingContext(commands); + + var baseline = await ApprovalRequestHelper.CapturePendingRequestBaselineAsync( + ctx, + ApprovalRequestKind.Node, + CancellationToken.None); + + Assert.False(baseline.Success); + Assert.Contains("exit 1", baseline.Error); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CaptureSetupBaselineOnce_RetryApprovesRefreshedRequestButNotPreexistingRequest( + bool node) + { + var kind = node ? ApprovalRequestKind.Node : ApprovalRequestKind.Device; + var noun = kind == ApprovalRequestKind.Device ? "devices" : "nodes"; + var identityField = kind == ApprovalRequestKind.Device ? "deviceId" : "nodeId"; + var role = kind == ApprovalRequestKind.Device ? "operator" : "node"; + var listCalls = 0; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains($"{noun} list --json", StringComparison.Ordinal)) + { + return ++listCalls switch + { + 1 => Ok("""{"pending":[{"requestId":"preexisting-request"}]}"""), + 2 => Fail("transient list failure"), + 3 => Ok($$""" + {"pending":[ + {"requestId":"preexisting-request","{{identityField}}":"{{PairingSocketDeviceId}}","role":"{{role}}"}, + {"requestId":"refreshed-request","{{identityField}}":"{{PairingSocketDeviceId}}","role":"{{role}}"} + ]} + """), + _ => Fail("unexpected list") + }; + } + + return command.Contains($"{noun} approve ", StringComparison.Ordinal) + ? Ok("{}") + : Fail($"unexpected wsl command: {command}"); + }); + var ctx = CreateNodePairingContext(commands); + var initial = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(ctx, kind, CancellationToken.None); + if (kind == ApprovalRequestKind.Device) + ctx.CurrentDeviceApprovalBaseline = initial; + else + ctx.CurrentNodeApprovalBaseline = initial; + + var firstApproval = kind == ApprovalRequestKind.Device + ? await PairOperatorStep.AutoApprovePairing(ctx, requestId: null, CancellationToken.None) + : await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + Assert.False(firstApproval.IsSuccess); + + var retry = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(ctx, kind, CancellationToken.None); + Assert.Same(initial, retry); + if (kind == ApprovalRequestKind.Device) + ctx.CurrentDeviceApprovalBaseline = retry; + else + ctx.CurrentNodeApprovalBaseline = retry; + var retryApproval = kind == ApprovalRequestKind.Device + ? await PairOperatorStep.AutoApprovePairing(ctx, requestId: null, CancellationToken.None) + : await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + + Assert.True(retryApproval.IsSuccess, retryApproval.Message); + Assert.Equal(3, listCalls); + AssertApprovedRequest(commands, $"{noun} approve", "refreshed-request"); + Assert.DoesNotContain( + commands.WslEnvironments, + env => env is not null && + env.TryGetValue(ApprovalRequestHelper.RequestIdEnvironmentVariable, out var requestId) && + requestId == "preexisting-request"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task CaptureSetupBaselineOnce_FailedFirstCaptureRecapturesSafelyOnRetry( + bool node, bool firstAttemptMintedRequest) + { + var kind = node ? ApprovalRequestKind.Node : ApprovalRequestKind.Device; + var noun = node ? "nodes" : "devices"; + var identityField = node ? "nodeId" : "deviceId"; + var role = node ? "node" : "operator"; + var baselineAtRetry = firstAttemptMintedRequest + ? """{"pending":[{"requestId":"first-attempt-request"}]}""" + : """{"pending":[]}"""; + var pendingAfterConnect = firstAttemptMintedRequest + ? $$"""{"pending":[{"requestId":"first-attempt-request","{{identityField}}":"{{PairingSocketDeviceId}}","role":"{{role}}"}]}""" + : $$"""{"pending":[{"requestId":"retry-request","{{identityField}}":"{{PairingSocketDeviceId}}","role":"{{role}}"}]}"""; + var listCalls = 0; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains($"{noun} list --json", StringComparison.Ordinal)) + { + return ++listCalls switch + { + 1 => Fail("initial baseline unavailable"), + 2 => Ok(baselineAtRetry), + 3 => Ok(pendingAfterConnect), + _ => Fail("unexpected list") + }; + } + + return command.Contains($"{noun} approve ", StringComparison.Ordinal) + ? Ok("{}") + : Fail($"unexpected wsl command: {command}"); + }); + var ctx = CreateNodePairingContext(commands); + + var initial = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(ctx, kind, CancellationToken.None); + var retry = await ApprovalRequestHelper.CaptureSetupBaselineOnceAsync(ctx, kind, CancellationToken.None); + if (kind == ApprovalRequestKind.Device) + ctx.CurrentDeviceApprovalBaseline = retry; + else + ctx.CurrentNodeApprovalBaseline = retry; + var retryApproval = kind == ApprovalRequestKind.Device + ? await PairOperatorStep.AutoApprovePairing(ctx, requestId: null, CancellationToken.None) + : await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None); + + Assert.False(initial.Success); + Assert.True(retry.Success, retry.Error); + Assert.NotSame(initial, retry); + Assert.Equal(3, listCalls); + if (firstAttemptMintedRequest) + { + Assert.Contains("first-attempt-request", retry.RequestIds); + Assert.False(retryApproval.IsSuccess); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains(" approve ", StringComparison.Ordinal)); + } + else + { + Assert.True(retryApproval.IsSuccess, retryApproval.Message); + AssertApprovedRequest(commands, $"{noun} approve", "retry-request"); + } + } + [Fact] public async Task AutoApproveNodePairing_ReturnsTerminalWhenApproveReportsDevicePairPluginNotFound() { 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)); + } +}