From 3945e4c1597f904b1ca19a5fa0dab38df38eaeb5 Mon Sep 17 00:00:00 2001 From: "bakudies@microsoft.com" Date: Sat, 26 Sep 2026 17:04:50 -0700 Subject: [PATCH 01/14] feat: guide Windows onboarding from setup to native AI tasks Add native gateway and AI onboarding, verified Chat/Channels/Skills completion, isolated startup behavior, and ownership-safe persistence and recovery. Remove superseded onboarding paths and include focused regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .editorconfig | 3 + docs/ARCHITECTURE.md | 73 + docs/CONNECTION_ARCHITECTURE.md | 124 ++ docs/ONBOARDING_WIZARD.md | 628 +++++++- docs/PROVIDER_ARTWORK.md | 99 ++ docs/SETUP_ENGINE_REDESIGN.md | 91 +- .../0002-onboarding-access-preset-labels.md | 25 + docs/setup-wizard-ux/README.md | 6 + .../GatewayConnectionManager.cs | 86 +- .../GatewayConnectionValidator.cs | 221 +++ .../GatewayCredentialRecoveryPolicy.cs | 13 + .../GatewayDashboardBinding.cs | 18 + src/OpenClaw.Connection/GatewayRegistry.cs | 180 ++- .../GatewayValidationIdentity.cs | 89 ++ .../IGatewayConnectionManager.cs | 11 + .../LocalAi/LlamaServerRuntimeService.cs | 5 +- .../LocalAi/LocalAiRuntimeModels.cs | 3 + src/OpenClaw.Connection/SetupCodeResult.cs | 5 +- .../Controls/LocalAiSetupControl.xaml | 64 + .../Controls/LocalAiSetupControl.xaml.cs | 560 +++++++ .../Controls/OnboardingMascot.cs | 278 ++++ .../Controls/OnboardingMascotDrawing.cs | 410 +++++ .../Controls/OnboardingMascotGlow.cs | 59 + .../Controls/ProviderArtwork.xaml | 17 + .../Controls/ProviderArtwork.xaml.cs | 199 +++ .../Controls/ProviderArtworkDecoder.cs | 75 + .../Controls/ProviderSetupDialog.xaml | 49 + .../Controls/ProviderSetupDialog.xaml.cs | 258 +++ .../Controls/SetupPhaseStatus.xaml | 24 + .../Controls/SetupPhaseStatus.xaml.cs | 20 + .../Controls/SetupProgressIndicator.cs | 54 + .../Controls/TailscaleSetupControl.xaml | 42 + .../Controls/TailscaleSetupControl.xaml.cs | 208 +++ .../OpenClaw.SetupEngine.UI.csproj | 5 + .../Pages/AdvancedSetupPage.xaml | 126 +- .../Pages/AdvancedSetupPage.xaml.cs | 46 +- .../Pages/AiReadyPage.xaml | 52 + .../Pages/AiReadyPage.xaml.cs | 102 ++ .../Pages/AiSetupPage.xaml | 214 +++ .../Pages/AiSetupPage.xaml.cs | 910 +++++++++++ .../Pages/CapabilitiesPage.xaml | 422 ++--- .../Pages/CapabilitiesPage.xaml.cs | 1342 ++-------------- .../Pages/CapabilitiesPageArgs.cs | 7 - .../Pages/CompletePage.xaml | 34 +- .../Pages/CompletePage.xaml.cs | 1 - .../Pages/GatewaySetupDetailPage.xaml | 38 + .../Pages/GatewaySetupDetailPage.xaml.cs | 101 ++ .../Pages/GatewaySetupPage.xaml | 68 + .../Pages/GatewaySetupPage.xaml.cs | 143 ++ .../Pages/NativeGatewaySetupPage.xaml.cs | 38 +- .../Pages/ProgressPage.xaml | 86 +- .../Pages/ProgressPage.xaml.cs | 473 ++---- .../Pages/SecurityNoticePage.xaml | 71 +- .../Pages/SecurityNoticePage.xaml.cs | 5 + .../Pages/SetupNativeConnectionPage.xaml | 81 + .../Pages/SetupNativeConnectionPage.xaml.cs | 247 +++ .../Pages/SetupPermissionHelper.cs | 223 --- .../Pages/WelcomePage.xaml | 238 ++- .../Pages/WelcomePage.xaml.cs | 285 ++-- .../Pages/WizardPage.xaml | 26 +- .../Pages/WizardPage.xaml.cs | 70 +- src/OpenClaw.SetupEngine.UI/SetupWindow.xaml | 8 +- .../SetupWindow.xaml.cs | 556 ++++++- .../AiSetupPresentationModel.cs | 65 + .../CleanupStaleGatewayStep.cs | 7 +- .../GatewayAiSetupClient.cs | 488 ++++++ .../GatewayAiSetupCompletion.cs | 48 + .../GatewayAiSetupController.cs | 164 ++ .../GatewayAiSetupModels.cs | 115 ++ .../GatewayAiSetupPresentation.cs | 109 ++ .../GatewayAiSetupTransport.cs | 27 + .../LocalAiInstallReconciler.cs | 12 + src/OpenClaw.SetupEngine/LocalAiOnboarding.cs | 214 +++ .../LocalAiRecoveryPolicy.cs | 10 +- .../OnboardingFlowPolicy.cs | 101 ++ .../OnboardingMascotAnimator.cs | 427 +++++ .../OnboardingMascotGestures.cs | 196 +++ .../OnboardingMascotParticles.cs | 48 + .../OnboardingMascotPose.cs | 100 ++ src/OpenClaw.SetupEngine/PairOperatorStep.cs | 10 +- .../ProviderArtworkContent.cs | 180 +++ .../ProviderArtworkLoader.cs | 169 ++ .../ProviderArtworkNetworkPolicy.cs | 112 ++ .../ProviderArtworkSession.cs | 51 + src/OpenClaw.SetupEngine/SetupAccessDraft.cs | 210 +++ src/OpenClaw.SetupEngine/SetupContext.cs | 99 +- .../SetupGatewaySession.cs | 151 ++ .../SetupGatewaySessionBinding.cs | 34 + .../SetupInstallationProgress.cs | 92 ++ .../SetupNativeCompletion.cs | 50 + .../SetupNativeCompletionCoordinator.cs | 68 + .../SetupNativeCompletionVerifier.cs | 52 + .../SetupNativeConnection.cs | 86 + src/OpenClaw.SetupEngine/SetupPipeline.cs | 8 + .../SetupTailscaleReadiness.cs | 21 + .../TrayArtifactCleanup.cs | 1 + .../VerifyEndToEndStep.cs | 8 +- src/OpenClaw.Shared/ConnectEnvelopeBuilder.cs | 1 + src/OpenClaw.Shared/DeviceIdentity.cs | 81 +- .../GatewayServerMethodAdvertisement.cs | 19 + src/OpenClaw.Shared/IFileSystem.cs | 2 + src/OpenClaw.Shared/IOperatorGatewayClient.cs | 1 + src/OpenClaw.Shared/OpenClawGatewayClient.cs | 71 +- src/OpenClaw.Shared/PersistenceFileLease.cs | 50 + .../WindowsStartupTaskRegistration.cs | 106 ++ .../App.ActivationRouter.cs | 3 + .../App.AppShutdownCoordinator.cs | 2 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 168 +- src/OpenClaw.Tray.WinUI/AppIdentity.cs | 7 + .../Assets/Setup/Mascot-NOTICE.txt | 68 + .../Assets/Setup/ProviderIcons/ATTRIBUTION.md | 40 + .../Assets/Setup/ProviderIcons/NOTICE.md | 48 + .../ProviderIcons/ProviderIcon-claude.svg | 3 + .../ProviderIcons/ProviderIcon-codex.svg | 3 + .../ProviderIcons/ProviderIcon-gemini.svg | 4 + .../Setup/ProviderIcons/ProviderIcon-kimi.svg | 4 + .../ProviderIcons/ProviderIcon-lmstudio.svg | 4 + .../ProviderIcons/ProviderIcon-ollama.svg | 4 + .../ProviderIcons/ProviderIcon-opencode.svg | 4 + .../Setup/ProviderIcons/ProviderIcon-pi.svg | 4 + .../Setup/ProviderIcons/ProviderIcon-xai.svg | 4 + .../Helpers/FluentIconCatalog.cs | 8 +- .../Helpers/GatewayDashboardUrlBuilder.cs | 19 +- .../Pages/ChannelsPage.xaml.cs | 138 +- src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml | 28 +- .../Pages/ChatPage.xaml.cs | 54 + src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml | 2 + .../Pages/SkillsPage.xaml.cs | 86 +- .../Presentation/HubPageRegistry.cs | 9 + .../Presentation/ISettingsStore.cs | 2 + .../Presentation/SettingsStore.cs | 5 +- .../SetupNativeNavigationRequest.cs | 60 + .../Presentation/SetupNativeSkills.cs | 28 + .../Presentation/SetupSettingsWriter.cs | 68 + .../Services/ActivationRoute.cs | 1 + .../Services/ActivationRouter.cs | 25 +- .../Services/AutoStartManager.cs | 50 +- .../Services/AutoStartSettingsApplier.cs | 17 + .../Services/DeepLinkHandler.cs | 6 + .../Services/GatewayDashboardLauncher.cs | 43 + .../Services/GatewayDirectConnectService.cs | 331 +++- .../Services/IWindowManager.cs | 3 + .../Services/LocalAiGatewayDistroResolver.cs | 27 +- .../LocalAiGatewayProviderCoordinator.cs | 58 +- .../Services/LocalAiSetupRouteResolver.cs | 38 + .../Services/NativeRestartAdmission.cs | 41 + .../Services/NativeRestartRecoveryStore.cs | 71 + .../Services/SettingsManager.cs | 49 +- .../Services/SetupDashboardHandoff.cs | 15 + .../Services/SetupDashboardHandoffStore.cs | 179 +++ .../Services/SetupDashboardLiveFacts.cs | 41 + .../Services/SetupLocalAiHost.cs | 151 ++ .../Services/SetupNativeConnectionHost.cs | 121 ++ .../Services/SetupNativeHandoffLauncher.cs | 76 + .../Services/SetupStartupPolicy.cs | 44 + .../Services/SetupWindowArgumentProjection.cs | 6 +- .../Services/ToastService.cs | 6 + .../Services/WindowManager.cs | 242 ++- .../Services/WslGatewayKeepAliveService.cs | 8 + .../Services/WslKeepAlivePolicy.cs | 3 + .../Strings/en-us/Resources.resw | 379 ++++- .../Strings/fr-fr/Resources.resw | 381 ++++- .../Strings/nl-nl/Resources.resw | 381 ++++- .../Strings/pt-br/Resources.resw | 381 ++++- .../Strings/zh-cn/Resources.resw | 381 ++++- .../Strings/zh-tw/Resources.resw | 381 ++++- .../Windows/ChatWindow.xaml | 7 +- .../Windows/ChatWindow.xaml.cs | 3 + .../Windows/HubWindow.xaml.cs | 32 +- .../GatewayConnectionManagerTests.cs | 41 + .../GatewayConnectionValidatorTests.cs | 517 ++++++ .../GatewayRegistryPersistenceTests.cs | 110 ++ .../GatewayRegistryTests.cs | 41 + .../IdentityCleanupTransactionTests.cs | 137 ++ .../LocalAiPortLifecycleTests.cs | 4 + .../Setup/E2ESetupFixture.cs | 12 +- .../Setup/MxcSetupAndConnectTests.cs | 32 + .../AiSetupPresentationModelTests.cs | 153 ++ .../GatewayAiSetupClientTests.cs | 778 +++++++++ .../GatewayAiSetupControllerTests.cs | 247 +++ .../GatewayAiSetupLifecycleContractTests.cs | 146 ++ .../LocalAiOnboardingRecoveryTests.cs | 72 + .../LocalAiOnboardingTests.cs | 682 ++++++++ .../LocalAiOnboardingUseTests.cs | 124 ++ .../OnboardingFlowPolicyTests.cs | 154 ++ .../OnboardingMascotAnimatorTests.cs | 380 +++++ .../OnboardingMascotAssetTests.cs | 54 + .../OnboardingMascotSourceContractTests.cs | 134 ++ .../OpenClaw.SetupEngine.Tests.csproj | 7 + .../ProviderArtworkSourceContractTests.cs | 72 + .../ProviderArtworkTests.cs | 438 +++++ .../SetupAccessDraftTests.cs | 332 ++++ .../SetupCompletionAuthorityTests.cs | 118 ++ .../SetupGatewaySessionBindingTests.cs | 102 ++ .../SetupInstallReadinessTests.cs | 116 ++ .../SetupInstallationProgressTests.cs | 116 ++ .../SetupNativeCompletionCoordinatorTests.cs | 116 ++ .../SetupNativeConnectionPageContractTests.cs | 47 + .../SetupNativeConnectionTests.cs | 82 + .../SetupPipelineSettlementTests.cs | 118 ++ .../SetupReviewOwnershipTests.cs | 41 + .../SetupSettingsPersistenceTests.cs | 40 + .../SetupStepsTests.cs | 10 +- .../NativeProofLayoutTests.cs | 112 ++ .../OpenClawGatewayClientTests.cs | 108 +- .../StartupTaskInspectionTests.cs | 28 + .../OpenClaw.TestSupport/NativeProofLayout.cs | 57 + .../ActivationRouterTests.cs | 19 + .../AppRefactorContractTests.cs | 263 ++- .../ConnectionRegressionSourceTests.cs | 6 +- .../FluentIconCatalogTests.cs | 2 +- .../GatewayDashboardUrlBuilderTests.cs | 17 + .../GatewayDirectConnectServiceTests.cs | 658 ++++++++ .../GatewayFixtureIsolationContractTests.cs | 4 +- .../IsolatedWindowsRegistrationTests.cs | 69 + .../LocalAiOnboardingOwnershipTests.cs | 105 ++ ...ocalAiSetupAvailabilityCoordinatorTests.cs | 15 + .../LocalAiSetupUxContractTests.cs | 99 +- .../LocalizationValidationTests.cs | 28 + .../NativeCompletionPresentationTests.cs | 150 ++ .../NativeGatewaySetupUxContractTests.cs | 177 +- .../NativeRestartAdmissionTests.cs | 167 ++ .../OnboardingCopyRefinementTests.cs | 114 ++ .../OnboardingMockPresentationTests.cs | 117 ++ .../OnboardingPresentationContractTests.cs | 410 +++++ .../OpenClaw.Tray.Tests.csproj | 15 + .../Presentation/SettingsStoreTests.cs | 69 + .../SetupAssetPublishTests.cs | 4 +- .../SetupDashboardHandoffStoreTests.cs | 170 ++ .../SetupDashboardHandoffTests.cs | 202 +++ .../SetupNativeHandoffTests.cs | 236 +++ .../SetupNativeSkillsTests.cs | 75 + .../SetupProofIsolationContractTests.cs | 19 + .../SetupStartupPolicyTests.cs | 97 ++ .../SetupWindowArgumentProjectionTests.cs | 18 +- .../OpenClaw.Tray.Tests/WindowManagerTests.cs | 36 +- .../WslKeepAlivePolicyTests.cs | 13 + .../AiReadyPageRenderingTests.cs | 196 +++ .../OnboardingAiPageTests.cs | 1418 +++++++++++++++++ .../OnboardingArtworkRenderingTests.cs | 728 +++++++++ .../OnboardingFeedbackTests.cs | 127 ++ .../OnboardingNativeProof.cs | 931 +++++++++++ .../OnboardingSetupGalleryData.cs | 249 +++ .../OnboardingSetupGalleryTests.cs | 1072 +++++++++++++ .../OnboardingWindowsFlowTests.cs | 1223 ++++++++++++++ .../OpenClaw.Tray.UITests.csproj | 1 + tests/OpenClaw.Tray.UITests/TestApp.cs | 28 +- tests/OpenClaw.Tray.UITests/TestSupport.cs | 40 + .../OpenClaw.Tray.UITests/UIThreadFixture.cs | 62 +- 249 files changed, 30522 insertions(+), 3539 deletions(-) create mode 100644 docs/PROVIDER_ARTWORK.md create mode 100644 docs/design/reference/concepts/_decisions/0002-onboarding-access-preset-labels.md create mode 100644 src/OpenClaw.Connection/GatewayConnectionValidator.cs create mode 100644 src/OpenClaw.Connection/GatewayCredentialRecoveryPolicy.cs create mode 100644 src/OpenClaw.Connection/GatewayDashboardBinding.cs create mode 100644 src/OpenClaw.Connection/GatewayValidationIdentity.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/LocalAiSetupControl.xaml create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/LocalAiSetupControl.xaml.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascot.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascotDrawing.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascotGlow.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/ProviderArtwork.xaml create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/ProviderArtwork.xaml.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/ProviderArtworkDecoder.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/ProviderSetupDialog.xaml create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/ProviderSetupDialog.xaml.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/SetupPhaseStatus.xaml create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/SetupPhaseStatus.xaml.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/SetupProgressIndicator.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/TailscaleSetupControl.xaml create mode 100644 src/OpenClaw.SetupEngine.UI/Controls/TailscaleSetupControl.xaml.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/AiReadyPage.xaml create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/AiReadyPage.xaml.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml.cs delete mode 100644 src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPageArgs.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/GatewaySetupDetailPage.xaml create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/GatewaySetupDetailPage.xaml.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/GatewaySetupPage.xaml create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/GatewaySetupPage.xaml.cs create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/SetupNativeConnectionPage.xaml create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/SetupNativeConnectionPage.xaml.cs delete mode 100644 src/OpenClaw.SetupEngine.UI/Pages/SetupPermissionHelper.cs create mode 100644 src/OpenClaw.SetupEngine/AiSetupPresentationModel.cs create mode 100644 src/OpenClaw.SetupEngine/GatewayAiSetupClient.cs create mode 100644 src/OpenClaw.SetupEngine/GatewayAiSetupCompletion.cs create mode 100644 src/OpenClaw.SetupEngine/GatewayAiSetupController.cs create mode 100644 src/OpenClaw.SetupEngine/GatewayAiSetupModels.cs create mode 100644 src/OpenClaw.SetupEngine/GatewayAiSetupPresentation.cs create mode 100644 src/OpenClaw.SetupEngine/GatewayAiSetupTransport.cs create mode 100644 src/OpenClaw.SetupEngine/LocalAiOnboarding.cs create mode 100644 src/OpenClaw.SetupEngine/OnboardingFlowPolicy.cs create mode 100644 src/OpenClaw.SetupEngine/OnboardingMascotAnimator.cs create mode 100644 src/OpenClaw.SetupEngine/OnboardingMascotGestures.cs create mode 100644 src/OpenClaw.SetupEngine/OnboardingMascotParticles.cs create mode 100644 src/OpenClaw.SetupEngine/OnboardingMascotPose.cs create mode 100644 src/OpenClaw.SetupEngine/ProviderArtworkContent.cs create mode 100644 src/OpenClaw.SetupEngine/ProviderArtworkLoader.cs create mode 100644 src/OpenClaw.SetupEngine/ProviderArtworkNetworkPolicy.cs create mode 100644 src/OpenClaw.SetupEngine/ProviderArtworkSession.cs create mode 100644 src/OpenClaw.SetupEngine/SetupAccessDraft.cs create mode 100644 src/OpenClaw.SetupEngine/SetupGatewaySession.cs create mode 100644 src/OpenClaw.SetupEngine/SetupGatewaySessionBinding.cs create mode 100644 src/OpenClaw.SetupEngine/SetupInstallationProgress.cs create mode 100644 src/OpenClaw.SetupEngine/SetupNativeCompletion.cs create mode 100644 src/OpenClaw.SetupEngine/SetupNativeCompletionCoordinator.cs create mode 100644 src/OpenClaw.SetupEngine/SetupNativeCompletionVerifier.cs create mode 100644 src/OpenClaw.SetupEngine/SetupNativeConnection.cs create mode 100644 src/OpenClaw.SetupEngine/SetupTailscaleReadiness.cs create mode 100644 src/OpenClaw.Shared/GatewayServerMethodAdvertisement.cs create mode 100644 src/OpenClaw.Shared/PersistenceFileLease.cs create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/Mascot-NOTICE.txt create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/ATTRIBUTION.md create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/NOTICE.md create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/ProviderIcon-claude.svg create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/ProviderIcon-codex.svg create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/ProviderIcon-gemini.svg create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/ProviderIcon-kimi.svg create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/ProviderIcon-lmstudio.svg create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/ProviderIcon-ollama.svg create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/ProviderIcon-opencode.svg create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/ProviderIcon-pi.svg create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Setup/ProviderIcons/ProviderIcon-xai.svg create mode 100644 src/OpenClaw.Tray.WinUI/Presentation/SetupNativeNavigationRequest.cs create mode 100644 src/OpenClaw.Tray.WinUI/Presentation/SetupNativeSkills.cs create mode 100644 src/OpenClaw.Tray.WinUI/Presentation/SetupSettingsWriter.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/LocalAiSetupRouteResolver.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/NativeRestartAdmission.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/NativeRestartRecoveryStore.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/SetupDashboardHandoff.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/SetupDashboardHandoffStore.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/SetupDashboardLiveFacts.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/SetupLocalAiHost.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/SetupNativeConnectionHost.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/SetupNativeHandoffLauncher.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/SetupStartupPolicy.cs create mode 100644 tests/OpenClaw.Connection.Tests/GatewayConnectionValidatorTests.cs create mode 100644 tests/OpenClaw.Connection.Tests/GatewayRegistryPersistenceTests.cs create mode 100644 tests/OpenClaw.Connection.Tests/IdentityCleanupTransactionTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/AiSetupPresentationModelTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupClientTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupControllerTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupLifecycleContractTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingRecoveryTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingUseTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/OnboardingFlowPolicyTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/OnboardingMascotAnimatorTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/OnboardingMascotAssetTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/OnboardingMascotSourceContractTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/ProviderArtworkSourceContractTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/ProviderArtworkTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupAccessDraftTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupCompletionAuthorityTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupGatewaySessionBindingTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupInstallReadinessTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupInstallationProgressTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupNativeCompletionCoordinatorTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupNativeConnectionPageContractTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupNativeConnectionTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupPipelineSettlementTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupReviewOwnershipTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupSettingsPersistenceTests.cs create mode 100644 tests/OpenClaw.Shared.Tests/NativeProofLayoutTests.cs create mode 100644 tests/OpenClaw.Shared.Tests/StartupTaskInspectionTests.cs create mode 100644 tests/OpenClaw.TestSupport/NativeProofLayout.cs create mode 100644 tests/OpenClaw.Tray.Tests/IsolatedWindowsRegistrationTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/LocalAiOnboardingOwnershipTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/NativeCompletionPresentationTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/NativeRestartAdmissionTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/OnboardingCopyRefinementTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/OnboardingMockPresentationTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/OnboardingPresentationContractTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/SetupDashboardHandoffStoreTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/SetupNativeHandoffTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/SetupNativeSkillsTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/SetupProofIsolationContractTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/SetupStartupPolicyTests.cs create mode 100644 tests/OpenClaw.Tray.UITests/AiReadyPageRenderingTests.cs create mode 100644 tests/OpenClaw.Tray.UITests/OnboardingAiPageTests.cs create mode 100644 tests/OpenClaw.Tray.UITests/OnboardingArtworkRenderingTests.cs create mode 100644 tests/OpenClaw.Tray.UITests/OnboardingFeedbackTests.cs create mode 100644 tests/OpenClaw.Tray.UITests/OnboardingNativeProof.cs create mode 100644 tests/OpenClaw.Tray.UITests/OnboardingSetupGalleryData.cs create mode 100644 tests/OpenClaw.Tray.UITests/OnboardingSetupGalleryTests.cs create mode 100644 tests/OpenClaw.Tray.UITests/OnboardingWindowsFlowTests.cs diff --git a/.editorconfig b/.editorconfig index c4efd4414..0989bcf62 100644 --- a/.editorconfig +++ b/.editorconfig @@ -166,6 +166,9 @@ dotnet_diagnostic.REACTOR_DIALOG_001.severity = none [src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs] dotnet_diagnostic.REACTOR_DIALOG_001.severity = none +[src/OpenClaw.Tray.WinUI/Services/WindowManager.cs] +dotnet_diagnostic.REACTOR_DIALOG_001.severity = none + [src/OpenClaw.Tray.WinUI/Windows/**/*.cs] dotnet_diagnostic.REACTOR_DIALOG_001.severity = none diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8dfc79515..d58883bc6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -68,6 +68,8 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Page view-model activation/deactivation + disposal lifetime | `NavigationScopeManager` | authoritative | | Presentation-layer DI composition root | `AppServiceRegistration` (root `ServiceProvider`, owned by `App`) | authoritative | | Settings snapshot read + field-scoped save + origin-aware change notification | `ISettingsStore` | authoritative | +| Hosted setup settings writes | `SetupSettingsWriter` through `ISettingsStore`; SetupWindow and the pipeline supply only reviewed field patches | authoritative | +| Cooperating JSON persistence coordination | `PersistenceFileLease`; registry expected-snapshot Save and settings loaded-JSON CAS hold it through atomic replacement | authoritative | | V2 exec-approvals snapshot/CAS persistence + observation | `ExecApprovalsStore` through `IExecApprovalsPresentationStore` | authoritative | | Settings page load/persist view logic | `SettingsPageViewModel` | authoritative | | Native tool identity, display arguments, payload extraction, and flattened-history projection | `NativeToolProjector` | authoritative | @@ -91,6 +93,35 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Local AI gateway-record ownership and WSL distro binding | `LocalAiGatewayDistroResolver` | authoritative | | Local AI model cache acquisition, explicit legacy migration, and active-path receipt selection | `HuggingFaceModelInstaller` + `LocalAiManifestStore` + `LocalAiInstallReconciler` | authoritative | | Exact Gateway wizard terminal-restart compatibility and bounded retry policy | `GatewayWizardRestartRecoveryPolicy` | authoritative | +| Interactive onboarding routes and installation-step selection | `OnboardingFlowPolicy` | authoritative | +| Setup-lifetime capability/profile/explicit Custom intent/consent draft and ordered installation requirements | `SetupAccessDraft` + `SetupCapabilityProfiles`, owned by `SetupWindow` | authoritative | +| Setup provider artwork resolution and bounded page-owned download lifetime | `GatewayAiSetupPresentation` + `ProviderArtworkSession` + `ProviderArtworkLoader`, rendered by `ProviderArtwork` | authoritative | +| Native setup editor mounting and committed-result routing | `SetupWindow` through `ISetupNativeConnectionHost` + `SetupNativeConnectionPage` | authoritative | +| Setup-only Windows privacy preview and probing | Retired; current Permissions settings and runtime consent retain their existing owners | closed | +| Setup Local AI and Tailscale control lifetimes | `LocalAiSetupControl` in `GatewaySetupDetailPage`; `TailscaleSetupControl` inline in `GatewaySetupPage` with a compatibility detail route | authoritative | +| Temporary setup operator connection, captured identity and endpoint provenance | `SetupGatewaySession` + `SetupGatewaySessionBinding` | authoritative | +| Native setup verify-only connection and isolated validation identity/tunnel | `GatewayConnectionValidator` + `GatewayValidationIdentity` | authoritative | +| Native setup connection input and host transaction adapter | `SetupNativeConnectionInputResolver` + `SetupNativeConnectionHost` through `GatewayDirectConnectService` | authoritative | +| Focused AI setup protocol state and provider progress polling | `GatewayAiSetupClient` + `GatewayAiSetupController` | authoritative | +| Verified AI completion intent and opaque pending restart handoff | `GatewayAiSetupClient` + `GatewayAiSetupCompletion` + `SetupDashboardHandoffStore` + `SetupDashboardHandoff` | authoritative | +| Native verified destination choice and selection-time read-only verification | `SetupNativeCompletionCoordinator` + `SetupNativeCompletionVerifier`; `AiReadyPage` renders, `SetupWindow` composes finalization | authoritative | +| Native pending launch and bound Chat/Channels/Skills entry | `SetupNativeHandoffLauncher` + `SetupNativeNavigationRequest`; `SetupNativeSkills` owns response-bound read-only skills loading; `WindowManager` and pages apply the selected route | authoritative | +| Setup startup availability | `WindowManager` supplies app identity availability; `SetupWindow` gates presentation and persisted preference | authoritative | +| Verified setup authority across fresh clients | `OpenClawGatewayClient.AuthenticatedSigningDeviceId` + `SetupCompletionAuthority` + `SetupGatewaySessionBinding`; accepted signing identity and exact session survive completion, disk reads only detect drift | authoritative | +| Native startup versus ordinary update prompt | `ActivationRouter.CheckOrdinaryStartupUpdateAsync`; App retains startup composition and receipt dispatch | authoritative | +| Credential-recovery transport admission | `GatewayCredentialRecoveryPolicy`; normal connection recovery and disposable native validation retain their endpoint-provenance checks | authoritative | +| User-requested Dashboard launch and visible retry | `GatewayDashboardLauncher`; dialog lifetime remains in `WindowManager`; no setup receipt or intent | authoritative | +| Experimental browser setup-completion handoff | Removed; all completion activation goes through the native receipt owner, including visible rejection of obsolete handles | closed | +| Explicit AI preparation continuation, fresh auth-URL admission and bounded restart wait | `GatewayAiSetupController` | authoritative | +| Continuous AI provider dialog visibility and exact row-command admission | `AiSetupPage` | authoritative | +| Focused AI discovery display grouping | `AiSetupPresentationModel` | authoritative | +| Setup installation three-phase overview and exact step-count projection | `SetupInstallationProgress`; `SetupPhaseStatus` renders native status icons/text; `ProgressPage` retains logs and real download progress | authoritative | +| Onboarding Local AI readiness, fresh-unsupported visibility projection and cancellable read-only observation | `LocalAiOnboardingSnapshot` + `LocalAiOnboardingObservation` | authoritative | +| Same-window Local AI admission and runtime action bridge | `ISetupLocalAiHost` + `SetupLocalAiHost`; route inspection shared with Settings through `LocalAiSetupRouteResolver` | authoritative | +| Explicit Local AI mutation drain and retained Gateway/model verification binding | `LocalAiOnboardingUse`; `SetupWindow` retains the setup lock through its drain | authoritative | +| Focused provider prompt controls and input clearing | `ProviderSetupDialog`, owned by `AiSetupPage` | authoritative | +| Inline provider wizard rendering in `AiSetupPage` | `ProviderSetupDialog` replaces the inline WizardPanel; page retains request/lifetime ownership | closed | +| AI provider list selection plus a duplicate page-footer Continue | Explicit native row command or inline API Connect, bound to the exact choice | closed | | Managed-local automatic repair eligibility and orchestration | `ManagedLocalGatewayAutoRepairMonitor` + `ManagedLocalGatewayRepairCoordinator` | authoritative | | Permissions page state, settings commands, and exec-approvals presentation | `PermissionsPageViewModel` | authoritative | | Permissions runtime status projection | `PermissionsPageRuntimeSource` | authoritative | @@ -533,6 +564,27 @@ leading and trailing pipe. Columns, in order: | id | status | old_owner | closed_responsibility | new_owner | allowed_residue | invariant | guard_test | guard_type | retirement_condition | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| setup-installation-overview | authoritative | src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs | overview phase inference from visual row order | SetupInstallationProgress | page retains logs, download detail, dispatcher forwarding and pipeline lifetime; SetupPhaseStatus applies localized icon/text state | every real installation step has an explicit phase; failed state outranks running; skipped work is not claimed as installed | SetupInstallationProgressTests.EveryActualStep_HasAnExplicitPhaseInPipelineOrder | behavioral | - | +| setup-native-window-lifetime | authoritative | src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs | implicit classic Settings handoff for native routes | SetupNativeConnectionPage + ISetupNativeConnectionHost | SetupWindow owns typed mounting, same-draft routing, cancellation and drain; AdvancedSetupRequested remains explicit classic fallback only | only committed native results advance to access/privacy then AI; departed native pages drain before the setup lock is released | OnboardingPresentationContractTests.NativeEditor_IsTypedAndDrainedBeforeTheRunLockIsReleased | source-shape | when native setup mounting and close ordering have mounted UI lifecycle tests | +| setup-access-draft | authoritative | src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs | capability preset detection and per-visit setup defaults | SetupAccessDraft + SetupCapabilityProfiles | page applies typed projections and forwards input; SetupWindow owns the draft lifetime | only bundled all-on placeholder defaults once; explicit profiles, consent and independent transports survive navigation without persistence | SetupAccessDraftTests.BundledPlaceholder_DefaultsOnlyAtDraftCreation | behavioral | - | +| setup-capabilities-review-closed | closed | src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs | Local AI probing, Tailscale probing, OS privacy probing and installation review | LocalAiSetupControl + TailscaleSetupControl + GatewaySetupPage; OS privacy stays outside setup | capability page owns transport/profile/Fine-tune controls only | ordinary capabilities must not reabsorb probe lifetimes or consent; only the window draft retains profile intent and disclosure | OnboardingPresentationContractTests.SetupOwnership_ClosesCombinedCapabilitiesReview | source-shape | when setup no longer presents capability and installation choices | +| setup-windows-access-preview-closed | closed | src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs | obsolete permissions preview, dedicated control, probe helper and observation policy | removed; native Permissions settings retain their existing owners | no setup privacy probes, preview route or extra stage | removing the developer-only screen must not reintroduce OS probing in capabilities or change runtime consent | OnboardingPresentationContractTests.RetiredWindowsAccessPreview_CannotReintroduceProbesOrAnExtraSetupStage | source-shape | when setup no longer has a preview router | +| setup-browser-completion-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | experimental browser-completion issuer, event payload and activation fallback | SetupNativeHandoffLauncher + SetupDashboardHandoffStore | ordinary Dashboard opening and classic chat/settings/connection restart targets remain | only native receipts can complete setup; obsolete or invalid handles fail visibly without opening a browser | NativeCompletionPresentationTests.CompletionActivation_HasNoSupersededBrowserFallback | source-shape | when setup no longer uses restart receipts | +| setup-instance-admission | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | forwarding an expiring native handoff to a shutting-down primary | NativeRestartAdmission + NativeRestartRecoveryStore | App applies synchronous mutex admission before starting services; ordinary secondary forwarding remains | bounded same-thread waits retain the private handle until authoritative admission; no failed-forward exit discards it | NativeRestartAdmissionTests.DelayedOldOwnerIsNeverForwardedToAndMutexAcquisitionStaysOnOneThread | behavioral | - | +| direct-connect-rollback-observation | authoritative | src/OpenClaw.Tray.WinUI/Services/GatewayDirectConnectService.cs | reasserting stale candidate after rollback CAS conflict | GatewayRegistry.ReplaceSnapshotAndSave/AdoptPersistedSnapshot | only observed persisted active state can reconcile settings; unknown state reports attention | concurrent saved/live authority is preserved, candidate commit requires actual active equivalence, and later normal saves remain possible | GatewayDirectConnectServiceTests.RollbackConflictAdoptsActualNewerSavedSelectionNotStaleCandidate | behavioral | - | +| strict-startup-registration-proof | authoritative | src/OpenClaw.Tray.WinUI/Services/SetupStartupPolicy.cs | interpreting ambiguous registration failure as absence | WindowsStartupTaskRegistration.RegisterForSetup/InspectStrict | old best-effort API stays separate | uncertain registration cannot create duplicate Run-key fallback; only exact enabled task proof may complete it | SetupStartupPolicyTests.AmbiguousRegistrationNeverCreatesRunKey | behavioral | - | +| registry-shared-persistence | authoritative | src/OpenClaw.Connection/GatewayRegistry.cs | per-instance-only compare/write ordering | PersistenceFileLease + persisted snapshot CAS | instance lock precedes path lease; Changed events are outside both | all registry writers reject stale authority and coordinate final compare through replacement; LastConnected merges only for unchanged authority | GatewayRegistryPersistenceTests.OtherInstanceCannotWriteBetweenFinalCheckAndReplacement | behavioral | - | +| setup-settings-owner | authoritative | src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs | hosted direct settings-file merging | SetupSettingsWriter + ISettingsStore | standalone merging remains under the shared path lease | background app.settings.set and setup serialize through one owner, preserve unrelated fields and reject same-field conflicts | SettingsStoreTests.HostedSetupSerializesWithBackgroundSettingsMutationWithoutLosingUnrelatedFields | behavioral | - | +| setup-strict-startup | authoritative | src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs | treating legacy best-effort return as setup success | SetupStartupPolicy + AutoStartSettingsApplier | native strict application uses the existing mutation gate; classic startup failure warns before restart continues | failed removal/fallback never marks native startup applied; classic optional failure does not lose durable setup | SetupStartupPolicyTests.DisableRequiresBothRunKeyRemovalAndSuccessfulTaskRemoval | behavioral | - | +| setup-completion-stable-authority | authoritative | src/OpenClaw.SetupEngine/GatewayAiSetupCompletion.cs | original signing identity and exact session ownership across fresh clients | OpenClawGatewayClient.AuthenticatedSigningDeviceId + SetupCompletionAuthority + SetupNativeVerification | domain-separated identity hash and exact session persist without path or token contents; server echo remains diagnostics only | accepted connect signing identity supplies authority; missing/rotated disk identity rejects without regeneration or relabeling the live client | OpenClawGatewayClientTests.AuthenticatedSigningIdentity_ComesFromConnectNotOptionalHelloEcho | behavioral | - | +| setup-completion-effective-endpoint | authoritative | src/OpenClaw.Connection/GatewayDashboardBinding.cs | stable persisted endpoint ownership including SSH forwarding port | GatewayClientEndpointResolver + GatewayDashboardBinding | temporary validation listener allocation remains separate | only-local-port drift rejects the original receipt before a new connection | SetupCompletionAuthorityTests.PersistedPortDrift_IsRejectedBeforeFreshSessionCanConnect | behavioral | - | +| setup-native-revoked-token-recovery | authoritative | src/OpenClaw.Connection/GatewayConnectionValidator.cs | typed, one-shot saved operator-token mismatch recovery | GatewayValidationIdentity + GatewayCredentialRecoveryPolicy | shared/bootstrap requires renewed endpoint authorization; normal device-token precedence remains | Check never changes saved identity; recovery preserves keypair and original CAS baseline; authenticated bootstrap replacement survives Next | GatewayConnectionValidatorTests.RevokedDeviceToken_RecoversOnceInCopyAndPreservesKeypairThroughCheckAndNext | behavioral | - | +| setup-startup-update-admission | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | ordinary startup update prompt ordering versus expiring native receipt | ActivationRouter.CheckOrdinaryStartupUpdateAsync | App composes startup and dispatch; ordinary startup update behavior is retained | a shaped native handle bypasses update prompting, not receipt verification; installer exit cannot skip that handoff | SetupDashboardHandoffTests.NativeStartup_DoesNotWaitForUpdatePromptOrExitForInstaller | behavioral | - | +| setup-snapshot-bookkeeping | authoritative | src/OpenClaw.Connection/GatewayRegistry.cs | setup baseline admission during LastConnected Update/Save gap | GatewayRegistry.CapturePersistedSnapshot | canonical memory snapshot is returned unchanged | only LastConnected differences are ignored; authority and configuration edits still reject | GatewayRegistryTests.CapturePersistedSnapshot_AcceptsOnlyPendingConnectionBookkeeping | behavioral | - | +| setup-native-progress | authoritative | src/OpenClaw.SetupEngine.UI/Pages/SetupNativeConnectionPage.xaml | route progress and nonoverlapping narrow-window actions | SetupProgressIndicator + SetupWindow.RefreshFlowProgress | separate progress and wrapping action rows | native connection has the Gateway stage announcement without overlaying Back/Cancel/Check/Next | NativeCompletionPresentationTests.NativeConnectionProgress_HasItsOwnRowAboveWrappableActions | source-shape | when the native editor footer is no longer XAML | +| setup-local-ai-review | authoritative | src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs | Local AI hardware/model readiness and generation-fenced recheck | LocalAiSetupControl | GatewaySetupDetailPage forwards lifecycle; SetupWindow keeps hardware probe cache | unknown is not unsupported; pinned recovery cannot silently opt out; global WSL consent is explicit and retained | SetupReviewOwnershipTests.LocalAiReview_PreservesGenerationEligibilityAndPinnedRecovery | source-shape | when the setup Local AI control has mounted hardware-probe tests | +| setup-tailscale-review | authoritative | src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs | Windows Tailscale read-only readiness probe and draft options | TailscaleSetupControl + SetupTailscaleReadiness | GatewaySetupPage binds inline and detaches on navigation/unload; GatewaySetupDetailPage retains compatibility hosting | bounded generation-fenced status probe precedes selected Tailscale installation; off does not probe; rebind cancels before changing drafts; auth key and identity trust remain separate | SetupReviewOwnershipTests.TailscaleReview_PreservesBoundedReadOnlyProbeAndGenerationFence | source-shape | when mounted injected-probe lifecycle tests have authorized native execution proof | +| setup-wsl-route-guard | authoritative | src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs | installation eligibility for alternate routes and reviewed replacement | SetupAccessDraft | SetupWindow checks CanInstall before navigating; engine retains destructive ownership guard | only ManagedWsl installs and replacement consent binds to the exact inspected distro | SetupAccessDraftTests.AlternateRoutes_NeverInstallWsl | behavioral | - | | test-temp-dir | authoritative | scattered test files | hand-rolled Path.GetTempPath temp dirs in migrated tests | OpenClaw.TestSupport.TempDirectory | pre-existing un-migrated tests until adopted | temp dirs are created unique and best-effort deleted | TestSupportFixtureTests.TempDirectory_CreatesAndDeletes | behavioral | when all temp-dir tests are migrated | | test-env-scope | authoritative | scattered test files | hand-rolled env var save/restore in migrated tests | OpenClaw.TestSupport.EnvironmentScope | pre-existing un-migrated tests until adopted | env vars set in a test are restored on dispose | TestSupportFixtureTests.EnvironmentScope_RestoresOriginal | behavioral | when all env-mutating tests are migrated | | test-cli-harness | authoritative | CLI test projects | duplicated stdout/stderr/env capture tuples | OpenClaw.TestSupport.CliHarness | - | stdout/stderr/env lookup are captured consistently | TestSupportFixtureTests.CliHarness_CapturesAndLooksUp | behavioral | when CLI tests adopt the harness | @@ -651,6 +703,27 @@ leading and trailing pipe. Columns, in order: | chat-copy-feedback | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs and ChatMarkdownPresentation.cs | copy invocation status, temporary success/failure announcement and reset lifetime | ChatCopyButton with ClipboardHelper.TryCopyText | renderers pass immutable text/identity and keep parser, metadata and workflow ownership | only confirmed clipboard writes show Copied; content changes, repeated clicks and unmount cancel stale resets | ReactorChatLayoutProofTests.CopyFeedback_ResetsRepeatedClicksContentIdentityAndDisposal | behavioral | - | | chat-effort-picker-presentation | authoritative | ReactorChatComposer's native reasoning menu | effort popover, discrete slider/single-stop presentation and Default affordance | ChatReasoningPicker | ChatThinkingProfile resolves advertised options; ChatComposerController validates and performs mutations; composer owns responsive trigger placement | opening a popover never writes a setting, only advertised IDs are selected, Default stays an explicit clear, and compact icons retain textual automation names | ReactorChatLayoutProofTests.ReasoningPicker_UsesConcreteWireValuesAndExplicitDefaultClear | behavioral | - | | chat-composer-button-chrome | authoritative | ReactorChatComposer's duplicated per-button resource overrides | shared idle, hover and pressed toolbar resource overrides | ChatVisuals.ToolbarButtonResources | composer constructs the controls and applies the shared resources; native templates retain disabled and keyboard-focus behavior | compact effort stays transparent at rest and matches neighboring toolbar states across resize without replacing native templates | ReactorChatLayoutProofTests.EffortTrigger_PreservesTransparentChromeAndCenteredContent | behavioral | - | +| setup-interactive-flow | authoritative | src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs | interactive installation-step selection and required AI setup decision | OnboardingFlowPolicy | ProgressPage applies progress events and navigation; SetupWindow supplies the selected route to the progress indicator | interactive install never runs the classic wizard or workspace finalization; Local AI still requires exact-model verification when SkipWizard is set; headless step order is unchanged | OnboardingFlowPolicyTests.InteractiveInstallation_DefersWizardAndWorkspaceFinalization | behavioral | - | +| setup-page-flow-closed | closed | src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs | private copy of default installation filtering and implicit post-install milestone gating | OnboardingFlowPolicy | BuildSteps delegates to the policy and success applies its AI setup decision | UI must not recreate the default pipeline selection or require an extra milestone click on successful modern setup | OnboardingPresentationContractTests.SuccessfulInstallation_UsesAiSetupWithoutAMilestoneClick | source-shape | when ProgressPage no longer hosts the interactive installation pipeline | +| setup-gateway-session | authoritative | src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs | temporary setup operator client construction, registry identity, endpoint resolution, and reconnect provenance | SetupGatewaySession | setup pages own their session lifetime and render protocol state; GatewayConnectionManager still owns the normal app connection | setup uses the active registry record and SSH-resolved endpoint with device-token precedence and managed-local provenance before sending strong credentials | AppRefactorContractTests.WizardConnect_UsesActiveGatewayRecordUrl | source-shape | when setup reuses the normal connection manager rather than a temporary operator session | +| setup-page-client-construction-closed | closed | src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs | private gateway-client constructor and duplicated endpoint/credential resolution | SetupGatewaySession | ConnectClientAsync forwards to the shared setup owner and keeps the returned host-access plan | classic and focused setup cannot diverge in endpoint or credential selection | AppRefactorContractTests.WizardConnect_UsesActiveGatewayRecordUrl | source-shape | when the classic gateway wizard is removed | +| connection-validation-client | authoritative | src/OpenClaw.Connection/GatewayConnectionManager.cs | one-shot validation client construction and credential persistence policy | GatewayConnectionValidator | manager retains shared-token replacement workflow and endpoint authorization callback | validation denies reconnect, pins SSH ownership, and cannot persist handshake tokens | GatewayConnectionValidatorTests.ValidationClient_DisablesHandshakeTokenPersistence | behavioral | - | +| connection-validation-construction-closed | closed | src/OpenClaw.Connection/GatewayConnectionManager.cs | private validation-client constructor policy | GatewayConnectionValidator | CreateSharedTokenValidationClient delegates to the focused owner for compatibility | native setup and shared-token replacement share the same one-shot client policy | GatewayConnectionValidatorTests.ValidationClient_DisablesHandshakeTokenPersistence | behavioral | - | +| setup-native-transaction | authoritative | src/OpenClaw.SetupEngine.UI/Pages/AdvancedSetupPage.xaml.cs | native connection checks and commit orchestration | SetupNativeConnectionHost + GatewayDirectConnectService | SetupNativeConnectionPage edits immutable draft and renders results; WindowManager injects the existing transaction owner | Check cannot write saved gateway or live connection state; Next revalidates and cancellation restores previous state | GatewayDirectConnectServiceTests.NativeNext_CancelAfterHandshakeRestoresPreviousIdentityAndLiveConnection | behavioral | - | +| setup-native-identity-promotion | authoritative | src/OpenClaw.Tray.WinUI/Services/GatewayDirectConnectService.cs | native setup identity promotion and compare-and-swap rollback without gateway-ID replacement | DeviceIdentity.ReplaceValidatedIdentity + DeviceIdentity.RestoreValidatedIdentity | direct-connect owner sequences disconnect, promotion, registry/settings commit and rollback | same-realm setup retains logical gateway ID and sidecars; a newer credential writer is preserved and surfaced as incomplete rollback | GatewayDirectConnectServiceTests.NativeNext_ManagedGatewayRetainsLogicalIdAndLocalAiOwnership | behavioral | - | +| setup-local-ai-route | authoritative | src/OpenClaw.Tray.WinUI/Services/WindowManager.cs | Local AI Gateway inspection and first-install or recovery route resolution | LocalAiSetupRouteResolver + LocalAiSetupRoutePolicy | WindowManager composes the resolver and retains Settings window lifetime; SetupLocalAiHost consumes its same typed target | first install without a receipt uses recovery only after unique app-owned Gateway proof; ambiguous and remote targets cannot receive Windows loopback configuration | LocalAiOnboardingTests.Host_FirstInstallWithoutReceipt_AdmitsSameGatewayWithoutRuntimeMutation | behavioral | - | +| setup-local-ai-mutation-drain | authoritative | src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml.cs | treating local mutation as a bounded observation request | LocalAiOnboardingUse | page cancels requests and awaits actual local mutation drain before CloseAsync completes | runtime rollback must finish before the setup lock is released; uncertain outcomes retain the exact Gateway and model without replay | LocalAiOnboardingUseTests.CancelledUse_DrainRetainsOwnershipUntilActualRollbackEnds | behavioral | - | +| setup-local-ai-registry-handoff | authoritative | src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs | stale live registry after separate-instance pipeline writes and rollback | SetupPipeline.RunWithSettlementAsync + SetupLocalAiHost + GatewayRegistry.ReconcileSetupOutcome | settlement precedes closed-page early return and setup-lock release; notifications are outside locks | all outcomes adopt only known operation output against admitted memory; concurrent edits require explicit recovery, and stale connections are conditionally disconnected | SetupPipelineSettlementTests.FailedOrCancelledPipelineSettlesBeforeClosedOwnerFinishes | behavioral | - | +| setup-local-ai-route-inline-closed | closed | src/OpenClaw.Tray.WinUI/Services/WindowManager.cs | private Local AI route detection algorithm | LocalAiSetupRouteResolver | constructor/delegate composition and failure notification only | Settings and onboarding cannot grow divergent Gateway ownership admission | WindowManagerTests.LocalAiSetup_ChoosesRecoveryOnlyAfterManagedGatewayProof | source-shape | when both Settings and onboarding route admission have mounted host tests | +| setup-local-ai-observation | authoritative | src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml.cs | hardware, receipt, runtime and ownership observation or mutation policy | LocalAiOnboardingObservation + LocalAiOnboardingSnapshot + SetupLocalAiHost | page applies localized state and forwards explicit actions; SetupWindow alone navigates existing review and recovery pipeline | observation is independent from Gateway discovery and never starts, publishes, grants consent, migrates or writes; stale and closed callbacks are discarded | LocalAiOnboardingTests.Observation_CancelsRefreshAndDiscardsStaleCallbacks | behavioral | - | +| setup-local-ai-host-boundary | closed | src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml.cs | opening a second setup window or constructing a parallel Local AI runtime and provider-registration owner | ISetupLocalAiHost + SetupWindow | explicit review callback, cancellation and exact-model verification | Local AI review stays under the existing setup lock, preserves access/startup choices and verifies the exact model before completion | LocalAiOnboardingOwnershipTests.AiPage_UsesTypedSameWindowHostAndNeverOwnsRuntimeOrGatewayRegistration | source-shape | when mounted same-window Local AI navigation tests replace source guards | +| setup-ai-completion-intent | authoritative | src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml.cs | implicit destination from discovery or authentication success | GatewayAiSetupClient + GatewayAiSetupCompletion | page forwards a current verified receipt; intent remains activation provenance, not the native destination | only exact main-model verification yields a completion; native destinations require explicit choice and cannot fall back to browser completion | GatewayAiSetupClientTests.Completion_UsesExplicitActivationKind_NotSetupComplete | behavioral | - | +| setup-native-final-choice | authoritative | src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs | automatic finalization and web launch on modern AI verification | SetupNativeCompletionCoordinator + SetupNativeCompletionVerifier | window owns page mounting, existing finalization hooks and prior page drain; AiReadyPage only renders and forwards choices | showing the chooser issues no nonce and performs no finalization; every explicit choice freshly verifies the same primary-model ownership before once-only finalization and publication | SetupNativeCompletionCoordinatorTests.ShowingChooserDoesNothing_ExplicitChoiceVerifiesThenFinalizesAndPublishes | behavioral | - | +| setup-native-pending-launch | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | implicit browser destination for new verified onboarding | SetupNativeHandoffLauncher + SetupNativeNavigationRequest + SetupDashboardHandoffStore | App supplies composition callbacks; WindowManager mounts typed native pages; page-level metadata loading remains in ChannelsPage | native versioned records retain exact Gateway/agent/model/session and exclusive lease; failed opens require explicit retry; successful opens consume | SetupNativeHandoffTests.NativeOpenVerifiesBeforeNavigationAndKeepsFailedLaunchForExplicitRetryOnly | behavioral | - | +| dashboard-launch-owner | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | ordinary Dashboard endpoint construction, credential export policy and browser result handling | GatewayDashboardLauncher + GatewayDashboardUrlBuilder | App supplies credential, tunnel, browser and failure delegates; WindowManager owns visible error/retry lifetime | only shared credentials enter fragment auth; browser failure is visible and never automatically replayed; setup completion cannot enter this path | SetupDashboardHandoffTests.DeviceAndBootstrapTokens_NeverEnterBrowserUrl | behavioral | - | +| app-dashboard-launch-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | inline Dashboard URL construction and silent Process.Start failure handling | GatewayDashboardLauncher | composition delegates and normal Dashboard entry forwarding only | App does not regain a parallel Dashboard URL or credential policy; explicit retries keep the requested path | AppRefactorContractTests.Dashboard_SurfacesSshTunnelConfigurationFailure | source-shape | when the WinUI Dashboard adapter has injected mounted lifecycle coverage | +| setup-session-captured-authority | authoritative | src/OpenClaw.SetupEngine/SetupGatewaySession.cs | deriving client authority from a later active registry record | SetupGatewaySessionBinding | session reloads registry only to validate captured identity at admission, handshake, reconnect and request boundaries | a socket opened for Gateway A cannot be reported as Gateway B; same-ID endpoint and SSH changes fail while connection timestamps remain valid | SetupGatewaySessionBindingTests.ChangedRegistryDuringConnect_CannotRelabelAlreadyCreatedClient | behavioral | - | +| setup-dashboard-pending-proof | authoritative | src/OpenClaw.Tray.WinUI/Services/SetupDashboardHandoff.cs | accepting external serialized completion JSON as verified proof | SetupDashboardHandoffStore + SetupNativeHandoffLauncher | activation parser admits only an opaque native handle; WindowManager rechecks current observations with SetupDashboardLiveFacts | a short-lived local pending record is exclusively leased, consumed on successful native presentation and retained only for explicit failed-launch retry; unknown, expired, forged, consumed and in-flight replay fail visibly; old generation numbers are not live authority | SetupDashboardHandoffStoreTests.ForgedShapeAndUnknownHandle_AreNotVerificationAuthority | behavioral | - | ## Deferred test builders diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index 2d94f6aa8..6388496db 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -41,6 +41,75 @@ orchestration. Three narrower owners sit behind it: interfaces/DTOs/enums remain separate. This project has zero WinUI dependencies and is independently testable. +Native Check/Next keeps device-token precedence. Only a typed +`AUTH_DEVICE_TOKEN_MISMATCH` may trigger one recovery in the disposable +`GatewayValidationIdentity`: recheck trusted transport and owned-listener +provenance, clear its rejected operator token, then resolve shared before +bootstrap. The saved identity, keypair and original compare-and-swap baseline +are not changed by Check. Successful bootstrap authentication retains its +replacement operator token in memory for Next instead of replaying bootstrap. +Wrong shared tokens, plain remote WebSocket endpoints, ambiguous listeners and +repeated mismatch cannot cause additional credential fallback. +Native automatic recovery also rejects unowned manual-loopback listeners. +Explicit manual-loopback connection is unchanged; the existing non-native +recovery owner retains its prior admission policy through the shared policy's +explicit legacy allowance. Disposable identity copies use the product's +sensitive-file ACL writer, not inherited copy permissions. + +Canceling a Check, or a Next with confirmed rollback, retains the same draft's +staged keypair and authenticated replacement token. Draft edits and close discard +it; completed or uncertain commits also discard it. No ambiguous transaction +result can be reused as a validated draft. + +Setup's persisted-registry snapshot comparison ignores only `LastConnected`, +which can differ briefly between a connection's Update and Save. The snapshot +retains canonical in-memory records; active gateway, credentials, endpoint and +other configuration differences still reject admission. +Reconciliation additionally requires the operation-produced expected output +snapshot. The pipeline checks its prior expected state before reading/writing +registry changes and carries its own resulting snapshot to the UI; it does not +derive authority by rereading disk immediately before adoption. + +Pipeline settlement runs after execution and rollback on success, failure, +cancellation, and window close. It adopts only the operation's known final +registry output against its admitted baseline, refreshes the persistence +baseline, and publishes changes outside locks. Stale live connections are +disconnected conditionally against their captured connection snapshot; a newer +connection is not canceled. External conflicts preserve live edits and provide +an explicit reopen/reload recovery message instead of weakening save CAS. + +Direct-connect commit and rollback use admitted registry snapshots. If rollback +loses a CAS race, it observes the actual persisted active selection and +reconciles only that selection, never reasserting the stale candidate. Unreadable +state remains unknown with attention required, no guessed settings or old +connection restore. Identity preparation failures before transaction admission +remain retryable and cannot be reported as committed. + +After a failed initial commit, cleanup removes a newly copied candidate identity +only when no live or persisted record adopted its ID and its sole key file still +matches this operation's copy. The registry lease spans that final absence check +and removal. Copy publication returns an exact-content creation transaction: +the absence check, baseline calculation and write share the existing identity +mutex. Cleanup acquires that same identity mutex inside the registry lease for +comparison and deletion. No unlocked post-copy read can adopt a newer writer's +bytes as the cleanup baseline. Existing identities, changed files, reparse paths +and unknown persisted state are preserved. + +`PersistenceFileLease` serializes cooperating settings/registry writers by +normalized path across instances and local processes. Registry Load/Save, +UpdateAndSave, setup's expected-output Save and reconciliation share that lease. +The final persisted-snapshot comparison and atomic replacement happen within +one lease. A stale writer must reload after a conflict; it cannot overwrite a +new endpoint, credential, active selection or record addition. LastConnected +alone merges monotonically for unchanged authorities. + +Hosted setup applies only its owned fields through `ISettingsStore`, including +the background pipeline settings save. It rejects conflicting same-field edits +while preserving unrelated `app.settings.set` changes. Standalone setup uses the +same path lease for read/merge/replace. `SettingsManager` additionally checks its +last loaded/saved JSON before replacing the file, rolls back failed store edits, +and publishes notifications after releasing the file lease. + **OpenClaw.Tray.WinUI** consumes the connection layer through interfaces. It never creates gateway clients directly - `GatewayConnectionManager` owns that entirely. ## Consumer API @@ -189,6 +258,61 @@ Many gateway records may be saved, but only `ActiveId` in `gateways.json` is the ## Credential precedence +### Native onboarding connection boundary + +`SetupNativeConnectionPage` retains an immutable `SetupNativeConnectionRequest`. +Its `ISetupNativeConnectionHost` is composed by `WindowManager`, using the same +`GatewayDirectConnectService` instance as the existing Connection surfaces. +It does not create a second connection manager or write settings itself. + +**Check connection** uses `GatewayConnectionValidator` with a disposable identity +copy, no handshake-token persistence and no reconnect. SSH checks use a separate +owned tunnel on an isolated port, with generation checks again at authentication. +Exact bootstrap-scope and signature compatibility fallbacks use at most two +additional fresh clients with endpoint authorization repeated, not unrestricted +transport reconnect. +Managed-local strong credentials still require the connection manager's +provenance authorization. Setup-code addresses must match an explicitly edited +address; bootstrap tokens never become shared tokens. + +The setup host retains that temporary key for the same immutable draft across +Check, approval retry and Next. Successful handshake credentials are retained +only in setup-owned memory, so a consumed bootstrap code is upgraded to explicit +device-token authentication for revalidation. They are not written into the +temporary identity file. A draft change or editor close discards the temporary +key and in-memory credentials; a successful Next writes them only into the +transaction's candidate identity. Temporary key files do exist during editing, +but the previous saved identity remains untouched. + +**Next** always repeats validation against the current draft before mutating +saved or active state. For the same credential realm, it retains the logical +gateway ID, Local AI ownership and all ID-keyed state. The validated identity is +promoted using the canonical identity lock and atomic writer, with a snapshot and +compare-and-swap rollback. Paired device credentials retain precedence over shared +and bootstrap tokens. A newer identity writer is preserved and reported as an +incomplete rollback, not silently overwritten. + +A changed address or SSH endpoint never inherits the old credentials. Native +onboarding adds that realm as a separate gateway and retains the prior saved +gateway and identity. The existing Direct editor's replacement semantics are +unchanged. Failed or cancelled connection attempts restore the old record, +settings and live connection, and discard the candidate identity when newly +created. Rollback errors remain failures even if a candidate remains +committed. Incomplete rollback or cleanup also reaches a persistent host +notification and Connection settings, even if the editor has already closed. +Operator pairing-pending is not AI-ready. + +When the editor has no saved gateway ID, native Check, identity staging and Next +share one lookup by logical URL and SSH credential endpoint before the ordinary +URL fallback. This reuses the correct saved identity when several SSH gateways +have the same public or loopback URL. Direct editing keeps its existing lookup. + +Next is the explicit commit boundary. After it succeeds, later setup cancellation +may retain the chosen gateway. Setup config receives only the effective endpoint; +the existing `SetupGatewaySession` reads credentials from the active registry. +No browser-profile credentials are accepted by this port, and it does not change +Node mode, local MCP, capability settings, or install WSL. + Credential resolution order is intentionally strict: 1. **Stored device token** in the per-gateway identity directory. diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md index 35b267b32..3f289c0b4 100644 --- a/docs/ONBOARDING_WIZARD.md +++ b/docs/ONBOARDING_WIZARD.md @@ -271,21 +271,213 @@ The implemented path must actually run the Gateway in the package's recorded isolated session. Do not relabel the historical ordinary-process proof as MXC, or recommend any MSIX as isolated based only on its registration. +The onboarding wizard installs a new app-owned local WSL gateway on Windows, +connects an AI provider, and opens a selected native app page after live +primary-model verification. It uses focused Gateway-hosted AI setup when +supported and retains the classic OpenClaw onboard wizard for older gateways. + ## Overview +### Native presentation + +Setup uses a 720-by-820 window with a scrolling body, centered vector mascot +and wrapping heading. The 180-DIP mascot frame includes its motion/glow gutter; +the title-bar mark is 24 DIPs. Theme-aware Mica/Fluent surfaces retain solid +background and high-contrast fallbacks. + +Capabilities, installation review and AI provider setup use native controls. +The [screen details](#screen-details) below own their behavior and consent rules; +the [artwork section](#artwork-and-motion) covers motion and asset packaging. + +### Verified native completion + +`AiReadyPage` is mounted only through the setup window's current verified +completion coordinator. Its celebrating mascot and "Your AI is ready" heading +follow real primary-model verification, not discovery or browser sign-in. +Showing the page creates no pending handoff and performs no finalization. + +The final screen has exactly three choices: **Talk to my agent**, with a visible +Recommended badge, opens Chat; **Connect channels** opens Channels (WhatsApp, +Telegram, and more); **Explore skills** opens Skills for the verified agent. +There are no bottom Return or Skip buttons; the progress dots remain. Selecting +a card is the final action, with no extra Finish button. These are navigation +choices, never automatic sign-in, channel configuration, or skill installation. +Legacy WhatsApp/Telegram receipt destinations retain their numeric mappings and +channel focus behavior, but are not separate choices on this screen. + +Each explicit choice drains the prior AI page, rechecks the same Gateway, +endpoint, agent, model and main session through a bounded read-only verification, +then finalizes Windows choices once. Failures stay on the chooser with retry or +return-to-AI guidance. **Back to AI setup** is available only inside the error +message, so changed verification has an actionable recovery without a permanent +footer escape. Returning invalidates its admission and requires another +verification. MCP-only/deferred routes do not claim verified AI. + +The original verification retains the exact main session and a SHA256 binding +over the normalized identity directory and the local device ID that signed the +accepted connect request. A current-generation authenticated signing snapshot, +not an optional server device-ID echo, supplies that identity. The standard +Gateway hello schema does not declare such an echo. Current disk identity is +validated only to detect rotation, never to relabel an already connected client; +verified reconnects cannot generate a missing identity. +Receipts contain the hash, not the identity path or credentials. Fresh clients +must match those stable fields before model verification and finalization; +their independent connection-generation counters need not match. Missing +authority fields in earlier development receipts fail closed and require new +verification. The persisted endpoint binding includes the effective SSH local +forwarding endpoint, which is checked before a fresh connection is attempted. + +The Skills handoff ignores unscoped cached status, loads a response-bound +`skills.status` for the verified agent, and rechecks the same connected client +before presentation. Its agent filter stays bound to that agent. An unavailable +or changed Gateway does not consume the receipt as a successful page launch. + +Isolated app hosts do not offer Windows startup registration. The host passes +that availability into setup, which hides the startup preference, keeps it off +even if an old isolated setting was true, and saves `AutoStart=false` before +native completion. Isolated and recovery completion do not invoke OS startup +registration. An ordinary explicit choice applies both enable and disable; +recovery preserves the saved preference. Registration failures remain visible +and retryable rather than being swallowed. `AutoStartManager`'s isolation refusal +remains unchanged. + +Native completion uses strict startup application: task removal must succeed +when the task exists, unknown task state is an error, and Run-key failures are +not swallowed. Existing best-effort callers retain their prior API. Classic +completion instead acknowledges a startup-specific warning and continues the +established restart path, so an optional startup failure cannot strand durable +setup or masquerade as a failed application restart. + +Strict task registration distinguishes acknowledged success, a request that +did not start, and uncertain completion. An uncertain result cannot create a +Run-key fallback. It may complete only if Task Scheduler confirms the exact +enabled executable/action, principal, task path and logon trigger. A definite +rejection with confirmed absence retains the legitimate Run-key fallback. + +The trusted profile-local handoff retains exclusive leasing, five-minute expiry, +consumption on successful native presentation and explicit failure retry. New +`ai-v3:` opaque handles carry a typed native destination and exact verified +session in the protected record, not in public activation JSON. The experimental +`ai-v2:` browser-completion path is removed; those handles fail visibly and are +never reinterpreted as native receipts or ordinary Dashboard requests. The public +`setup-dashboard` activation route and profile-local storage name remain stable +for current native receipts. Ordinary Dashboard actions and headless setup are unchanged. + +Failures after acquiring a receipt are settled before reporting: changed or +unreadable identity consumes it as Changed; an unavailable verification API or +malformed verification response retains explicit retry. Malformed stored +receipts, including oversized files rejected before acquisition, remain Invalid. + +A startup activation with a well-formed native handle bypasses the ordinary +update prompt for that launch. Receipt validation still runs normally; handle +shape is not verification authority. This prevents an unattended update prompt +or accepted installer from expiring or skipping the selected destination. + +Native post-setup children never forward to the shutting-down parent. They +acquire the instance mutex synchronously on the same thread, with a 60-second +wait per attempt followed by visible Retry/Cancel recovery. The unadmitted +handle is retained in a protected, profile-local `setup-dashboard-handoff/restart.json` +file, not logs or UI text. A newly owned ordinary launch can resume it; ordinary +secondary forwarding is unchanged. Admission clears the matching recovery +record. Retention does not extend receipt expiry or replace normal verification. + +An invalid regular `restart.json` is rechecked and removed under its writer lease +before reporting the error once. A newer valid handle, other handoff files and +reparse paths are never removed by this recovery cleanup. + +After restart, native launch re-verifies ownership before mounting Chat, +Channels or Skills. Legacy targeted Channels uses a fresh response from the bound Gateway, +never generic fallback rows as evidence of support. Missing authoritative +metadata produces an unconfirmed/retry state; an offered-channel list that +omits the target produces an unavailable state. Native Chat selects the verified +session without changing the user's persistent chat-surface preference. + On first launch, the wizard appears only when there is no usable saved gateway connection. Users with existing gateways manage connections from the tray app's Connections tab. The local WSL setup affordance in Connections is shown only when setup has not already created an app-owned WSL gateway on this device. The setup flow walks users through: -1. **Security notice** - Device-trust warning before setup choices -2. **Welcome / Advanced** - Capability-gated native Gateway recommendation, optional WSL fallback, or connect existing gateway from Settings -3. **Capabilities** - Recommended profile, inline Windows permission status, and install review -4. **Local setup progress** - Fresh app-owned `OpenClawGateway` WSL installation -5. **Gateway installed** - Explicit handoff from infrastructure setup to OpenClaw onboard -6. **OpenClaw onboard** - Gateway-driven provider/model/key configuration -7. **All set** - Feature summary, startup preference, and completion - -The setup flow no longer configures remote/manual gateways inline. The Welcome page's **Connect to an existing gateway** option routes through `AdvancedSetupPage`, closes setup, and opens the tray app's Connections tab. +1. **Welcome and trust** - Three feature rows and an inline device-trust notice +2. **Gateway** - Capability-gated native Gateway recommendation (with WSL fallback), existing, remote, local MCP only, or deferred setup +3. **PC capabilities** - Strict, Balanced (Recommended), Open, and inline Fine-tune; Node mode, local MCP and Ollama sharing stay independent. No Windows-access advisory panel or probe runs here. +4. **Gateway setup** - Managed WSL reviews installation and replacement consent; native Gateway checks its package and prepares a dedicated profile without WSL or Local AI installation. +5. **AI setup** - Native Gateway uses its hosted classic wizard; WSL and existing/remote gateways use focused AI setup and live model verification. +6. **Completion** - The verified focused AI flow opens Chat, Channels or Skills; the native Gateway wizard retains its setup-complete and connection handoff. + +`SetupWindow` owns one `SetupAccessDraft` over the same `SetupConfig` for the +entire flow. The native connection contract separates verify-only **Check** from +**Next**, which revalidates and commits that same editor request. A successful +Next is the commit boundary: later Back/close preserves that committed choice; +cancelling before successful commit restores the prior active connection. +Existing/Remote then review capabilities and enter focused AI setup +using the committed registry. They never run WSL workspace finalization. +MCP-only/Deferred persist reviewed settings/startup and complete without AI or +WSL. MCP-only opens Companion Settings; deferred setup opens Connection settings. +Verified focused AI routes use the native chooser. `AdvancedSetupRequested` is only the explicit classic Settings fallback, +not the normal native route. Connection verification, credentials and commit +remain native host responsibilities. Progress indicators +come from `OnboardingFlowPolicy`, not a fixed six-page count. Local AI recovery +does not replay the introductory pages. + +The injected `ISetupNativeConnectionHost` owns connection checks and commits. +The editor draft is not serialized into `SetupConfig`; see +[native connection checks and cancellation](#native-connection-checks-and-cancellation) +for the identity, drain and rollback contracts. + +The legacy milestone and completion pages remain available for resume, classic +wizard compatibility and errors. They are not gates in the new successful path. + +After the install pipeline saves its Gateway, the typed Local AI host reconciles +that exact active record into the canonical registry before AI discovery. This +read-only handoff compares disk against the expected snapshot produced by the +owning cleanup/pairing/bootstrap-clear writes. It refuses intervening authority +edits, unrelated record changes, or invalid saved data instead of adopting a +freshly reread, unbound record. +The expected recovery reconnect may update `LastConnected`; newer canonical +timestamps are retained without rewriting the registry file or emitting events. +The runtime's initially unbound owner can bind once to this canonical record; an +already bound owner cannot switch to another Gateway. + +Local AI use retains the selected Gateway ID with its model across reconnects +and recovery returns. A different active Gateway is rejected before credential +lookup, and again before verification/completion. Closing cancels and drains the +actual runtime mutation and rollback before releasing the setup lock; only +observations and transport cleanup have bounded deadlines. A failed start with +confirmed terminal cleanup restores provider choices and Local AI repair. An +uncertain publication retains exact-target verification only, without replaying +the mutation. + +### Verification provenance + +`GatewayAiSetupClient` records intent from the explicit activation kind, never +from `setupComplete`. This provenance remains in the verification record; +the user's native destination choice, not that intent, determines the app page. + +Only successful exact-model, main-role verification on the same authority and +connection generation produces a `GatewayAiSetupCompletion`. `SetupWindow` +checks the saved Gateway binding before and after workspace finalization. +`SetupDashboardHandoffStore` writes one atomic, credential-free pending record in +the current profile's data directory after verified completion. Restart and +activation carry only a random opaque handle, not serialized verification JSON. +The pending record binds the run, Gateway, endpoint, agent, model and intent. +A new completion supersedes the previous pending run. Only native `ai-v3:` +receipts can be issued or consumed. + +`SetupGatewaySessionBinding` captures the exact record and resolved endpoint +before the temporary client is constructed. Registry revalidation before +credentials/connect, at handshake/reconnect, after connect and at request/route +boundaries can reject a changed Gateway, but cannot relabel the existing socket. +`LastConnected` bookkeeping does not change this binding; changes to Gateway ID, +endpoint or SSH intent do. + +`WindowManager.ShowNativeSetupAsync` rechecks current Gateway, agent and +provider-qualified model observations after page readiness. A bare display model +ID is not treated as an exact provider route. + +Ordinary Dashboard actions still use `GatewayDashboardLauncher` and the normal +credential resolver, with no completion receipt or automatic onboarding query. +Device/bootstrap tokens are never exported to browser URLs. Explicit error-dialog +retry remains available. Legacy `chat`, `settings` and `connection` restart +arguments remain supported. ### Store migration preview @@ -363,10 +555,17 @@ feature-policy changes are automatic. The single-selection list always shows native first, WSL second and **Connect to an existing gateway** third. WSL is visible and selectable while native capability is being checked, when it succeeds, and when it fails or is -unavailable. There is no **Other gateway options** expander. Page load starts the -existing WSL/Local AI discovery; choosing WSL retains the fresh readiness gate -and destructive-replacement confirmation before Capabilities. Native package -setup independently rechecks capability before configuration. +unavailable. There is no **Other gateway options** expander. Page load starts +WSL/Local AI discovery; choosing WSL retains the fresh readiness gate before +Capabilities, then reviews replacement consent on the WSL setup page. Native +package setup independently rechecks capability before configuration. + +Choosing local Gateway setup runs read-only WSL viability and existing-config +inspection before PC capabilities. Failures appear inline with a fresh-inspection +retry, not a modal. Fresh installation has no redundant confirmation. A detected +replacement is reviewed on the WSL setup page before Install; explicit +consent is bound to the exact distro name, and unproven ownership still requires +the engine's destructive confirmation. External saved Gateways stay untouched. The gateway-choice scroll viewport owns the 560-DIP maximum width and stretches its list content. Keep the width constraint on the viewport, not on the nested @@ -374,15 +573,300 @@ ListView, so the choices share the header's center line as the window resizes. Back, Next, and the step indicator remain outside the scrolling area. ### Local setup progress +`SetupInstallationProgress` maps real pipeline events to prepare, install and +connect/check phases. Local AI recovery uses its own install label. Counts include +completed and skipped steps, not estimated percentages; only a running phase +spins. Unknown step IDs require an explicit phase mapping and test coverage. +Detailed activity is collapsed by default; actionable authorization and real +download progress remain visible outside it. + Installs and connects a new app-owned `OpenClawGateway` WSL instance from a clean WSL baseline. If the WSL platform is missing or its optional component is not initialized, setup requests administrator approval to install it, re-inspects readiness, and reports when a Windows restart is required. Setup does not export from or mutate an existing user Ubuntu distro; if WSL cannot create the named app-owned distro directly, setup fails with an actionable update message. Cleanup automatically unregisters a distro only when durable OpenClaw evidence is paired with exactly one readable current-user WSL registration whose canonical base path matches the expected managed install path. Automatic orphan-directory cleanup requires a marker bound to that exact path. An unproven same-named distro or leftover data directory is preserved unless the user explicitly confirms its permanent replacement in the setup UI or passes `--confirm-destructive`. When replacing an app-owned local gateway, the removal step is shown as part of progress and can be retried on failure. The managed distro is locked down and is not intended to be a normal interactive Ubuntu profile. For editing `openclaw.json` as the `openclaw` user and using root for protected-file administration, see [Managing the locked-down WSL gateway](WSL_GATEWAY_ADMIN.md). -### Capabilities and Windows permissions - -The Capabilities page applies the selected profile to both setup config and runtime `Node*` settings. Inline Windows permission rows are shown only for capabilities that need OS-level state (camera, microphone, location, screen capture). Notifications are always shown as an app-level permission. Screen capture is passive: Windows asks what to share each capture through the Graphics Capture picker. - -### OpenClaw onboard +### Capabilities and access presets + +The draft updates setup capability flags and runtime `Node*` values in memory +only. Its exact profile order is System, Canvas, Screen, Camera, Location, +Browser, Tts, Stt. Strict (`ReadOnly`) enables Canvas and Screen; Balanced +(`Standard`) adds System, Tts and Stt; Open (`Full`) enables all eight. Only an +implicit bundled all-on placeholder defaults to Balanced at draft creation. +Explicit Open and custom choices survive +navigation. Device information is fixed, not a toggle. System controls +`system.run` and `system.run.prepare`, not file/clipboard access. + +Profiles use vertical, full-width native single-selection rows with the system +selection indicator, selected background and keyboard behavior. A visible +**Choose what your agent can do** heading introduces Strict, Balanced (Recommended) +and Open. The adjacent **Fine-tune** expander shows the selected-capability summary. +Opening it only inspects existing flags; it does not select Custom. Editing any +flag shows a **Custom capabilities** badge with no preset falsely selected, even +if the flags later match a preset. Imported arbitrary flags have no invented base. +Explicit Custom intent and `FineTuneExpanded` live only in `SetupAccessDraft` and +survive page recreation. Selecting a preset reapplies exactly its eight flags +without changing disclosure state. The Toolkit `SettingsExpander.Items` contain +eight real `SettingsCard` rows; explanatory notes use `ItemsFooter`. Browser +prerequisites remain in the Browser row description. + +Node mode, local MCP and Ollama sharing are independent of profiles. When both +transports are off, profile/capability controls are disabled and dimmed without +clearing choices; Fine-tune remains inspectable, with an explanation to enable +either transport. Custom never +bypasses these gates. Browser requires Node mode and a genuinely available Gateway, +not the default loopback URL; a pending selection remains visible. + +Onboarding does not probe Windows privacy settings or grant OS access. +Remembered capture consent, execution approvals, sandbox grants, MCP tokens +and voice configuration remain in native Companion Settings. Runtime screen +capture uses monitor capture, not a guaranteed picker on each request. + +Next routes directly to WSL review (managed), AI setup (committed existing/remote), +or asynchronous completion (MCP-only/deferred). Review Back returns to capabilities. +There is no ordinary permissions page or progress dot: the standard managed route +has seven stages, existing/remote five, MCP-only/deferred three. Headless +`SkipPermissions` is unchanged. The obsolete Windows-access preview and its +dedicated probing code have been removed; the inert gallery covers current +capabilities without probing Windows or opening Settings. + +Local AI uses the existing managed Windows llama-server eligibility coordinator. +Unknown readiness and unsupported pinned recovery block installation only when +Local AI is required. Users may turn it off outside recovery. Model selection, +networking consent and the original wizard-skip preference survive Back/Next. +Consent authorizes installation to change global `.wslconfig` and stop all WSL +distributions once; the review itself never performs those actions. +Selected Tailscale requires a successful bounded Windows signed-in/MagicDNS +probe. WSL browser/auth-key sign-in is distinct from Windows sign-in and from the +separate, default-off identity-trust toggle. Auth keys remain session-only. +Serve is private tailnet access, not Funnel. + +The WSL review shows its current installation blocker directly below the +centered heading, before optional choices. `SetupAccessDraft.GetInstallRequirements` +is the shared source for both `CanInstall` and this presentation, including +incomplete or stale inspection, exact-target replacement consent, Local AI, +networking consent and Tailscale readiness. When replacement is required, the +same page shows the full inspected consequences and an exact-distro checkbox near +the bottom, above the fixed footer. It starts unchecked and never installs or +navigates when changed. **Install** stays disabled while consent is outstanding. +Checking it enables Install only when all requirements are satisfied; other +requirements keep their specific recovery actions. Fresh installs and Local AI +recovery do not show replacement consent. The host rechecks `CanInstall` before progress. +A changed draft cannot turn a click on an already displayed review action into +installation. Users who want to keep an existing setup are directed back to the +existing-Gateway route, not encouraged to approve replacement. + +Networking review returns to its caller: review to networking to review, or +Local AI to networking to Local AI. Recovery mode, pinned model and explicit +networking consent remain intact. Reviewing a requirement performs no WSL +changes; installation remains the separate mutation boundary. + +### Connect your AI + +The focused client negotiates authenticated gateway method advertisements. +`openclaw.setup.detect` only presents available choices. Selecting a candidate, +provider login, local-provider preparation or manual key starts the corresponding +`openclaw.setup.*` operation. Detection never silently chooses or tests another +provider. Provider choices come from the gateway. Bundled brand aliases, bounded +public HTTPS metadata logos and native Fluent fallbacks supply their visual +identity without changing authentication. Metadata action labels are preserved. +See [Provider artwork](PROVIDER_ARTWORK.md) for trust boundaries and format differences. + +The grouped screen uses native WinUI `SettingsCard` commands and +`SettingsExpander` controls. Only this task page uses a compact, centered header +band with an 80-DIP mascot beside the wrapping title/status; earlier setup +pages retain their large centered heroes. Each provider has one native command +surface, not a selectable ListViewItem wrapped around another clickable card. +The page-local compact row style does not alter global Toolkit resources. + +1. **Local AI on this PC** is a concise native choice row beside the detected + Gateway choices, with actual GPU/model facts and its trailing action. It has + no separate introductory heading or always-visible technical paragraph. + Accessible help distinguishes managed Windows Local AI from NVIDIA's hosted API. +2. **Available on your Gateway** presents server-returned candidates, recommended + website links when needed, and unavailable discoveries. +3. **Set up a local model** presents Gateway preparation choices before providers. + The catalog and action labels come from Gateway metadata, not a Windows list + of installed services. The fallback action is **Connect / Set up**. +4. **Connect an AI provider** retains featured sign-in, a full **API Keys** row + and auth-only **More sign-in options**. Auth kind determines the fallback + **Pair**, **Set up…**, **Configure…** or **Sign in** action; metadata wins. +5. **Connect with an API key or token** is the separate expanded form. The + API Keys command uses `FluentIconCatalog.Key` and opens the form without + selecting or activating a provider. Its picker, PasswordBox and inline + **Connect** are horizontal when the actual form width and current text scale + allow, and stack otherwise. Empty keys cannot submit. The selected provider + is revalidated at the explicit Connect boundary; secrets clear on submission + and close. There is no duplicate page-footer Continue. + +`AiSetupPresentationModel` suppresses a managed-local duplicate only when the +exact model reference and Gateway identity both match. Native card actions are +explicit; discovery and keyboard focus alone never start a provider. Enter/Space +use that same command, carrying its exact kind, identifier and model reference. +The inline API form is already visible when there are +no Gateway candidates or visible Local AI choice, without selecting its provider. Website links do not run +installers. **Check again** repeats discovery explicitly; a failed scan is not +an empty catalog. Native-conversation discovery is a visible, default-off opt-in +when the Gateway requests it. The current Boolean is sent only with an explicit +provider action; unchecked means false, never automatic permission. When the +Gateway does not request a preference, the parameter remains absent. Refreshing +presentation retains the user's current checkbox value. + +#### One provider operation, one dialog + +The existing page-owned `ProviderSetupDialog` opens in a provider-specific +Starting state before waiting for the start response. A page-owned operation +lifetime keeps it visible across credential submission, server steps, +preparation, activation and exact verification. The client still owns protocol +state; the dialog never acquires another Gateway client or persistence store. +Confirmed rejection returns to the same cleared key field; confirmed cancellation +returns to usable choices. An uncertain outcome stays available for reconciliation +and is never replayed as another activation. + +Server-owned client notes/actions still require **Continue**, typed inputs use +**Submit**, and a client-owned device-code acknowledgment uses **I've signed in**. +Gateway-owned progress has no answer button and is polled without an answer. +Device codes remain selectable and have an explicit Copy action. Structured +device-code prompts show the formatted code card, instructions and expiry instead +of also repeating the Gateway's plain-text prompt. Prompts without structured +device codes retain their messages, and errors remain visible separately. The fresh HTTPS +URL from a user-started provider operation may open once; discovery, rerenders, +reconnects and uncertain reconciliation never auto-open tabs. The manual sign-in +link remains available, and opening a browser never counts as successful login. + +`GatewayAiSetupController` retains the explicit preparation choice and catalog +preference, then automatically activates only its authoritative +`preparedModelRef` on the unchanged Gateway connection. The dialog does not +dismiss for an extra page-level Activate button. Server download, plugin review +and promotion confirmations remain mandatory. Missing receipts, changed +connections or unsupported activation fail visibly without choosing another +model. If an uncertain preparation is later reconciled to an authoritative +prepared receipt, automatic continuation stays disarmed; an explicit +**Activate model** action in the same dialog can use that exact receipt without +replaying preparation. It cannot bypass a changed Gateway or generation. +This Gateway preparation path does not auto-install managed NVIDIA Local +AI or bypass its separate review/consent. A restart-required activation waits +up to 30 seconds for the existing connection's fresh same-authority handshake +before exact verification; timeout offers reconciliation without mutation replay. + +This interaction comparison is pinned to +`openclaw/openclaw@d69a5e74895cf64109eca0d3a172c17b74ddbe7b`, +`OnboardingAISetup.swift:1181-1212,1474-1506` and +`OnboardingAISetupSheet.swift:52-57,127-137,183-212`. +The compact Windows header and continuous dialog are intentional density/lifetime +improvements, not claims that Mac has identical geometry or never internally +reopens its sheet. + +#### Managed Local AI in the same setup window + +`LocalAiOnboardingObservation` observes independently of Gateway discovery and +fences cancellation, refresh and late results. `SetupLocalAiHost` reuses the +eligibility policy, canonical receipt/file inspection, runtime snapshot and +`LocalAiSetupRouteResolver`. Observation never calls runtime `RefreshAsync`: +that runtime API may publish or withdraw a Gateway route. Observation does not +install, migrate receipts, start inference, select a default or grant consent. + +The row distinguishes checking, eligible but absent (**Set up Local AI**), +installed/stopped (**Start and use**), healthy (**Use this model**), damaged +(**Repair Local AI**), busy GPU, unknown facts, unsupported hardware and an +unsupported Gateway. A loaded managed model's own GPU allocation is not treated +as another workload. Installed, healthy, reachable and Gateway-verified are +separate facts. + +`LocalAiOnboardingSnapshot.ShowLocalChoice` hides only a confirmed unsupported +fresh device: no NVIDIA GPU, insufficient total GPU memory, or CUDA capability +below the runtime requirement. It uses the existing eligibility failure code, +not another hardware probe or a production test override. Unknown/checking +facts, busy GPUs, old drivers, missing runtime/catalog entries and unknown +models stay visible. Receipt or damaged-receipt evidence, a pinned installation +or retained runtime attention also keeps the row visible, without promoting its +action or changing repair/admission rules. Unsupported remote Gateway attention +is unchanged. Hiding the local row collapses its empty container, not independently +detected Gateway choices. + +The layout comparison is pinned to +[`OnboardingAISetupView.swift` at `fef6b1290e412761888865da5b61ee1c0ce29586`](https://github.com/openclaw/openclaw/blob/fef6b1290e412761888865da5b61ee1c0ce29586/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift#L191-L674): +results at 191-254, candidate rows at 313-350, preparation at 440-480, +API Keys at 543-571, auth at 574-613 and manual entry at 616-674. Windows keeps +its managed local primary-model lifecycle and explicit consent, not Mac credential +reuse or utility-model semantics. + +The inert native gallery now inventories 93 scenes in both themes (186 scene/theme +states), including a distinct fresh-unsupported-hidden variant, installed +unsupported attention, preparation before providers and the expanded manual form. +The native proof inventory adds two mounted tests and the Working readiness case +to the previous 108-case selection (111 expected when the same selection is used). +These are source inventory counts, not claims of current rendered captures. +Existing source-immutability, owned-input, DPI and capture guards remain required. + +Set up and Repair enter the existing `LocalAiSetupControl` and networking review +inside this `SetupWindow`. **Install and use** is a separate consent boundary. +The host rechecks the same active, uniquely app-managed local Gateway and receipt +before selecting `BuildLocalAiRecoverySteps`, including on a first installation +with no receipt. These steps do not create/delete a distro, reinstall the Gateway, +mint tokens or pair devices. The existing global mirrored-networking warning +and explicit consent remain required. Back restores the prior configuration +draft; capabilities, route and startup preference are retained. + +Start and Use recheck ownership, receipt/model identity, eligibility and canonical +provider publication admission before calling the existing runtime. A Gateway +switch during the read-only admission check is a rejected selection, restoring +choices without starting the runtime. A target change after startup/publication +remains uncertain and retains the exact-target reconciliation boundary. Publication +preserves the recorded fallback and fails closed if the primary model or managed +provider drifted outside that contract; health alone cannot overwrite a newer +Gateway choice. A fresh setup-owned connection then verifies the exact returned +model. An uncertain local action retries verification, not a hidden activation. +Provider sessions must settle or confirm cancellation before local review. +Closing drains page observations and the recovery pipeline before releasing the +setup lock. Settings and headless entry points retain their existing contracts. + +#### Setup and utility metadata compatibility + +The pinned upstream contract is +[`openclaw/openclaw@fef6b1290e412761888865da5b61ee1c0ce29586`](https://github.com/openclaw/openclaw/tree/fef6b1290e412761888865da5b61ee1c0ce29586). +The same setup schema blob (`dbd8461f87f034b675b874313d2f401ac7c682c9`) +is present in release `v2026.9.6` at +`eb377ac59e6c9fd6c7705028034812becf00271b`. + +- [Schema lines 255-374](https://github.com/openclaw/openclaw/blob/fef6b1290e412761888865da5b61ee1c0ce29586/packages/gateway-protocol/src/schema/openclaw.ts#L255-L374): + optional `modelTarget: "utility"` decorates the same choice kinds; + `setupModel` and `utilityModel` are preserved alongside `configuredModel`. +- [Role validation](https://github.com/openclaw/openclaw/blob/fef6b1290e412761888865da5b61ee1c0ce29586/src/system-agent/setup-inference-core.ts#L583-L594) + (blob `6b8cc6e07ee6fb5367c7fd050fc4a53fd63bfc5b`) requires exact role matching. + [Provider staging](https://github.com/openclaw/openclaw/blob/fef6b1290e412761888865da5b61ee1c0ce29586/src/system-agent/setup-inference-credentials.ts#L376-L396) + (blob `9d93a352ed2342a695d9d189c4d1a8eefe7e016f`) enforces it too. +- [Existing-model activation](https://github.com/openclaw/openclaw/blob/fef6b1290e412761888865da5b61ee1c0ce29586/src/system-agent/setup-inference-activate.ts#L121-L148) + (blob `b3abbf8dbf42b8a84d8fb06702eaba027edb2aca`) checks exact model and role. +- [Utility route selection](https://github.com/openclaw/openclaw/blob/fef6b1290e412761888865da5b61ee1c0ce29586/src/agents/utility-model.ts#L17-L50) + (blob `666784e3cd5e8a689fbc287e692f5f13628b36ba`) can use a utility route when + no primary exists. +- [Verification result](https://github.com/openclaw/openclaw/blob/fef6b1290e412761888865da5b61ee1c0ce29586/src/system-agent/setup-inference-turn.ts#L582-L694) + (blob `1ce3a7737950f89798e655c7167e29bb72dbe535`) includes that role. + +Windows hides utility-only choices from the main-assistant list with a visible +explanation, rejects utility-marked activation/verification as main-assistant +success, and preserves absent-field behavior for older Gateways. Verify requests +send only the existing optional `agentId`, not `modelRef` or an invented +`"primary"` role. Full utility onboarding and a Gateway protocol upgrade are not +part of this change. + +Interactive authentication and activation retain the shared `wizard.next` and +`wizard.cancel` contract, including sensitive fields, device codes, browser +actions and gateway-executed progress. A generic terminal wizard result or a +prepared model is not proof of working inference. Activation receipts, exact +model verification and restart reconciliation remain distinct. + +An explicitly selected Local AI installation passes its resolved gateway model +reference to verification after the installation pipeline succeeds. The UI does +not guess that reference from a catalog ID. Cancellation or an uncertain reply +does not replay a mutation. Insufficient operator scopes remain an explicit +error, not a reason to fall back or use node credentials. + +After verification, the native chooser finalizes Windows-node workspace guidance +and the reviewed startup preference before restart. Native Chat and the flyout +retain their independent Dashboard action. Windows capability consent remains +in native Permissions, not in the web dashboard. + +### Classic OpenClaw onboard (compatibility) After OpenClaw onboard completes-or when the user explicitly skips it-local setup runs the installed gateway CLI's non-interactive baseline initializer against the final runtime workspace, then writes fixed Windows-node guidance into a setup-owned managed section of that workspace's `AGENTS.md`. The section is replaced idempotently between markers, preserves user-authored `AGENTS.md` content and file permissions outside those markers, and does not modify OpenClaw source files. This helps the initial companion-app OpenClaw session know to use the Windows node / `nodes` tool for Windows desktop, files, screenshots, camera, notifications, browser proxy, and Windows command tasks. @@ -405,11 +889,56 @@ The headless setup engine also treats one terminal wizard payload as completion When the gateway config wizard surfaces an error and the active gateway is an app-managed WSL distro, the error state also offers **Open terminal** and **Restart gateway**. The wizard does not parse or classify the gateway's error text; it leaves the message visible and selectable so the user can copy any command the gateway reports. The buttons reuse the shared `GatewayTerminalLauncher` and `WslGatewayController` (in `OpenClaw.Connection`, also used by the Connections tab). Restart re-enters the gateway config wizard (the provider/model onboarding step - not the whole V2 onboarding, and without re-installing the WSL distro) so fixes such as newly-installed tools are picked up on `PATH`. Because the gateway restart clears its wizard session, this resumes at the first config question rather than the exact step that failed. Detection is gated on `GatewayRecord.SetupManagedDistroName`, so it never appears for remote/SSH gateways. -### All set -Displays a completion summary, a Launch at startup toggle, and a Finish button that saves the startup preference before restarting the tray. Launch at startup defaults on so OpenClaw is ready after reboot. +### Completion and recovery +Focused setup finishes through the three-choice native page. Startup is reviewed +before completion and defaults on for a fresh, non-isolated setup. Recovery +preserves that preference; isolated hosts keep it off. The classic completion +page retains its summary and Open chat action. Errors retain diagnostics and +recovery instead of showing success. + +### Artwork and motion + +Onboarding uses the native vector `OnboardingMascot` control, adapted from +OpenClaw's Mac character at upstream commit +`9afae26e080602bf1e330bfd88fcbd39a2bf22c0`. Idle, curious, thinking, working, +happy, sad and celebrating poses reflect the current task. Working includes the +hard hat and hammer animation. Windows animation preferences select static +poses, and hidden/unloaded controls stop ticking. Navigation also respects the +Windows animation setting. Motion never adds a completion delay. + +Provider artwork is bundled under `Assets/Setup/ProviderIcons` with its upstream +notices. Keep asset identity separate from the gateway-provided provider catalog. +Check light, dark and high-contrast rendering and the actual published +library-qualified paths, not only loose development assets. ## Security +### Native connection checks and cancellation + +The tray-hosted native connection editor is a main-window page. It supports a +gateway address, a setup code or shared token, and optional SSH host, user and +ports. SSH uses existing OpenSSH keys/configuration, not a separate key store. +The browser-only profile route is not implemented through native credentials. + +The connection editor shows the Gateway-stage progress indicator above a +separate row of wrapping Back, Cancel, Check and Next actions. + +Check connection authenticates with an isolated identity copy and optional +temporary owned SSH listener, without saving a gateway or changing the active +connection. Editing invalidates the displayed check result. Next checks the +current draft again and transactionally commits before the PC capabilities page. +Failed checks, editor cancellation and cancellation during +Next leave or restore the previously active gateway. If rollback cannot be +confirmed, setup shows an error and does not advance. After Next has succeeded, +closing setup retains that explicitly committed gateway. Pairing-pending is not +shown as a successful operator connection or as permission to enter AI setup. + +The editor retains a temporary key for the same draft while gateway approval is +pending. Successful bootstrap handoff tokens remain in memory until Next, allowing +revalidation without reusing a consumed bootstrap code. Draft changes and editor +close discard this staging state. No received token is written into the temporary +key file during Check. + The onboarding wizard follows these security practices: - **Input validation**: Setup codes limited to 2KB, decoded JSON validated, gateway URLs checked via `GatewayUrlHelper` @@ -424,7 +953,7 @@ Gateway credentials are registry-backed. Setup codes and QR payloads create or u ## Localization -All user-visible strings use `LocalizationHelper.GetString()` with the `Onboarding_*` key namespace. Supported languages are discovered from the `Strings//Resources.resw` directories; the current locales are English, French, Dutch, Chinese Simplified, and Chinese Traditional. +All user-visible strings use localization helpers with the `Onboarding_*` key namespace. Setup library pages use `SetupLocalization`; tray surfaces use `LocalizationHelper`. Supported languages are discovered from the `Strings//Resources.resw` directories; the current locales are English, French, Dutch, Brazilian Portuguese, Chinese Simplified, and Chinese Traditional. Translations are AI-generated following the repo convention. Technical terms (Gateway, Token, Node Mode) are kept in English across all locales. @@ -438,13 +967,30 @@ See [DEVELOPMENT.md](../DEVELOPMENT.md#developing--testing-the-onboarding-wizard Use a temp settings directory for tests that construct `SettingsManager`, or set `OPENCLAW_TRAY_DATA_DIR` before the test process starts. +Real tray and setup proof launches must set all three isolation variables: +`OPENCLAW_TRAY_DATA_DIR` is the direct data folder; +`OPENCLAW_TRAY_APPDATA_DIR` and `OPENCLAW_TRAY_LOCALAPPDATA_DIR` are separate +roaming and local roots. The product may append `OpenClawTray` to those roots. +`OPENCLAW_TRAY_LOCAL_DATA_DIR` is a legacy direct-folder override, not a +replacement for the canonical local root. + +Data directories alone do not isolate Windows registration. When the direct +data override is present, `AppIdentity.IsIsolated` suppresses Toolkit toast +activation subscription/unsubscription, toast display, and URI registration. +Startup mutations are explicitly refused. WSL keepalive lifecycle actions +require an explicitly managed gateway record, rather than adopting the normal +user's default distro. These guards do not authorize using an existing real +gateway or profile in a test. Use only owned disposable resources and compare +protected profile metadata and notification/COM, URI, and startup registration +fingerprints before and after native runs. + ### Setup image packaging Setup images use `ms-appx:///OpenClaw.SetupEngine.UI/Assets/Setup/...` URIs. Published installer and portable ZIP payloads must include that library-qualified directory, not just the tray's loose `Assets/Setup` copies. The tray publish target preserves both layouts; `SetupAssetPublishTests` executes that target against a -clean directory and checks every setup PNG, including nested assets. +clean directory and checks every setup asset, including nested SVGs and notices. ### Key Files @@ -452,14 +998,46 @@ clean directory and checks every setup PNG, including nested assets. |------|---------| | `src/OpenClaw.SetupEngine.UI/SetupWindow.xaml(.cs)` | Tray-hosted setup shell, run lock, preview routing, and page navigation | | `src/OpenClaw.SetupEngine.UI/Pages/SecurityNoticePage.xaml(.cs)` | First-run device-trust warning before setup choices | -| `src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml(.cs)` | Install-new-WSL vs connect-existing choice and existing-gateway replacement prompt | +| `src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml(.cs)` | Native Gateway, WSL, and connect-existing choice with capability/readiness checks | | `src/OpenClaw.SetupEngine.UI/Pages/AdvancedSetupPage.xaml(.cs)` | Connect-existing handoff to Connection settings | -| `src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml(.cs)` | Capability profile, inline Windows permission status, and install review | +| `src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml(.cs)` | Shared capability profile and transport settings draft | | `src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml(.cs)` | WSL gateway install progress and gateway-installed handoff | | `src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml(.cs)` | OpenClaw onboard provider/model/key wizard driven by gateway `wizard.*` frames | +| `src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml(.cs)` | Focused provider choices, authentication, verification and bounded page-owned cleanup | +| `src/OpenClaw.SetupEngine/OnboardingFlowPolicy.cs` | Interactive stage list and installation subset; preserves the headless pipeline | +| `src/OpenClaw.SetupEngine/GatewayAiSetupClient.cs` | Typed route-bound discovery, explicit selection, activation and reconciliation | +| `src/OpenClaw.SetupEngine/GatewayAiSetupController.cs` | Bounded gateway-executed wizard progress polling | +| `src/OpenClaw.SetupEngine/SetupGatewaySession.cs` | Shared temporary setup operator session with registry identity and endpoint provenance | +| `src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascot.cs` | Native vector control, animation preferences, theme updates and unload lifetime | | `src/OpenClaw.SetupEngine/GatewayWizardRestartRecoveryPolicy.cs` | Exact terminal-restart classification and bounded restart provenance/reconnect retry policy | | `src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml(.cs)` | Success, failure, log/help, and startup preference summary | -| `src/OpenClaw.SetupEngine.UI/Pages/SetupPermissionHelper.cs` | Passive Windows permission checks and inline permission rows | | `src/OpenClaw.Connection/GatewayRegistry.cs` | Persistent gateway records and migration target | | `src/OpenClaw.Connection/GatewayConnectionManager.cs` | Operator/node connection lifecycle used by onboarding | | `src/OpenClaw.Tray.WinUI/Services/SetupExistingGatewayClassifier.cs` | Existing gateway classification for Welcome and startup gating | + +### Focused validation + +Run the repository-required build, Shared and Tray suites, plus +`OpenClaw.SetupEngine.Tests` for the flow and AI protocol contracts. +`AiReadyPageRenderingTests` covers the three destinations and isolated startup. +`ApprovedMock_FivePagesAndProviderPopup_LightAndDark` provides an opt-in native +comparison without installation. Building fixtures alone is not rendered proof; +high contrast and Windows text scaling need authorized visible validation. +`OnboardingAiPageTests` mounts the production page with a scoped transport +double and checks explicit selection, masked input, conversation-discovery +consent, exact-model retry, uncertain replies, and cancellation before handoff. +`OnboardingArtworkRenderingTests` decodes the bundled SVGs through WinUI, +checks library-qualified URIs, and renders all static mascot moods in light and +dark themes. Set `OPENCLAW_UI_PROOF_DIR` to an isolated artifact directory to +save the current rendered scenes. + +Mounted tests are not real-provider or gateway-to-node proof. Keep those +claims separate, run the required WSL/MXC validation path for setup/connect +changes, and report unavailable desktop or provider dependencies explicitly. +The real setup fixture uses separate data, roaming-root and local-root paths. +Its uninstall passes run-specific startup registry/task identities so teardown +cannot remove the normal Companion's startup registration. +The real Gateway proof also calls `openclaw.setup.detect` through the production +focused client, asserting advertised support, operator scope and typed discovery +without selecting a provider or starting a wizard. Provider-specific sign-in and +billing-dependent inference remain separate from that read-only contract proof. diff --git a/docs/PROVIDER_ARTWORK.md b/docs/PROVIDER_ARTWORK.md new file mode 100644 index 000000000..9d6995d55 --- /dev/null +++ b/docs/PROVIDER_ARTWORK.md @@ -0,0 +1,99 @@ +# AI setup provider artwork + +`GatewayAiSetupPresentation` resolves provider artwork independently of credential, +discovery and setup workflows. `AiSetupPage` forwards `brandId`, provider/candidate +ID, `icon`, provider `kind` and `actionLabel`. `ProviderArtwork` owns the WinUI +image and `ProviderArtworkSession` owns the AI page's download/cache lifetime. + +## Pinned platform comparison + +The reference is `openclaw/openclaw` commit +`0fd603a6fece58d60c010e565df9e26c6601db8b`, specifically +`apps/macos/Sources/OpenClaw/OnboardingProviderArtwork.swift` and +`OnboardingAISetupView.swift`. + +- The nine bundled SVGs and their notices remain unchanged. The closed alias map + resolves Claude/Anthropic, Codex/OpenAI/ChatGPT, Gemini/Google, Ollama, LM Studio, + Pi, OpenCode, Kimi/Moonshot and Grok/xAI, including the leading-token fallback + for method-suffixed IDs. Explicit `brandId` wins over candidate kind/provider ID. +- A bundled logo wins over a remote URL. Unknown brands may load a Gateway-supplied + public HTTPS logo. Any failure leaves a native Fluent icon, never a broken image. +- Artwork is decorative. Provider names, descriptions and selection semantics + remain accessible independently of the image. +- Provider metadata action labels take precedence. Windows defaults are **Pair**, + **Set up…**, **Configure…**, and **Connect**. The pinned Mac auth-row default is **Sign in**, and its prepare-row + fallback is **Connect / Set up**. These are explicit copy differences, not + assertions of exact text parity. +- Bundled sources use WinUI's library-qualified resource URI resolution, including + unpackaged installations. A visible Image consumes the source before awaiting + Opened because URI decoding is demand-driven. The fallback stays visible until + success; rebind/unload cancels loading without allowing an old completion to + clear the replacement source. + +## Remote trust boundary + +These are display downloads, not authenticated Gateway calls: + +| Gate | Bound | +| --- | --- | +| URI | HTTPS, port 443, at most 2,048 characters, no userinfo or fragment | +| Host | No local/single-label names or non-public IP literals | +| Connection | Resolve inside `ConnectCallback`; reject all answers if any is unsafe; connect directly to a checked IP with no second lookup | +| Address | Loopback, private, link/site-local, multicast, reserved, documentation, benchmark, transition and mapped-private addresses blocked; IPv6 limited to global unicast | +| TLS | Platform hostname/certificate validation and SNI retained | +| HTTP | No redirects, auth retries, credentials, cookies, proxy, referer, trace propagation or automatic decompression | +| Acquisition | 6-second deadline including admission; 4 concurrent, 16 admitted requests | +| Encoded data | 256 KiB, checked against both advertised length and actual streamed bytes | +| Cache | Memory only, page lifetime, 16 entries, 2 MiB total, 5-minute expiry | +| Native decode | 2 active decoders; excess work falls back; 10-second control deadline includes acquisition and decode | +| Raster | PNG/JPEG MIME and signatures; native codec and single frame checked; width/height at most 1,024, at most 1,048,576 source pixels before BGRA extraction | +| Render output | At most 24 by 24 BGRA pixels for raster, 24 by 24 rasterization for vector | + +The decoder keeps its concurrency slot and stream until native work actually +finishes, even after a caller's deadline. A timeout does not falsely imply native +work was forcibly terminated. Rebind, unload and page closure cancel and fence +old completions; unload and closure immediately release displayed image references. +The page owns and disposes its finite encoded cache. + +Failures use a local tooltip with a finite category: blocked, network, HTTP, +oversized, unsupported, invalid image, timeout or busy. No URL, query, image, +exception message, credentials or raw response is written to application logs or +telemetry. Framework HTTP diagnostics are not subscribed/exported by this feature. + +## Remote SVG subset and remaining differences + +Remote SVGs are not passed to a browser or unrestricted native parser. XML has +DTDs and external resolution disabled. A new document is reconstructed from only +`svg`, `g`, `path`, `rect`, `circle`, `ellipse`, `line`, `polyline`, and `polygon`. +Attributes are limited to bounded numbers, viewBox, path/point data, plain +fill/stroke colors, opacity and enumerated stroke/fill rules. + +Limits: 256 reader nodes, depth 16, 16 attributes per element, 32,768 total path +characters, 2,048 explicit commands and 4,096 numeric tokens per path. Numeric +values must be finite and no greater than 10,000 in magnitude; viewBox width and +height must be positive and no greater than 1,024. Compact adjacent signed path +numbers that do not tokenize into this strict subset are rejected. + +No scripts, event attributes, styles/CSS, fonts, external or internal references, +`use`, images, nested SVGs, foreign objects, filters, gradients, transforms or +animation are accepted. XML stylesheet instructions and unknown attributes fail +closed. Accepted vectors are monochrome in the existing dark logo well; raster +colors are preserved. Bundled SVGs remain native `SvgImageSource` resources. + +This is deliberately narrower than Mac's unrestricted `NSImage` decoding and +unbounded shared URL session. Other raster formats, arbitrary SVG documents, +nonstandard HTTPS ports, redirects and non-public hosts use the fallback. +This implementation does **not** claim complete remote-format parity. + +## Validation + +`ProviderArtworkTests` uses synthetic HTTP handlers and injected DNS/socket +functions only. It covers aliases and precedence, action labels, URI/IP policy, +mixed DNS answers, actual-address pinning, credential/redirect rejection, MIME, +stream size, cancellation/deadline, request admission/concurrency, cache bounds, +SVG rejection and stale generations. `ProviderArtworkSourceContractTests` covers +metadata forwarding, WinUI lifetime wiring and the native decoder's gates. + +Native rendering, malformed native codec behavior, accessibility, high contrast, +and real network/TLS behavior still require an authorized isolated UI proof pass. +Unit/source tests are not runtime or screenshot proof. diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 12885090f..c180260c5 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -116,8 +116,10 @@ checks Windows package registration and package-qualified aliases. `NativeGatewayMsixInstaller` installs the fixed Store product using the current-user App Installer alias and `CommandRunner`: `winget install --id 9NV70LV3D6XC --source msstore --silent --accept-package-agreements --accept-source-agreements --disable-interactivity --no-upgrade`. -Native review explicitly explains installation and agreement acceptance before -the user selects **Set up gateway**. Microsoft Store owns architecture selection, +The shared capabilities page shows the native installation and agreement +disclosure before its **Set up gateway** action; other routes retain **Next**. +This preserves consent without adding a separate native review step. +Microsoft Store owns architecture selection, signature validation and deployment; no local MSIX path is required. Missing WinGet, Store access failures and nonzero exit codes surface bounded, sanitized diagnostics and retry guidance instead of opening a manual Store page. @@ -130,12 +132,13 @@ registration. Installation and verified package readiness share a five-minute deadline. Cancellation stops the WinGet request, but Windows deployment may continue. Repair errors and timeouts stay visible, with explicit retry rather than repeated installer launches; retries recheck registration before installing. -Native setup shares the capability profiles and Windows permissions page with -WSL but skips WSL/Local AI/Tailscale installation review and probes. The native -progress page uses shared spinner/checkmark rows and automatically enters the -Gateway wizard after preparing its runtime. Finalization applies the selected -Gateway command allowlist before config/health gates, then persists only the -Companion node/capability settings. Completion does not claim node pairing. +Native setup shares the `SetupAccessDraft` capability profiles with WSL but +skips WSL/Local AI/Tailscale installation review and probes. The native +progress page uses `SetupPhaseStatus` rows and automatically enters the Gateway +wizard after preparing its runtime. Finalization applies the selected Gateway +command allowlist before config/health gates, then persists reviewed Companion +settings without overwriting unrelated settings or startup preferences. +Completion does not claim node pairing. `NativeGatewaySetupHost` runs captured `clawctl setup`, config validation, and health commands, plus an explicitly requested profile-scoped recovery terminal. It never launches `openclaw onboard` or WSL. @@ -166,6 +169,43 @@ manual recheck button; reopening the page checks again. The separate isolation w session provisioning. Gateway distribution includes x64, ARM64 and MSIX bundle artifacts, but the temporary development installer remains ARM64-only. +Interactive WSL setup uses `OnboardingFlowPolicy` to select the installation +subset. It defers the classic wizard and Windows workspace finalization until +after the focused AI flow. The separate native MSIX route retains the hosted +classic wizard. `GatewayAiSetupClient` owns typed provider discovery, +selection, activation and recovery; the native AI page renders that state. +`AiSetupPresentationModel` owns presentation-only grouping. A single +page-owned `ProviderSetupDialog` owns prompt controls and secret clearing, +without another client, persistence store or top-level setup page. +`SetupWindow` remains the completion host. Verified AI opens the native chooser; +an explicit choice restarts into Chat, Channels or Skills with a bound receipt. +The experimental browser-completion path is removed. See the completion handoff +in `ONBOARDING_WIZARD.md`. +Headless setup keeps `SetupStepFactory.BuildDefaultSteps()` and the +classic wizard contract. See [Onboarding Wizard](ONBOARDING_WIZARD.md) for current +page composition, compatibility behavior and artwork. + +AI provider commands admit one page-owned dialog lifetime immediately, before +awaiting Gateway replies. The controller continues an explicitly chosen Gateway +preparation into its exact returned model, preserving conversation-discovery +consent, route and generation fences; it never answers client-owned server +prompts automatically. It also owns fresh HTTPS auth-link admission and the +bounded same-authority restart wait. Native input/secret clearing stays in +`ProviderSetupDialog`; exact activation/verification stays in the focused client. +No provisioning or earlier setup-page ownership moves into this presentation. + +Managed Local AI can be chosen after Gateway installation on **Connect your AI**. +`ISetupLocalAiHost` is the typed bridge to the existing tray-owned runtime and +canonical provider coordinator. `LocalAiSetupRouteResolver` shares Settings' +unique app-owned Gateway admission with this same-window path. The read-only +`LocalAiOnboardingObservation` does not use runtime refresh or receipt +reconciliation because those owners can mutate state. Explicit setup/repair +reuses `BuildLocalAiRecoverySteps`, even when no prior Local AI receipt exists. +`SetupWindow` retains its lock, access draft and startup choice throughout review, +pipeline execution and exact-model Gateway verification. Interactive normal +new-setup review no longer offers a competing Local AI toggle; explicit config, +Settings recovery and headless contracts remain supported. + > **Status note (2026-07-06):** Current default setup includes `WindowsNodeBootstrapContextStep`, which injects Windows-node context into the WSL workspace `AGENTS.md` after onboarding. --- @@ -206,7 +246,9 @@ src/OpenClaw.SetupEngine.UI/ └── Pages/ ├── SecurityNoticePage.xaml / .cs # Device-trust warning ├── WelcomePage.xaml / .cs # Install WSL gateway vs connect existing - ├── CapabilitiesPage.xaml / .cs # Profile, inline permissions, install review + ├── CapabilitiesPage.xaml / .cs # Typed capability profile and transport choices + ├── GatewaySetupPage.xaml / .cs # Generated WSL installation review + ├── GatewaySetupDetailPage.xaml / .cs # Full-window Local AI, Tailscale and consent views ├── ProgressPage.xaml / .cs # Live step rows + gateway-installed handoff ├── WizardPage.xaml / .cs # OpenClaw onboard transcript └── CompletePage.xaml / .cs # Mascot status badge, summary, startup toggle @@ -529,26 +571,39 @@ Log path defaults to `%APPDATA%\OpenClawTray\Logs\Setup\setup-engine- ConnectAsync(gatewayId, CancellationToken.None); + + public async Task ConnectAsync(string? gatewayId, CancellationToken cancellationToken) { ThrowIfDisposed(); - await _transitionSemaphore.WaitAsync(); + await _transitionSemaphore.WaitAsync(cancellationToken); try { var targetId = gatewayId ?? _registry.ActiveGatewayId; if (targetId is not null) SetGatewayConnectionIntent(targetId, shouldBeConnected: true); - await ConnectCoreAsync(gatewayId, "connect"); + await ConnectCoreAsync(gatewayId, "connect", cancellationToken); } finally { @@ -1050,6 +1052,19 @@ private async Task StopTunnelAfterFailedConnectionAsync(string operation) } } + public async Task DisconnectIfCurrentAsync(GatewayConnectionSnapshot expected) + { + ThrowIfDisposed(); + await _transitionSemaphore.WaitAsync(); + try + { + if (!ReferenceEquals(CurrentSnapshot, expected)) return false; + await DisconnectCoreAsync(); + return true; + } + finally { _transitionSemaphore.Release(); } + } + public async Task DisconnectAsync() { ThrowIfDisposed(); @@ -1980,6 +1995,24 @@ private async Task ValidateSharedTokenBeforeReplacementAsync( } } + public Task ValidateConnectionAsync( + GatewayRecord candidate, GatewayValidationIdentity identity, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + var excludedPorts = new HashSet(); + if (_tunnelManager?.ActiveConfig is { } active) + { + excludedPorts.Add(active.LocalPort); + if (active.IncludeBrowserProxyForward) + excludedPorts.Add(active.LocalPort + 2); + } + return new GatewayConnectionValidator( + _credentialResolver, _validationTunnelFactory, + AuthorizeValidationCredentialHandshakeAsync, _logger) + .ValidateAsync(candidate, identity, excludedPorts, cancellationToken); + } + internal OpenClawGatewayClient CreateSharedTokenValidationClient( string gatewayUrl, string token, @@ -1991,39 +2024,12 @@ internal OpenClawGatewayClient CreateSharedTokenValidationClient( { var diagLogger = new DiagnosticTeeLogger(_logger, _diagnostics); expectedTunnelOwnershipGeneration ??= validationTunnel?.OwnershipGeneration; - var client = new OpenClawGatewayClient( - gatewayUrl, - token, - diagLogger, - tokenIsBootstrapToken: false, - bootstrapPairAsNode: false, - identityPath: identityDir, - ignoreStoredDeviceToken: true, - persistHandshakeDeviceTokens: false) - { - UseV2Signature = - validationRecord.IsLocal || validationRecord.RequiresV2Signature - }; - // This is a one-shot validation client. A reconnect would reuse the strong shared token after - // ownership may have changed; fail the validation instead and let the caller retry from a new - // provenance preflight. - client.ReconnectAuthorizationAsync = _ => Task.FromResult( - new ReconnectAuthorizationResult( - false, - GatewayErrorKind.Auth, - "Shared-token validation is one-shot.")); - client.HandshakeAuthorizationAsync = cancellationToken => - AuthorizeValidationCredentialHandshakeAsync( - validationRecord, - new GatewayCredential( - token, - IsBootstrapToken: false, - CredentialResolver.SourceSharedGatewayToken), - validationTunnel, - validationTunnelConfig, - expectedTunnelOwnershipGeneration, - cancellationToken); - return client; + var credential = new GatewayCredential(token, false, CredentialResolver.SourceSharedGatewayToken); + return GatewayConnectionValidator.CreateClient( + gatewayUrl, credential, identityDir, validationRecord, diagLogger, + cancellationToken => AuthorizeValidationCredentialHandshakeAsync( + validationRecord, credential, validationTunnel, validationTunnelConfig, + expectedTunnelOwnershipGeneration, cancellationToken)); } internal static async Task AuthorizeValidationTunnelHandshakeAsync( @@ -2377,6 +2383,8 @@ private async Task IsRecoverySafeEndpointAsync( (await _nativeGatewayRuntime.InspectAsync(record, cancellationToken).ConfigureAwait(false)).Kind == GatewayEndpointProvenanceKind.ExpectedManagedGateway; } + if (!GatewayCredentialRecoveryPolicy.IsTransportSafe(record, allowUnmanagedLoopback: true)) + return false; if (GatewayRecordEditing.IsLoopbackEndpoint(record.Url)) { if (record.IsLocal || GatewayRecordEditing.ResolveManagedDistroName(record) is not null) @@ -2398,11 +2406,7 @@ await _tunnelManager cancellationToken) .ConfigureAwait(false); } - if (string.IsNullOrWhiteSpace(record.Url)) - return false; - return Uri.TryCreate(record.Url, UriKind.Absolute, out var uri) && - (string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase) || - string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)); + return true; } private async Task AuthorizeCredentialForEndpointAsync( diff --git a/src/OpenClaw.Connection/GatewayConnectionValidator.cs b/src/OpenClaw.Connection/GatewayConnectionValidator.cs new file mode 100644 index 000000000..2cbe7d695 --- /dev/null +++ b/src/OpenClaw.Connection/GatewayConnectionValidator.cs @@ -0,0 +1,221 @@ +using System.Net; +using System.Net.Sockets; +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +internal delegate Task ValidationHandshakeAuthorization( + GatewayRecord record, GatewayCredential credential, ISshTunnelManager? tunnel, + SshTunnelConfig? config, long? generation, CancellationToken cancellationToken); + +/// One-shot operator validation, isolated from the active manager, saved identity and SSH listener. +internal sealed class GatewayConnectionValidator( + ICredentialResolver resolver, + Func tunnelFactory, + ValidationHandshakeAuthorization authorize, + IOpenClawLogger logger, + Func>? validateHandshake = null) +{ + public async Task ValidateAsync( + GatewayRecord candidate, GatewayValidationIdentity identity, + IReadOnlySet excludedPorts, CancellationToken cancellationToken) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(35)); + var ct = deadline.Token; + ISshTunnelManager? tunnel = null; + try + { + ct.ThrowIfCancellationRequested(); + GatewayCredential? ResolveCredential() => identity.OperatorCredential is { } ephemeral + ? new GatewayCredential(ephemeral.Token, false, CredentialResolver.SourceDeviceToken) + : resolver.ResolveOperator(candidate, identity.DirectoryPath); + if (ResolveCredential() is null) + return new(SetupCodeOutcome.ConnectionFailed, "Enter a setup code or shared token for this gateway."); + SshTunnelConfig? config = null; + long? generation = null; + var url = candidate.Url; + if (candidate.SshTunnel is { } ssh) + { + config = ssh with { LocalPort = AllocatePort(excludedPorts), IncludeBrowserProxyForward = false }; + tunnel = tunnelFactory(); + var started = await tunnel.StartOwnedAsync(config, ct).ConfigureAwait(false); + generation = started.OwnershipGeneration; + // Do not trust an arbitrary URL or a different config returned by a tunnel implementation. + if (started.Config != config || !GatewayRecordEditing.AreEquivalentLoopbackEndpoints( + started.Url, $"ws://127.0.0.1:{config.LocalPort}")) + return new(SetupCodeOutcome.ConnectionFailed, "The isolated SSH listener did not match the requested endpoint."); + url = started.Url; + } + // Signature, scope and revoked-token recovery may each advance once, never reconnect without a bound. + for (var attempt = 0; attempt < 4; attempt++) + { + var credential = ResolveCredential(); + if (credential is null) + return new(SetupCodeOutcome.ConnectionFailed, "Enter a setup code or shared token for this gateway."); + var record = candidate with { Url = url, RequiresV2Signature = candidate.RequiresV2Signature || identity.UseV2Signature }; + var permission = await authorize(record, credential, tunnel, config, generation, ct).ConfigureAwait(false); + if (!permission.Allowed) + return new(SetupCodeOutcome.ConnectionFailed, permission.Detail); + + var boundedScopes = identity.UseBoundedBootstrapScopes; + using var client = CreateClient( + url, credential, identity.DirectoryPath, record, logger, + token => authorize(record, credential, tunnel, config, generation, token), + identity.OperatorCredential, boundedScopes); + var v2Signature = client.UseV2Signature; + var receivedTokens = new List(); + client.DeviceTokenReceived += (_, token) => receivedTokens.Add(token); + void CaptureAuthenticatedTokens() + { + foreach (var token in receivedTokens) + identity.CaptureToken(token); + } + // Capture before graceful disconnect: teardown failure must not lose a consumed + // bootstrap token's authenticated replacement. + client.HandshakeSucceeded += (_, _) => CaptureAuthenticatedTokens(); + SetupCodeResult result; + try + { + result = await (validateHandshake ?? RunHandshakeAsync)(client, ct).ConfigureAwait(false); + } + finally + { + identity.UseBoundedBootstrapScopes |= client.UsesBoundedBootstrapScopes; + identity.UseV2Signature |= client.UseV2Signature; + } + if (result.Outcome == SetupCodeOutcome.Success) + { + ct.ThrowIfCancellationRequested(); + CaptureAuthenticatedTokens(); + return result; + } + if (result.ErrorKind == GatewayErrorKind.DeviceTokenMismatch && + credential.Source == CredentialResolver.SourceDeviceToken && + identity.OperatorCredential is null && !identity.OperatorTokenRecoveryAttempted && + GatewayCredentialRecoveryPolicy.IsTransportSafe(candidate)) + { + var fallback = !string.IsNullOrWhiteSpace(candidate.SharedGatewayToken) + ? new GatewayCredential(candidate.SharedGatewayToken, false, CredentialResolver.SourceSharedGatewayToken) + : !string.IsNullOrWhiteSpace(candidate.BootstrapToken) + ? new GatewayCredential(candidate.BootstrapToken, true, CredentialResolver.SourceBootstrapToken) + : null; + if (fallback is not null) + { + var recovery = await authorize(record, fallback, tunnel, config, generation, ct).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + if (!recovery.Allowed) + return result with { ErrorMessage = recovery.Detail }; + if (identity.RejectStoredOperatorToken(credential.Token)) + continue; + } + } + if ((boundedScopes || !client.UsesBoundedBootstrapScopes) && + (v2Signature || !client.UseV2Signature)) + return result; + } + return new(SetupCodeOutcome.ConnectionFailed, "Gateway authentication compatibility checks were exhausted."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.Warn($"Native gateway validation failed: {ex.GetType().Name}"); + return new(SetupCodeOutcome.ConnectionFailed, + ex is OperationCanceledException ? "Gateway connection check timed out." : "Gateway connection check failed. Review the address, credentials and SSH settings."); + } + finally + { + if (tunnel is not null) + { + try { await tunnel.StopAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); } + finally { tunnel.Dispose(); } + } + } + } + + internal static OpenClawGatewayClient CreateClient( + string url, GatewayCredential credential, string identityPath, GatewayRecord record, + IOpenClawLogger logger, Func> authorize, + DeviceTokenReceivedEventArgs? ephemeralOperatorCredential = null, + bool useBoundedBootstrapScopes = false) + { + var client = new OpenClawGatewayClient( + url, credential.Token, logger, + tokenIsBootstrapToken: credential.IsBootstrapToken, + bootstrapPairAsNode: false, + identityPath: identityPath, + ignoreStoredDeviceToken: credential.Source != CredentialResolver.SourceDeviceToken, + persistHandshakeDeviceTokens: false, + ephemeralOperatorCredential: ephemeralOperatorCredential, + useBoundedBootstrapScopes: useBoundedBootstrapScopes) + { + UseV2Signature = record.IsLocal || record.RequiresV2Signature || credential.IsBootstrapToken + }; + client.ReconnectAuthorizationAsync = _ => Task.FromResult(new ReconnectAuthorizationResult( + false, GatewayErrorKind.Auth, "Gateway validation is one-shot.")); + client.HandshakeAuthorizationAsync = authorize; + return client; + } + + internal static async Task RunHandshakeAsync( + OpenClawGatewayClient client, CancellationToken cancellationToken) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void Handshake(object? sender, EventArgs e) => + completion.TrySetResult(new(SetupCodeOutcome.Success)); + void AuthenticationFailed(object? sender, string message) => + completion.TrySetResult(AuthenticationFailure(message)); + void StatusChanged(object? sender, ConnectionStatus status) + { + if (status is ConnectionStatus.Error or ConnectionStatus.Disconnected) + completion.TrySetResult(new(SetupCodeOutcome.ConnectionFailed, "Gateway connection check failed.")); + } + + client.HandshakeSucceeded += Handshake; + client.AuthenticationFailed += AuthenticationFailed; + client.StatusChanged += StatusChanged; + using var cancellation = cancellationToken.Register(client.Dispose); + try + { + cancellationToken.ThrowIfCancellationRequested(); + await client.ConnectAsync().WaitAsync(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false); + var result = await completion.Task.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return result; + } + finally + { + client.HandshakeSucceeded -= Handshake; + client.AuthenticationFailed -= AuthenticationFailed; + client.StatusChanged -= StatusChanged; + if (!cancellationToken.IsCancellationRequested) + await client.DisconnectAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + } + + internal static SetupCodeResult AuthenticationFailure(string message) + { + var kind = GatewayErrorClassifier.ClassifyWithCode(message); + return new(SetupCodeOutcome.ConnectionFailed, + kind == GatewayErrorKind.DeviceTokenMismatch + ? "The saved device token was rejected. Supply a current shared token or setup code for this Gateway and check again." + : "Gateway authentication failed or device approval is required.", + ErrorKind: kind); + } + + private static int AllocatePort(IReadOnlySet excludedPorts) + { + for (var i = 0; i < 16; i++) + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + if (!excludedPorts.Contains(port)) + return port; + } + throw new InvalidOperationException("No isolated SSH validation port is available."); + } +} diff --git a/src/OpenClaw.Connection/GatewayCredentialRecoveryPolicy.cs b/src/OpenClaw.Connection/GatewayCredentialRecoveryPolicy.cs new file mode 100644 index 000000000..300fbe241 --- /dev/null +++ b/src/OpenClaw.Connection/GatewayCredentialRecoveryPolicy.cs @@ -0,0 +1,13 @@ +namespace OpenClaw.Connection; + +/// Transport admission only; owned SSH and managed-loopback provenance must also be rechecked. +internal static class GatewayCredentialRecoveryPolicy +{ + public static bool IsTransportSafe(GatewayRecord record, bool allowUnmanagedLoopback = false) => + record.SshTunnel is not null || + GatewayRecordEditing.IsLoopbackEndpoint(record.Url) && + (allowUnmanagedLoopback || record.IsLocal || GatewayRecordEditing.ResolveManagedDistroName(record) is not null) || + Uri.TryCreate(record.Url, UriKind.Absolute, out var uri) && + (uri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/OpenClaw.Connection/GatewayDashboardBinding.cs b/src/OpenClaw.Connection/GatewayDashboardBinding.cs new file mode 100644 index 000000000..5b25a7cc1 --- /dev/null +++ b/src/OpenClaw.Connection/GatewayDashboardBinding.cs @@ -0,0 +1,18 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace OpenClaw.Connection; + +/// Stable endpoint ownership across app restarts, without transferring credentials. +public static class GatewayDashboardBinding +{ + public static string Capture(GatewayRecord record) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new + { + record.Id, record.Url, record.IsLocal, record.SetupManagedDistroName, + EffectiveEndpoint = GatewayClientEndpointResolver.Resolve(record), + SshUser = record.SshTunnel?.User, SshHost = record.SshTunnel?.Host, + SshPort = record.SshTunnel?.SshPort, RemotePort = record.SshTunnel?.RemotePort, + })))); +} diff --git a/src/OpenClaw.Connection/GatewayRegistry.cs b/src/OpenClaw.Connection/GatewayRegistry.cs index dcb8a94ed..cec82de8a 100644 --- a/src/OpenClaw.Connection/GatewayRegistry.cs +++ b/src/OpenClaw.Connection/GatewayRegistry.cs @@ -3,6 +3,8 @@ namespace OpenClaw.Connection; +public sealed record GatewayRegistrySnapshot(IReadOnlyList Records, string? ActiveId); + /// /// Pure data catalog of known gateway endpoints. Persistence only — no runtime state. /// Thread-safe: lock-protected internal list; events fire outside the lock. @@ -16,6 +18,7 @@ public sealed class GatewayRegistry private readonly IOpenClawLogger _logger; private List _records = []; private string? _activeId; + private GatewayRegistrySnapshot _persisted = new([], null); private static readonly JsonSerializerOptions s_jsonOptions = new() { @@ -61,6 +64,100 @@ public string? ActiveGatewayId get { lock (_lock) return _activeId; } } + public GatewayRegistrySnapshot GetSnapshot() + { + lock (_lock) return new(_records.ToArray(), _activeId); + } + + public static bool HasSameSetupAuthority(GatewayRegistrySnapshot left, GatewayRegistrySnapshot right) => + left.ActiveId == right.ActiveId && + left.Records.Select(record => record with { LastConnected = null }).SequenceEqual( + right.Records.Select(record => record with { LastConnected = null })); + + public GatewayRegistrySnapshot CapturePersistedSnapshot() + { + lock (_lock) + { + using var lease = PersistenceFileLease.Acquire(_filePath); + var data = _fs.FileExists(_filePath) + ? JsonSerializer.Deserialize(_fs.ReadAllText(_filePath), s_jsonOptions) + ?? throw new InvalidDataException("The saved Gateway registry is invalid.") + : new RegistryData(); + if (!HasSameSetupAuthority(new(_records, _activeId), new(data.Gateways ?? [], data.ActiveId))) + throw new InvalidOperationException("The Gateway registry has unsaved or external changes. Refresh before setup."); + _persisted = new((data.Gateways ?? []).ToArray(), data.ActiveId); + return new(_records.ToArray(), _activeId); + } + } + + public GatewayRegistrySnapshot ReconcileCompletedSetup( + GatewayRegistrySnapshot baseline, GatewayRegistrySnapshot expectedOutput, string gatewayId) => + ReconcileSetupOutcome(baseline, expectedOutput, gatewayId); + + /// Settles known pipeline/rollback output on every outcome, including an empty registry. + public GatewayRegistrySnapshot ReconcileSetupOutcome( + GatewayRegistrySnapshot baseline, GatewayRegistrySnapshot expectedOutput, string? completedGatewayId = null) => + AdoptPersistedCore(baseline, expectedOutput, completedGatewayId); + + /// Explicit conflict recovery. Only the admitted, unchanged in-memory state may be replaced. + public GatewayRegistrySnapshot AdoptPersistedSnapshot(GatewayRegistrySnapshot expectedMemory) => + AdoptPersistedCore(expectedMemory, null, null); + + private GatewayRegistrySnapshot AdoptPersistedCore( + GatewayRegistrySnapshot baseline, GatewayRegistrySnapshot? expectedOutput, string? completedGatewayId) + { + GatewayRegistrySnapshot snapshot; + bool changed; + lock (_lock) + { + using var lease = PersistenceFileLease.Acquire(_filePath); + if (!HasSameSetupAuthority(new(_records, _activeId), baseline)) + throw new InvalidOperationException("The Gateway registry changed during setup. Refresh before continuing."); + var data = _fs.FileExists(_filePath) + ? JsonSerializer.Deserialize(_fs.ReadAllText(_filePath), s_jsonOptions) + ?? throw new InvalidDataException("The saved Gateway registry is invalid.") + : new RegistryData(); + var records = data.Gateways ?? []; + if (records.Any(record => string.IsNullOrWhiteSpace(record.Id)) || + records.Select(record => record.Id).Distinct(StringComparer.Ordinal).Count() != records.Count || + data.ActiveId is not null && records.All(record => record.Id != data.ActiveId) || + completedGatewayId is not null && data.ActiveId != completedGatewayId || + expectedOutput is not null && !HasSameSetupAuthority(new(records, data.ActiveId), expectedOutput)) + throw new InvalidDataException("The saved Gateway registry does not match this setup operation. Refresh before continuing."); + var current = _records.ToDictionary(record => record.Id); + _records = records.Select(record => + current.TryGetValue(record.Id, out var canonical) && canonical.LastConnected is { } connected && + (record.LastConnected is null || connected > record.LastConnected) + ? record with { LastConnected = connected } : record).ToList(); + _activeId = data.ActiveId; + _persisted = new(records.ToArray(), data.ActiveId); + snapshot = new(_records.ToArray(), _activeId); + changed = !HasSameSetupAuthority(baseline, snapshot); + } + if (changed) Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot.Records, snapshot.ActiveId)); + return snapshot; + } + + /// Applies an operation-owned state atomically; concurrent memory or disk authority is preserved. + public GatewayRegistrySnapshot ReplaceSnapshotAndSave(GatewayRegistrySnapshot expectedMemory, GatewayRegistrySnapshot replacement) + { + GatewayRegistrySnapshot snapshot; + lock (_lock) + { + if (!HasSameSetupAuthority(new(_records, _activeId), expectedMemory)) + throw new InvalidOperationException("The live Gateway registry changed during the operation."); + var previousRecords = _records; + var previousActive = _activeId; + _records = replacement.Records.ToList(); + _activeId = replacement.ActiveId; + try { SaveLocked(expectedMemory); } + catch { _records = previousRecords; _activeId = previousActive; throw; } + snapshot = new(_records.ToArray(), _activeId); + } + Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot.Records, snapshot.ActiveId)); + return snapshot; + } + /// /// Returns the identity directory path for a given gateway ID. /// @@ -69,6 +166,29 @@ public string GetIdentityDirectory(string gatewayId) return Path.Combine(_gatewaysDir, gatewayId); } + /// Deletes only an operation's unchanged new key file after confirming no live or saved record adopted it. + public bool RemoveUnregisteredIdentity(string gatewayId, DeviceIdentityReplacementTransaction creation) + { + if (!Guid.TryParse(gatewayId, out _)) + throw new ArgumentException("Candidate identity requires a generated gateway ID.", nameof(gatewayId)); + lock (_lock) + { + using var lease = PersistenceFileLease.Acquire(_filePath); + var disk = _fs.FileExists(_filePath) + ? JsonSerializer.Deserialize(_fs.ReadAllText(_filePath), s_jsonOptions) + ?? throw new InvalidDataException("The saved Gateway registry is invalid.") + : new RegistryData(); + if (_records.Any(record => record.Id == gatewayId) || disk.Gateways?.Any(record => record.Id == gatewayId) == true) + return false; + var directory = GetIdentityDirectory(gatewayId); + var key = Path.GetFullPath(Path.Combine(directory, "device-key-ed25519.json")); + if (!string.Equals(key, Path.GetFullPath(creation.IdentityPath), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + throw new ArgumentException("The creation transaction does not belong to this candidate.", nameof(creation)); + return DeviceIdentity.RemoveCreatedIdentity(creation); + } + } + // ─── Mutate ─── public GatewayRecord AddOrUpdate(GatewayRecord record) @@ -161,6 +281,7 @@ public void SetActive(string? gatewayId) try { SaveLocked(); + updated = _records[idx]; } catch { @@ -178,15 +299,32 @@ public void SetActive(string? gatewayId) // ─── Persistence ─── - public void Save() + public void Save() => Save(expected: null); + + public void Save(GatewayRegistrySnapshot? expected) { lock (_lock) - SaveLocked(); + SaveLocked(expected); } - private void SaveLocked() + private void SaveLocked(GatewayRegistrySnapshot? expected = null) { - var data = new RegistryData { Gateways = _records.ToList(), ActiveId = _activeId }; + using var lease = PersistenceFileLease.Acquire(_filePath); + var disk = _fs.FileExists(_filePath) + ? JsonSerializer.Deserialize(_fs.ReadAllText(_filePath), s_jsonOptions) + ?? throw new InvalidDataException("The saved Gateway registry is invalid.") + : new RegistryData(); + var diskSnapshot = new GatewayRegistrySnapshot(disk.Gateways ?? [], disk.ActiveId); + if (!HasSameSetupAuthority(diskSnapshot, _persisted) || + expected is not null && !HasSameSetupAuthority(diskSnapshot, expected)) + throw new InvalidOperationException("The saved Gateway registry changed. Reload before saving."); + var diskById = diskSnapshot.Records.ToDictionary(record => record.Id); + var records = _records.Select(record => + diskById.TryGetValue(record.Id, out var current) && + (record with { LastConnected = null }) == (current with { LastConnected = null }) && + current.LastConnected is { } stamp && (record.LastConnected is null || stamp > record.LastConnected) + ? record with { LastConnected = stamp } : record).ToList(); + var data = new RegistryData { Gateways = records, ActiveId = _activeId }; var json = JsonSerializer.Serialize(data, s_jsonOptions); var dir = Path.GetDirectoryName(_filePath); @@ -199,7 +337,9 @@ private void SaveLocked() try { _fs.WriteAllText(tempPath, json); - File.Move(tempPath, _filePath, overwrite: true); + _fs.MoveFile(tempPath, _filePath, overwrite: true); + _records = records; + _persisted = new(records.ToArray(), _activeId); } catch { @@ -223,25 +363,31 @@ private void TryDeleteTempFile(string tempPath) public void Load() { - if (!_fs.FileExists(_filePath)) - return; - - try + lock (_lock) { - var json = _fs.ReadAllText(_filePath); - var data = JsonSerializer.Deserialize(json, s_jsonOptions); - if (data != null) + using var lease = PersistenceFileLease.Acquire(_filePath); + if (!_fs.FileExists(_filePath)) + { + _records = []; + _activeId = null; + _persisted = new([], null); + return; + } + try { - lock (_lock) + var json = _fs.ReadAllText(_filePath); + var data = JsonSerializer.Deserialize(json, s_jsonOptions); + if (data != null) { _records = data.Gateways ?? []; _activeId = data.ActiveId; + _persisted = new(_records.ToArray(), _activeId); } } - } - catch (JsonException ex) - { - _logger.Warn($"Gateway registry file '{_filePath}' is not valid JSON; starting with an empty registry. {ex.Message}"); + catch (JsonException ex) + { + _logger.Warn($"Gateway registry file '{_filePath}' is not valid JSON; starting with an empty registry. {ex.Message}"); + } } } diff --git a/src/OpenClaw.Connection/GatewayValidationIdentity.cs b/src/OpenClaw.Connection/GatewayValidationIdentity.cs new file mode 100644 index 000000000..4942691d2 --- /dev/null +++ b/src/OpenClaw.Connection/GatewayValidationIdentity.cs @@ -0,0 +1,89 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Owns a disposable copy of a gateway identity. Validation may initialize or read this copy, +/// but never writes the saved gateway's key or tokens. +/// +public sealed class GatewayValidationIdentity : IDisposable +{ + private const string FileName = "device-key-ed25519.json"; + private readonly string? _originalJson; + public string DirectoryPath { get; } + private readonly Dictionary _tokens = new(StringComparer.Ordinal); + internal DeviceTokenReceivedEventArgs? OperatorCredential => _tokens.GetValueOrDefault("operator"); + internal bool UseBoundedBootstrapScopes { get; set; } + internal bool OperatorTokenRecoveryAttempted { get; private set; } + public bool UseV2Signature { get; internal set; } + + internal bool RejectStoredOperatorToken(string rejectedToken) + { + if (OperatorTokenRecoveryAttempted || OperatorCredential is not null || + DeviceIdentity.TryReadStoredDeviceToken(DirectoryPath) != rejectedToken) + return false; + OperatorTokenRecoveryAttempted = true; + return DeviceIdentity.TryClearDeviceToken(DirectoryPath); + } + + internal void CaptureToken(DeviceTokenReceivedEventArgs token) + { + if (token.Role is "operator" or "node" && !string.IsNullOrWhiteSpace(token.Token)) + _tokens[token.Role] = new(token.Token, token.Scopes?.ToArray(), token.Role); + } + + public GatewayValidationIdentity(string? sourceDirectory = null) + { + DirectoryPath = Path.Combine(Path.GetTempPath(), "openclaw-connection-check-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(DirectoryPath); + try + { + if (sourceDirectory is not null) + { + var source = Path.Combine(sourceDirectory, FileName); + if (File.Exists(source)) + { + _originalJson = File.ReadAllText(source); + DeviceIdentity.AtomicWriteKeyFileRaw(Path.Combine(DirectoryPath, FileName), _originalJson); + } + } + } + catch + { + Dispose(); + throw; + } + } + + public DeviceIdentityReplacementTransaction CopyTo(string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + return DeviceIdentity.ReplaceValidatedIdentity(destinationDirectory, null, CreateCommittedJson()); + } + + public DeviceIdentityReplacementTransaction ReplaceExisting(string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + return DeviceIdentity.ReplaceValidatedIdentity(destinationDirectory, _originalJson, CreateCommittedJson()); + } + + private string CreateCommittedJson() + { + using var committed = new GatewayValidationIdentity(DirectoryPath); + if (_tokens.Count > 0) + { + var identity = new DeviceIdentity(committed.DirectoryPath); + identity.Initialize(); + foreach (var token in _tokens.Values) + identity.StoreDeviceTokenForRole(token.Role, token.Token, token.Scopes); + } + return File.ReadAllText(Path.Combine(committed.DirectoryPath, FileName)); + } + + public void Dispose() + { + _tokens.Clear(); + if (Directory.Exists(DirectoryPath)) + Directory.Delete(DirectoryPath, recursive: true); + } +} diff --git a/src/OpenClaw.Connection/IGatewayConnectionManager.cs b/src/OpenClaw.Connection/IGatewayConnectionManager.cs index 0dc94a65f..2deee61df 100644 --- a/src/OpenClaw.Connection/IGatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/IGatewayConnectionManager.cs @@ -20,6 +20,11 @@ public interface IGatewayConnectionManager : IDisposable, IAsyncDisposable // ─── Lifecycle ─── Task ConnectAsync(string? gatewayId = null); + Task ConnectAsync(string? gatewayId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return ConnectAsync(gatewayId); + } Task ConnectNodeOnlyAsync(string? gatewayId = null); Task DisconnectAsync(); Task DisconnectByUserAsync(); @@ -66,6 +71,12 @@ Task RestartSshTunnelAsync(CancellationToken cancellationToken = default) Task EnsureNodeConnectedAsync(CancellationToken cancellationToken = default); // ─── Setup ─── + Task ValidateConnectionAsync( + GatewayRecord candidate, GatewayValidationIdentity identity, + CancellationToken cancellationToken = default) => + Task.FromResult(new SetupCodeResult( + SetupCodeOutcome.ConnectionFailed, "Native connection validation is unavailable.")); + Task ApplySetupCodeAsync(string setupCode, SshTunnelConfig? sshTunnel = null); Task ConnectWithSharedTokenAsync(string gatewayUrl, string token, SshTunnelConfig? sshTunnel = null); diff --git a/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs b/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs index 3828b4cc6..cc43b7214 100644 --- a/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs +++ b/src/OpenClaw.Connection/LocalAi/LlamaServerRuntimeService.cs @@ -1598,7 +1598,10 @@ private LocalAiRuntimeSnapshot Publish( _install?.Manifest.KeyCachePrecision, _install?.Manifest.ValueCachePrecision, _install?.Manifest.DraftKeyCachePrecision, - _install?.Manifest.DraftValueCachePrecision); + _install?.Manifest.DraftValueCachePrecision) + { + GatewayRouteRequiresResolution = _gatewayRouteRequiresResolution, + }; lock (_snapshotGate) _snapshot = value; diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiRuntimeModels.cs b/src/OpenClaw.Connection/LocalAi/LocalAiRuntimeModels.cs index 1c66dd5c3..0d32877f2 100644 --- a/src/OpenClaw.Connection/LocalAi/LocalAiRuntimeModels.cs +++ b/src/OpenClaw.Connection/LocalAi/LocalAiRuntimeModels.cs @@ -111,6 +111,9 @@ public sealed record LocalAiRuntimeSnapshot( KvCachePrecision? DraftKeyCachePrecision = null, KvCachePrecision? DraftValueCachePrecision = null) { + /// False only when the runtime confirmed publication or terminal route cleanup. + public bool GatewayRouteRequiresResolution { get; init; } = true; + public static LocalAiRuntimeSnapshot Initial(Uri endpoint, DateTimeOffset now) => new( LocalAiRuntimeState.Stopped, diff --git a/src/OpenClaw.Connection/SetupCodeResult.cs b/src/OpenClaw.Connection/SetupCodeResult.cs index e3b750192..1650de9b4 100644 --- a/src/OpenClaw.Connection/SetupCodeResult.cs +++ b/src/OpenClaw.Connection/SetupCodeResult.cs @@ -1,3 +1,5 @@ +using OpenClaw.Shared; + namespace OpenClaw.Connection; /// @@ -7,7 +9,8 @@ public sealed record SetupCodeResult( SetupCodeOutcome Outcome, string? ErrorMessage = null, string? GatewayUrl = null, - bool GatewayCommitted = false); + bool GatewayCommitted = false, + GatewayErrorKind ErrorKind = GatewayErrorKind.Unknown); public enum SetupCodeOutcome { diff --git a/src/OpenClaw.SetupEngine.UI/Controls/LocalAiSetupControl.xaml b/src/OpenClaw.SetupEngine.UI/Controls/LocalAiSetupControl.xaml new file mode 100644 index 000000000..115c9d499 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Controls/LocalAiSetupControl.xaml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Controls/LocalAiSetupControl.xaml.cs b/src/OpenClaw.SetupEngine.UI/Controls/LocalAiSetupControl.xaml.cs new file mode 100644 index 000000000..3d4bfe833 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Controls/LocalAiSetupControl.xaml.cs @@ -0,0 +1,560 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using OpenClaw.Shared; +using OpenClaw.Shared.Inference; +using OpenClaw.Shared.Inference.Catalog; +using OpenClaw.SetupEngine.UI.Pages; +using System.Diagnostics; + +namespace OpenClaw.SetupEngine.UI.Controls; + +public sealed partial class LocalAiSetupControl : UserControl +{ + private SetupConfig? _config; + private SetupAccessDraft? _draft; + private SetupWindow? _setupWindow; + private bool _suppressLocalAiToggle; + private bool _suppressLocalAiSelection; + private bool _localAiSelectionEligible; + private bool _localAiNetworkingConsentRequired; + private HostHardwareInfo? _localAiHardware; + private string? _localAiRecommendedModelId; + private WslGlobalConfigStatus? _localAiNetworkingStatus; + private string _localAiUnavailableReason = string.Empty; + private readonly LocalAiSetupAvailabilityCoordinator _localAiAvailability = new(); + private bool _forceLocalAiNetworkingConsent; + private bool _localAiRecoveryOnly; + private bool _localAiRecoveryModelPinned; + + public event EventHandler? StateChanged; + + public LocalAiSetupControl() + { + InitializeComponent(); + Unloaded += (_, _) => Deactivate(); + } + + internal void Initialize(SetupAccessDraft draft, bool recovery, bool pinModel) + { + _draft = draft; + _config = draft.Config; + _setupWindow = SetupWindow.Active; + _localAiRecoveryOnly = recovery; + _localAiRecoveryModelPinned = pinModel; + _forceLocalAiNetworkingConsent = SetupPreview.RequestedPage == "capabilities-review-consent"; + AsyncEventHandlerGuard.Run( + () => InitializeLocalAiReviewAsync(_forceLocalAiNetworkingConsent), + NullLogger.Instance, nameof(InitializeLocalAiReviewAsync)); + } + + internal void Deactivate() + { + _localAiAvailability.CancelCurrent(); + _setupWindow = null; + } + + private async Task InitializeLocalAiReviewAsync( + bool forceNetworkingConsent, + bool refreshHardwareProbe = false, + LocalAiSetupAvailabilitySnapshot? startedAvailability = null) + { + LocalAiSetupAvailabilitySnapshot checking = + startedAvailability ?? _localAiAvailability.StartProbe(); + ShowLocalAiAvailabilityChecking(checking); + SetupWindow? setupWindow = _setupWindow; + Task hardwareTask = setupWindow is not null + ? setupWindow.GetLocalAiHardwareAsync(forceRefresh: refreshHardwareProbe) + : Task.Run(() => new CudaHostHardwareProbe().Probe()); + + string? hardwareReason = null; + LocalInferenceEligibilityResult? eligibility = null; + try + { + HostHardwareInfo hardware = await hardwareTask; + if (!CanApplyLocalAiAvailability(checking.Generation, setupWindow)) + return; + _localAiHardware = hardware; + + // Gate on device-level eligibility (the best catalog model this hardware can run), + // not the currently configured model. A stale/removed SelectedModelId must not make + // an otherwise-capable device look unavailable and hide the badge/option; it only + // means the configured model needs to fall back to a valid one below. + LocalInferenceEligibilityResult deviceEligibility = LocalInferenceEligibility.Evaluate(_localAiHardware); + if (deviceEligibility.FailureCode == LocalInferenceEligibilityFailureCode.HardwareFactsIncomplete) + { + // Incomplete facts (a partial/transient CUDA read) are inconclusive, not a + // definitive "this device cannot run Local AI". Report it the same way as a + // thrown probe failure so recheck stays available instead of the option being + // permanently disabled. + if (_localAiAvailability.TryApplyProbeFailure( + checking.Generation, + LocalAiProbeFailureReason, + out var incompleteSnapshot)) + { + ShowLocalAiProbeUnknown(incompleteSnapshot); + } + return; + } + _localAiRecommendedModelId = deviceEligibility.CanInstall + ? deviceEligibility.Plan?.Model.Id + : null; + + // A SKU with no recommended default (RTX Spark 32 GB) still runs an already + // configured model. Gate availability on that configured selection when there is + // one, so rerunning setup does not switch Local AI off on a working machine. + // _localAiRecommendedModelId stays null so nothing is labelled Recommended. + LocalInferenceEligibilityResult availability = + LocalInferenceEligibility.EvaluateForConfiguredAvailability( + _localAiHardware, + _config!.LocalAi.SelectedModelId); + + if (!availability.CanInstall || availability.Plan is null || availability.SelectedGpu is null) + { + hardwareReason = DescribeLocalAiUnavailable(availability); + } + else + { + // The device can run Local AI. Reconcile the configured model selection: a + // model that no longer exists in the catalog, or exists but this specific + // hardware cannot run at all (e.g. the config was moved to a machine with a + // smaller GPU), falls back to the recommended (or the device-eligible default) + // model instead of leaving setup stuck on a known-incompatible selection. A + // merely busy GPU (EligibleButBusy) is not reconciled away: the same model would + // still work once the GPU frees up, and CanInstall already covers that case. + if (_config.LocalAi.SelectedModelId is { } selectedModelId) + { + LocalInferenceEligibilityResult selectedEligibility = + LocalInferenceEligibility.Evaluate(_localAiHardware, selectedModelId); + if (_localAiRecoveryModelPinned) + { + eligibility = selectedEligibility; + } + else if (!selectedEligibility.CanInstall) + { + _config.LocalAi.SelectedModelId = null; + } + } + _config.LocalAi.SelectedModelId ??= _localAiRecommendedModelId ?? availability.Plan.Model.Id; + + eligibility ??= LocalInferenceEligibility.Evaluate( + _localAiHardware, + _config.LocalAi.SelectedModelId); + if (_localAiRecoveryModelPinned && !eligibility.CanInstall) + hardwareReason = DescribeLocalAiUnavailable(eligibility); + } + } + catch (Exception ex) + { + // Trace.TraceWarning is not compiled out in Release (unlike Debug.WriteLine) and its + // default listener forwards to OutputDebugString, so this stays visible via + // DebugView/ETW instead of silently disappearing in a packaged build. + Trace.TraceWarning($"Local AI hardware probe failed: {ex}"); + if (_localAiAvailability.TryApplyProbeFailure( + checking.Generation, + LocalAiProbeFailureReason, + out var unavailableSnapshot)) + { + ShowLocalAiProbeUnknown(unavailableSnapshot); + } + return; + } + + string? wslNetworkingReason = null; + try + { + WslGlobalConfigStatus networkingStatus = forceNetworkingConsent + ? new(false, false) + : CreateWslGlobalConfigManager().Inspect(); + if (!CanApplyLocalAiAvailability(checking.Generation, setupWindow)) + return; + _localAiNetworkingStatus = networkingStatus; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + Trace.TraceWarning($"WSL networking inspection failed: {ex}"); + wslNetworkingReason = SetupLocalization.GetString("Onboarding_LocalAi_WslConfigReadFailureReason"); + } + + if (!CanApplyLocalAiAvailability(checking.Generation, setupWindow)) + return; + + string? unavailableReason = LocalAiAvailabilityReasons.Build( + hardwareReason, + wslNetworkingReason); + if (unavailableReason is not null) + { + if (_localAiAvailability.TryApplyUnsupported( + checking.Generation, + unavailableReason, + out var unavailableSnapshot)) + { + ShowLocalAiUnavailable(unavailableSnapshot); + } + return; + } + + if (!_localAiAvailability.TryApplyAvailable(checking.Generation, out var availableSnapshot)) + return; + Debug.Assert(eligibility is not null); + ApplyLocalAiAvailabilityChrome(availableSnapshot); + LocalAiInstallReviewCard.Visibility = Visibility.Visible; + LocalAiToggle.Visibility = Visibility.Visible; + SetLocalAiOptionAvailability(isAvailable: true); + _localAiSelectionEligible = eligibility.CanInstall; + _config!.LocalAi.SelectedModelId ??= eligibility.Plan!.Model.Id; + _config.LocalAi.SelectedProfileId = eligibility.Plan!.Profile.Id; + PopulateLocalAiModels(); + _suppressLocalAiToggle = true; + LocalAiToggle.IsOn = _config!.LocalAi.Enabled; + _suppressLocalAiToggle = false; + UpdateLocalAiOptions(forceNetworkingConsent); + PublishState(); + } + + private static string LocalAiProbeFailureReason => + SetupLocalization.GetString("Onboarding_LocalAi_ProbeFailureReason"); + + private bool CanApplyLocalAiAvailability(int generation, SetupWindow? setupWindow) => + _localAiAvailability.IsCurrent(generation) && + (_setupWindow is not null || setupWindow is null); + + private static WslGlobalConfigManager CreateWslGlobalConfigManager() + { + var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var configPath = Path.Combine(profile, ".wslconfig"); + var localDataDir = SetupWindow.Active?.LocalDataDir ?? SetupContext.ResolveLocalDataDir(); + return new WslGlobalConfigManager( + configPath, + Path.Combine(localDataDir, "LocalAI", "network-backup")); + } + + private void ShowLocalAiAvailabilityChecking(LocalAiSetupAvailabilitySnapshot snapshot) + { + ApplyLocalAiAvailabilityChrome(snapshot); + _localAiSelectionEligible = false; + _suppressLocalAiToggle = true; + LocalAiToggle.IsOn = _config!.LocalAi.Enabled; + _suppressLocalAiToggle = false; + LocalAiToggle.Visibility = Visibility.Visible; + LocalAiDetailsPanel.Visibility = Visibility.Collapsed; + LocalAiInstallReviewCard.Visibility = Visibility.Visible; + SetLocalAiOptionAvailability( + isAvailable: false, + SetupLocalization.GetString("Onboarding_LocalAi_CheckingHelpText")); + RestoreLocalAiToggleAsPendingStateEscapeHatch(); + PublishState(); + UpdatePrimaryButtonState(); + } + + private void ShowLocalAiProbeUnknown(LocalAiSetupAvailabilitySnapshot snapshot) + { + ApplyLocalAiAvailabilityChrome(snapshot); + _localAiSelectionEligible = false; + _suppressLocalAiToggle = true; + LocalAiToggle.IsOn = _config!.LocalAi.Enabled; + _suppressLocalAiToggle = false; + LocalAiToggle.Visibility = Visibility.Visible; + LocalAiDetailsPanel.Visibility = Visibility.Collapsed; + LocalAiInstallReviewCard.Visibility = Visibility.Visible; + SetLocalAiOptionAvailability( + isAvailable: false, + SetupLocalization.GetString("Onboarding_LocalAi_ProbeUnknownHelpText")); + RestoreLocalAiToggleAsPendingStateEscapeHatch(); + PublishState(); + UpdatePrimaryButtonState(); + } + + /// + /// SetLocalAiOptionAvailability(isAvailable: false) sets LocalAiOptionContent.IsHitTestVisible + /// to false, which suppresses pointer input for its entire subtree regardless of any + /// descendant's own IsEnabled value; setting LocalAiToggle.IsEnabled back to true alone would + /// not make it clickable. Availability being merely pending (Checking/ProbeUnknown), not yet a + /// definitive result, must not remove the user's only way out, so this restores hit-testing on + /// the shared container and re-enables just the toggle outside recovery: turning Local AI off + /// unblocks Continue via the existing LocalAiToggle.IsOn != true branch, instead of ever + /// letting Continue itself bypass an as-yet-undetermined WSL networking-consent requirement. + /// Recovery requires Local AI to remain selected, so its toggle stays disabled. The other + /// Local AI controls (model selector, consent checkbox) stay genuinely non-interactive because + /// their own IsEnabled is still false, independent of the container's hit-testability. + /// + private void RestoreLocalAiToggleAsPendingStateEscapeHatch() + { + LocalAiOptionContent.IsHitTestVisible = true; + LocalAiToggle.IsEnabled = !_localAiRecoveryOnly; + } + + private void ShowLocalAiUnavailable(LocalAiSetupAvailabilitySnapshot snapshot) + { + _localAiSelectionEligible = false; + _suppressLocalAiToggle = true; + LocalAiToggle.IsOn = _config!.LocalAi.Enabled; + _suppressLocalAiToggle = false; + LocalAiToggle.Visibility = Visibility.Visible; + LocalAiDetailsPanel.Visibility = Visibility.Collapsed; + ApplyLocalAiAvailabilityChrome(snapshot); + LocalAiInstallReviewCard.Visibility = Visibility.Visible; + SetLocalAiOptionAvailability(isAvailable: false); + RestoreLocalAiToggleAsPendingStateEscapeHatch(); + PublishState(); + UpdatePrimaryButtonState(); + } + + internal static string DescribeLocalAiUnavailable(LocalInferenceEligibilityResult eligibility) + { + LocalInferenceUnavailableReason reason = LocalInferenceEligibilityDiagnostics.GetUnavailableReason(eligibility); + return reason.Kind switch + { + LocalInferenceUnavailableReasonKind.RuntimeUnavailable => + SetupLocalization.GetString("LocalAi_Reason_RuntimeUnavailable"), + LocalInferenceUnavailableReasonKind.NoNvidiaGpu => + SetupLocalization.GetString("LocalAi_Reason_NoNvidiaGpu"), + LocalInferenceUnavailableReasonKind.UnknownModel => + SetupLocalization.GetString("LocalAi_Reason_UnknownModel"), + LocalInferenceUnavailableReasonKind.HardwareFactsIncomplete => + SetupLocalization.GetString("LocalAi_Reason_HardwareFactsIncomplete"), + LocalInferenceUnavailableReasonKind.InsufficientGpuMemory => + SetupLocalization.Format( + "LocalAi_Reason_InsufficientGpuMemory", + reason.ModelDisplayName ?? SetupLocalization.GetString("LocalAi_Reason_UnknownModelName"), + FormatGigabytes(reason.RequiredGigabytes), + reason.DetectedGigabytes is { } detected + ? FormatGigabytes(detected) + : SetupLocalization.GetString("LocalAi_Reason_UnknownMemoryAmount")), + LocalInferenceUnavailableReasonKind.DriverTooOld => + SetupLocalization.Format( + "LocalAi_Reason_DriverTooOld", + reason.DetectedDriverVersion ?? SetupLocalization.GetString("LocalAi_Reason_UnknownDriverVersion"), + reason.MinimumDriverVersion), + LocalInferenceUnavailableReasonKind.CudaCapabilityTooLow => + SetupLocalization.GetString("LocalAi_Reason_CudaCapabilityTooLow"), + _ => SetupLocalization.GetString("LocalAi_Reason_Generic"), + }; + } + + private static string FormatGigabytes(double gigabytes) => + SetupLocalization.Format("LocalAi_Reason_GigabytesFormat", gigabytes); + + private void ApplyLocalAiAvailabilityChrome(LocalAiSetupAvailabilitySnapshot snapshot) + { + _localAiUnavailableReason = snapshot.Reason ?? string.Empty; + LocalAiUnavailablePanel.Visibility = + snapshot.IsAvailable ? Visibility.Collapsed : Visibility.Visible; + LocalAiUnavailablePanel.Title = snapshot.Status switch + { + LocalAiSetupAvailabilityStatus.Checking => + SetupLocalization.GetString("Onboarding_LocalAi_CheckingTitle"), + LocalAiSetupAvailabilityStatus.Unknown => + SetupLocalization.GetString("Onboarding_LocalAi_ProbeUnknownTitle"), + _ => SetupLocalization.GetString("Onboarding_LocalAi_UnavailableTitle"), + }; + LocalAiUnavailablePanel.Message = snapshot.Status switch + { + LocalAiSetupAvailabilityStatus.Checking => + SetupLocalization.GetString("Onboarding_LocalAi_CheckingMessage"), + LocalAiSetupAvailabilityStatus.Unknown => + SetupLocalization.GetString("Onboarding_LocalAi_ProbeUnknownMessage"), + _ => SetupLocalization.GetString("Onboarding_LocalAi_UnavailableMessage"), + }; + LocalAiUnavailableDetailsButton.Visibility = + string.IsNullOrWhiteSpace(_localAiUnavailableReason) ? Visibility.Collapsed : Visibility.Visible; + LocalAiAvailabilityRecoveryPanel.Visibility = + snapshot.IsChecking || snapshot.IsUnknown ? Visibility.Visible : Visibility.Collapsed; + LocalAiAvailabilityProgressRing.IsActive = snapshot.IsChecking; + LocalAiAvailabilityProgressRing.Visibility = + snapshot.IsChecking ? Visibility.Visible : Visibility.Collapsed; + LocalAiRecheckAvailabilityButton.Visibility = + snapshot.IsUnknown ? Visibility.Visible : Visibility.Collapsed; + LocalAiRecheckAvailabilityButton.IsEnabled = snapshot.CanRecheck; + } + + private void SetLocalAiOptionAvailability(bool isAvailable, string? helpText = null) + { + LocalAiOptionContent.IsHitTestVisible = isAvailable; + LocalAiOptionContent.Opacity = isAvailable ? 1 : 0.55; + LocalAiToggle.IsEnabled = isAvailable && !_localAiRecoveryOnly; + LocalAiModelSelector.IsEnabled = isAvailable && !_localAiRecoveryModelPinned; + AutomationProperties.SetHelpText( + LocalAiOptionContent, + isAvailable + ? string.Empty + : helpText ?? SetupLocalization.GetString("Onboarding_LocalAi_UnavailableHelpText")); + } + + private void LocalAiRecheckAvailability_Click(object sender, RoutedEventArgs e) => + AsyncEventHandlerGuard.Run( + RecheckLocalAiAvailabilityAsync, + NullLogger.Instance, + nameof(LocalAiRecheckAvailability_Click)); + + private Task RecheckLocalAiAvailabilityAsync() + { + if (!_localAiAvailability.TryStartRecheck(out var snapshot)) + { + ApplyLocalAiAvailabilityChrome(snapshot); + return Task.CompletedTask; + } + + return InitializeLocalAiReviewAsync( + forceNetworkingConsent: _forceLocalAiNetworkingConsent, + refreshHardwareProbe: true, + startedAvailability: snapshot); + } + + private void LocalAiUnavailableDetails_Click(object sender, RoutedEventArgs e) + { + LocalAiUnavailableReasonText.Text = _localAiUnavailableReason; + LocalAiUnavailableReasonText.Visibility = Visibility.Visible; + } + + private void Networking_Click(object sender, RoutedEventArgs e) => + _setupWindow?.NavigateToWslNetworking(); + + private void PopulateLocalAiModels() + { + _suppressLocalAiSelection = true; + LocalAiModelSelector.Items.Clear(); + int selectedIndex = 0; + (LocalModelInfo Model, LocalInferencePlan Plan)[] fittingModels = LocalModelCatalog.Models + .Select(model => (Model: model, Eligibility: LocalInferenceEligibility.Evaluate(_localAiHardware!, model.Id))) + .Where(candidate => candidate.Eligibility.CanInstall && candidate.Eligibility.Plan is not null) + .Select(candidate => (candidate.Model, candidate.Eligibility.Plan!)) + .ToArray(); + for (int index = 0; index < fittingModels.Length; index++) + { + (LocalModelInfo model, LocalInferencePlan plan) = fittingModels[index]; + bool isRecommended = string.Equals( + _localAiRecommendedModelId, + model.Id, + StringComparison.OrdinalIgnoreCase); + LocalAiModelSelector.Items.Add(new ComboBoxItem + { + Content = $"{SetupReviewSummaryBuilder.DisplayModelName(model)} " + + $"({FormatSize(LocalModelCatalog.TotalDownloadSizeBytes(model))}, " + + $"{FormatContext(plan.Profile.ContextTokens)}, " + + $"{LocalModelCatalog.ToDisplayCacheType(plan.Profile.KeyCachePrecision)} KV)" + + (isRecommended ? $" ({SetupLocalization.GetString("Onboarding_V2_Recommended")})" : string.Empty), + Tag = model.Id, + }); + string? selectedModelId = _config!.LocalAi.SelectedModelId ?? _localAiRecommendedModelId; + if (string.Equals(selectedModelId, model.Id, StringComparison.OrdinalIgnoreCase)) + selectedIndex = index; + } + LocalAiModelSelector.SelectedIndex = selectedIndex; + _suppressLocalAiSelection = false; + } + + private void LocalAiToggle_Toggled(object sender, RoutedEventArgs e) + { + if (_suppressLocalAiToggle || _config is null) + return; + UpdateLocalAiOptions(); + PublishState(); + } + + private void LocalAiModelSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressLocalAiSelection || _config is null || + LocalAiModelSelector.SelectedItem is not ComboBoxItem { Tag: string modelId }) + { + return; + } + _config.LocalAi.SelectedModelId = modelId; + UpdateLocalAiModelDetails(); + PublishState(); + } + + private void UpdateLocalAiOptions(bool forceNetworkingConsent = false) + { + var config = _config!; + bool enabled = LocalAiToggle.IsOn == true; + _draft!.SetLocalAiEnabled(enabled); + LocalAiDetailsPanel.Visibility = enabled ? Visibility.Visible : Visibility.Collapsed; + LocalAiNetworkingInspectionError.Visibility = Visibility.Collapsed; + _localAiNetworkingConsentRequired = false; + + if (!enabled) + { + LocalAiNetworkingConsentPanel.Visibility = Visibility.Collapsed; + UpdatePrimaryButtonState(); + return; + } + + UpdateLocalAiModelDetails(); + WslGlobalConfigStatus status = forceNetworkingConsent + ? new(false, false) + : _localAiNetworkingStatus ?? new(false, false); + _localAiNetworkingConsentRequired = !status.IsMirrored; + LocalAiNetworkingConsentPanel.Visibility = _localAiNetworkingConsentRequired + ? Visibility.Visible + : Visibility.Collapsed; + UpdatePrimaryButtonState(); + } + + private void UpdateLocalAiModelDetails() + { + if (_localAiHardware is null || + LocalAiModelSelector.SelectedItem is not ComboBoxItem { Tag: string modelId }) + { + return; + } + + LocalInferenceEligibilityResult eligibility = LocalInferenceEligibility.Evaluate(_localAiHardware, modelId); + if (eligibility.Plan is not { } plan || eligibility.SelectedGpu is not { } gpu) + { + _localAiSelectionEligible = false; + _config!.LocalAi.SelectedProfileId = null; + LocalAiHardwareStatusText.Text = SetupLocalization.GetString("Onboarding_V2_ModelNotQualified"); + UpdatePrimaryButtonState(); + return; + } + + _localAiSelectionEligible = eligibility.CanInstall; + _config!.LocalAi.SelectedProfileId = plan.Profile.Id; + LocalAiHardwareStatusText.Text = eligibility.Status switch + { + LocalInferenceEligibilityStatus.Eligible or LocalInferenceEligibilityStatus.EligibleButBusy => + SetupLocalization.Format("Onboarding_V2_HardwareMemory", + FormatMemorySize(eligibility.RequiredTotalMemoryBytes), + FormatOptionalMemorySize(eligibility.DetectedTotalMemoryBytes), gpu.Name), + _ => DescribeLocalAiUnavailable(eligibility), + }; + LocalAiEngineDetailText.Text = SetupLocalization.Format("Onboarding_V2_EngineDownload", + FormatSize(plan.Runtime.Artifacts.Sum(artifact => artifact.SizeBytes))); + LocalAiModelDetailText.Text = SetupLocalization.Format("Onboarding_V2_ModelDownload", + SetupReviewSummaryBuilder.DisplayModelName(plan.Model), FormatSize(LocalModelCatalog.TotalDownloadSizeBytes(plan.Model))); + UpdatePrimaryButtonState(); + } + + private void UpdatePrimaryButtonState() => PublishState(); + + private void PublishState() + { + if (_draft is null) + return; + _draft.LocalAiReady = _localAiSelectionEligible; + _draft.LocalAiNetworkingConsentRequired = _localAiNetworkingConsentRequired; + NetworkingButton.Content = SetupLocalization.GetString( + _draft.Config.LocalAi.WslMirroredNetworkingConsent + ? "Onboarding_V2_NetworkConsentRecorded" + : "Onboarding_V2_ReviewNetworking"); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private static string FormatSize(long bytes) => + $"{bytes / 1_000_000_000d:0.#} GB"; + + private static string FormatMemorySize(long bytes) => + $"{bytes / (1024d * 1024d * 1024d):0.#} GiB"; + + private static string FormatOptionalMemorySize(long? bytes) => + bytes is { } value ? FormatMemorySize(value) : SetupLocalization.GetString("LocalAi_Reason_UnknownMemoryAmount"); + + private static string FormatContext(int tokens) => + tokens % 1024 == 0 + ? $"{tokens / 1024}K" + : tokens % 1000 == 0 + ? $"{tokens / 1000}K" + : SetupLocalization.Format("Onboarding_V2_Tokens", tokens); + +} diff --git a/src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascot.cs b/src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascot.cs new file mode 100644 index 000000000..11c5556c3 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascot.cs @@ -0,0 +1,278 @@ +// Adapted from OpenClaw's MIT-licensed Mac mascot. See Assets/Setup/Mascot-NOTICE.txt. +using System.Diagnostics; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Input; +using Windows.UI.ViewManagement; + +namespace OpenClaw.SetupEngine.UI.Controls; + +/// +/// Decorative native vector mascot. The containing page owns accessible status text. +/// Size with Width/Height; Mood selects the expression. No work runs before Loaded. +/// +public sealed class OnboardingMascot : UserControl +{ + // Match the approved 128-DIP artwork optically, retaining the 24-unit motion/glow gutter. + // A 180-DIP native frame renders the 120/168 artwork at approximately 129 DIPs. + public const double HeroSize = 180; + public static readonly DependencyProperty MoodProperty = DependencyProperty.Register( + nameof(Mood), typeof(OnboardingMascotMood), typeof(OnboardingMascot), + new PropertyMetadata(OnboardingMascotMood.Idle, OnMoodChanged)); + + public static readonly DependencyProperty IsAnimationEnabledProperty = DependencyProperty.Register( + nameof(IsAnimationEnabled), typeof(bool), typeof(OnboardingMascot), + new PropertyMetadata(true, OnAnimationEnabledChanged)); + + public static readonly DependencyProperty AccessoryProperty = DependencyProperty.Register( + nameof(Accessory), typeof(OnboardingMascotAccessory), typeof(OnboardingMascot), + new PropertyMetadata(OnboardingMascotAccessory.None, OnAccessoryChanged)); + + public static readonly DependencyProperty IsInteractiveProperty = DependencyProperty.Register( + nameof(IsInteractive), typeof(bool), typeof(OnboardingMascot), + new PropertyMetadata(true, OnInteractiveChanged)); + + private readonly OnboardingMascotAnimator _animator = new(allowsAutoSleep: true); + private readonly OnboardingMascotDrawing _drawing = new(); + private readonly Stopwatch _clock = new(); + private readonly List<(UIElement Element, long Token)> _visibilitySubscriptions = []; + private DispatcherTimer? _timer; + private UISettings? _uiSettings; + private AccessibilitySettings? _accessibilitySettings; + private XamlRoot? _root; + private bool _loaded; + private bool _inViewport = true; + private OnboardingMascotGlow? _heroGlow; + + public OnboardingMascotAccessory Accessory + { + get => (OnboardingMascotAccessory)GetValue(AccessoryProperty); + set => SetValue(AccessoryProperty, value); + } + + /// Tap reactions and idle sleep. Decorative heroes never enter the keyboard tab order. + public bool IsInteractive + { + get => (bool)GetValue(IsInteractiveProperty); + set => SetValue(IsInteractiveProperty, value); + } + + public OnboardingMascotMood Mood + { + get => (OnboardingMascotMood)GetValue(MoodProperty); + set => SetValue(MoodProperty, value); + } + + /// Optional host pause. Windows' animation preference always takes precedence. + public bool IsAnimationEnabled + { + get => (bool)GetValue(IsAnimationEnabledProperty); + set => SetValue(IsAnimationEnabledProperty, value); + } + + public OnboardingMascot() + { + IsTabStop = false; + Width = Height = HeroSize; + AutomationProperties.SetAccessibilityView(this, AccessibilityView.Raw); + Content = new Grid + { + Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + Children = { new Viewbox { Stretch = Stretch.Uniform, Child = _drawing.Surface } }, + }; + Loaded += OnLoaded; + Unloaded += OnUnloaded; + ActualThemeChanged += OnThemeChanged; + EffectiveViewportChanged += OnEffectiveViewportChanged; + PointerMoved += OnPointerMoved; + PointerExited += OnPointerExited; + Tapped += OnTapped; + _drawing.Render(OnboardingMascotPose.Static(Mood)); + } + + /// + /// Optional pointer forwarding from the parent, normalized to -1..1 around the mascot center. + /// Pass null on pointer exit. Reduced motion suppresses all pointer effects. + /// + public void SetPointerGaze(double? x, double? y) + { + if (x.HasValue != y.HasValue) + throw new ArgumentException("Both gaze coordinates must be supplied or both must be null."); + if (CanAnimate) + _animator.SetPointer(x is { } px && y is { } py ? new(px, py) : null); + } + + private void OnPointerMoved(object sender, PointerRoutedEventArgs args) + { + if (!CanAnimate || ActualWidth <= 0 || ActualHeight <= 0) + return; + var point = args.GetCurrentPoint(this).Position; + var artRadius = Math.Min(ActualWidth, ActualHeight) * 120 / 168 / 2; + SetPointerGaze((point.X - ActualWidth / 2) / artRadius, + (point.Y - ActualHeight / 2) / artRadius); + } + + private void OnPointerExited(object sender, PointerRoutedEventArgs args) => SetPointerGaze(null, null); + + private void OnTapped(object sender, TappedRoutedEventArgs args) + { + if (!CanAnimate || !IsInteractive) + return; + _animator.HandleTap(); + args.Handled = true; + } + + private static void OnAccessoryChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) + { + var mascot = (OnboardingMascot)sender; + mascot._animator.SetAccessory((OnboardingMascotAccessory)args.NewValue); + mascot.RenderFrame(TimeSpan.Zero); + } + + private static void OnInteractiveChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) + { + var mascot = (OnboardingMascot)sender; + mascot._animator.AllowsAutoSleep = (bool)args.NewValue; + } + + private static void OnMoodChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) + { + var mascot = (OnboardingMascot)sender; + mascot._animator.SetMood((OnboardingMascotMood)args.NewValue); + mascot.RenderFrame(TimeSpan.Zero); + } + + private static void OnAnimationEnabledChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) => + ((OnboardingMascot)sender).UpdateAnimation(); + + private void OnLoaded(object sender, RoutedEventArgs args) + { + if (_loaded) + return; + _loaded = true; + _inViewport = true; + _uiSettings = new UISettings(); + _uiSettings.AnimationsEnabledChanged += OnAnimationsEnabledChanged; + _uiSettings.ColorValuesChanged += OnColorsChanged; + _accessibilitySettings = new AccessibilitySettings(); + _root = XamlRoot; + if (_root is not null) + _root.Changed += OnRootChanged; + for (DependencyObject? ancestor = this; ancestor is not null; ancestor = VisualTreeHelper.GetParent(ancestor)) + { + if (ancestor is UIElement element) + { + var token = element.RegisterPropertyChangedCallback(VisibilityProperty, OnAncestorVisibilityChanged); + _visibilitySubscriptions.Add((element, token)); + } + } + _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1d / OnboardingMascotAnimator.FramesPerSecond) }; + _timer.Tick += OnTick; + _heroGlow = new OnboardingMascotGlow(_drawing); + UpdatePalette(); + UpdateAnimation(); + } + + private void OnUnloaded(object sender, RoutedEventArgs args) + { + _loaded = false; + _heroGlow?.Dispose(); + _heroGlow = null; + _clock.Reset(); + if (_timer is not null) + { + _timer.Stop(); + _timer.Tick -= OnTick; + _timer = null; + } + if (_uiSettings is not null) + { + _uiSettings.AnimationsEnabledChanged -= OnAnimationsEnabledChanged; + _uiSettings.ColorValuesChanged -= OnColorsChanged; + } + if (_root is not null) + _root.Changed -= OnRootChanged; + foreach (var (element, token) in _visibilitySubscriptions) + element.UnregisterPropertyChangedCallback(VisibilityProperty, token); + _visibilitySubscriptions.Clear(); + _uiSettings = null; + _accessibilitySettings = null; + _root = null; + _animator.SetPointer(null); + } + + private bool CanAnimate => _loaded && IsAnimationEnabled && _uiSettings?.AnimationsEnabled == true && + _inViewport && _root?.IsHostVisible == true && + _visibilitySubscriptions.All(subscription => subscription.Element.Visibility == Visibility.Visible); + + private void UpdateAnimation() + { + if (CanAnimate) + { + if (_timer is { IsEnabled: false }) + { + _clock.Restart(); + _timer.Start(); + } + } + else + { + _timer?.Stop(); + _clock.Reset(); + } + RenderFrame(TimeSpan.Zero); + } + + private void OnTick(object? sender, object args) + { + if (!CanAnimate) + { + UpdateAnimation(); + return; + } + var elapsed = _clock.Elapsed; + _clock.Restart(); + RenderFrame(elapsed); + } + + private void RenderFrame(TimeSpan elapsed) + { + if (_loaded) + _drawing.Render(_animator.Advance(elapsed, CanAnimate)); + else + { + var pose = OnboardingMascotPose.Static(Mood); + if (Accessory != OnboardingMascotAccessory.None && pose.HardHat == 0) + pose = pose with { Accessory = Accessory, AccessoryAmount = 1 }; + _drawing.Render(pose); + } + } + private void OnAncestorVisibilityChanged(DependencyObject sender, DependencyProperty property) => UpdateAnimation(); + private void OnRootChanged(XamlRoot sender, XamlRootChangedEventArgs args) => UpdateAnimation(); + private void OnThemeChanged(FrameworkElement sender, object args) => UpdatePalette(); + + private void OnEffectiveViewportChanged(FrameworkElement sender, EffectiveViewportChangedEventArgs args) + { + var viewport = args.EffectiveViewport; + _inViewport = viewport.Width > 0 && viewport.Height > 0 && + viewport.Right > 0 && viewport.Bottom > 0 && viewport.Left < ActualWidth && viewport.Top < ActualHeight; + UpdateAnimation(); + } + + private void OnAnimationsEnabledChanged(UISettings sender, object args) => + DispatcherQueue.TryEnqueue(() => { if (_loaded) UpdateAnimation(); }); + + private void OnColorsChanged(UISettings sender, object args) => + DispatcherQueue.TryEnqueue(() => { if (_loaded) UpdatePalette(); }); + + private void UpdatePalette() + { + var light = ActualTheme == ElementTheme.Light; + var highContrast = _accessibilitySettings?.HighContrast == true; + _drawing.SetPalette(light, highContrast); + _heroGlow?.SetPalette(light, highContrast); + } +} diff --git a/src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascotDrawing.cs b/src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascotDrawing.cs new file mode 100644 index 000000000..c5d6f13f9 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascotDrawing.cs @@ -0,0 +1,410 @@ +// Adapted from OpenClaw's MIT-licensed Mac mascot. See Assets/Setup/Mascot-NOTICE.txt. +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Shapes; +using Windows.Foundation; +using Windows.UI; +using Windows.UI.ViewManagement; +using NativePath = Microsoft.UI.Xaml.Shapes.Path; + +namespace OpenClaw.SetupEngine.UI.Controls; + +/// Retained WinUI shapes. Geometry and brushes are allocated once, not on each animation tick. +internal sealed class OnboardingMascotDrawing +{ + // A gutter contains the original 120-unit art, its whole-body travel, and the hat entrance. + public Canvas Surface { get; } = new() { Width = 168, Height = 168, IsHitTestVisible = false }; + internal Canvas Artwork { get; } = new() { Width = 168, Height = 168, IsHitTestVisible = false }; + internal Canvas GlowHost { get; } = new() { Width = 168, Height = 168, IsHitTestVisible = false }; + private readonly Canvas _body = Layer(); + private readonly CompositeTransform _bodyTransform = new() { CenterX = 60, CenterY = 110 }; + private readonly RotateTransform _bodyTilt = new() { CenterX = 60, CenterY = 60 }; + private readonly TranslateTransform _float = new(); + private readonly RotateTransform _leftClaw = new() { CenterX = 26, CenterY = 53 }; + private readonly RotateTransform _rightClaw = new() { CenterX = 94, CenterY = 53 }; + private readonly RotateTransform _leftAntenna = new() { CenterX = 37.5, CenterY = 11 }; + private readonly RotateTransform _rightAntenna = new() { CenterX = 82.5, CenterY = 11 }; + private readonly RotateTransform _leftDroop = new() { CenterX = 45, CenterY = 15 }; + private readonly RotateTransform _rightDroop = new() { CenterX = 75, CenterY = 15 }; + private readonly Canvas _hat = Layer(); + private readonly CompositeTransform _hatTransform = new() { CenterX = 60, CenterY = 15, Rotation = -5 }; + private readonly LinearGradientBrush _coral = Gradient(); + private readonly SolidColorBrush _antenna = new(); + private readonly SolidColorBrush _eyes = new(); + private readonly SolidColorBrush _glow = new(); + private readonly SolidColorBrush _amber = new(); + private readonly LinearGradientBrush _hatFill = Gradient(); + private readonly SolidColorBrush _hatOutline = new(); + private readonly SolidColorBrush _sweatFill = new(); + private readonly SolidColorBrush _blushFill = new(); + private readonly SolidColorBrush _heartFill = new(); + private readonly SolidColorBrush _capBlue = new(); + private readonly SolidColorBrush _bandBlue = new(); + private readonly SolidColorBrush _capOutline = new(); + private readonly SolidColorBrush _gradFill = new(); + private readonly SolidColorBrush _gradOutline = new(); + private readonly Canvas _nightcap = Layer(); + private readonly Canvas _gradCap = Layer(); + private readonly TranslateTransform _accessoryTransform = new(); + private readonly LineSegment _tasselEnd = Line(72, 14); + private readonly TranslateTransform _tasselBob = new(); + private readonly Canvas _blush = Layer(); + private readonly Eye _leftEye; + private readonly Eye _rightEye; + private readonly NativePath _mouth; + private readonly QuadraticBezierSegment _mouthCurve = new() { Point2 = new(67.5, 49) }; + private readonly PathFigure _mouthFigure; + private readonly Ellipse _roundMouth; + private readonly CompositeTransform _roundMouthTransform = new() { CenterX = 1, CenterY = 1 }; + private readonly NativePath[] _particles = new NativePath[OnboardingMascotParticles.Capacity]; + private readonly NativePath[] _hearts = new NativePath[OnboardingMascotParticles.Capacity]; + private readonly CompositeTransform[] _particleTransforms = new CompositeTransform[OnboardingMascotParticles.Capacity]; + private readonly NativePath _sweat; + private readonly TranslateTransform _sweatTransform = new(); + private readonly TextBlock[] _sleepLetters = new TextBlock[3]; + private readonly CompositeTransform[] _sleepTransforms = new CompositeTransform[3]; + + public OnboardingMascotDrawing() + { + Canvas.SetLeft(_body, 24); + Canvas.SetTop(_body, 24); + Surface.Children.Add(GlowHost); + Surface.Children.Add(Artwork); + Artwork.Children.Add(_body); + _body.RenderTransform = Group(_bodyTilt, _bodyTransform, _float); + _hatFill.MappingMode = BrushMappingMode.Absolute; + _hatFill.StartPoint = new(60, 3); + _hatFill.EndPoint = new(60, 16); + + _body.Children.Add(Shape(Figure(60, 10, true, + Curve(30, 10, 15, 35, 15, 55), Curve(15, 75, 30, 95, 45, 100), + Line(45, 110), Line(55, 110), Line(55, 100), Curve(55, 100, 60, 102, 65, 100), + Line(65, 110), Line(75, 110), Line(75, 100), + Curve(90, 95, 105, 75, 105, 55), Curve(105, 35, 90, 10, 60, 10)), _coral)); + _body.Children.Add(Shape(Figure(20, 45, true, + Curve(5, 40, 0, 50, 5, 60), Curve(10, 70, 20, 65, 25, 55), Curve(28, 48, 25, 45, 20, 45)), _coral, _leftClaw)); + _body.Children.Add(Shape(Figure(100, 45, true, + Curve(115, 40, 120, 50, 115, 60), Curve(110, 70, 100, 65, 95, 55), Curve(92, 48, 95, 45, 100, 45)), _coral, _rightClaw)); + _body.Children.Add(Stroke(Figure(45, 15, false, Quad(35, 5, 30, 8)), _antenna, 2, Group(_leftAntenna, _leftDroop))); + _body.Children.Add(Stroke(Figure(75, 15, false, Quad(85, 5, 90, 8)), _antenna, 2, Group(_rightAntenna, _rightDroop))); + + _hat.RenderTransform = _hatTransform; + var dome = Shape(Figure(45, 15, true, Curve(47, 7, 54, 3, 60, 3), Curve(66, 3, 73, 7, 75, 15)), _hatFill); + dome.Stroke = _hatOutline; + dome.StrokeThickness = 0.8; + _hat.Children.Add(dome); + var brim = new Rectangle + { + Width = 38, Height = 5, RadiusX = 2, RadiusY = 2, Fill = _amber, Stroke = _hatOutline, StrokeThickness = 0.8, + }; + Canvas.SetLeft(brim, 41); + Canvas.SetTop(brim, 14); + _hat.Children.Add(brim); + _body.Children.Add(_hat); + AddAccessories(); + _blush.Children.Add(EllipseAt(32.5, 42.5, 9, 5, _blushFill)); + _blush.Children.Add(EllipseAt(78.5, 42.5, 9, 5, _blushFill)); + _body.Children.Add(_blush); + + _leftEye = AddEye(45); + _rightEye = AddEye(75); + _mouthFigure = Figure(52.5, 49, false, _mouthCurve); + _mouth = Stroke(_mouthFigure, _eyes, 2.2); + _body.Children.Add(_mouth); + _roundMouth = EllipseAt(59, 50, 2, 2, _eyes); + _roundMouth.RenderTransform = _roundMouthTransform; + _body.Children.Add(_roundMouth); + for (var i = 0; i < _particles.Length; i++) + { + var particleLayer = Layer(); + var transform = new CompositeTransform(); + particleLayer.RenderTransform = transform; + var star = Shape(Figure(0, -1, true, Quad(0, 0, 1, 0), Quad(0, 0, 0, 1), + Quad(0, 0, -1, 0), Quad(0, 0, 0, -1)), _glow); + // WinUI geometry has a single owner. Each retained path owns its geometry; + // frame changes only switch opacity, never reparent shared Path.Data. + var heart = new NativePath + { + Width = 120, Height = 120, Stretch = Stretch.None, Fill = _heartFill, + Data = new GeometryGroup + { + FillRule = FillRule.Nonzero, + Children = + { + new EllipseGeometry { Center = new(-0.35, -0.25), RadiusX = 0.4, RadiusY = 0.4 }, + new EllipseGeometry { Center = new(0.35, -0.25), RadiusX = 0.4, RadiusY = 0.4 }, + new PathGeometry { Figures = { Figure(-0.7, -0.05, true, Line(0.7, -0.05), Line(0, 0.75)) } }, + }, + }, + }; + _particles[i] = star; + _hearts[i] = heart; + _particleTransforms[i] = transform; + particleLayer.Children.Add(star); + particleLayer.Children.Add(heart); + _body.Children.Add(particleLayer); + } + for (var i = 0; i < _sleepLetters.Length; i++) + { + var letter = new TextBlock + { + Text = "z", FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.Bold, + Foreground = _glow, IsHitTestVisible = false, + }; + var transform = new CompositeTransform(); + letter.RenderTransform = transform; + _sleepLetters[i] = letter; + _sleepTransforms[i] = transform; + _body.Children.Add(letter); + } + _sweat = Shape(Figure(42, 21, true, + Curve(38, 25, 40, 27, 42, 27), Curve(44, 27, 46, 25, 42, 21)), _sweatFill, _sweatTransform); + _body.Children.Add(_sweat); + SetPalette(light: false, highContrast: false); + } + + // Brand artwork intentionally preserves upstream colors; high contrast uses the user's system colors. + public void SetPalette(bool light, bool highContrast) + { + if (highContrast) + { + var settings = new UISettings(); + var foreground = settings.UIElementColor(UIElementType.WindowText); + var background = settings.UIElementColor(UIElementType.Window); + var highlight = settings.UIElementColor(UIElementType.Highlight); + SetGradient(_coral, foreground, foreground); + _antenna.Color = foreground; + _eyes.Color = background; + _glow.Color = highlight; + _amber.Color = foreground; + SetGradient(_hatFill, foreground, foreground); + _hatOutline.Color = background; + _sweatFill.Color = highlight; + _blushFill.Color = _heartFill.Color = highlight; + _capBlue.Color = _bandBlue.Color = _gradFill.Color = foreground; + _capOutline.Color = _gradOutline.Color = background; + return; + } + SetGradient(_coral, light ? Rgb(255, 112, 121) : Rgb(255, 77, 77), + light ? Rgb(234, 76, 89) : Rgb(153, 27, 27)); + _antenna.Color = light ? Rgb(239, 75, 88) : Rgb(255, 77, 77); + _eyes.Color = Rgb(5, 8, 16); + _glow.Color = Rgb(0, 229, 204); + _amber.Color = Rgb(242, 168, 51); + SetGradient(_hatFill, Rgb(255, 214, 89), _amber.Color); + _hatOutline.Color = Color.FromArgb(179, 184, 115, 31); + _sweatFill.Color = Rgb(128, 212, 255); + _blushFill.Color = Rgb(255, 158, 173); + _heartFill.Color = Rgb(255, 115, 140); + _capBlue.Color = Rgb(168, 199, 232); + _bandBlue.Color = Rgb(120, 163, 207); + _capOutline.Color = Color.FromArgb(179, 72, 108, 146); + _gradFill.Color = Rgb(28, 31, 38); + _gradOutline.Color = Color.FromArgb(191, 0, 0, 0); + } + + public void Render(OnboardingMascotPose pose) + { + _bodyTransform.ScaleY = pose.BodyStretch; + _bodyTransform.ScaleX = Math.Clamp(1 + (1 - pose.BodyStretch) * 0.5, 0.97, 1.03); + _bodyTilt.Angle = pose.BodyTilt; + _float.Y = pose.FloatOffset; + _leftClaw.Angle = pose.LeftClawDegrees; + _rightClaw.Angle = pose.RightClawDegrees; + _leftAntenna.Angle = _rightAntenna.Angle = pose.AntennaDegrees * (1 - pose.AntennaDroop); + _leftDroop.Angle = -40 * pose.AntennaDroop; + _rightDroop.Angle = 40 * pose.AntennaDroop; + _hat.Opacity = pose.HardHat > 0.01 ? pose.HardHat : 0; + _hatTransform.TranslateY = -14 * (1 - pose.HardHat); + var accessoryAmount = pose.HardHat > 0.01 || pose.AccessoryAmount <= 0.01 ? 0 : pose.AccessoryAmount; + _nightcap.Opacity = pose.Accessory == OnboardingMascotAccessory.Nightcap ? accessoryAmount : 0; + _gradCap.Opacity = pose.Accessory == OnboardingMascotAccessory.GradCap ? accessoryAmount : 0; + _accessoryTransform.Y = -14 * (1 - accessoryAmount); + var angle = (26.565 + pose.BodyTilt * 1.5) * Math.PI / 180; + var tasselX = 60 + Math.Cos(angle) * 13.416; + var tasselY = 8 + Math.Sin(angle) * 13.416; + _tasselEnd.Point = new(tasselX, tasselY); + _tasselBob.X = tasselX; + _tasselBob.Y = tasselY; + _blush.Opacity = pose.Blush > 0.02 ? pose.Blush * 0.55 : 0; + RenderEye(_leftEye, pose.LeftEyeOpenness, pose); + RenderEye(_rightEye, pose.RightEyeOpenness, pose); + var open = pose.MouthOpen > 0.05; + var round = pose.MouthRound > 0.05; + _mouth.Opacity = !round && (open || Math.Abs(pose.MouthCurve) > 0.05) ? 1 : 0; + _roundMouth.Opacity = round ? 1 : 0; + _roundMouthTransform.ScaleX = 1 + 3.2 * pose.MouthRound; + _roundMouthTransform.ScaleY = 1 + 4.2 * pose.MouthRound; + _mouthFigure.StartPoint = new(52.5, open ? 48.5 : 49); + _mouthFigure.IsClosed = open; + _mouthCurve.Point1 = new(60, open ? 48.5 + 14 * pose.MouthOpen : 49 + 8 * pose.MouthCurve); + _mouthCurve.Point2 = new(67.5, open ? 48.5 : 49); + _mouth.Fill = open ? _eyes : null; + _mouth.StrokeThickness = open ? 0 : 2.2; + for (var i = 0; i < _particles.Length; i++) + { + var particle = OnboardingMascotParticles.Sample(pose, i); + _particles[i].Opacity = pose.Effect == OnboardingMascotEffect.Sparks ? particle.Opacity : + pose.Effect == OnboardingMascotEffect.Sparkles && particle.Opacity > 0.05 ? particle.Opacity : 0; + _hearts[i].Opacity = pose.Effect == OnboardingMascotEffect.Hearts && particle.Opacity > 0.05 + ? particle.Opacity : 0; + _particles[i].Fill = particle.Accent ? _glow : pose.Effect == OnboardingMascotEffect.Sparks ? _amber : _antenna; + _particleTransforms[i].ScaleX = _particleTransforms[i].ScaleY = particle.Size; + _particleTransforms[i].TranslateX = particle.X; + _particleTransforms[i].TranslateY = particle.Y; + } + for (var i = 0; i < _sleepLetters.Length; i++) + { + var particle = OnboardingMascotParticles.Sample(pose, i); + _sleepLetters[i].Opacity = pose.Effect == OnboardingMascotEffect.Zzz && particle.Opacity > 0.045 ? particle.Opacity : 0; + var scale = particle.Size / 10; + _sleepTransforms[i].ScaleX = _sleepTransforms[i].ScaleY = scale; + _sleepTransforms[i].TranslateX = particle.X - _sleepLetters[i].ActualWidth * scale / 2; + _sleepTransforms[i].TranslateY = particle.Y - _sleepLetters[i].ActualHeight * scale / 2; + } + var sweatOpacity = pose.Effect == OnboardingMascotEffect.Sweat ? OnboardingMascotAnimator.Bell(pose.EffectPhase) : 0; + _sweat.Opacity = sweatOpacity > 0.02 ? sweatOpacity : 0; + _sweatTransform.Y = 7 * pose.EffectPhase; + } + + private Eye AddEye(double x) + { + var layer = Layer(); + var gaze = new TranslateTransform(); + layer.RenderTransform = gaze; + var ellipse = new Ellipse { Width = 12, Height = 12, Fill = _eyes }; + Canvas.SetLeft(ellipse, x - 6); + Canvas.SetTop(ellipse, 29); + var lid = new CompositeTransform(); + ellipse.RenderTransform = lid; + var arc = Stroke(Figure(x - 6, 37, false, Quad(x, 29.5, x + 6, 37)), _eyes, 2.6); + var glow = new Ellipse { Width = 4, Height = 4, Fill = _glow }; + Canvas.SetLeft(glow, x - 1); + Canvas.SetTop(glow, 32); + var glowTransform = new CompositeTransform { CenterX = 2, CenterY = 2 }; + glow.RenderTransform = glowTransform; + layer.Children.Add(ellipse); + layer.Children.Add(arc); + layer.Children.Add(glow); + var dizzy = EllipseAt(x - 1.8, 33.2, 3.6, 3.6, _glow); + var dizzyTransform = new TranslateTransform(); + dizzy.RenderTransform = dizzyTransform; + layer.Children.Add(dizzy); + _body.Children.Add(layer); + return new(ellipse, arc, glow, gaze, lid, glowTransform, dizzy, dizzyTransform, x > 60); + } + + private static void RenderEye(Eye eye, double openness, OnboardingMascotPose pose) + { + eye.Gaze.X = pose.Gaze.X * 2; + eye.Gaze.Y = pose.Gaze.Y * 1.5; + var height = Math.Max(1.2, 12 * openness * (1 - 0.6 * pose.HappyEyes)); + eye.Lid.ScaleY = height / 12; + eye.Lid.TranslateY = (12 - height) * 0.65; + eye.Ellipse.Opacity = 1 - pose.HappyEyes; + eye.Arc.Opacity = pose.HappyEyes; + var glowOpacity = pose.EyeGlowOpacity * openness * (1 - pose.HappyEyes) * (1 - pose.Dizzy); + eye.Glow.Opacity = glowOpacity > 0.01 ? glowOpacity : 0; + eye.GlowTransform.ScaleX = eye.GlowTransform.ScaleY = pose.GlowScale; + eye.GlowTransform.TranslateX = pose.Gaze.X * 1.2; + eye.GlowTransform.TranslateY = pose.Gaze.Y * 0.9; + eye.Dizzy.Opacity = pose.Dizzy; + var angle = pose.DizzyPhase * 2 * Math.PI + (eye.Right ? Math.PI : 0); + eye.DizzyTransform.X = Math.Cos(angle) * 3.4; + eye.DizzyTransform.Y = Math.Sin(angle) * 2.6; + } + + private sealed record Eye(Ellipse Ellipse, NativePath Arc, Ellipse Glow, TranslateTransform Gaze, + CompositeTransform Lid, CompositeTransform GlowTransform, Ellipse Dizzy, TranslateTransform DizzyTransform, bool Right); + + private void AddAccessories() + { + _nightcap.RenderTransform = _gradCap.RenderTransform = _accessoryTransform; + _nightcap.Children.Add(Outlined(Figure(47, 14, true, + Curve(53, 1, 70, 0, 86, 8), Curve(82, 11, 77, 13, 72, 14)), _capBlue, _capOutline)); + var brim = new Rectangle + { + Width = 32, Height = 5, RadiusX = 2, RadiusY = 2, + Fill = _bandBlue, Stroke = _capOutline, StrokeThickness = 0.8, + }; + Canvas.SetLeft(brim, 44); + Canvas.SetTop(brim, 12); + _nightcap.Children.Add(brim); + var pompom = EllipseAt(83, 5, 6, 6, _capBlue); + pompom.Stroke = _capOutline; + pompom.StrokeThickness = 0.8; + _nightcap.Children.Add(pompom); + _gradCap.Children.Add(Outlined(Figure(50, 8, true, Line(70, 8), Line(68, 15), Line(52, 15)), _gradFill, _gradOutline)); + _gradCap.Children.Add(Outlined(Figure(60, 3, true, Line(77, 8), Line(60, 13), Line(43, 8)), _gradFill, _gradOutline)); + _gradCap.Children.Add(Stroke(Figure(60, 8, false, _tasselEnd), _amber, 1.2)); + _gradCap.Children.Add(EllipseAt(58.5, 6.5, 3, 3, _amber)); + var bob = EllipseAt(-2, -2, 4, 4, _amber); + bob.RenderTransform = _tasselBob; + _gradCap.Children.Add(bob); + _body.Children.Add(_nightcap); + _body.Children.Add(_gradCap); + } + + private static NativePath Outlined(PathFigure figure, Brush fill, Brush outline) + { + var path = Shape(figure, fill); + path.Stroke = outline; + path.StrokeThickness = 0.8; + return path; + } + + private static Ellipse EllipseAt(double x, double y, double width, double height, Brush fill) + { + var ellipse = new Ellipse { Width = width, Height = height, Fill = fill }; + Canvas.SetLeft(ellipse, x); + Canvas.SetTop(ellipse, y); + return ellipse; + } + + private static Canvas Layer() => new() { Width = 120, Height = 120 }; + private static Color Rgb(byte r, byte g, byte b) => Color.FromArgb(255, r, g, b); + private static LinearGradientBrush Gradient() => new() + { + StartPoint = new(0, 0), EndPoint = new(1, 1), + GradientStops = { new() { Offset = 0 }, new() { Offset = 1 } }, + }; + private static void SetGradient(LinearGradientBrush brush, Color top, Color bottom) + { + brush.GradientStops[0].Color = top; + brush.GradientStops[1].Color = bottom; + } + private static TransformGroup Group(params Transform[] transforms) + { + var group = new TransformGroup(); + foreach (var transform in transforms) + group.Children.Add(transform); + return group; + } + private static NativePath Shape(PathFigure figure, Brush fill, Transform? transform = null) => new() + { + Width = 120, Height = 120, Stretch = Stretch.None, + Data = new PathGeometry { Figures = { figure } }, Fill = fill, RenderTransform = transform, + }; + private static NativePath Stroke(PathFigure figure, Brush brush, double width, Transform? transform = null) + { + var shape = Shape(figure, brush, transform); + shape.Fill = null; + shape.Stroke = brush; + shape.StrokeThickness = width; + shape.StrokeStartLineCap = shape.StrokeEndLineCap = PenLineCap.Round; + return shape; + } + private static PathFigure Figure(double x, double y, bool closed, params PathSegment[] segments) + { + var figure = new PathFigure { StartPoint = new(x, y), IsClosed = closed }; + foreach (var segment in segments) + figure.Segments.Add(segment); + return figure; + } + private static LineSegment Line(double x, double y) => new() { Point = new(x, y) }; + private static QuadraticBezierSegment Quad(double x, double y, double endX, double endY) => + new() { Point1 = new(x, y), Point2 = new(endX, endY) }; + private static BezierSegment Curve(double x1, double y1, double x2, double y2, double x, double y) => + new() { Point1 = new(x1, y1), Point2 = new(x2, y2), Point3 = new(x, y) }; +} diff --git a/src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascotGlow.cs b/src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascotGlow.cs new file mode 100644 index 000000000..d085578cc --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Controls/OnboardingMascotGlow.cs @@ -0,0 +1,59 @@ +using System.Numerics; +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml.Hosting; +using Windows.UI; + +namespace OpenClaw.SetupEngine.UI.Controls; + +/// +/// A live alpha mask of the existing retained artwork, not a second renderer. +/// The source excludes the shadow host so the capture never feeds back into itself. +/// +internal sealed class OnboardingMascotGlow : IDisposable +{ + private readonly OnboardingMascotDrawing _drawing; + private readonly CompositionVisualSurface _surface; + private readonly CompositionSurfaceBrush _mask; + private readonly DropShadow _shadow; + private readonly SpriteVisual _visual; + + public OnboardingMascotGlow(OnboardingMascotDrawing drawing) + { + _drawing = drawing; + var source = ElementCompositionPreview.GetElementVisual(drawing.Artwork); + var compositor = source.Compositor; + _surface = compositor.CreateVisualSurface(); + _surface.SourceVisual = source; + _surface.SourceSize = new Vector2(168); + _mask = compositor.CreateSurfaceBrush(_surface); + _shadow = compositor.CreateDropShadow(); + _shadow.Mask = _mask; + // 12 native units become 13 DIPs at the canonical 130-DIP hero size. + _shadow.BlurRadius = 12; + _shadow.Offset = Vector3.Zero; + _visual = compositor.CreateSpriteVisual(); + _visual.Size = new Vector2(168); + _visual.Shadow = _shadow; + ElementCompositionPreview.SetElementChildVisual(drawing.GlowHost, _visual); + } + + public void SetPalette(bool light, bool highContrast) + { + _visual.IsVisible = !highContrast; + _shadow.Color = light ? Color.FromArgb(255, 239, 75, 88) : Color.FromArgb(255, 255, 77, 77); + _shadow.Opacity = light ? 0.2f : 0.4f; + } + + public void Dispose() + { + ElementCompositionPreview.SetElementChildVisual(_drawing.GlowHost, null); + _visual.Shadow = null; + _shadow.Mask = null; + _mask.Surface = null; + _surface.SourceVisual = null; + _visual.Dispose(); + _shadow.Dispose(); + _mask.Dispose(); + _surface.Dispose(); + } +} diff --git a/src/OpenClaw.SetupEngine.UI/Controls/ProviderArtwork.xaml b/src/OpenClaw.SetupEngine.UI/Controls/ProviderArtwork.xaml new file mode 100644 index 000000000..3d73d0ee8 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Controls/ProviderArtwork.xaml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Controls/ProviderArtwork.xaml.cs b/src/OpenClaw.SetupEngine.UI/Controls/ProviderArtwork.xaml.cs new file mode 100644 index 000000000..bd8d93f36 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Controls/ProviderArtwork.xaml.cs @@ -0,0 +1,199 @@ +using System.Runtime.InteropServices; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using OpenClaw.Shared; +using OpenClawTray.Helpers; + +namespace OpenClaw.SetupEngine.UI.Controls; + +public sealed partial class ProviderArtwork : UserControl +{ + public static readonly DependencyProperty DescriptorProperty = DependencyProperty.Register( + nameof(Descriptor), typeof(ProviderArtworkDescriptor), typeof(ProviderArtwork), new PropertyMetadata(null, Changed)); + public static readonly DependencyProperty SessionProperty = DependencyProperty.Register( + nameof(Session), typeof(ProviderArtworkSession), typeof(ProviderArtwork), new PropertyMetadata(null, Changed)); + private readonly ProviderArtworkGeneration _generation = new(); + private ProviderArtworkSession? _observedSession; + + public ProviderArtworkDescriptor? Descriptor + { + get => (ProviderArtworkDescriptor?)GetValue(DescriptorProperty); + set => SetValue(DescriptorProperty, value); + } + public ProviderArtworkSession? Session + { + get => (ProviderArtworkSession?)GetValue(SessionProperty); + set => SetValue(SessionProperty, value); + } + + public ProviderArtwork() + { + InitializeComponent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + private static void Changed(DependencyObject sender, DependencyPropertyChangedEventArgs args) => + ((ProviderArtwork)sender).Refresh(); + private void OnLoaded(object sender, RoutedEventArgs args) => Refresh(); + private void OnUnloaded(object sender, RoutedEventArgs args) + { + ObserveSession(null); + _generation.Stop(); + ClearImage(); + } + + private void ObserveSession(ProviderArtworkSession? session) + { + if (ReferenceEquals(session, _observedSession)) + return; + if (_observedSession is not null) + _observedSession.Closed -= OnSessionClosed; + _observedSession = session; + if (session is not null) + session.Closed += OnSessionClosed; + } + + private void OnSessionClosed() + { + _generation.Stop(); + ClearImage(); + ObserveSession(null); + } + + private void ClearImage() + { + ArtworkImage.Source = null; + ArtworkImage.Visibility = Visibility.Collapsed; + FallbackIcon.Visibility = Visibility.Visible; + ToolTipService.SetToolTip(this, null); + } + + private void Refresh() + { + _generation.Stop(); + if (!IsLoaded) + return; + ObserveSession(Session); + ClearImage(); + var descriptor = Descriptor; + FallbackIcon.Glyph = descriptor?.Fallback switch + { + ProviderArtworkFallback.Pair => FluentIconCatalog.Devices, + ProviderArtworkFallback.Install => FluentIconCatalog.Setup, + ProviderArtworkFallback.Configure => FluentIconCatalog.Settings, + ProviderArtworkFallback.Verified => FluentIconCatalog.StatusOk, + ProviderArtworkFallback.Code => FluentIconCatalog.Develop, + ProviderArtworkFallback.Account => FluentIconCatalog.Operator, + _ => FluentIconCatalog.Lock + }; + if (descriptor is null || Session is not { } session || session.Token.IsCancellationRequested) + return; + var request = _generation.Begin(session.Token); + AsyncEventHandlerGuard.Run( + () => LoadAsync(descriptor, session, request.Generation, request.Token), + onError: _ => ShowFailure(ProviderArtworkStatus.InvalidImage, request.Generation)); + } + + private async Task LoadAsync(ProviderArtworkDescriptor descriptor, ProviderArtworkSession session, + int generation, CancellationToken ct) + { + try + { + DecodedProviderArtwork decoded; + if (descriptor.BundledFileName is { } file) + { + if (!file.StartsWith("ProviderIcon-", StringComparison.Ordinal) || + !file.EndsWith(".svg", StringComparison.Ordinal) || + GatewayAiSetupPresentation.GetBundledProviderIconFileName(file[13..^4]) != file) + { + ShowFailure(ProviderArtworkStatus.Blocked, generation); + return; + } + // WinUI resolves library resources in unpackaged installs too; Windows Storage + // ms-appx stream resolution requires package identity. + var uri = new Uri("ms-appx:///OpenClaw.SetupEngine.UI/Assets/Setup/ProviderIcons/" + file); + decoded = await LoadBundledAsync(uri, generation, ct); + } + else if (descriptor.RemoteUri is { } remote) + { + var loaded = await session.Loader.LoadAsync(remote, ct); + decoded = loaded.Data is { } data ? await ProviderArtworkDecoder.DecodeAsync(data, ct) : + new(loaded.Status); + } + else + { + if (descriptor.RemoteRejected) + ShowFailure(ProviderArtworkStatus.Blocked, generation); + return; + } + if (!_generation.IsCurrent(generation) || !IsLoaded) + return; + if (decoded.Source is null) + { + ShowFailure(decoded.Status, generation); + return; + } + ArtworkImage.Source = decoded.Source; + ArtworkImage.Visibility = Visibility.Visible; + FallbackIcon.Visibility = Visibility.Collapsed; + } + catch (OperationCanceledException) + { + // Cancellation is expected on rebind, unload and page closure. A current control's + // deadline still leaves a coarse, local explanation rather than a broken-image box. + if (IsLoaded && !session.Token.IsCancellationRequested) + ShowFailure(ProviderArtworkStatus.TimedOut, generation, allowCancelled: true); + } + catch (COMException) { ShowFailure(ProviderArtworkStatus.InvalidImage, generation); } + catch (IOException) { ShowFailure(ProviderArtworkStatus.InvalidImage, generation); } + catch (ArgumentException) { ShowFailure(ProviderArtworkStatus.InvalidImage, generation); } + } + + private async Task LoadBundledAsync(Uri uri, int generation, CancellationToken ct) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var source = new SvgImageSource { RasterizePixelWidth = 24, RasterizePixelHeight = 24 }; + void Opened(SvgImageSource sender, SvgImageSourceOpenedEventArgs args) => + completion.TrySetResult(SvgImageSourceLoadStatus.Success); + void Failed(SvgImageSource sender, SvgImageSourceFailedEventArgs args) => completion.TrySetResult(args.Status); + source.Opened += Opened; + source.OpenFailed += Failed; + try + { + ct.ThrowIfCancellationRequested(); + // URI-backed SVG decoding starts when a visible Image consumes the source. + // Keep the fallback visible until Opened; awaiting an unattached source deadlocks. + ArtworkImage.Source = source; + ArtworkImage.Visibility = Visibility.Visible; + source.UriSource = uri; + var status = await completion.Task.WaitAsync(ct); + return status == SvgImageSourceLoadStatus.Success + ? new(ProviderArtworkStatus.Loaded, source) : new(ProviderArtworkStatus.InvalidImage); + } + finally + { + source.Opened -= Opened; + source.OpenFailed -= Failed; + if (ct.IsCancellationRequested) + { + source.UriSource = null; + if (_generation.Matches(generation) && ReferenceEquals(ArtworkImage.Source, source)) + { + ArtworkImage.Source = null; + ArtworkImage.Visibility = Visibility.Collapsed; + } + } + } + } + + private void ShowFailure(ProviderArtworkStatus status, int generation, bool allowCancelled = false) + { + if (!IsLoaded || !(allowCancelled ? _generation.Matches(generation) : _generation.IsCurrent(generation))) + return; + ClearImage(); + ToolTipService.SetToolTip(this, + SetupLocalization.Format("Onboarding_AiSetup_ArtworkUnavailable", status.ToString())); + } +} diff --git a/src/OpenClaw.SetupEngine.UI/Controls/ProviderArtworkDecoder.cs b/src/OpenClaw.SetupEngine.UI/Controls/ProviderArtworkDecoder.cs new file mode 100644 index 000000000..1bc3b0e89 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Controls/ProviderArtworkDecoder.cs @@ -0,0 +1,75 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +namespace OpenClaw.SetupEngine.UI.Controls; + +internal sealed record DecodedProviderArtwork(ProviderArtworkStatus Status, ImageSource? Source = null); + +internal static class ProviderArtworkDecoder +{ + private static readonly SemaphoreSlim Slots = new(2); + + internal static async Task DecodeAsync(ProviderArtworkData data, CancellationToken ct) + { + if (!await Slots.WaitAsync(0, ct)) + return new(ProviderArtworkStatus.Busy); + // The core retains its semaphore and stream until the native operation actually ends, + // even if the caller times out or unloads. Abandoned native work cannot free a slot + // for unbounded concurrent decoders. No task continuation retains the control/page. + var decode = DecodeOwnedAsync(data); + try { return await decode.WaitAsync(ct); } + catch (OperationCanceledException) + { + _ = decode.ContinueWith(task => _ = task.Exception, + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + throw; + } + } + + private static async Task DecodeOwnedAsync(ProviderArtworkData data) + { + try + { + if (data.Bytes.Length > ProviderArtworkLoader.MaxBytes) + return new(ProviderArtworkStatus.TooLarge); + using var stream = new InMemoryRandomAccessStream(); + await stream.WriteAsync(data.Bytes.AsBuffer()); + stream.Seek(0); + if (data.Format == ProviderArtworkFormat.Svg) + { + var svg = new SvgImageSource { RasterizePixelWidth = 24, RasterizePixelHeight = 24 }; + var status = await svg.SetSourceAsync(stream); + return status == SvgImageSourceLoadStatus.Success + ? new(ProviderArtworkStatus.Loaded, svg) : new(ProviderArtworkStatus.InvalidImage); + } + var decoder = await BitmapDecoder.CreateAsync(stream); + var expected = data.Format == ProviderArtworkFormat.Png ? BitmapDecoder.PngDecoderId : BitmapDecoder.JpegDecoderId; + if (decoder.DecoderInformation.CodecId != expected || decoder.FrameCount != 1 || + !ProviderArtworkContent.AreDimensionsAllowed(decoder.PixelWidth, decoder.PixelHeight)) + return new(ProviderArtworkStatus.InvalidImage); + var scale = Math.Min(24d / decoder.PixelWidth, 24d / decoder.PixelHeight); + var width = (uint)Math.Max(1, Math.Round(decoder.PixelWidth * scale)); + var height = (uint)Math.Max(1, Math.Round(decoder.PixelHeight * scale)); + var pixels = await decoder.GetPixelDataAsync( + BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied, + new BitmapTransform { ScaledWidth = width, ScaledHeight = height }, + ExifOrientationMode.IgnoreExifOrientation, ColorManagementMode.DoNotColorManage); + var bytes = pixels.DetachPixelData(); + if (bytes.Length != (long)width * height * 4) + return new(ProviderArtworkStatus.InvalidImage); + var image = new WriteableBitmap((int)width, (int)height); + using var target = image.PixelBuffer.AsStream(); + target.Write(bytes); + image.Invalidate(); + return new(ProviderArtworkStatus.Loaded, image); + } + catch (COMException) { return new(ProviderArtworkStatus.InvalidImage); } + catch (ArgumentException) { return new(ProviderArtworkStatus.InvalidImage); } + catch (IOException) { return new(ProviderArtworkStatus.InvalidImage); } + finally { Slots.Release(); } + } +} diff --git a/src/OpenClaw.SetupEngine.UI/Controls/ProviderSetupDialog.xaml b/src/OpenClaw.SetupEngine.UI/Controls/ProviderSetupDialog.xaml new file mode 100644 index 000000000..79d05e15f --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Controls/ProviderSetupDialog.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + private void OnContentFrameNavigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e) { - var tag = e.Parameter as string; + var nativeRequest = e.Parameter as SetupNativeNavigationRequest; + var tag = nativeRequest?.PageTag ?? e.Parameter as string; _currentNavTag = tag; + if (nativeRequest is not null) + { + _currentAgentId = nativeRequest.Completion.Verification.AgentId!; + _cachedCommands = null; + } // Keep _currentAgentId aligned with the page that's now visible. if (tag != null && tag.StartsWith("agent:")) diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs index 2f6bcc76a..bdae02d6a 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs @@ -11,6 +11,47 @@ namespace OpenClaw.Connection.Tests; public class GatewayConnectionManagerTests : IDisposable { + [Fact] + public async Task RegistrySettlementCannotDisconnectANewerConnectionSnapshot() + { + var before = _manager.CurrentSnapshot; + SetupGateway("settlement-new", "wss://new.example"); + _resolver.OperatorCredential = new GatewayCredential("token", false, "test"); + await _manager.ConnectAsync("settlement-new"); + var current = _manager.CurrentSnapshot; + Assert.False(await _manager.DisconnectIfCurrentAsync(before)); + Assert.Same(current, _manager.CurrentSnapshot); + Assert.True(await _manager.DisconnectIfCurrentAsync(current)); + } + + [Fact] + public async Task NativeRecovery_ProductionAuthorizerDoesNotDowngradeAtUnownedManualLoopback() + { + var path = Path.Combine(_tempDir, "saved-native"); + var saved = new DeviceIdentity(path); + saved.Initialize(); + saved.StoreDeviceTokenForRole("operator", "revoked"); + using var copy = new GatewayValidationIdentity(path); + var record = new GatewayRecord { Id = "manual", Url = "ws://127.0.0.1:18789", SharedGatewayToken = "fallback" }; + var explicitCheck = await _manager.AuthorizeValidationCredentialHandshakeAsync(record, + new("fallback", false, CredentialResolver.SourceSharedGatewayToken), null, null, null, CancellationToken.None); + Assert.True(explicitCheck.Allowed); + var attempts = 0; + var validator = new GatewayConnectionValidator(new CredentialResolver(DeviceIdentityFileReader.Instance), + () => throw new InvalidOperationException("No SSH"), + _manager.AuthorizeValidationCredentialHandshakeAsync, NullLogger.Instance, + (_, _) => + { + attempts++; + return Task.FromResult(GatewayConnectionValidator.AuthenticationFailure("AUTH_DEVICE_TOKEN_MISMATCH")); + }); + Assert.Equal(SetupCodeOutcome.ConnectionFailed, + (await validator.ValidateAsync(record, copy, new HashSet(), CancellationToken.None)).Outcome); + Assert.Equal(1, attempts); + Assert.Equal("revoked", DeviceIdentity.TryReadStoredDeviceToken(copy.DirectoryPath)); + Assert.Equal("revoked", DeviceIdentity.TryReadStoredDeviceToken(path)); + } + private readonly string _tempDir; private readonly GatewayRegistry _registry; private readonly MockCredentialResolver _resolver; diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionValidatorTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionValidatorTests.cs new file mode 100644 index 000000000..40fb35813 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionValidatorTests.cs @@ -0,0 +1,517 @@ +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +public sealed class GatewayConnectionValidatorTests +{ + [Fact] + public void StagedIdentity_CopyUsesProtectedSensitiveFileAcl() + { + using var saved = new TempDirectory(); + new DeviceIdentity(saved.Path).Initialize(); + using var copy = new GatewayValidationIdentity(saved.Path); + var file = new FileInfo(Path.Combine(copy.DirectoryPath, "device-key-ed25519.json")); + Assert.Equal(File.ReadAllText(Path.Combine(saved.Path, "device-key-ed25519.json")), File.ReadAllText(file.FullName)); + if (OperatingSystem.IsWindows()) + { + var acl = System.IO.FileSystemAclExtensions.GetAccessControl(file); + Assert.True(acl.AreAccessRulesProtected); + var rules = acl.GetAccessRules(true, true, typeof(System.Security.Principal.SecurityIdentifier)); + foreach (System.Security.AccessControl.FileSystemAccessRule rule in rules) + Assert.False(rule.IsInherited); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task RevokedDeviceToken_RecoversOnceInCopyAndPreservesKeypairThroughCheckAndNext(bool bootstrap, bool ssh) + { + using var saved = new TempDirectory(); + var original = new DeviceIdentity(saved.Path); + original.Initialize(); + original.StoreDeviceTokenForRole("operator", "revoked"); + original.StoreDeviceTokenForRole("node", "node-keep"); + var file = Path.Combine(saved.Path, "device-key-ed25519.json"); + var before = File.ReadAllBytes(file); + using var copy = new GatewayValidationIdentity(saved.Path); + var credentials = new List(); + var ownedTunnel = ssh ? new FakeTunnel() : null; + var validator = CreateValidator(async (record, credential, tunnel, config, generation, ct) => + { + credentials.Add(credential); + return tunnel is null ? ReconnectAuthorizationResult.AllowedResult : + await GatewayConnectionManager.AuthorizeValidationTunnelHandshakeAsync(tunnel, config!, generation!.Value, ct); + }, (client, ct) => + { + if (client.ConnectAuthToken == "revoked") + return Task.FromResult(GatewayConnectionValidator.AuthenticationFailure("AUTH_DEVICE_TOKEN_MISMATCH: rejected")); + var received = typeof(OpenClawGatewayClient).GetField("DeviceTokenReceived", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + Assert.IsType>(received.GetValue(client))( + client, new("replacement-device", ["operator.read"], "operator")); + return Task.FromResult(new SetupCodeResult(SetupCodeOutcome.Success)); + }, ownedTunnel); + var candidate = new GatewayRecord + { + Url = "wss://gateway.example", + SharedGatewayToken = bootstrap ? null : "shared", + BootstrapToken = "bootstrap", + SshTunnel = ssh ? new("user", "host.example", 18789, 45678) : null, + }; + Assert.Equal(SetupCodeOutcome.Success, + (await validator.ValidateAsync(candidate, copy, new HashSet(), CancellationToken.None)).Outcome); + Assert.Equal(before, File.ReadAllBytes(file)); + Assert.True(copy.OperatorTokenRecoveryAttempted); + var copied = new DeviceIdentity(copy.DirectoryPath); + copied.Initialize(); + Assert.Equal(original.DeviceId, copied.DeviceId); + Assert.Equal("node-keep", DeviceIdentity.TryReadStoredDeviceTokenForRole(copy.DirectoryPath, "node")); + Assert.Equal(SetupCodeOutcome.Success, + (await validator.ValidateAsync(candidate, copy, new HashSet(), CancellationToken.None)).Outcome); + Assert.Equal("replacement-device", credentials[^1].Token); + Assert.Equal(CredentialResolver.SourceDeviceToken, credentials[^1].Source); + Assert.Contains(credentials, value => value.Source == (bootstrap + ? CredentialResolver.SourceBootstrapToken : CredentialResolver.SourceSharedGatewayToken)); + if (!bootstrap) Assert.DoesNotContain(credentials, value => value.IsBootstrapToken); + copy.ReplaceExisting(saved.Path); + var committed = new DeviceIdentity(saved.Path); + committed.Initialize(); + Assert.Equal(original.DeviceId, committed.DeviceId); + Assert.Equal("replacement-device", DeviceIdentity.TryReadStoredDeviceToken(saved.Path)); + Assert.Equal("node-keep", DeviceIdentity.TryReadStoredDeviceTokenForRole(saved.Path, "node")); + } + + [Theory] + [InlineData("wrong-shared")] + [InlineData("no-fallback")] + [InlineData("unsafe")] + [InlineData("conflict")] + [InlineData("repeated")] + [InlineData("rejected-fallback")] + [InlineData("cancel")] + [InlineData("cancel-fallback")] + public async Task RecoveryRejectsUnsafeOrUnrelatedFailuresWithoutChangingSavedIdentity(string scenario) + { + using var saved = new TempDirectory(); + var identity = new DeviceIdentity(saved.Path); + identity.Initialize(); + if (scenario != "wrong-shared") identity.StoreDeviceTokenForRole("operator", "revoked"); + var file = Path.Combine(saved.Path, "device-key-ed25519.json"); + var before = File.ReadAllBytes(file); + using var copy = new GatewayValidationIdentity(saved.Path); + using var cancellation = new CancellationTokenSource(); + var handshakes = 0; + var validator = CreateValidator((record, credential, tunnel, config, generation, ct) => + { + if (scenario == "cancel" && credential.Source != CredentialResolver.SourceDeviceToken) + cancellation.Cancel(); + return Task.FromResult(scenario == "conflict" && credential.Source != CredentialResolver.SourceDeviceToken + ? new ReconnectAuthorizationResult(false, GatewayErrorKind.LocalPortConflict, "unowned listener") + : ReconnectAuthorizationResult.AllowedResult); + }, (_, _) => + { + handshakes++; + if (scenario == "cancel-fallback" && handshakes == 2) + { + cancellation.Cancel(); + return Task.FromResult(new SetupCodeResult(SetupCodeOutcome.Success)); + } + return Task.FromResult(GatewayConnectionValidator.AuthenticationFailure( + scenario == "wrong-shared" || scenario == "rejected-fallback" && handshakes == 2 + ? "AUTH_TOKEN_MISMATCH: wrong shared token" : "AUTH_DEVICE_TOKEN_MISMATCH: revoked")); + }); + var record = new GatewayRecord + { + Url = scenario == "unsafe" ? "ws://remote.example" : + scenario == "conflict" ? "ws://127.0.0.1:18789" : "wss://gateway.example", + IsLocal = scenario == "conflict", + SharedGatewayToken = scenario == "no-fallback" ? null : "shared", + BootstrapToken = scenario == "rejected-fallback" ? "must-not-fall-back-again" : null, + }; + if (scenario is "cancel" or "cancel-fallback") + await Assert.ThrowsAnyAsync(() => + validator.ValidateAsync(record, copy, new HashSet(), cancellation.Token)); + else + Assert.Equal(SetupCodeOutcome.ConnectionFailed, + (await validator.ValidateAsync(record, copy, new HashSet(), cancellation.Token)).Outcome); + var recovered = scenario is "repeated" or "rejected-fallback" or "cancel-fallback"; + Assert.Equal(recovered ? 2 : 1, handshakes); + Assert.Equal(before, File.ReadAllBytes(file)); + if (!recovered) Assert.Equal(before, File.ReadAllBytes(Path.Combine(copy.DirectoryPath, "device-key-ed25519.json"))); + } + + [Fact] + public void AuthenticationFailure_PreservesOnlyStructuredDeviceMismatchForRecovery() + { + Assert.Equal(GatewayErrorKind.DeviceTokenMismatch, + GatewayConnectionValidator.AuthenticationFailure("AUTH_DEVICE_TOKEN_MISMATCH: rejected").ErrorKind); + Assert.NotEqual(GatewayErrorKind.DeviceTokenMismatch, + GatewayConnectionValidator.AuthenticationFailure("token mismatch").ErrorKind); + Assert.NotEqual(GatewayErrorKind.DeviceTokenMismatch, + GatewayConnectionValidator.AuthenticationFailure("device token invalid").ErrorKind); + } + + [Fact] + public async Task Validate_UsesCopiedDeviceIdentityWithoutWritingSavedFiles() + { + using var saved = new TempDirectory(); + var original = new DeviceIdentity(saved.Path); + original.Initialize(); + original.StoreDeviceTokenForRole("operator", "device-token"); + var before = File.ReadAllBytes(Path.Combine(saved.Path, "device-key-ed25519.json")); + using var copy = new GatewayValidationIdentity(saved.Path); + var copiedIdentity = new DeviceIdentity(copy.DirectoryPath); + copiedIdentity.Initialize(); + Assert.Equal(original.DeviceId, copiedIdentity.DeviceId); + GatewayCredential? authorized = null; + var validator = CreateValidator((record, credential, tunnel, config, generation, ct) => + { + authorized = credential; + return Task.FromResult(ReconnectAuthorizationResult.AllowedResult); + }, async (client, ct) => + { + Assert.False((await client.ReconnectAuthorizationAsync!(ct)).Allowed); + Assert.True((await client.HandshakeAuthorizationAsync!(ct)).Allowed); + return new(SetupCodeOutcome.Success); + }); + var result = await validator.ValidateAsync(new() + { + Url = "wss://gateway.example", SharedGatewayToken = "shared", BootstrapToken = "bootstrap" + }, copy, new HashSet(), CancellationToken.None); + Assert.Equal(SetupCodeOutcome.Success, result.Outcome); + Assert.Equal(CredentialResolver.SourceDeviceToken, authorized!.Source); + Assert.Equal("device-token", authorized.Token); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(saved.Path, "device-key-ed25519.json"))); + } + + [Fact] + public async Task Validate_BootstrapIsNotTreatedAsSharedCredential() + { + using var identity = new GatewayValidationIdentity(); + var validator = CreateValidator((record, credential, tunnel, config, generation, ct) => + { + Assert.True(credential.IsBootstrapToken); + Assert.Equal(CredentialResolver.SourceBootstrapToken, credential.Source); + return Task.FromResult(ReconnectAuthorizationResult.AllowedResult); + }); + var result = await validator.ValidateAsync(new() + { + Url = "wss://gateway.example", BootstrapToken = "bootstrap" + }, identity, new HashSet(), CancellationToken.None); + Assert.Equal(SetupCodeOutcome.Success, result.Outcome); + } + + [Fact] + public async Task Validate_DeniedProvenanceNeverConstructsHandshakeClient() + { + using var identity = new GatewayValidationIdentity(); + var validator = CreateValidator((record, credential, tunnel, config, generation, ct) => + Task.FromResult(new ReconnectAuthorizationResult(false, GatewayErrorKind.LocalPortConflict, "unowned")), + (_, _) => throw new InvalidOperationException("Must not contact the gateway.")); + var result = await validator.ValidateAsync(new() + { + Url = "ws://127.0.0.1:18789", IsLocal = true, SharedGatewayToken = "shared" + }, identity, new HashSet(), CancellationToken.None); + Assert.Equal(SetupCodeOutcome.ConnectionFailed, result.Outcome); + Assert.Equal("unowned", result.ErrorMessage); + Assert.Empty(Directory.GetFiles(identity.DirectoryPath)); + } + + [Fact] + public async Task Validate_SshUsesOwnedTemporaryPortAndPinnedGeneration() + { + using var identity = new GatewayValidationIdentity(); + var tunnel = new FakeTunnel(); + var ssh = new SshTunnelConfig("user", "host.example", 18789, 45678, true, 2222); + var validator = CreateValidator(async (record, credential, manager, config, generation, ct) => + { + Assert.Same(tunnel, manager); + Assert.NotNull(config); + Assert.NotEqual(ssh.LocalPort, config.LocalPort); + Assert.False(config.IncludeBrowserProxyForward); + Assert.Equal(2222, config.SshPort); + return await GatewayConnectionManager.AuthorizeValidationTunnelHandshakeAsync( + manager!, config, generation!.Value, ct); + }, async (client, ct) => + { + tunnel.Generation++; + var permission = await client.HandshakeAuthorizationAsync!(ct); + Assert.False(permission.Allowed); + return new(SetupCodeOutcome.ConnectionFailed, permission.Detail); + }, tunnel); + var result = await validator.ValidateAsync(new() + { + Url = "ws://127.0.0.1:18789", SharedGatewayToken = "shared", SshTunnel = ssh + }, identity, new HashSet { 45678, 45680 }, CancellationToken.None); + Assert.Equal(SetupCodeOutcome.ConnectionFailed, result.Outcome); + Assert.Contains("shared token was not sent", result.ErrorMessage); + Assert.True(tunnel.Stopped); + Assert.True(tunnel.Disposed); + } + + [Fact] + public async Task Validate_CancellationDrainsOwnedTunnelAndDoesNotReportSuccess() + { + using var identity = new GatewayValidationIdentity(); + using var cancellation = new CancellationTokenSource(); + var tunnel = new FakeTunnel(); + var validator = CreateValidator((record, credential, manager, config, generation, ct) => + Task.FromResult(ReconnectAuthorizationResult.AllowedResult), + async (_, ct) => + { + cancellation.Cancel(); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return new(SetupCodeOutcome.Success); + }, tunnel); + await Assert.ThrowsAnyAsync(() => validator.ValidateAsync(new() + { + Url = "ws://127.0.0.1:18789", SharedGatewayToken = "shared", + SshTunnel = new("user", "host.example", 18789, 45678) + }, identity, new HashSet(), cancellation.Token)); + Assert.True(tunnel.Stopped); + Assert.True(tunnel.Disposed); + } + + [Fact] + public void ValidationClient_DisablesHandshakeTokenPersistence() + { + using var identity = new GatewayValidationIdentity(); + using var client = GatewayConnectionValidator.CreateClient( + "wss://gateway.example", new("shared", false, CredentialResolver.SourceSharedGatewayToken), + identity.DirectoryPath, new(), NullLogger.Instance, + _ => Task.FromResult(ReconnectAuthorizationResult.AllowedResult)); + var persist = typeof(OpenClawGatewayClient).GetField("_persistHandshakeDeviceTokens", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.False(Assert.IsType(persist!.GetValue(client))); + } + + [Fact] + public async Task Validate_RetainsIssuedBootstrapHandoffOnlyInMemoryUntilCommit() + { + using var identity = new GatewayValidationIdentity(); + using var committed = new TempDirectory(); + var requests = new List(); + var validator = CreateValidator((record, credential, tunnel, config, generation, ct) => + { + requests.Add(credential); + return Task.FromResult(ReconnectAuthorizationResult.AllowedResult); + }, (client, ct) => + { + var tokenEvent = typeof(OpenClawGatewayClient).GetField("DeviceTokenReceived", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var handler = Assert.IsType>(tokenEvent!.GetValue(client)); + handler(client, new("issued-device-token", ["operator.read"], "operator")); + return Task.FromResult(new SetupCodeResult(SetupCodeOutcome.Success)); + }); + var record = new GatewayRecord { Url = "wss://gateway.example", BootstrapToken = "single-use-bootstrap" }; + Assert.Equal(SetupCodeOutcome.Success, + (await validator.ValidateAsync(record, identity, new HashSet(), CancellationToken.None)).Outcome); + Assert.Null(DeviceIdentity.TryReadStoredDeviceToken(identity.DirectoryPath)); + Assert.Equal(SetupCodeOutcome.Success, + (await validator.ValidateAsync(record, identity, new HashSet(), CancellationToken.None)).Outcome); + Assert.Equal(CredentialResolver.SourceBootstrapToken, requests[0].Source); + Assert.Equal(CredentialResolver.SourceDeviceToken, requests[1].Source); + Assert.Equal("issued-device-token", requests[1].Token); + Assert.Null(DeviceIdentity.TryReadStoredDeviceToken(identity.DirectoryPath)); + identity.CopyTo(committed.Path); + Assert.Equal("issued-device-token", DeviceIdentity.TryReadStoredDeviceToken(committed.Path)); + } + + private static GatewayConnectionValidator CreateValidator( + ValidationHandshakeAuthorization authorize, + Func>? handshake = null, + ISshTunnelManager? tunnel = null) => + new(new CredentialResolver(DeviceIdentityFileReader.Instance), + () => tunnel ?? throw new InvalidOperationException("SSH was not expected."), + authorize, NullLogger.Instance, handshake ?? ((_, _) => Task.FromResult(new SetupCodeResult(SetupCodeOutcome.Success)))); + + [Fact] + public void StagedIdentity_SameLogicalGatewayPromotionAndRollbackPreserveOriginalKey() + { + using var saved = new TempDirectory(); + var original = new DeviceIdentity(saved.Path); + original.Initialize(); + original.StoreDeviceTokenForRole("operator", "old-token"); + var file = Path.Combine(saved.Path, "device-key-ed25519.json"); + var before = File.ReadAllBytes(file); + using var staged = new GatewayValidationIdentity(saved.Path); + staged.CaptureToken(new("new-token", ["operator.read"], "operator")); + var transaction = staged.ReplaceExisting(saved.Path); + Assert.Equal("new-token", DeviceIdentity.TryReadStoredDeviceToken(saved.Path)); + var reloaded = new DeviceIdentity(saved.Path); + reloaded.Initialize(); + Assert.Equal(original.DeviceId, reloaded.DeviceId); + Assert.Equal(DeviceTokenRestoreOutcome.Restored, DeviceIdentity.RestoreValidatedIdentity(transaction).Outcome); + Assert.Equal(before, File.ReadAllBytes(file)); + } + + [Fact] + public void StagedIdentity_ConcurrentSavedChangeIsNotOverwritten() + { + using var saved = new TempDirectory(); + var identity = new DeviceIdentity(saved.Path); + identity.Initialize(); + using var staged = new GatewayValidationIdentity(saved.Path); + identity.StoreDeviceTokenForRole("operator", "newer-writer"); + Assert.Throws(() => staged.ReplaceExisting(saved.Path)); + Assert.Equal("newer-writer", DeviceIdentity.TryReadStoredDeviceToken(saved.Path)); + } + + [Fact] + public void StagedIdentity_RollbackDoesNotClobberNewerCredentials() + { + using var saved = new TempDirectory(); + new DeviceIdentity(saved.Path).Initialize(); + using var staged = new GatewayValidationIdentity(saved.Path); + var transaction = staged.ReplaceExisting(saved.Path); + var newer = new DeviceIdentity(saved.Path); + newer.Initialize(); + newer.StoreDeviceTokenForRole("operator", "newer-writer"); + Assert.Equal(DeviceTokenRestoreOutcome.Superseded, DeviceIdentity.RestoreValidatedIdentity(transaction).Outcome); + Assert.Equal("newer-writer", DeviceIdentity.TryReadStoredDeviceToken(saved.Path)); + } + + [Fact] + public void StagedIdentity_PreviouslyAbsentKeyIsRemovedOnRollbackWithoutDeletingSidecars() + { + using var saved = new TempDirectory(); + var sidecar = Path.Combine(saved.Path, "sidecar.txt"); + File.WriteAllText(sidecar, "retained"); + using var staged = new GatewayValidationIdentity(saved.Path); + new DeviceIdentity(staged.DirectoryPath).Initialize(); + var transaction = staged.ReplaceExisting(saved.Path); + Assert.Equal(DeviceTokenRestoreOutcome.Restored, DeviceIdentity.RestoreValidatedIdentity(transaction).Outcome); + Assert.False(File.Exists(Path.Combine(saved.Path, "device-key-ed25519.json"))); + Assert.Equal("retained", File.ReadAllText(sidecar)); + } + + [Fact] + public async Task Validate_ExactBoundedBootstrapFallbackGetsOneFreshAuthorizedAttempt() + { + using var identity = new GatewayValidationIdentity(); + var authorizations = 0; + var attempts = 0; + var validator = CreateValidator((record, credential, tunnel, config, generation, ct) => + { + authorizations++; + return Task.FromResult(ReconnectAuthorizationResult.AllowedResult); + }, async (client, ct) => + { + attempts++; + Assert.False((await client.ReconnectAuthorizationAsync!(ct)).Allowed); + if (attempts == 1) + { + using var error = System.Text.Json.JsonDocument.Parse( + """{"error":{"code":"AUTH_BOOTSTRAP_TOKEN_INVALID","message":"bootstrap token invalid"}}"""); + var handle = typeof(OpenClawGatewayClient).GetMethod("HandleRequestError", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + handle!.Invoke(client, ["connect", error.RootElement, 0L]); + Assert.True(client.UsesBoundedBootstrapScopes); + return new(SetupCodeOutcome.ConnectionFailed, "full profile rejected"); + } + Assert.True(client.UsesBoundedBootstrapScopes); + return new(SetupCodeOutcome.Success); + }); + var result = await validator.ValidateAsync(new() + { + Url = "wss://gateway.example", BootstrapToken = "bootstrap" + }, identity, new HashSet(), CancellationToken.None); + Assert.Equal(SetupCodeOutcome.Success, result.Outcome); + Assert.Equal(2, attempts); + Assert.Equal(2, authorizations); + } + + [Fact] + public async Task Validate_SignatureFallbackIsBoundedAndReauthorizesEndpoint() + { + using var identity = new GatewayValidationIdentity(); + var authorizations = 0; + var attempts = 0; + var validator = CreateValidator((record, credential, tunnel, config, generation, ct) => + { + authorizations++; + return Task.FromResult(ReconnectAuthorizationResult.AllowedResult); + }, (client, ct) => + { + attempts++; + if (attempts == 1) + client.UseV2Signature = true; + else + Assert.True(client.UseV2Signature); + return Task.FromResult(new SetupCodeResult(SetupCodeOutcome.ConnectionFailed, "rejected")); + }); + var result = await validator.ValidateAsync(new() + { + Url = "wss://gateway.example", SharedGatewayToken = "shared" + }, identity, new HashSet(), CancellationToken.None); + Assert.Equal(SetupCodeOutcome.ConnectionFailed, result.Outcome); + Assert.Equal("rejected", result.ErrorMessage); + Assert.Equal(2, attempts); + Assert.Equal(2, authorizations); + } + + [Fact] + public async Task Validate_DisconnectFailureRetainsAuthenticatedBootstrapUpgradeForRetry() + { + using var identity = new GatewayValidationIdentity(); + var credentials = new List(); + var attempts = 0; + var validator = CreateValidator((record, credential, tunnel, config, generation, ct) => + { + credentials.Add(credential); + return Task.FromResult(ReconnectAuthorizationResult.AllowedResult); + }, (client, ct) => + { + if (++attempts == 1) + { + const System.Reflection.BindingFlags flags = + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; + var tokenHandler = Assert.IsType>( + typeof(OpenClawGatewayClient).GetField("DeviceTokenReceived", flags)!.GetValue(client)); + tokenHandler(client, new("issued-before-close", ["operator.read"], "operator")); + var handshake = Assert.IsType( + typeof(OpenClawGatewayClient).GetField("HandshakeSucceeded", flags)!.GetValue(client)); + handshake(client, EventArgs.Empty); + throw new TimeoutException("graceful disconnect timed out"); + } + return Task.FromResult(new SetupCodeResult(SetupCodeOutcome.Success)); + }); + var record = new GatewayRecord { Url = "wss://gateway.example", BootstrapToken = "single-use" }; + Assert.Equal(SetupCodeOutcome.ConnectionFailed, + (await validator.ValidateAsync(record, identity, new HashSet(), CancellationToken.None)).Outcome); + Assert.Null(DeviceIdentity.TryReadStoredDeviceToken(identity.DirectoryPath)); + Assert.Equal(SetupCodeOutcome.Success, + (await validator.ValidateAsync(record, identity, new HashSet(), CancellationToken.None)).Outcome); + Assert.Equal("issued-before-close", credentials[1].Token); + Assert.Equal(CredentialResolver.SourceDeviceToken, credentials[1].Source); + } + + private sealed class FakeTunnel : ISshTunnelManager + { + public long Generation = 1; + public bool Stopped; + public bool Disposed; + public bool IsActive => ActiveConfig is not null && !Stopped; + public long OwnershipGeneration => Generation; + public SshTunnelConfig? ActiveConfig { get; private set; } + public string? LocalTunnelUrl => ActiveConfig is null ? null : $"ws://localhost:{ActiveConfig.LocalPort}"; + public bool IsRestartPending(SshTunnelExit exit) => false; + public Task IsOwnedListenerReadyAsync(SshTunnelConfig config, int port, CancellationToken ct) => + Task.FromResult(config == ActiveConfig && port == config.LocalPort); + public async Task StartAsync(SshTunnelConfig config, CancellationToken ct) => + (await StartOwnedAsync(config, ct)).Url; + public Task StartOwnedAsync(SshTunnelConfig config, CancellationToken ct) + { + ActiveConfig = config; + return Task.FromResult(new SshTunnelStartResult(LocalTunnelUrl!, config, Generation)); + } + public Task StopAsync() { Stopped = true; return Task.CompletedTask; } + public Task StopIfOwnedAsync(SshTunnelConfig config, long generation, CancellationToken ct) => + Task.FromResult(config == ActiveConfig && generation == Generation); + public void Dispose() => Disposed = true; + } +} diff --git a/tests/OpenClaw.Connection.Tests/GatewayRegistryPersistenceTests.cs b/tests/OpenClaw.Connection.Tests/GatewayRegistryPersistenceTests.cs new file mode 100644 index 000000000..313369f55 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/GatewayRegistryPersistenceTests.cs @@ -0,0 +1,110 @@ +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +public sealed class GatewayRegistryPersistenceTests +{ + [Theory] + [InlineData("endpoint")] + [InlineData("credential")] + [InlineData("active")] + [InlineData("addition")] + public void StaleInstanceCannotOverwriteAnotherWriter(string change) + { + using var temp = new TempDirectory(); + var first = new GatewayRegistry(temp.Path); + first.AddOrUpdate(new() { Id = "a", Url = "wss://a.example" }); + first.AddOrUpdate(new() { Id = "b", Url = "wss://b.example" }); + first.SetActive("a"); + first.Save(); + var stale = new GatewayRegistry(temp.Path); + stale.Load(); + switch (change) + { + case "endpoint": first.Update("a", value => value with { Url = "wss://new.example" }); break; + case "credential": first.Update("a", value => value with { SharedGatewayToken = "new" }); break; + case "active": first.SetActive("b"); break; + case "addition": first.AddOrUpdate(new() { Id = "c", Url = "wss://c.example" }); break; + } + first.Save(); + var saved = File.ReadAllBytes(Path.Combine(temp.Path, "gateways.json")); + var before = stale.GetSnapshot(); + Assert.Throws(() => stale.UpdateAndSave("a", value => value with { LastConnected = DateTime.UtcNow })); + Assert.Equal(before.Records, stale.GetAll()); + Assert.Throws(() => stale.Save()); + Assert.Equal(saved, File.ReadAllBytes(Path.Combine(temp.Path, "gateways.json"))); + } + + [Fact] + public async Task OtherInstanceCannotWriteBetweenFinalCheckAndReplacement() + { + using var temp = new TempDirectory(); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var fs = new HeldWriteFileSystem(entered, release); + var first = new GatewayRegistry(temp.Path, fs); + first.AddOrUpdate(new() { Id = "a", Url = "wss://a.example" }); + first.SetActive("a"); + first.Save(); + var second = new GatewayRegistry(temp.Path); + second.Load(); + first.Update("a", value => value with { SharedGatewayToken = "first-writer" }); + fs.Hold = true; + var saving = Task.Run(() => first.Save()); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var competing = Task.Run(() => + { + started.SetResult(); + return Record.Exception(() => second.UpdateAndSave("a", value => value with { Url = "wss://second.example" })); + }); + try + { + await started.Task; + Assert.NotSame(competing, await Task.WhenAny(competing, Task.Delay(150))); + } + finally { release.Set(); } + await saving.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsType(await competing.WaitAsync(TimeSpan.FromSeconds(5))); + var actual = new GatewayRegistry(temp.Path); + actual.Load(); + Assert.Equal("first-writer", actual.GetActive()!.SharedGatewayToken); + Assert.Equal("wss://a.example", actual.GetActive()!.Url); + } + + [Fact] + public void LastConnectedMergesMonotonicallyUnderTheLease() + { + using var temp = new TempDirectory(); + var first = new GatewayRegistry(temp.Path); + first.AddOrUpdate(new() { Id = "a", Url = "wss://a.example" }); + first.Save(); + var second = new GatewayRegistry(temp.Path); + second.Load(); + var newer = new DateTime(2026, 9, 26, 12, 0, 0, DateTimeKind.Utc); + first.UpdateAndSave("a", value => value with { LastConnected = newer }); + second.UpdateAndSave("a", value => value with { LastConnected = newer.AddMinutes(-1) }); + Assert.Equal(newer, second.GetById("a")!.LastConnected); + first.Load(); + Assert.Equal(newer, first.GetById("a")!.LastConnected); + } + + private sealed class HeldWriteFileSystem(ManualResetEventSlim entered, ManualResetEventSlim release) : IFileSystem + { + public bool Hold { get; set; } + public bool FileExists(string path) => File.Exists(path); + public bool DirectoryExists(string path) => Directory.Exists(path); + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + public string ReadAllText(string path) => File.ReadAllText(path); + public void WriteAllText(string path, string contents) + { + File.WriteAllText(path, contents); + if (!Hold) return; + entered.Set(); + if (!release.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("Test barrier timed out."); + } + public void CopyFile(string source, string destination, bool overwrite) => File.Copy(source, destination, overwrite); + public void DeleteFile(string path) => File.Delete(path); + } +} diff --git a/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs b/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs index 08c034e9f..83a56025e 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayRegistryTests.cs @@ -22,6 +22,47 @@ public void Dispose() _temp.Dispose(); } + [Fact] + public void CapturePersistedSnapshot_AcceptsOnlyPendingConnectionBookkeeping() + { + var original = MakeRecord("gw-1", "wss://test1"); + _registry.AddOrUpdate(original); + _registry.SetActive(original.Id); + _registry.Save(); + var connected = original with { LastConnected = DateTime.UtcNow }; + _registry.Update(original.Id, _ => connected); + var snapshot = _registry.CapturePersistedSnapshot(); + Assert.Equal(connected, Assert.Single(snapshot.Records)); + _registry.Update(original.Id, _ => connected with { SharedGatewayToken = "new-authority" }); + Assert.Throws(() => _registry.CapturePersistedSnapshot()); + } + + [Fact] + public void CapturePersistedSnapshot_StillRejectsAuthorityActiveAndOtherEdits() + { + var record = MakeRecord("gw-1", "wss://test1"); + _registry.AddOrUpdate(record); + _registry.SetActive(record.Id); + _registry.Save(); + foreach (var edited in new[] + { + record with { Url = "wss://other" }, + record with { SharedGatewayToken = "changed" }, + record with { BootstrapToken = "changed" }, + record with { SshTunnel = new("user", "host", 18789, 19001) }, + record with { FriendlyName = "changed" }, + }) + { + _registry.Update(record.Id, _ => edited); + Assert.Throws(() => _registry.CapturePersistedSnapshot()); + _registry.Update(record.Id, _ => record); + } + _registry.AddOrUpdate(MakeRecord("gw-2", "wss://test2")); + _registry.Save(); + _registry.SetActive("gw-2"); + Assert.Throws(() => _registry.CapturePersistedSnapshot()); + } + [Fact] public void InitialState_IsEmpty() { diff --git a/tests/OpenClaw.Connection.Tests/IdentityCleanupTransactionTests.cs b/tests/OpenClaw.Connection.Tests/IdentityCleanupTransactionTests.cs new file mode 100644 index 000000000..9c53b3a67 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/IdentityCleanupTransactionTests.cs @@ -0,0 +1,137 @@ +using System.Security.Cryptography; +using System.Text; +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Connection.Tests; + +public sealed class IdentityCleanupTransactionTests +{ + [Fact] + public void CreationReceiptKeepsTheExactPublishedBaselineAfterLaterWrites() + { + using var temp = new TempDirectory(); + var registry = new GatewayRegistry(temp.Path); + var id = Guid.NewGuid().ToString(); + using var staged = new GatewayValidationIdentity(); + new DeviceIdentity(staged.DirectoryPath).Initialize(); + var expectedJson = File.ReadAllText(Path.Combine(staged.DirectoryPath, "device-key-ed25519.json")); + var creation = staged.CopyTo(registry.GetIdentityDirectory(id)); + var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(expectedJson))); + Assert.Null(creation.OriginalJson); + Assert.Equal(expectedHash, creation.AppliedContentHash); + var later = new DeviceIdentity(registry.GetIdentityDirectory(id)); + later.LoadExisting(); + later.StoreDeviceTokenForRole("operator", "newer-writer"); + var newerJson = File.ReadAllText(creation.IdentityPath); + Assert.Equal(expectedHash, creation.AppliedContentHash); + Assert.NotEqual(expectedHash, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(newerJson)))); + Assert.False(registry.RemoveUnregisteredIdentity(id, creation)); + Assert.Equal(newerJson, File.ReadAllText(creation.IdentityPath)); + } + + [Fact] + public async Task CleanupWaitsForTheIdentityWriterThenRejectsItsOlderCreationBaseline() + { + using var temp = new TempDirectory(); + var registry = new GatewayRegistry(temp.Path); + var id = Guid.NewGuid().ToString(); + using var staged = new GatewayValidationIdentity(); + new DeviceIdentity(staged.DirectoryPath).Initialize(); + var creation = staged.CopyTo(registry.GetIdentityDirectory(id)); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var writer = new DeviceIdentity(registry.GetIdentityDirectory(id), new HoldingLogger(entered, release)); + writer.LoadExisting(); + var updating = Task.Run(() => writer.StoreDeviceTokenForRole("operator", "newer-writer")); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deleting = Task.Run(() => + { + started.SetResult(); + return registry.RemoveUnregisteredIdentity(id, creation); + }); + try + { + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.NotSame(deleting, await Task.WhenAny(deleting, Task.Delay(150))); + } + finally { release.Set(); } + await updating.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(await deleting.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Equal("newer-writer", DeviceIdentity.TryReadStoredDeviceToken(registry.GetIdentityDirectory(id))); + } + + [Fact] + public async Task CreateIfAbsentWaitsForIdentityWriterAndCannotOverwriteItsFile() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("destination"); + var existing = new DeviceIdentity(destination); + existing.Initialize(); + using var staged = new GatewayValidationIdentity(); + new DeviceIdentity(staged.DirectoryPath).Initialize(); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var writer = new DeviceIdentity(destination, new HoldingLogger(entered, release)); + writer.LoadExisting(); + var updating = Task.Run(() => writer.StoreDeviceTokenForRole("operator", "keep")); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var copying = Task.Run(() => + { + started.SetResult(); + return Record.Exception(() => staged.CopyTo(destination)); + }); + try + { + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.NotSame(copying, await Task.WhenAny(copying, Task.Delay(150))); + } + finally { release.Set(); } + await updating.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsType(await copying.WaitAsync(TimeSpan.FromSeconds(5))); + var actual = new DeviceIdentity(destination); + actual.LoadExisting(); + Assert.Equal(existing.DeviceId, actual.DeviceId); + Assert.Equal("keep", actual.DeviceToken); + } + + [Fact] + public void UnchangedUnregisteredCreationCanBeRemovedButAdoptedAndUnknownRemain() + { + using var temp = new TempDirectory(); + var registry = new GatewayRegistry(temp.Path); + using var staged = new GatewayValidationIdentity(); + new DeviceIdentity(staged.DirectoryPath).Initialize(); + var id = Guid.NewGuid().ToString(); + var creation = staged.CopyTo(registry.GetIdentityDirectory(id)); + File.WriteAllText(temp.Combine("gateways.json"), "{"); + Assert.Throws(() => registry.RemoveUnregisteredIdentity(id, creation)); + Assert.True(File.Exists(creation.IdentityPath)); + File.Delete(temp.Combine("gateways.json")); + var external = new GatewayRegistry(temp.Path); + external.AddOrUpdate(new() { Id = id, Url = "wss://adopted.example" }); + external.Save(); + Assert.False(registry.RemoveUnregisteredIdentity(id, creation)); + Assert.True(File.Exists(creation.IdentityPath)); + external.Remove(id); + external.Save(); + Assert.True(registry.RemoveUnregisteredIdentity(id, creation)); + Assert.False(Directory.Exists(registry.GetIdentityDirectory(id))); + } + + private sealed class HoldingLogger(ManualResetEventSlim entered, ManualResetEventSlim release) : IOpenClawLogger + { + public void Info(string message) + { + if (message != "Device token stored") return; + // The production token writer invokes this while holding WithIdentityFileLock. + entered.Set(); + if (!release.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("Identity-writer barrier timed out."); + } + public void Debug(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? error = null) { } + } +} diff --git a/tests/OpenClaw.Connection.Tests/LocalAiPortLifecycleTests.cs b/tests/OpenClaw.Connection.Tests/LocalAiPortLifecycleTests.cs index 97f86b893..9b22a1dc5 100644 --- a/tests/OpenClaw.Connection.Tests/LocalAiPortLifecycleTests.cs +++ b/tests/OpenClaw.Connection.Tests/LocalAiPortLifecycleTests.cs @@ -1278,6 +1278,7 @@ public async Task Startup_TeardownFailureAfterExitedChildPublishesFailedCleanupS Assert.Equal(LocalAiOwnership.None, failed.Ownership); Assert.Null(failed.ProcessId); Assert.Contains("could not be safely disabled", failed.Detail, StringComparison.Ordinal); + Assert.True(failed.GatewayRouteRequiresResolution); Assert.Equal( ["quiesce:EndpointCycle", "start", "quiesce:Teardown", "stop"], events); @@ -1323,6 +1324,7 @@ await Assert.ThrowsAnyAsync( Assert.Equal(LocalAiRuntimeState.Failed, runtime.Snapshot.State); Assert.Equal(LocalAiOwnership.None, runtime.Snapshot.Ownership); Assert.Contains("could not be safely disabled", runtime.Snapshot.Detail, StringComparison.Ordinal); + Assert.True(runtime.Snapshot.GatewayRouteRequiresResolution); Assert.Equal( ["quiesce:EndpointCycle", "start", "quiesce:Teardown", "stop"], events); @@ -1354,6 +1356,7 @@ public async Task Startup_EndpointCycleExceptionPublishesFailedState() Assert.Equal(LocalAiRuntimeState.Failed, failed.State); Assert.Equal(LocalAiOwnership.None, failed.Ownership); Assert.Equal("endpoint-cycle withdrawal failed", failed.Detail); + Assert.False(failed.GatewayRouteRequiresResolution); Assert.Equal(["quiesce:EndpointCycle", "quiesce:Teardown"], events); } @@ -1381,6 +1384,7 @@ public async Task Startup_PublishExceptionWithdrawsUsingVerifiedEndpoint() Assert.Equal( [null, new Uri("http://127.0.0.1:28785/v1")], lifecycle.QuiescedEndpoints); + Assert.False(failed.GatewayRouteRequiresResolution); Assert.Equal( ["quiesce:EndpointCycle", "start", "probe:28785", "publish:28785", "quiesce:Teardown", "stop"], events); diff --git a/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs b/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs index a2fc2ad63..2a24adaf8 100644 --- a/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs +++ b/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs @@ -36,6 +36,7 @@ public sealed class E2ESetupFixture : IAsyncLifetime /// Set as OPENCLAW_TRAY_DATA_DIR env var. /// public string DataDir { get; } + public string RoamingAppDataRoot { get; } public string LocalAppDataRoot { get; } public int McpPort { get; private set; } @@ -68,6 +69,7 @@ internal E2ESetupFixture( { _distroName = "OpenClawE2E-disabled"; DataDir = string.Empty; + RoamingAppDataRoot = string.Empty; LocalAppDataRoot = string.Empty; ArtifactDir = string.Empty; _configPath = string.Empty; @@ -88,7 +90,9 @@ internal E2ESetupFixture( runId) : Path.Combine(Path.GetTempPath(), $"openclaw-e2e-{runId}"); LocalAppDataRoot = Path.Combine(Path.GetTempPath(), $"openclaw-e2e-localappdata-{runId}"); + RoamingAppDataRoot = Path.Combine(Path.GetTempPath(), $"openclaw-e2e-roaming-{runId}"); Directory.CreateDirectory(DataDir); + Directory.CreateDirectory(RoamingAppDataRoot); Directory.CreateDirectory(LocalAppDataRoot); // Artifact dir under repo TestResults — persists after cleanup for CI upload @@ -139,7 +143,7 @@ private async Task InitializeEnabledAsync() var setupLogPath = Path.Combine(ArtifactDir, "setup-engine.jsonl"); Environment.SetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR", DataDir); - Environment.SetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR", DataDir); + Environment.SetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR", RoamingAppDataRoot); Environment.SetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR", LocalAppDataRoot); var setupArguments = new List @@ -238,7 +242,7 @@ private async Task DisposeEnabledAsync() var uninstallLogPath = Path.Combine(ArtifactDir, "uninstall-engine.jsonl"); Environment.SetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR", DataDir); - Environment.SetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR", DataDir); + Environment.SetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR", RoamingAppDataRoot); Environment.SetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR", LocalAppDataRoot); try @@ -260,6 +264,8 @@ private async Task DisposeEnabledAsync() catch (Exception ex) { Log($"Warning: temp dir cleanup failed: {ex.Message}"); } try { Directory.Delete(LocalAppDataRoot, recursive: true); } catch (Exception ex) { Log($"Warning: temp local appdata cleanup failed: {ex.Message}"); } + try { Directory.Delete(RoamingAppDataRoot, recursive: true); } + catch (Exception ex) { Log($"Warning: temp roaming appdata cleanup failed: {ex.Message}"); } Log("Teardown complete."); } @@ -612,7 +618,7 @@ private Process SpawnTray(string exePath) RedirectStandardError = true, }; psi.Environment["OPENCLAW_TRAY_DATA_DIR"] = DataDir; - psi.Environment["OPENCLAW_TRAY_APPDATA_DIR"] = DataDir; + psi.Environment["OPENCLAW_TRAY_APPDATA_DIR"] = RoamingAppDataRoot; psi.Environment["OPENCLAW_TRAY_LOCALAPPDATA_DIR"] = LocalAppDataRoot; psi.Environment["OPENCLAW_MCP_PORT"] = McpPort.ToString(); psi.Environment["OPENCLAW_SUPPRESS_EXTERNAL_BROWSER"] = "1"; diff --git a/tests/OpenClaw.E2ETests/Setup/MxcSetupAndConnectTests.cs b/tests/OpenClaw.E2ETests/Setup/MxcSetupAndConnectTests.cs index 9e9161e4d..88e4d0d71 100644 --- a/tests/OpenClaw.E2ETests/Setup/MxcSetupAndConnectTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/MxcSetupAndConnectTests.cs @@ -74,6 +74,38 @@ public MxcSetupAndConnectTests(MxcE2ESetupFixture fixture) throw new InvalidOperationException("E2E fixture MCP client not initialized"); } + [MxcE2EFact] + public async Task RealGateway_FocusedAiDiscovery_UsesAdvertisedOperatorContract() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + await using var session = await SetupGatewaySession.ConnectAsync(_fixture.DataDir, ct: timeout.Token); + Assert.Contains("openclaw.setup.detect", session.Client.AdvertisedServerMethods); + Assert.Contains("operator.admin", session.Client.GrantedOperatorScopes); + var client = new GatewayAiSetupClient(new GatewayAiSetupTransport(session.Client, session.GetRoute)); + var detection = await client.DetectAsync(timeout.Token); + Assert.NotNull(detection); + Assert.Equal(GatewayAiSetupPhase.Choosing, client.Phase); + Assert.Null(client.Selection); + Assert.Null(client.SessionId); + Assert.False(string.IsNullOrWhiteSpace(detection.Workspace)); + + await File.WriteAllTextAsync( + Path.Combine(_fixture.ArtifactDir, "focused-ai-discovery.json"), + JsonSerializer.Serialize(new + { + method = "openclaw.setup.detect", + advertised = true, + operatorAdmin = true, + candidates = detection.Candidates.Length, + manualProviders = detection.ManualProviders.Length, + authOptions = detection.AuthOptions.Length, + prepareOptions = detection.PrepareOptions.Length, + selectionMade = false, + wizardStarted = false, + }, new JsonSerializerOptions { WriteIndented = true }), + timeout.Token); + } + [MxcE2EFact] public async Task MirroredWslSafeGatewayPort_IsListeningAndRecorded() { diff --git a/tests/OpenClaw.SetupEngine.Tests/AiSetupPresentationModelTests.cs b/tests/OpenClaw.SetupEngine.Tests/AiSetupPresentationModelTests.cs new file mode 100644 index 000000000..56a05367d --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/AiSetupPresentationModelTests.cs @@ -0,0 +1,153 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class AiSetupPresentationModelTests +{ + private static readonly IReadOnlySet AllChoices = + Enum.GetValues().ToHashSet(); + + [Fact] + public void MixedDetection_KeepsEveryGroupDistinctAndServerOrdered() + { + var detection = Detection(withCandidates: true); + var view = AiSetupPresentationModel.Create(detection, AllChoices); + Assert.Equal(detection.Candidates, view.Candidates); + Assert.Equal(detection.UnavailableCandidates, view.UnavailableCandidates); + Assert.Equal(detection.PrepareOptions, view.PrepareOptions); + Assert.Equal(detection.ManualProviders, view.ManualProviders); + Assert.Equal(["featured"], view.FeaturedSignIn.Select(option => option.Id)); + Assert.Equal(["additional", "last"], view.MoreSignIn.Select(option => option.Id)); + Assert.Empty(view.RecommendedInstalls); + Assert.False(view.ShowApiKeyForm); + Assert.False(view.NoUsableCandidates); + Assert.True(view.HasChoices); + Assert.True(view.NativeSessionCatalogPreferenceRequired); + } + + [Fact] + public void NoUsableCandidates_ExposesReturnedWebsitesAndInlineManualForm() + { + var detection = Detection(withCandidates: false); + var view = AiSetupPresentationModel.Create(detection, AllChoices); + Assert.True(view.NoUsableCandidates); + Assert.True(view.ShowApiKeyForm); + Assert.Equal(["website"], view.RecommendedInstalls.Select(option => option.Id)); + Assert.Single(view.UnavailableCandidates); + Assert.Single(view.PrepareOptions); + Assert.Single(view.ManualProviders); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, true)] + public void ManualForm_WithCandidates_RequiresExplicitRequest(bool requested, bool visible) + { + var view = AiSetupPresentationModel.Create(Detection(true), AllChoices, apiKeyFormRequested: requested); + Assert.Equal(visible, view.ShowApiKeyForm); + Assert.Equal(["manual"], view.ManualProviders.Select(option => option.Id)); + } + + [Theory] + [InlineData(false, false, true)] + [InlineData(true, false, false)] + [InlineData(true, true, true)] + public void LocalChoice_IsTheSamePresentationTierAsDetectedModels(bool localVisible, bool requested, bool expanded) + { + var view = AiSetupPresentationModel.Create(Detection(false), AllChoices, + apiKeyFormRequested: requested, hasLocalChoice: localVisible); + Assert.Equal(localVisible, view.HasLocalChoice); + Assert.Equal(!localVisible, view.NoUsableCandidates); + Assert.Equal(expanded, view.ShowApiKeyForm); + Assert.Single(view.ManualProviders); + Assert.Single(view.PrepareOptions); + } + + [Fact] + public void EmptySuccessfulDetection_IsNotAnErrorAndDoesNotInventChoices() + { + var view = AiSetupPresentationModel.Create(new() + { + Candidates = [], ManualProviders = [], Workspace = "workspace", SetupComplete = false, + }, AllChoices); + Assert.True(view.HasDetection); + Assert.True(view.NoUsableCandidates); + Assert.False(view.DiscoveryFailed); + Assert.False(view.HasChoices); + Assert.False(view.ShowApiKeyForm); + Assert.Empty(view.RecommendedInstalls); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FailedDiscovery_NeverPresentsAnEmptyOrStaleCatalog(bool previousDetection) + { + var view = AiSetupPresentationModel.Create(previousDetection ? Detection(false) : null, + AllChoices, discoveryFailed: true, apiKeyFormRequested: true); + Assert.True(view.DiscoveryFailed); + Assert.False(view.HasDetection); + Assert.False(view.NoUsableCandidates); + Assert.False(view.HasChoices); + Assert.False(view.ShowApiKeyForm); + Assert.Empty(view.UnavailableCandidates); + Assert.Empty(view.RecommendedInstalls); + } + + [Fact] + public void PendingDiscovery_IsNeitherEmptyNorFailed() + { + var view = AiSetupPresentationModel.Create(null, AllChoices); + Assert.False(view.HasDetection); + Assert.False(view.DiscoveryFailed); + Assert.False(view.NoUsableCandidates); + } + + [Fact] + public void UnadvertisedActions_AreNotOfferedAndNeverChangeClassification() + { + var view = AiSetupPresentationModel.Create(Detection(true), + new HashSet { GatewayAiSetupChoiceKind.Auth }); + Assert.Empty(view.Candidates); + Assert.Empty(view.ManualProviders); + Assert.Empty(view.PrepareOptions); + Assert.False(view.ShowApiKeyForm); + Assert.Single(view.FeaturedSignIn); + Assert.Equal(2, view.MoreSignIn.Count); + Assert.Single(view.UnavailableCandidates); + } + + [Fact] + public void Projection_DoesNotMutateGatewayArraysOrInterpretCredentialFlags() + { + var detection = Detection(true); + var candidates = detection.Candidates.ToArray(); + var auth = detection.AuthOptions.ToArray(); + _ = AiSetupPresentationModel.Create(detection, AllChoices); + Assert.Equal(candidates, detection.Candidates); + Assert.Equal(auth, detection.AuthOptions); + Assert.Equal(2, detection.Candidates.Length); + // The Gateway classifies candidates and unavailable discoveries, not Windows. + Assert.False(detection.Candidates[1].Credentials); + } + + private static GatewayAiSetupDetection Detection(bool withCandidates) => new() + { + Candidates = withCandidates + ? [new("existing", "Existing", "Ready", "provider/model", true), + new("server-choice", "Another", "Gateway supplied", "provider/other", false, Credentials: false)] + : [], + UnavailableCandidates = [new("not-ready", "Detected tool", "Sign-in required", "missing-credentials")], + PrepareOptions = [new("prepare", "Local preparation")], + ManualProviders = [new("manual", "Manual credential")], + AuthOptions = [new("additional", "Additional"), new("featured", "Featured", Featured: true), new("last", "Last")], + RecommendedInstalls = + [ + new("website", "Returned website", Website: "https://provider.example/install"), + new("missing", "No website"), + new("unsafe", "Not a website", Website: "file:///C:/installer.exe"), + new("credential", "Embedded credentials", Website: "https://user:password@provider.example"), + ], + Workspace = "workspace", + SetupComplete = false, + NativeSessionCatalogPreferenceRequired = true, + }; +} diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupClientTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupClientTests.cs new file mode 100644 index 000000000..0e9ba4ed9 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupClientTests.cs @@ -0,0 +1,778 @@ +using System.Text.Json; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class GatewayAiSetupClientTests +{ + private const string Detection = """ + {"candidates":[{"kind":"provider-auto:demo","label":"Demo AI","detail":"Saved login","modelRef":"demo/model","recommended":true}], + "manualProviders":[{"id":"demo-key","label":"Demo key","groupLabel":"Demo"}], + "authOptions":[{"id":"demo-login","label":"Sign in","kind":"device-code","featured":true}], + "prepareOptions":[{"id":"demo-install","label":"Install local model","actionLabel":"Download"}], + "workspace":"/home/test","setupComplete":false} + """; + private const string Activated = """{"done":true,"status":"done","modelActivation":{"modelRef":"demo/model","gatewayRestartRequired":true}}"""; + private const string Persisted = """{"candidates":[],"manualProviders":[],"workspace":"/home/test","setupComplete":true,"configuredModel":"demo/model"}"""; + private const string Verified = """{"ok":true,"modelRef":"demo/model","latencyMs":12}"""; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task NativeVerifier_ActualMissingApiOrEmptyResponseIsUnavailable(bool missingApi) + { + var transport = new FakeTransport(); + if (missingApi) transport.Methods = []; + else transport.Replies.Enqueue(Json("null")); + var client = new GatewayAiSetupClient(transport, "demo/model"); + var error = await Assert.ThrowsAsync(() => + SetupNativeCompletionVerifier.VerifyModelAsync(client, "demo/model", CancellationToken.None)); + if (missingApi) Assert.IsType(error.InnerException); + else Assert.IsType(error.InnerException); + } + + [Fact] + public async Task VerifiedModelWithoutAuthenticatedSigningAuthority_CannotIssueCompletion() + { + var transport = new FakeTransport(); + transport.Route = transport.Route with { IdentityBinding = null }; + transport.Replies.Enqueue(Json(Verified)); + var client = new GatewayAiSetupClient(transport, "demo/model"); + Assert.True((await client.VerifyConfiguredAsync("demo/model")).Ok); + Assert.Throws(() => client.GetVerifiedCompletion()); + } + + [Fact] + public async Task Detect_IsPresentationOnly_AndStartRequiresSelection() + { + var (client, transport) = await CreateAsync(); + Assert.Equal(GatewayAiSetupPhase.Choosing, client.Phase); + Assert.Null(client.Selection); + Assert.Equal("Demo AI", client.Detection!.Candidates[0].Label); + await Assert.ThrowsAsync(() => client.StartSelectedAsync()); + Assert.Single(transport.Calls); + Assert.Equal("openclaw.setup.detect", transport.Calls[0].Method); + } + + [Theory] + [InlineData("")] + [InlineData("operator.read")] + [InlineData("node")] + public async Task NonAdmin_NeverSendsFocusedRequest(string scope) + { + var transport = new FakeTransport { OperatorScopes = [scope] }; + var client = new GatewayAiSetupClient(transport); + await Assert.ThrowsAsync(() => client.DetectAsync()); + Assert.Empty(transport.Calls); + Assert.NotEqual(GatewayAiSetupPhase.ClassicWizardRequired, client.Phase); + } + + [Fact] + public async Task MissingFocusedMethods_ExplicitlySignalsClassicFallback() + { + var transport = new FakeTransport { Methods = ["wizard.start", "wizard.next", "wizard.cancel"] }; + var client = new GatewayAiSetupClient(transport); + Assert.Null(await client.DetectAsync()); + Assert.Equal(GatewayAiSetupPhase.ClassicWizardRequired, client.Phase); + Assert.Empty(transport.Calls); + } + + [Theory] + [InlineData(GatewayAiSetupChoiceKind.Candidate, "openclaw.setup.activate.start")] + [InlineData(GatewayAiSetupChoiceKind.ManualProvider, "openclaw.setup.activate.start")] + [InlineData(GatewayAiSetupChoiceKind.Auth, "openclaw.setup.auth.start")] + [InlineData(GatewayAiSetupChoiceKind.Prepare, "openclaw.setup.prepare.start")] + public async Task SelectedStarts_SendExactTypedParameters(GatewayAiSetupChoiceKind kind, string method) + { + var (client, transport) = await CreateAsync(); + switch (kind) + { + case GatewayAiSetupChoiceKind.Candidate: client.SelectCandidate("provider-auto:demo", "demo/model"); break; + case GatewayAiSetupChoiceKind.ManualProvider: client.SelectManualProvider("demo-key"); break; + case GatewayAiSetupChoiceKind.Auth: client.SelectAuthOption("demo-login"); break; + case GatewayAiSetupChoiceKind.Prepare: client.SelectPrepareOption("demo-install"); break; + } + await client.StartSelectedAsync(kind == GatewayAiSetupChoiceKind.ManualProvider ? "test-secret" : null, false); + var call = transport.Calls.Last(); + Assert.Equal(method, call.Method); + Assert.Equal(client.SessionId, call.Parameters.GetProperty("sessionId").GetString()); + Assert.True(Guid.TryParse(client.SessionId, out _)); + Assert.Equal("agent-a", call.Parameters.GetProperty("agentId").GetString()); + Assert.Equal("/home/test", call.Parameters.GetProperty("workspace").GetString()); + Assert.False(call.Parameters.GetProperty("nativeSessionCatalogsEnabled").GetBoolean()); + var expectedNames = kind switch + { + GatewayAiSetupChoiceKind.Candidate => new[] { "agentId", "kind", "modelRef", "nativeSessionCatalogsEnabled", "sessionId", "workspace" }, + GatewayAiSetupChoiceKind.ManualProvider => ["agentId", "apiKey", "authChoice", "kind", "nativeSessionCatalogsEnabled", "sessionId", "workspace"], + _ => ["agentId", "authChoice", "nativeSessionCatalogsEnabled", "sessionId", "workspace"] + }; + Assert.Equal(expectedNames.Order(), call.Parameters.EnumerateObject().Select(p => p.Name).Order()); + Assert.DoesNotContain("test-secret", client.Selection!.ToString()); + } + + [Fact] + public async Task LegacyDirectActivation_OnlyWithoutInteractiveAdvertisement_OmitsExactModel() + { + var (client, transport) = await CreateAsync(); + transport.Methods = transport.Methods.Where(m => m != "openclaw.setup.activate.start").ToArray(); + client.SelectCandidate("provider-auto:demo", "demo/model"); + transport.Replies.Enqueue(Json("""{"ok":true,"modelRef":"demo/model"}""")); + await client.StartSelectedAsync(); + var call = transport.Calls.Last(); + Assert.Equal("openclaw.setup.activate", call.Method); + Assert.False(call.Parameters.TryGetProperty("modelRef", out _)); + Assert.False(call.Parameters.TryGetProperty("sessionId", out _)); + Assert.Equal(GatewayAiSetupPhase.VerificationRequired, client.Phase); + } + + [Fact] + public async Task AdvertisedInteractiveWithMissingWizardSupport_DoesNotDowngrade() + { + var (client, transport) = await CreateAsync(); + transport.Methods = transport.Methods.Where(m => m != "wizard.cancel").ToArray(); + Assert.Throws(() => client.SelectCandidate("provider-auto:demo", "demo/model")); + Assert.Single(transport.Calls); + } + + [Fact] + public async Task LostStartReply_RetainsClientSession_ForCancelWithoutReplay() + { + var (client, transport) = await CreateAsync(); + client.SelectAuthOption("demo-login"); + transport.Failure = new TimeoutException(); + await Assert.ThrowsAsync(() => client.StartSelectedAsync()); + var id = client.SessionId; + Assert.NotNull(id); + Assert.Equal(GatewayAiSetupPhase.Uncertain, client.Phase); + await Assert.ThrowsAsync(() => client.StartSelectedAsync()); + transport.Replies.Enqueue(Json("""{"status":"cancelled"}""")); + await client.CancelAsync(); + Assert.Equal(id, transport.Calls.Last().Parameters.GetProperty("sessionId").GetString()); + Assert.Equal(GatewayAiSetupPhase.Cancelled, client.Phase); + Assert.Single(transport.Calls, c => c.Method.EndsWith(".start")); + } + + [Theory] + [InlineData("running")] + [InlineData("done")] + [InlineData("error")] + public async Task CancelNonCancelledStatus_DoesNotReleaseUncertainMutation(string status) + { + var (client, transport) = await StartAsync(); + transport.Replies.Enqueue(Json($$"""{"status":"{{status}}"}""")); + await client.CancelAsync(); + Assert.Equal(GatewayAiSetupPhase.Uncertain, client.Phase); + Assert.NotNull(client.SessionId); + Assert.Throws(() => client.SelectAuthOption("demo-login")); + } + + [Theory] + [InlineData("""{"done":true,"status":"done"}""")] + [InlineData("""{"done":true,"status":"error","error":"post-commit failure"}""")] + [InlineData("""{"done":true,"status":"done","modelActivation":{"modelRef":"wrong/model"}}""")] + [InlineData("""{"done":true,"status":"done","modelActivation":{"modelRef":"demo/model","modelTarget":"utility"}}""")] + [InlineData("""{"done":true,"status":"error","activationRejection":{"disposition":"unknown","status":"auth"}}""")] + public async Task AmbiguousTerminal_NeverMeansVerifiedOrSafeToReplay(string payload) + { + var (client, transport) = await StartAsync(); + transport.Replies.Enqueue(Json(payload)); + await Assert.ThrowsAsync(() => client.RefreshAsync()); + Assert.Equal(GatewayAiSetupPhase.Uncertain, client.Phase); + Assert.Null(client.VerifiedModelRef); + await Assert.ThrowsAsync(() => client.DetectAsync()); + } + + [Fact] + public async Task ExplicitRejection_ReleasesAttemptWithoutClaimingCredentialRollback() + { + var (client, transport) = await StartAsync(); + transport.Replies.Enqueue(Json("""{"done":true,"status":"error","error":"No access","activationRejection":{"disposition":"rejected-before-promotion","status":"auth"}}""")); + await client.RefreshAsync(); + Assert.Equal(GatewayAiSetupPhase.Rejected, client.Phase); + Assert.Equal("No access", client.Wizard!.Error); + Assert.Null(client.VerifiedModelRef); + } + + [Fact] + public async Task PreparationIsNotActivation() + { + var (client, transport) = await CreateAsync(); + client.SelectPrepareOption("demo-install"); + await client.StartSelectedAsync(); + transport.Replies.Enqueue(Json("""{"done":true,"status":"done","preparedModelRef":"demo/local"}""")); + await client.RefreshAsync(); + Assert.Equal(GatewayAiSetupPhase.Prepared, client.Phase); + Assert.Equal("demo/local", client.Wizard!.PreparedModelRef); + Assert.Null(client.VerifiedModelRef); + } + + [Fact] + public async Task WizardStep_PreservesDeviceCodeExternalLinkSensitiveAndTypedOptions() + { + var (client, transport) = await StartAsync(); + transport.Replies.Enqueue(Json(""" + {"done":false,"status":"running","step":{"id":"login","type":"text","title":"Sign in", + "sensitive":true,"executor":"client","externalUrl":"https://example.com/device", + "deviceCode":{"code":"DEMO-CODE","expiresInMinutes":15,"message":"Open your browser"}, + "options":[{"value":{"id":42},"label":"Demo","hint":"Hint"}],"placeholder":"Code","initialValue":"initial"}} + """)); + await client.RefreshAsync(); + var step = client.Wizard!.Step!; + Assert.True(step.Sensitive); + Assert.Equal("client", step.Executor); + Assert.Equal("DEMO-CODE", step.DeviceCode!.Code); + Assert.Equal(15, step.DeviceCode.ExpiresInMinutes); + Assert.Equal(42, step.Options[0].Value.GetProperty("id").GetInt32()); + Assert.Equal("https://example.com/device", step.ExternalUrl); + await Assert.ThrowsAsync(() => client.NextAsync("stale", Json("true"))); + await client.NextAsync("login", Json("true")); + var call = transport.Calls.Last(); + Assert.Equal("login", call.Parameters.GetProperty("answer").GetProperty("stepId").GetString()); + Assert.True(call.Parameters.GetProperty("answer").GetProperty("value").GetBoolean()); + } + + [Theory] + [InlineData("progress", "client")] + [InlineData("action", "gateway")] + public async Task GatewayOrProgressStep_IsPolledWithoutAnswer(string type, string executor) + { + var (client, transport) = await StartAsync(); + transport.Replies.Enqueue(JsonSerializer.SerializeToElement(new { done = false, step = new { id = "p", type, executor } })); + await client.RefreshAsync(); + await Assert.ThrowsAsync(() => client.NextAsync("p")); + await client.RefreshAsync(); + Assert.False(transport.Calls.Last().Parameters.TryGetProperty("answer", out _)); + } + + [Fact] + public async Task ActivationReceipt_StillNeedsExactCurrentRouteVerification() + { + var (client, transport) = await StartAsync(); + transport.Replies.Enqueue(Json(Activated)); + await client.RefreshAsync(); + Assert.Equal(GatewayAiSetupPhase.VerificationRequired, client.Phase); + Assert.Null(client.VerifiedModelRef); + transport.Generation++; + transport.Replies.Enqueue(Json(Persisted)); + transport.Replies.Enqueue(Json(Verified)); + Assert.True((await client.VerifyAsync()).Ok); + Assert.Equal("demo/model", client.VerifiedModelRef); + } + + [Fact] + public async Task UnknownActivation_ReconcilesOnlyChangedPersistedExactRouteAfterReconnect() + { + var (client, transport) = await CreateAsync(); + client.SelectCandidate("provider-auto:demo", "demo/model"); + transport.Failure = new TimeoutException(); + await Assert.ThrowsAsync(() => client.StartSelectedAsync()); + transport.Replies.Enqueue(Json(Persisted)); + await Assert.ThrowsAsync(() => client.VerifyAsync()); + transport.Generation++; + transport.Replies.Enqueue(Json(Persisted)); + transport.Replies.Enqueue(Json(Verified)); + Assert.True((await client.VerifyAsync()).Ok); + Assert.Equal(GatewayAiSetupPhase.Verified, client.Phase); + } + + [Fact] + public async Task ExistingWorkingRoute_CannotProveUnknownActivationSettled() + { + var transport = new FakeTransport(); + transport.Replies.Enqueue(Json(Detection.Replace("\"setupComplete\":false", "\"setupComplete\":true,\"configuredModel\":\"demo/model\""))); + var client = new GatewayAiSetupClient(transport); + await client.DetectAsync(); + client.SelectCandidate("provider-auto:demo", "demo/model"); + transport.Failure = new TimeoutException(); + await Assert.ThrowsAsync(() => client.StartSelectedAsync()); + transport.Generation++; + transport.Replies.Enqueue(Json(Persisted)); + await Assert.ThrowsAsync(() => client.VerifyAsync()); + Assert.DoesNotContain(transport.Calls, c => c.Method == "openclaw.setup.verify"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StaleReply_IsRejectedAcrossGenerationOrGatewayAgentRoute(bool changeGeneration) + { + var transport = new FakeTransport(); + var pending = new TaskCompletionSource(); + transport.Pending = pending.Task; + var client = new GatewayAiSetupClient(transport); + var request = client.DetectAsync(); + if (changeGeneration) transport.Generation++; + else transport.Route = transport.Route with { AgentId = "agent-b" }; + pending.SetResult(Json(Detection)); + await Assert.ThrowsAsync(() => request); + Assert.Null(client.Detection); + } + + [Fact] + public async Task ExplicitConfiguredModelVerification_DoesNotActivateOrDetect() + { + var transport = new FakeTransport(); + transport.Replies.Enqueue(Json(Verified)); + var client = new GatewayAiSetupClient(transport); + Assert.True((await client.VerifyConfiguredAsync("demo/model")).Ok); + Assert.Equal("openclaw.setup.verify", Assert.Single(transport.Calls).Method); + Assert.Equal(GatewayAiSetupPhase.Verified, client.Phase); + } + + [Fact] + public void Advertisement_DoesNotInventMethodsFromVersionOrMalformedFields() + { + Assert.Empty(GatewayServerMethodAdvertisement.Parse(Json("""{"server":{"version":"2026.9.1"}}"""))); + Assert.Equal(["wizard.next"], GatewayServerMethodAdvertisement.Parse(Json("""{"features":{"methods":["wizard.next",42,"wizard.next",""]}}"""))); + } + + [Fact] + public async Task PreparedModel_RequiresExplicitSelection_ThenActivatesExactServerModel() + { + var (client, transport) = await CreateAsync(); + client.SelectPrepareOption("demo-install"); + await client.StartSelectedAsync(); + transport.Replies.Enqueue(Json("""{"done":true,"status":"done","preparedModelRef":"demo/local"}""")); + await client.RefreshAsync(); + Assert.DoesNotContain(transport.Calls, c => c.Method == "openclaw.setup.activate.start"); + client.SelectPreparedModel(); + await client.StartSelectedAsync(); + var call = transport.Calls.Last(); + Assert.Equal("openclaw.setup.activate.start", call.Method); + Assert.Equal("provider-auto:demo-install", call.Parameters.GetProperty("kind").GetString()); + Assert.Equal("demo/local", call.Parameters.GetProperty("modelRef").GetString()); + } + + [Fact] + public async Task RestartReceipt_CannotVerifyBeforeFreshHandshake() + { + var (client, transport) = await StartAsync(); + transport.Replies.Enqueue(Json(Activated)); + await client.RefreshAsync(); + await Assert.ThrowsAsync(() => client.VerifyAsync()); + Assert.True(client.GatewayRestartRequired); + Assert.DoesNotContain(transport.Calls, c => c.Method == "openclaw.setup.verify"); + } + + [Fact] + public async Task CancellationInvalidatesLateStartReply() + { + var (client, transport) = await CreateAsync(); + client.SelectAuthOption("demo-login"); + var pending = new TaskCompletionSource(); + transport.Pending = pending.Task; + var start = client.StartSelectedAsync(); + var sessionId = client.SessionId; + transport.Replies.Enqueue(Json("""{"status":"cancelled"}""")); + await client.CancelAsync(); + pending.SetResult(JsonSerializer.SerializeToElement(new { sessionId, done = false, status = "running" })); + await Assert.ThrowsAsync(() => start); + Assert.Equal(GatewayAiSetupPhase.Cancelled, client.Phase); + Assert.Null(client.SessionId); + } + + [Fact] + public async Task NewHandshakeInvalidatesPreviouslySelectedChoice() + { + var (client, transport) = await CreateAsync(); + client.SelectCandidate("provider-auto:demo", "demo/model"); + transport.Generation++; + await Assert.ThrowsAsync(() => client.StartSelectedAsync()); + Assert.Single(transport.Calls); + } + + [Fact] + public async Task NativeCatalogConsent_IsExplicitAndNotAssumed() + { + var transport = new FakeTransport(); + transport.Replies.Enqueue(Json(Detection.Replace("\"setupComplete\":false", "\"setupComplete\":false,\"nativeSessionCatalogPreferenceRequired\":true"))); + var client = new GatewayAiSetupClient(transport); + await client.DetectAsync(); + client.SelectAuthOption("demo-login"); + await Assert.ThrowsAsync(() => client.StartSelectedAsync()); + Assert.Single(transport.Calls); + } + + [Theory] + [InlineData("note")] + [InlineData("action")] + [InlineData("confirm")] + [InlineData("text")] + [InlineData("select")] + [InlineData("multiselect")] + public async Task ClientOwnedProviderStep_IsNeverAcknowledgedByProgressController(string type) + { + var (client, transport) = await StartAsync(); + transport.Replies.Enqueue(JsonSerializer.SerializeToElement(new + { + done = false, status = "running", + step = new { id = "essential-consent", type, executor = "client", title = "Provider consent" }, + })); + await client.RefreshAsync(); + var calls = transport.Calls.Count; + await new GatewayAiSetupController(client).WaitForInputAsync(); + Assert.Equal(calls, transport.Calls.Count); + Assert.Equal(GatewayAiSetupPhase.Running, client.Phase); + await client.NextAsync("essential-consent", type == "confirm" ? Json("false") : null); + var answer = transport.Calls.Last().Parameters.GetProperty("answer"); + Assert.Equal("essential-consent", answer.GetProperty("stepId").GetString()); + if (type == "confirm") + Assert.False(answer.GetProperty("value").GetBoolean()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RequiredCatalogPreference_ForwardsExactExplicitBoolean(bool consent) + { + var transport = new FakeTransport(); + transport.Replies.Enqueue(Json(Detection.Replace("\"setupComplete\":false", + "\"setupComplete\":false,\"nativeSessionCatalogPreferenceRequired\":true"))); + var client = new GatewayAiSetupClient(transport); + await client.DetectAsync(); + client.SelectAuthOption("demo-login"); + await client.StartSelectedAsync(nativeSessionCatalogsEnabled: consent); + Assert.Equal(consent, transport.Calls.Last().Parameters.GetProperty("nativeSessionCatalogsEnabled").GetBoolean()); + } + + [Fact] + public async Task OptionalCatalogPreference_RemainsAbsentWithoutAnExplicitValue() + { + var (client, transport) = await CreateAsync(); + client.SelectAuthOption("demo-login"); + await client.StartSelectedAsync(); + Assert.False(transport.Calls.Last().Parameters.TryGetProperty("nativeSessionCatalogsEnabled", out _)); + } + + [Fact] + public async Task PreparedModel_AfterReconnectRetainsReceiptAndRejectsImplicitReselection() + { + var (client, transport) = await CreateAsync(); + client.SelectPrepareOption("demo-install"); + await client.StartSelectedAsync(); + transport.Replies.Enqueue(Json("""{"done":true,"status":"done","preparedModelRef":"demo/local"}""")); + await client.RefreshAsync(); + transport.Generation++; + Assert.Throws(client.SelectPreparedModel); + Assert.Equal(GatewayAiSetupPhase.Prepared, client.Phase); + Assert.Equal("demo/local", client.Wizard!.PreparedModelRef); + Assert.DoesNotContain(transport.Calls, call => call.Method == "openclaw.setup.activate.start"); + } + + [Fact] + public async Task FailedRedetection_ClearsOldSelection() + { + var (client, transport) = await CreateAsync(); + client.SelectAuthOption("demo-login"); + transport.Failure = new TimeoutException(); + await Assert.ThrowsAsync(() => client.DetectAsync()); + Assert.Null(client.Selection); + await Assert.ThrowsAsync(() => client.StartSelectedAsync()); + } + + [Theory] + [InlineData("https://example.com/device", true)] + [InlineData("http://localhost/login", true)] + [InlineData("file:///C:/secret", false)] + [InlineData("javascript:alert(1)", false)] + [InlineData("https://user:secret@example.com", false)] + [InlineData("not a url", false)] + public void ExternalLinks_RequireSafeWebScheme(string value, bool expected) => + Assert.Equal(expected, GatewayAiSetupPresentation.TryGetExternalUri(value, out _)); + + [Theory] + [InlineData("Anthropic", "ProviderIcon-claude.svg")] + [InlineData("openai-codex", "ProviderIcon-codex.svg")] + [InlineData("../secret", null)] + [InlineData("https://tracking.example/icon", null)] + public void ProviderArtwork_OnlyUsesBundledSafeFileNames(string brand, string? expected) => + Assert.Equal(expected, GatewayAiSetupPresentation.GetBundledProviderIconFileName(brand)); + + [Theory] + [InlineData("select", "42", "Second")] + [InlineData("multiselect", "[42]", "Second")] + [InlineData("select", "{\"id\":1}", "First")] + public void WizardInitialChoices_PreserveTypedServerDefaults(string type, string initial, string label) + { + var step = new GatewayAiSetupWizardStep + { + Id = "choice", Type = type, InitialValue = Json(initial), + Options = [new(Json("""{"id":1}"""), "First"), new(Json("42"), "Second")] + }; + Assert.Equal(label, Assert.Single(GatewayAiSetupPresentation.GetInitialOptions(step)).Label); + } + + [Fact] + public async Task ConfiguredModelMode_RetriesExactVerificationWithoutOfferingOtherProviders() + { + var transport = new FakeTransport(); + transport.Replies.Enqueue(Json("""{"ok":false,"status":"unavailable","error":"Not ready"}""")); + var client = new GatewayAiSetupClient(transport, "demo/model"); + Assert.False((await client.VerifyConfiguredAsync("demo/model")).Ok); + Assert.Equal(GatewayAiSetupPhase.Rejected, client.Phase); + await Assert.ThrowsAsync(() => client.DetectAsync()); + await Assert.ThrowsAsync(() => client.StartSelectedAsync()); + transport.Replies.Enqueue(Json(Verified)); + Assert.True((await client.VerifyConfiguredAsync("demo/model")).Ok); + Assert.Equal(2, transport.Calls.Count); + Assert.All(transport.Calls, call => Assert.Equal("openclaw.setup.verify", call.Method)); + Assert.Equal("demo/model", client.VerifiedModelRef); + Assert.Null(client.Detection); + } + + [Fact] + public async Task ConfiguredModelMode_RejectsDifferentModelWithoutGatewayCall() + { + var transport = new FakeTransport(); + var client = new GatewayAiSetupClient(transport, "demo/model"); + await Assert.ThrowsAsync(() => client.VerifyConfiguredAsync("other/model")); + Assert.Empty(transport.Calls); + Assert.Null(client.VerifiedModelRef); + } + + [Fact] + public async Task CancelledVerification_RejectsLateSuccessBeforePublishingReadyState() + { + var transport = new FakeTransport(); + var pending = new TaskCompletionSource(); + transport.Pending = pending.Task; + var client = new GatewayAiSetupClient(transport, "demo/model"); + using var cancellation = new CancellationTokenSource(); + var verify = client.VerifyConfiguredAsync("demo/model", cancellation.Token); + cancellation.Cancel(); + pending.SetResult(Json(Verified)); + await Assert.ThrowsAnyAsync(() => verify); + Assert.NotEqual(GatewayAiSetupPhase.Verified, client.Phase); + Assert.Null(client.VerifiedModelRef); + } + + private static async Task<(GatewayAiSetupClient, FakeTransport)> CreateAsync() + { + var transport = new FakeTransport(); + transport.Replies.Enqueue(Json(Detection)); + var client = new GatewayAiSetupClient(transport); + await client.DetectAsync(); + return (client, transport); + } + + [Theory] + [InlineData(GatewayAiSetupChoiceKind.Candidate)] + [InlineData(GatewayAiSetupChoiceKind.ManualProvider)] + [InlineData(GatewayAiSetupChoiceKind.Auth)] + [InlineData(GatewayAiSetupChoiceKind.Prepare)] + public async Task UtilityChoices_ArePreservedButCannotActivateAsMain(GatewayAiSetupChoiceKind kind) + { + var transport = new FakeTransport(); + transport.Replies.Enqueue(Json(Detection.Replace("\"label\":", "\"modelTarget\":\"utility\",\"label\":") + .Replace("\"setupComplete\":false", "\"setupComplete\":false,\"setupModel\":\"demo/utility\",\"utilityModel\":\"demo/utility\""))); + var client = new GatewayAiSetupClient(transport); + await client.DetectAsync(); + Assert.Equal("demo/utility", client.Detection!.SetupModel); + Assert.Equal("demo/utility", client.Detection.UtilityModel); + Assert.Equal("utility", client.Detection.Candidates[0].ModelTarget); + Assert.Throws(() => + { + switch (kind) + { + case GatewayAiSetupChoiceKind.Candidate: client.SelectCandidate("provider-auto:demo", "demo/model"); break; + case GatewayAiSetupChoiceKind.ManualProvider: client.SelectManualProvider("demo-key"); break; + case GatewayAiSetupChoiceKind.Auth: client.SelectAuthOption("demo-login"); break; + case GatewayAiSetupChoiceKind.Prepare: client.SelectPrepareOption("demo-install"); break; + } + }); + Assert.Single(transport.Calls); + } + + [Theory] + [InlineData("""{"ok":true,"modelRef":"demo/model","modelTarget":"utility","latencyMs":12}""")] + [InlineData("""{"ok":true,"modelRef":"other/model","latencyMs":12}""")] + public async Task Verification_RejectsWrongRoleOrModel_WithoutHiddenFallback(string reply) + { + var transport = new FakeTransport(); + transport.Replies.Enqueue(Json(reply)); + var client = new GatewayAiSetupClient(transport, "demo/model"); + await Assert.ThrowsAsync(() => client.VerifyConfiguredAsync("demo/model")); + Assert.Null(client.VerifiedModelRef); + var call = Assert.Single(transport.Calls); + Assert.Equal("openclaw.setup.verify", call.Method); + Assert.Equal(["agentId"], call.Parameters.EnumerateObject().Select(p => p.Name)); + } + + [Theory] + [InlineData(GatewayAiSetupPhase.Running)] + [InlineData(GatewayAiSetupPhase.Uncertain)] + public async Task LocalAiSwitch_RejectsBusyProviderBeforeAnyLocalMutation(GatewayAiSetupPhase phase) + { + var (client, transport) = await CreateAsync(); + client.SelectAuthOption("demo-login"); + if (phase == GatewayAiSetupPhase.Uncertain) + transport.Failure = new TimeoutException(); + try { await client.StartSelectedAsync(); } + catch (TimeoutException) { } + Assert.False(client.CanLeaveForLocalAi); + Assert.Throws(() => client.EnsureLocalAiCanStart("gateway-a")); + transport.Replies.Enqueue(Json("""{"status":"cancelled"}""")); + await client.CancelAsync(); + Assert.True(client.CanLeaveForLocalAi); + client.EnsureLocalAiCanStart("gateway-a"); + Assert.Throws(() => client.EnsureLocalAiCanStart("different")); + } + + [Theory] + [InlineData("existing-model", SetupCompletionIntent.Dashboard)] + [InlineData("provider-auto:demo", SetupCompletionIntent.CustodianOnboarding)] + public async Task Completion_UsesExplicitActivationKind_NotSetupComplete( + string kind, SetupCompletionIntent intent) + { + var transport = new FakeTransport(); + transport.Replies.Enqueue(Json(Detection.Replace("provider-auto:demo", kind) + .Replace("\"setupComplete\":false", "\"setupComplete\":true,\"configuredModel\":\"demo/model\""))); + var client = new GatewayAiSetupClient(transport); + await client.DetectAsync(); + client.SelectCandidate(kind, "demo/model"); + await client.StartSelectedAsync(); + Assert.Throws(() => client.GetVerifiedCompletion()); + transport.Replies.Enqueue(Json(Activated)); + await client.RefreshAsync(); + Assert.Throws(() => client.GetVerifiedCompletion()); + transport.Generation++; + transport.Replies.Enqueue(Json(Persisted)); + transport.Replies.Enqueue(Json(Verified)); + await client.VerifyAsync(); + var completion = client.GetVerifiedCompletion(); + Assert.Equal(intent, completion.Intent); + Assert.Equal("gateway-a", completion.GatewayId); + Assert.Equal("agent-a", completion.AgentId); + Assert.Equal("demo/model", completion.ModelRef); + Assert.Equal(transport.Route.IdentityBinding, completion.IdentityBinding); + Assert.Equal(transport.Route.SessionKey, completion.SessionKey); + Assert.Null(completion.ModelTarget); + Assert.Equal(transport.Generation, completion.VerifiedGeneration); + transport.Generation++; + Assert.Throws(() => client.GetVerifiedCompletion()); + } + + [Theory] + [InlineData(GatewayAiSetupChoiceKind.ManualProvider)] + [InlineData(GatewayAiSetupChoiceKind.Auth)] + [InlineData(GatewayAiSetupChoiceKind.Prepare)] + public async Task FreshProviderChoices_RetainCustodianIntentThroughRestart(GatewayAiSetupChoiceKind kind) + { + var (client, transport) = await CreateAsync(); + switch (kind) + { + case GatewayAiSetupChoiceKind.ManualProvider: client.SelectManualProvider("demo-key"); break; + case GatewayAiSetupChoiceKind.Auth: client.SelectAuthOption("demo-login"); break; + case GatewayAiSetupChoiceKind.Prepare: client.SelectPrepareOption("demo-install"); break; + } + await client.StartSelectedAsync(kind == GatewayAiSetupChoiceKind.ManualProvider ? "synthetic" : null); + if (kind == GatewayAiSetupChoiceKind.Prepare) + { + transport.Replies.Enqueue(Json("""{"done":true,"status":"done","preparedModelRef":"demo/model"}""")); + await client.RefreshAsync(); + client.SelectPreparedModel(); + await client.StartSelectedAsync(); + } + transport.Replies.Enqueue(Json(Activated)); + await client.RefreshAsync(); + transport.Generation++; + transport.Replies.Enqueue(Json(Persisted)); + transport.Replies.Enqueue(Json(Verified)); + await client.VerifyAsync(); + Assert.Equal(SetupCompletionIntent.CustodianOnboarding, client.GetVerifiedCompletion().Intent); + } + + [Theory] + [InlineData(SetupCompletionIntent.Dashboard)] + [InlineData(SetupCompletionIntent.CustodianOnboarding)] + public async Task ConfiguredVerification_PreservesExplicitExistingOrFreshInstallIntent(SetupCompletionIntent intent) + { + var transport = new FakeTransport(); + var client = new GatewayAiSetupClient(transport, "demo/model", intent); + transport.Replies.Enqueue(Json(Verified)); + await client.VerifyConfiguredAsync("demo/model"); + Assert.Equal(intent, client.GetVerifiedCompletion().Intent); + Assert.Equal("openclaw.setup.verify", Assert.Single(transport.Calls).Method); + transport.Route = transport.Route with { AgentId = "different-agent" }; + Assert.Throws(() => client.GetVerifiedCompletion()); + } + + [Fact] + public async Task LostActivationReply_ReconcilesFreshIntentWithoutReplay() + { + var (client, transport) = await CreateAsync(); + client.SelectCandidate("provider-auto:demo", "demo/model"); + transport.Failure = new TimeoutException(); + await Assert.ThrowsAsync(() => client.StartSelectedAsync()); + transport.Generation++; + transport.Replies.Enqueue(Json(Persisted)); + transport.Replies.Enqueue(Json(Verified)); + await client.VerifyAsync(); + Assert.Equal(SetupCompletionIntent.CustodianOnboarding, client.GetVerifiedCompletion().Intent); + Assert.Single(transport.Calls, call => call.Method == "openclaw.setup.activate.start"); + } + + [Fact] + public async Task Reverification_DoesNotExposePreviousReceiptWhilePendingOrAfterFailure() + { + var transport = new FakeTransport(); + var client = new GatewayAiSetupClient(transport, "demo/model"); + transport.Replies.Enqueue(Json(Verified)); + await client.VerifyConfiguredAsync("demo/model"); + Assert.NotNull(client.GetVerifiedCompletion()); + var reply = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + transport.Pending = reply.Task; + var pending = client.VerifyConfiguredAsync("demo/model"); + Assert.Throws(() => client.GetVerifiedCompletion()); + reply.SetResult(Json("""{"ok":false,"status":"unavailable","error":"Not ready"}""")); + Assert.False((await pending).Ok); + Assert.Throws(() => client.GetVerifiedCompletion()); + } + + private static async Task<(GatewayAiSetupClient, FakeTransport)> StartAsync() + { + var (client, transport) = await CreateAsync(); + client.SelectCandidate("provider-auto:demo", "demo/model"); + await client.StartSelectedAsync(); + return (client, transport); + } + + private static JsonElement Json(string json) => JsonDocument.Parse(json).RootElement.Clone(); + + private sealed class FakeTransport : IGatewayAiSetupTransport + { + public GatewayAiSetupRoute Route { get; set; } = new("gateway-a", "agent-a", "authority-a", new string('A', 64), + new string('B', 64), "agent:agent-a:main"); + public long Generation { get; set; } = 1; + public bool IsConnected { get; set; } = true; + public IReadOnlyCollection OperatorScopes { get; set; } = ["operator.admin"]; + public IReadOnlyCollection Methods { get; set; } = + [ + "openclaw.setup.detect", "openclaw.setup.verify", "openclaw.setup.activate", + "openclaw.setup.activate.start", "openclaw.setup.auth.start", "openclaw.setup.prepare.start", + "wizard.next", "wizard.cancel" + ]; + public List<(string Method, JsonElement Parameters)> Calls { get; } = []; + public Queue Replies { get; } = []; + public Exception? Failure { get; set; } + public Task? Pending { get; set; } + public Task RequestAsync(string method, object parameters, int timeoutMs, CancellationToken cancellationToken) + { + var payload = JsonSerializer.SerializeToElement(parameters); + Calls.Add((method, payload)); + if (Failure is { } failure) + { + Failure = null; + return Task.FromException(failure); + } + if (Pending is { } pending) + { + Pending = null; + return pending; + } + return Task.FromResult(Replies.Count > 0 ? Replies.Dequeue() : + Json(method.EndsWith(".start") ? $$"""{"sessionId":"{{payload.GetProperty("sessionId").GetString()}}","done":false,"status":"running"}""" + : """{"done":false,"status":"running"}""")); + } + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupControllerTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupControllerTests.cs new file mode 100644 index 000000000..bb4a5aee0 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupControllerTests.cs @@ -0,0 +1,247 @@ +using System.Text.Json; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class GatewayAiSetupControllerTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExplicitPreparation_ChainsOnlyExactReceiptAndRetainedConsent(bool consent) + { + var (client, controller, transport) = await CreateAsync(requireConsent: true); + client.SelectPrepareOption("local/setup"); + transport.StartReply = (method, parameters) => method == "openclaw.setup.prepare.start" + ? Json(new { sessionId = Session(parameters), done = true, status = "done", preparedModelRef = "local/exact" }) + : Json(new { sessionId = Session(parameters), done = false, status = "running", + step = new { id = "promotion", type = "confirm", executor = "client", initialValue = false } }); + await controller.StartSelectedAsync(null, consent); + Assert.Equal(["openclaw.setup.detect", "openclaw.setup.prepare.start", "openclaw.setup.activate.start"], + transport.Calls.Select(call => call.Method)); + var activation = transport.Calls.Last().Parameters; + Assert.Equal("provider-auto:local%2Fsetup", activation.GetProperty("kind").GetString()); + Assert.Equal("local/exact", activation.GetProperty("modelRef").GetString()); + Assert.Equal(consent, activation.GetProperty("nativeSessionCatalogsEnabled").GetBoolean()); + Assert.NotEqual(Session(transport.Calls[1].Parameters), Session(activation)); + Assert.Equal(GatewayAiSetupPhase.Running, client.Phase); + Assert.Equal("promotion", client.Wizard!.Step!.Id); + Assert.DoesNotContain(transport.Calls, call => call.Method == "wizard.next"); + } + + [Fact] + public async Task PreparedAfterReconnect_IsRetainedWithoutActivationOrReplay() + { + var (client, controller, transport) = await CreateAsync(); + client.SelectPrepareOption("local/setup"); + transport.StartReply = (_, parameters) => + { + transport.Generation++; + return Json(new { sessionId = Session(parameters), done = true, status = "done", preparedModelRef = "local/exact" }); + }; + await Assert.ThrowsAsync(() => controller.StartSelectedAsync(null, null)); + Assert.DoesNotContain(transport.Calls, call => call.Method == "openclaw.setup.activate.start"); + // The client rejects the stale reply before it can become an authoritative receipt. + Assert.Equal(GatewayAiSetupPhase.Uncertain, client.Phase); + Assert.NotNull(client.SessionId); + Assert.Null(controller.TakeAutomaticAuthUri()); + } + + [Fact] + public async Task LostStartReply_IsNeverAutomaticallyReplayed() + { + var (client, controller, transport) = await CreateAsync(); + client.SelectAuthOption("login"); + transport.StartReply = (_, _) => throw new TimeoutException(); + await Assert.ThrowsAsync(() => controller.StartSelectedAsync(null, null)); + await controller.WaitForInputAsync(); + Assert.Equal(2, transport.Calls.Count); + Assert.Equal(GatewayAiSetupPhase.Uncertain, client.Phase); + Assert.NotNull(client.SessionId); + Assert.Null(controller.TakeAutomaticAuthUri()); + } + + [Fact] + public async Task ReconciledPreparedReceipt_RequiresOneExplicitActivationWithoutPreparationReplay() + { + var (client, controller, transport) = await CreateAsync(requireConsent: true); + client.SelectPrepareOption("local/setup"); + transport.StartReply = (_, _) => throw new TimeoutException(); + await Assert.ThrowsAsync(() => controller.StartSelectedAsync(null, false)); + transport.Polls.Enqueue(Json(new { done = true, status = "done", preparedModelRef = "local/exact" })); + await client.RefreshAsync(); + await controller.WaitForInputAsync(); + Assert.Equal(GatewayAiSetupPhase.Prepared, client.Phase); + Assert.Equal(1, transport.Calls.Count(call => call.Method == "openclaw.setup.prepare.start")); + Assert.DoesNotContain(transport.Calls, call => call.Method == "openclaw.setup.activate.start"); + transport.StartReply = (_, parameters) => Json(new + { + sessionId = Session(parameters), done = false, + step = new { id = "essential-promotion", type = "confirm", executor = "client" }, + }); + await controller.ActivatePreparedExplicitlyAsync(); + Assert.Equal(1, transport.Calls.Count(call => call.Method == "openclaw.setup.activate.start")); + Assert.Equal("local/exact", transport.Calls.Last().Parameters.GetProperty("modelRef").GetString()); + Assert.False(transport.Calls.Last().Parameters.GetProperty("nativeSessionCatalogsEnabled").GetBoolean()); + Assert.Equal("essential-promotion", client.Wizard!.Step!.Id); + } + + [Fact] + public async Task PreparedFallback_CannotBypassChangedGatewayOrGeneration() + { + var (client, controller, transport) = await CreateAsync(); + client.SelectPrepareOption("local/setup"); + transport.StartReply = (_, _) => throw new TimeoutException(); + await Assert.ThrowsAsync(() => controller.StartSelectedAsync(null, null)); + transport.Polls.Enqueue(Json(new { done = true, status = "done", preparedModelRef = "local/exact" })); + await client.RefreshAsync(); + transport.Generation++; + await Assert.ThrowsAsync(() => controller.ActivatePreparedExplicitlyAsync()); + Assert.Equal(GatewayAiSetupPhase.Prepared, client.Phase); + Assert.DoesNotContain(transport.Calls, call => call.Method == "openclaw.setup.activate.start"); + } + + [Theory] + [InlineData("https://login.example/device", 1)] + [InlineData("http://login.example/device", 0)] + [InlineData("https://user:password@login.example/device", 0)] + public async Task DeviceCodeProgress_OpensSafeUrlOnceAndNeverSendsAnAnswer(string url, int expectedLaunches) + { + var (client, controller, transport) = await CreateAsync(); + client.SelectAuthOption("login"); + transport.StartReply = (_, parameters) => Progress(Session(parameters), url); + transport.Polls.Enqueue(Progress(null, url)); + transport.Polls.Enqueue(Progress(null, url)); + transport.Polls.Enqueue(Json(new { done = false, status = "running", + step = new { id = "required-note", type = "note", executor = "client", message = "Required acknowledgment" } })); + var launches = new List(); + void Changed() + { + if (controller.TakeAutomaticAuthUri() is { } uri) launches.Add(uri); + } + await controller.StartSelectedAsync(null, null, Changed); + Assert.Equal(expectedLaunches, launches.Count); + Assert.All(transport.Calls.Where(call => call.Method == "wizard.next"), + call => Assert.False(call.Parameters.TryGetProperty("answer", out _))); + Assert.Equal("required-note", client.Wizard!.Step!.Id); + Assert.Null(controller.TakeAutomaticAuthUri()); + Assert.False(transport.Calls[1].Parameters.TryGetProperty("nativeSessionCatalogsEnabled", out _)); + } + + [Fact] + public async Task UncertainReconciliationAndReconnect_DoNotOpenBrowser() + { + var (client, controller, transport) = await CreateAsync(); + client.SelectAuthOption("login"); + transport.StartReply = (_, parameters) => Json(new { sessionId = Session(parameters), done = false, + step = new { id = "url", type = "note", externalUrl = "https://login.example/" } }); + await controller.StartSelectedAsync(null, null); + transport.Generation++; + Assert.Null(controller.TakeAutomaticAuthUri()); + controller.StopAutomaticContinuation(); + transport.Polls.Enqueue(Progress(null, "https://login.example/new")); + await client.RefreshAsync(); + transport.Polls.Enqueue(Json(new { done = false, step = new { id = "required", type = "confirm" } })); + await controller.WaitForInputAsync(); + Assert.Null(controller.TakeAutomaticAuthUri()); + Assert.Equal(1, transport.Calls.Count(call => call.Method == "openclaw.setup.auth.start")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExpectedRestart_WaitsForFreshHandshakeOrStopsAtBound(bool reconnect) + { + var (client, controller, transport) = await CreateAsync(); + client.SelectCandidate("existing", "provider/exact"); + transport.StartReply = (_, parameters) => Json(new { sessionId = Session(parameters), done = true, status = "done", + modelActivation = new { modelRef = "provider/exact", gatewayRestartRequired = true } }); + await controller.StartSelectedAsync(null, null); + if (reconnect) + transport.Clock.OnTick = () => transport.Generation++; + if (reconnect) + await controller.WaitForExpectedRestartAsync(); + else + await Assert.ThrowsAsync(() => controller.WaitForExpectedRestartAsync()); + Assert.Equal(!reconnect, client.WaitingForRestart); + Assert.Equal(GatewayAiSetupPhase.VerificationRequired, client.Phase); + Assert.Equal(2, transport.Calls.Count); + } + + [Theory] + [InlineData("progress", "client", false, null)] + [InlineData("action", "gateway", true, null)] + [InlineData("note", "client", false, "Continue.Content")] + [InlineData("action", "client", false, "Continue.Content")] + [InlineData("confirm", "client", false, "Submit")] + [InlineData("text", "client", true, "Submit")] + [InlineData("note", "client", true, "SignedIn")] + public void PromptActions_RespectActualExecutor(string type, string executor, bool code, string? expected) + { + Assert.Equal(expected, GatewayAiSetupPresentation.GetPromptAction(new() + { + Id = "step", Type = type, Executor = executor, DeviceCode = code ? new("SYNTHETIC") : null, + })); + } + + private static async Task<(GatewayAiSetupClient, GatewayAiSetupController, Transport)> CreateAsync(bool requireConsent = false) + { + var transport = new Transport { RequireConsent = requireConsent }; + var client = new GatewayAiSetupClient(transport); + await client.DetectAsync(); + return (client, new(client, transport.Clock), transport); + } + + private static string Session(JsonElement parameters) => parameters.GetProperty("sessionId").GetString()!; + private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value); + private static JsonElement Progress(string? sessionId, string url) => Json(new + { + sessionId, done = false, status = "running", + step = new { id = "device-poll", type = "progress", executor = "gateway", externalUrl = url, + deviceCode = new { code = "SYNTHETIC", expiresInMinutes = 5 } }, + }); + + private sealed class Transport : IGatewayAiSetupTransport + { + public GatewayAiSetupRoute Route { get; set; } = new("gateway", "main", "authority"); + public long Generation { get; set; } = 1; + public bool IsConnected => true; + public IReadOnlyCollection OperatorScopes => ["operator.admin"]; + public IReadOnlyCollection Methods => + ["openclaw.setup.detect", "openclaw.setup.verify", "openclaw.setup.auth.start", + "openclaw.setup.prepare.start", "openclaw.setup.activate.start", "wizard.next", "wizard.cancel"]; + public bool RequireConsent { get; init; } + public FastClock Clock { get; } = new(); + public List<(string Method, JsonElement Parameters)> Calls { get; } = []; + public Queue Polls { get; } = []; + public Func? StartReply { get; set; } + public Task RequestAsync(string method, object parameters, int timeoutMs, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var payload = Json(parameters); + Calls.Add((method, payload)); + return Task.FromResult(method == "openclaw.setup.detect" ? Json(new + { + candidates = new[] { new { kind = "existing", label = "Existing", detail = "", modelRef = "provider/exact", recommended = false } }, + manualProviders = Array.Empty(), + authOptions = new[] { new { id = "login", label = "Sign in" } }, + prepareOptions = new[] { new { id = "local/setup", label = "Set up and use" } }, + workspace = "synthetic", setupComplete = false, nativeSessionCatalogPreferenceRequired = RequireConsent, + }) : method.EndsWith(".start", StringComparison.Ordinal) ? StartReply!(method, payload) : Polls.Dequeue()); + } + } + + private sealed class FastClock : TimeProvider + { + private long _ticks; + public Action? OnTick { get; set; } + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => Interlocked.Read(ref _ticks); + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) => + new Timer(value => + { + Interlocked.Add(ref _ticks, dueTime.Ticks); + OnTick?.Invoke(); + callback(value); + }, state, TimeSpan.Zero, Timeout.InfiniteTimeSpan); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupLifecycleContractTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupLifecycleContractTests.cs new file mode 100644 index 000000000..66f3960d0 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayAiSetupLifecycleContractTests.cs @@ -0,0 +1,146 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class GatewayAiSetupLifecycleContractTests +{ + [Fact] + public void VerifiedCompletion_ShowsOwnedChooserWithoutFinalizingOrIssuingHandoff() + { + var window = ReadSource("OpenClaw.SetupEngine.UI", "SetupWindow.xaml.cs"); + Assert.Contains("_startAtLocalAiRecoveryReview && _pinLocalAiRecoveryModel", window); + Assert.Contains("? SetupCompletionIntent.Dashboard : SetupCompletionIntent.CustodianOnboarding", window); + Assert.Contains("SetupGatewaySession.RequireCompletionGateway(_dataDir, completion)", window); + Assert.Contains("GatewayDashboardBinding.Capture(active) != completion.EndpointBinding", + ReadSource("OpenClaw.SetupEngine", "SetupGatewaySession.cs")); + var ready = window[window.IndexOf("private Task CompleteVerifiedAiSetupAsync", StringComparison.Ordinal).. + window.IndexOf("internal bool OwnsReadyChoice", StringComparison.Ordinal)]; + Assert.Contains("NavigateTo(typeof(AiReadyPage)", ready); + Assert.Contains("new AiReadyPageArgs(_readyChoice, this)", ready); + Assert.DoesNotContain("return CompleteSetupAsync()", ready); + Assert.DoesNotContain(".Issue(", ready); + Assert.DoesNotContain("await _aiPageCleanupTask", ready); + var complete = window[window.IndexOf("private async Task CompleteSetupCoreAsync()", StringComparison.Ordinal).. + window.IndexOf("internal void RefreshFlowProgress()", StringComparison.Ordinal)]; + Assert.DoesNotContain("_verifiedAiCompletion", window); + Assert.Contains("RequestSetupCompleted(", complete); + Assert.DoesNotContain("NavigateToComplete(true", complete); + } + + [Fact] + public void WindowAndNavigationCleanup_ShareIdempotentAwaitableCloseContract() + { + var page = ReadSource("OpenClaw.SetupEngine.UI", "Pages", "AiSetupPage.xaml.cs"); + Assert.Contains("Page, IAsyncDisposable", page); + Assert.Contains("Unloaded += Page_Unloaded;", page); + Assert.Contains("protected override void OnNavigatedFrom(NavigationEventArgs e)\n => BeginClose();", page); + Assert.Contains("AsyncEventHandlerGuard.Run(CloseAsync", page); + Assert.Contains("public Task CloseAsync()", page); + Assert.Contains("if (_closeTask is not null)\n return _closeTask;", page); + Assert.Contains("_closeTask = completion.Task;", page); + Assert.Contains("public ValueTask DisposeAsync() => new(CloseAsync());", page); + Assert.True(page.IndexOf("_closed = true;", StringComparison.Ordinal) < + page.IndexOf("_request.Cancel();", StringComparison.Ordinal)); + } + + [Fact] + public void ClosedPage_RejectsNewWorkLateInitializationAndCompletion() + { + var page = ReadSource("OpenClaw.SetupEngine.UI", "Pages", "AiSetupPage.xaml.cs"); + Assert.Contains("if (_closed || _busy)\n return Task.CompletedTask;", page); + Assert.Contains("if (_closed || ct.IsCancellationRequested)", page); + Assert.Contains("ObjectDisposedException.ThrowIf(_closed, this);", page); + Assert.Contains("if (!_closed && generation == _generation && Client?.Phase == GatewayAiSetupPhase.Verified)", page); + Assert.Contains("await _activeRequest.WaitAsync(timeout.Token);", page); + Assert.Contains("await client.CancelAsync(ct).WaitAsync(CloseTimeout, ct);", page); + var session = ReadSource("OpenClaw.SetupEngine", "SetupGatewaySession.cs"); + Assert.Contains("ct.Register(static state => ((OpenClawGatewayClient)state!).Dispose(), client)", session); + Assert.Contains("await client.ConnectAsync().WaitAsync(ct);", session); + Assert.Contains("Client.DisconnectAsync().WaitAsync(TimeSpan.FromSeconds(5))", session); + Assert.Contains("finally { Client.Dispose(); }", session); + } + + [Fact] + public void ConfiguredModelRetry_PrecedesGeneralPhaseRoutingAndPinsClientMode() + { + var page = ReadSource("OpenClaw.SetupEngine.UI", "Pages", "AiSetupPage.xaml.cs"); + Assert.Contains("new GatewayAiSetupClient(transport, _args.ExpectedConfiguredModelRef,", page); + Assert.Contains("_args.ConfiguredCompletionIntent", page); + var refresh = page[page.IndexOf("private Task RefreshAsync()", StringComparison.Ordinal).. + page.IndexOf("private void Cancel_Click", StringComparison.Ordinal)]; + Assert.Contains("await Client.VerifyConfiguredAsync(expectedModel, ct);", refresh); + Assert.True(refresh.IndexOf("_args!.ExpectedConfiguredModelRef", StringComparison.Ordinal) < + refresh.IndexOf("Client.Phase == GatewayAiSetupPhase.Verified", StringComparison.Ordinal)); + } + + [Fact] + public void ProviderSurface_HasOnePageOwnerAndNoProtocolOrPersistenceOwner() + { + var page = ReadSource("OpenClaw.SetupEngine.UI", "Pages", "AiSetupPage.xaml.cs"); + var xaml = ReadSource("OpenClaw.SetupEngine.UI", "Pages", "AiSetupPage.xaml"); + var dialog = ReadSource("OpenClaw.SetupEngine.UI", "Controls", "ProviderSetupDialog.xaml.cs"); + Assert.Contains("private readonly ProviderSetupDialog _providerDialog = new();", page); + Assert.DoesNotContain("WizardPanel", xaml); + Assert.DoesNotContain("FeaturedChoices", xaml); + Assert.DoesNotContain("MoreChoices", xaml); + Assert.DoesNotContain("GatewayAiSetupClient", dialog); + Assert.DoesNotContain("SetupGatewaySession", dialog); + Assert.Contains("args.Cancel = true;", dialog); + Assert.Contains("if (!_showTask.IsCompleted)", dialog); + Assert.Contains("while (_requested && !_closed);", dialog); + Assert.Contains("_closed = true;", dialog); + Assert.Contains("SecretInput.Password = \"\";", dialog); + Assert.Contains("TextInput.Text = \"\";", dialog); + Assert.Contains("await dialogClose.WaitAsync(timeout.Token);", page); + Assert.Contains("_providerDialog.CancelRequested -= ProviderCancelRequested;", page); + Assert.Contains("if (_closed || _cancelling)", page); + Assert.Contains("if (Client?.SessionId is null)", page); + Assert.Contains("private bool ProviderPending => _providerOperationActive ||", page); + Assert.Contains("var showProvider = ProviderPending", page); + Assert.Contains("_controller!.StopAutomaticContinuation();", page); + var completion = page[page.IndexOf("Client?.Phase == GatewayAiSetupPhase.Verified", StringComparison.Ordinal)..]; + Assert.True(completion.IndexOf("_providerDialog.Dismiss();", StringComparison.Ordinal) < + completion.IndexOf("await complete(Client.GetVerifiedCompletion());", StringComparison.Ordinal)); + Assert.Contains("CompleteVerifiedSetup: CompleteVerifiedAiSetupAsync", + ReadSource("OpenClaw.SetupEngine.UI", "SetupWindow.xaml.cs")); + } + + [Fact] + public void ProviderBackdrop_IsRetainedAndLateFocusRestoreCannotCrossAnOperation() + { + var page = ReadSource("OpenClaw.SetupEngine.UI", "Pages", "AiSetupPage.xaml.cs"); + Assert.Contains("if (!freezeBackdrop)", page); + Assert.Contains("if (!freezeBackdrop) RenderLocalAi(phase)", page); + Assert.Contains("!_busy && !_localActionBusy && !BackdropLocked", page); + Assert.Contains("_deferredDetection = detection", page); + Assert.Contains("CaptureProviderBackdrop(returnFocus)", page); + Assert.Contains("ChoicesScroller.VerticalOffset, ++_backdropVersion", page); + Assert.Contains("await showing;", page); + Assert.Contains("backdrop.Version != _backdropVersion", page); + Assert.Contains("ChoicesScroller.ChangeView(null, backdrop.VerticalOffset, null, disableAnimation: true)", page); + Assert.Contains("status, _providerError, _operationTitle", page); + Assert.DoesNotContain("Frame.Navigate", page); + } + + [Fact] + public void ReceiptReconciliation_RetriesVerificationWhenNoWizardRemains() + { + var page = ReadSource("OpenClaw.SetupEngine.UI", "Pages", "AiSetupPage.xaml.cs"); + var refresh = page[page.IndexOf("else if (Client.RequiresReconciliation)", StringComparison.Ordinal).. + page.IndexOf("private void Cancel_Click", StringComparison.Ordinal)]; + Assert.Contains("if (Client.SessionId is not null)\n await Client.RefreshAsync(ct);", refresh); + Assert.Contains("else\n {\n var verification = await Client.VerifyAsync(ct);", refresh); + Assert.DoesNotContain("else if (Client.Phase == GatewayAiSetupPhase.Uncertain)", refresh); + } + + private static string ReadSource(params string[] components) + { + var root = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + for (string? directory = AppContext.BaseDirectory; root is null && directory is not null; + directory = Directory.GetParent(directory)?.FullName) + { + if (File.Exists(Path.Combine(directory, "openclaw-windows-node.slnx"))) + root = directory; + } + return File.ReadAllText(Path.Combine([root ?? throw new DirectoryNotFoundException("Repository root not found."), + "src", .. components])).Replace("\r\n", "\n", StringComparison.Ordinal); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingRecoveryTests.cs b/tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingRecoveryTests.cs new file mode 100644 index 000000000..a3c2a5ff9 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingRecoveryTests.cs @@ -0,0 +1,72 @@ +using OpenClaw.Connection; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared.Inference; +using OpenClaw.Shared.Inference.Catalog; +using OpenClaw.TestSupport; +using System.Runtime.InteropServices; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class LocalAiOnboardingRecoveryTests +{ + [Fact] + public void FirstInstallWithoutReceipt_UsesOnlyNonDestructiveRecoverySteps() + { + var steps = SetupStepFactory.BuildLocalAiRecoverySteps(); + Assert.Contains(steps, step => step is ReconcileLocalAiInstallationStep); + Assert.Contains(steps, step => step is AcquireLocalAiRuntimeStep); + Assert.Contains(steps, step => step is AcquireLocalAiModelStep); + Assert.Contains(steps, step => step is ConfigureLocalAiGatewayStep); + Assert.DoesNotContain(steps, step => step is CreateWslInstanceStep or CleanupStaleDistroStep + or CleanupStaleGatewayStep or InstallCliStep or InstallGatewayServiceStep + or PairOperatorStep or PairNodeStep or MintBootstrapTokenStep); + Assert.Equal("validate-local-ai-recovery-gateway", steps[1].Id); + Assert.True(steps.FindIndex(step => step.Id == "revalidate-local-ai-recovery-gateway") < + steps.FindIndex(step => step.Id == "configure-local-ai-gateway")); + } + + [Fact] + public async Task Reconcile_FirstInstallWithoutReceipt_DoesNotNeedAnOldInstallation() + { + using var directory = new TempDirectory(); + var hardware = new HostHardwareInfo(Architecture.X64, 128L << 30, 100L << 30, + [new(GpuVendor.Nvidia, "Test GPU", 96L << 30, 80L << 30, DriverVersion: "615.0", + CudaMajorVersion: 13, StableId: "GPU-test")], false); + var eligibility = LocalInferenceEligibility.Evaluate(hardware); + var reconciler = new LocalAiInstallReconciler(new NoRuntimeInspection(), new NoModelInspection()); + var result = await reconciler.ReconcileAsync(directory.Path, eligibility.Plan!, "GPU-test", + CancellationToken.None, allowIncompleteInstallation: true); + Assert.False(result.Reused); + Assert.Null(result.OriginalInstall); + Assert.Null(result.ResolvedInstall); + Assert.Empty(Directory.EnumerateFileSystemEntries(directory.Path)); + } + + private sealed class NoRuntimeInspection : ILlamaRuntimeInspector + { + public Task InspectAsync(string installDirectory, CancellationToken cancellationToken) => + throw new InvalidOperationException("No runtime exists to inspect."); + } + + private sealed class NoModelInspection : ILocalAiModelFileVerifier + { + public Task VerifyActiveAsync(LocalAiResolvedInstall install, PinnedArtifact artifact, + CancellationToken cancellationToken) => throw new InvalidOperationException("No model exists to inspect."); + public Task VerifyLegacyCompatibilityAsync(LocalAiResolvedInstall install, LocalAiPaths paths, + PinnedArtifact artifact, CancellationToken cancellationToken) => throw new InvalidOperationException("No model exists to inspect."); + } + + [Theory] + [InlineData("owned", "owned", true, true)] + [InlineData("owned", "other", true, false)] + [InlineData("owned", "owned", false, false)] + public void RecoveryAdmission_RequiresExactOwnedGateway( + string expected, string actual, bool owned, bool allowed) + { + var existing = new ExistingConfigDetector.ExistingConfig( + true, actual, "ws://127.0.0.1:18789", true, true, owned, + "OpenClawGateway", true, 0, []); + Assert.Equal(allowed, LocalAiRecoveryPolicy.CanRecoverExistingGateway( + existing, expected, "OpenClawGateway", "ws://localhost:18789")); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingTests.cs b/tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingTests.cs new file mode 100644 index 000000000..96e433004 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingTests.cs @@ -0,0 +1,682 @@ +using System.Collections.Immutable; +using System.Runtime.InteropServices; +using OpenClaw.Connection; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared; +using OpenClaw.Shared.Inference; +using OpenClaw.Shared.Inference.Catalog; +using OpenClaw.TestSupport; +using OpenClawTray.Services; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class LocalAiOnboardingTests +{ + private static readonly SetupLocalAiTarget Target = new("gateway", "Managed", 18789, null, null); + private static readonly HostHardwareInfo Hardware = new(Architecture.X64, 128L << 30, 100L << 30, + [new(GpuVendor.Nvidia, "Test GPU", 96L << 30, 80L << 30, DriverVersion: "615.0", + CudaMajorVersion: 13, StableId: "GPU-test")], false); + + [Theory] + [InlineData(false, false, false, LocalAiRuntimeState.Stopped, false, LocalAiOnboardingState.SetUp)] + [InlineData(true, true, false, LocalAiRuntimeState.Stopped, false, LocalAiOnboardingState.StartAndUse)] + [InlineData(true, true, false, LocalAiRuntimeState.Healthy, false, LocalAiOnboardingState.Use)] + [InlineData(true, false, false, LocalAiRuntimeState.Stopped, false, LocalAiOnboardingState.Repair)] + [InlineData(false, false, true, LocalAiRuntimeState.Stopped, false, LocalAiOnboardingState.Repair)] + [InlineData(true, true, false, LocalAiRuntimeState.Failed, false, LocalAiOnboardingState.Repair)] + [InlineData(true, true, false, LocalAiRuntimeState.Conflict, false, LocalAiOnboardingState.Repair)] + [InlineData(true, true, false, LocalAiRuntimeState.Starting, false, LocalAiOnboardingState.Working)] + [InlineData(true, true, false, LocalAiRuntimeState.Stopping, false, LocalAiOnboardingState.Working)] + [InlineData(false, false, false, LocalAiRuntimeState.Stopped, true, LocalAiOnboardingState.BusyGpu)] + public void Projection_DistinguishesEvidenceStates(bool installed, bool verified, bool damaged, + LocalAiRuntimeState runtimeState, bool busy, LocalAiOnboardingState expected) + { + var install = Install(); + var eligibility = LocalInferenceEligibility.Evaluate(Hardware, install.Manifest.ModelCatalogId); + if (busy) eligibility = eligibility with { Status = LocalInferenceEligibilityStatus.EligibleButBusy }; + var snapshot = LocalAiOnboardingSnapshot.Project(Target, eligibility, installed ? install : null, + verified, damaged, RuntimeSnapshot(install, runtimeState)); + Assert.Equal(expected, snapshot.State); + Assert.True(snapshot.ShowLocalChoice); + } + + [Theory] + [InlineData(LocalInferenceEligibilityFailureCode.CatalogSelectionFailed, LocalInferenceSelectionFailureCode.NoNvidiaGpu, false)] + [InlineData(LocalInferenceEligibilityFailureCode.InsufficientGpuMemory, LocalInferenceSelectionFailureCode.None, false)] + [InlineData(LocalInferenceEligibilityFailureCode.CudaCapabilityTooLow, LocalInferenceSelectionFailureCode.None, false)] + [InlineData(LocalInferenceEligibilityFailureCode.HardwareFactsIncomplete, LocalInferenceSelectionFailureCode.None, true)] + [InlineData(LocalInferenceEligibilityFailureCode.DriverTooOld, LocalInferenceSelectionFailureCode.None, true)] + [InlineData(LocalInferenceEligibilityFailureCode.CatalogSelectionFailed, LocalInferenceSelectionFailureCode.RuntimeUnavailable, true)] + [InlineData(LocalInferenceEligibilityFailureCode.CatalogSelectionFailed, LocalInferenceSelectionFailureCode.UnknownModel, true)] + [InlineData(LocalInferenceEligibilityFailureCode.CatalogSelectionFailed, LocalInferenceSelectionFailureCode.None, true)] + [InlineData(LocalInferenceEligibilityFailureCode.None, LocalInferenceSelectionFailureCode.None, true)] + public void FreshDevice_HidesOnlyConclusiveHardwareIncompatibility( + LocalInferenceEligibilityFailureCode failure, LocalInferenceSelectionFailureCode selectionFailure, bool show) + { + var eligibility = LocalInferenceEligibility.Evaluate(Hardware) with + { + Status = LocalInferenceEligibilityStatus.Unsupported, + FailureCode = failure, SelectionFailureCode = selectionFailure, + }; + var fresh = LocalAiOnboardingSnapshot.Project(Target, eligibility, null, false, false, null); + Assert.False(fresh.HasInstallationEvidence); + Assert.Equal(show, fresh.ShowLocalChoice); + Assert.False(fresh.CanReview); + Assert.False(fresh.CanUse); + + foreach (var existing in new[] + { + LocalAiOnboardingSnapshot.Project(Target, eligibility, Install(), false, false, null), + LocalAiOnboardingSnapshot.Project(Target, eligibility, null, false, true, null), + LocalAiOnboardingSnapshot.Project(Target, eligibility, null, false, false, null, "known-receipt"), + LocalAiOnboardingSnapshot.Project(Target, eligibility, null, false, false, null, installationKnown: true), + LocalAiOnboardingSnapshot.Project(Target with { ModelCatalogId = "pinned" }, eligibility, null, false, false, null), + LocalAiOnboardingSnapshot.Project(Target, eligibility, null, false, false, + RuntimeSnapshot(Install(), LocalAiRuntimeState.Failed)), + }) + { + Assert.True(existing.HasInstallationEvidence); + Assert.True(existing.ShowLocalChoice); + Assert.Equal(fresh.State, existing.State); + Assert.False(existing.CanUse); + Assert.False(existing.CanReview); + } + } + + [Fact] + public void MissingFactsAndRemoteGateway_KeepAttentionWithoutPromotingAChoice() + { + var unsupported = LocalInferenceEligibility.Evaluate(Hardware with { Gpus = [] }); + foreach (var snapshot in new[] + { + new LocalAiOnboardingSnapshot(LocalAiOnboardingState.Checking), + new LocalAiOnboardingSnapshot(LocalAiOnboardingState.Unsupported), + LocalAiOnboardingSnapshot.Project(Target, null, null, false, false, null), + LocalAiOnboardingSnapshot.Project(Target, null, null, false, true, null), + LocalAiOnboardingSnapshot.Project(null, unsupported, null, false, false, null), + }) + { + Assert.True(snapshot.ShowLocalChoice); + Assert.False(snapshot.CanReview); + Assert.False(snapshot.CanUse); + } + Assert.True(LocalAiOnboardingSnapshot.Project(Target, null, Install(), false, false, null).HasInstallationEvidence); + } + + [Fact] + public void UnsupportedFreshDevice_DoesNotHideIndependentGatewayCandidates() + { + var local = LocalAiOnboardingSnapshot.Project(Target, + LocalInferenceEligibility.Evaluate(Hardware with { Gpus = [] }), null, false, false, null); + var view = AiSetupPresentationModel.Create(new() + { + Candidates = [new("existing-model", "Gateway model", "Ready", "provider/model", true)], + ManualProviders = [], Workspace = "workspace", SetupComplete = true, + }, Enum.GetValues().ToHashSet(), hasLocalChoice: local.ShowLocalChoice); + Assert.False(local.ShowLocalChoice); + Assert.Single(view.Candidates); + Assert.True(view.HasChoices); + } + + [Fact] + public void UnknownUnsupportedAndRemote_AreNotSynonyms() + { + var eligible = LocalInferenceEligibility.Evaluate(Hardware); + Assert.True(eligible.CanInstall); + Assert.Equal(LocalAiOnboardingState.Unknown, + LocalAiOnboardingSnapshot.Project(Target, null, null, false, false, null).State); + Assert.Equal(LocalAiOnboardingState.Unknown, + LocalAiOnboardingSnapshot.Project(Target, eligible with + { FailureCode = LocalInferenceEligibilityFailureCode.HardwareFactsIncomplete }, null, false, false, null).State); + Assert.Equal(LocalAiOnboardingState.Unsupported, + LocalAiOnboardingSnapshot.Project(Target, + LocalInferenceEligibility.Evaluate(Hardware with { Gpus = [] }), null, false, false, null).State); + Assert.Equal(LocalAiOnboardingState.UnsupportedGateway, + LocalAiOnboardingSnapshot.Project(null, eligible, null, false, false, null).State); + } + + [Fact] + public void Healthy_WrongModelOrUnownedEndpoint_IsNotUse() + { + var install = Install(); + var eligibility = LocalInferenceEligibility.Evaluate(Hardware, install.Manifest.ModelCatalogId); + foreach (var runtime in new[] + { + RuntimeSnapshot(install, LocalAiRuntimeState.Healthy) with { ModelId = "other" }, + RuntimeSnapshot(install, LocalAiRuntimeState.Healthy) with { Ownership = LocalAiOwnership.None }, + RuntimeSnapshot(install, LocalAiRuntimeState.Healthy) with { Endpoint = new("http://127.0.0.1:19999/v1") }, + }) + Assert.NotEqual(LocalAiOnboardingState.Use, + LocalAiOnboardingSnapshot.Project(Target, eligibility, install, true, false, runtime).State); + } + + [Fact] + public void OwnLoadedModel_DoesNotMistakeItsGpuAllocationForAnotherBusyApplication() + { + var install = Install(); + var eligibility = LocalInferenceEligibility.Evaluate(Hardware, install.Manifest.ModelCatalogId) with + { Status = LocalInferenceEligibilityStatus.EligibleButBusy }; + var runtime = RuntimeSnapshot(install, LocalAiRuntimeState.Healthy) with + { + ModelEvidence = new(LocalAiModelAvailabilityState.Loaded, DateTimeOffset.UtcNow, + new string('a', 64), 1, install.Manifest.ModelAlias), + }; + Assert.Equal(LocalAiOnboardingState.Use, + LocalAiOnboardingSnapshot.Project(Target, eligibility, install, true, false, runtime).State); + } + + [Fact] + public async Task Observation_CancelsRefreshAndDiscardsStaleCallbacks() + { + var first = new TaskCompletionSource(); + var second = new TaskCompletionSource(); + var host = new ObservationHost(first.Task, second.Task); + await using var observation = new LocalAiOnboardingObservation(host); + var firstRequest = observation.RefreshAsync(); + var secondRequest = observation.RefreshAsync(); + Assert.True(host.Tokens[0].IsCancellationRequested); + second.SetResult(new(LocalAiOnboardingState.SetUp, Target)); + await secondRequest; + first.SetResult(new(LocalAiOnboardingState.Repair, Target)); + await firstRequest; + Assert.Equal(LocalAiOnboardingState.SetUp, observation.Snapshot.State); + Assert.Equal(0, host.Mutations); + } + + [Fact] + public async Task ClosingObservation_FencesCallbacksAndNeverMutates() + { + var pending = new TaskCompletionSource(); + var host = new ObservationHost(pending.Task); + var observation = new LocalAiOnboardingObservation(host); + var notifications = 0; + observation.Changed += () => notifications++; + var request = observation.RefreshAsync(); + var closing = observation.DisposeAsync().AsTask(); + Assert.True(host.Tokens[0].IsCancellationRequested); + pending.SetResult(new(LocalAiOnboardingState.Use, Target)); + await Task.WhenAll(request, closing); + Assert.Equal(1, notifications); + Assert.Equal(0, host.Mutations); + await observation.DisposeAsync(); + } + + [Fact] + public async Task FailedObservation_IsUnknown_AndRetryDoesNotBlockGatewayWork() + { + var host = new ObservationHost(Task.FromException(new IOException()), + Task.FromResult(new LocalAiOnboardingSnapshot(LocalAiOnboardingState.SetUp, Target))); + await using var observation = new LocalAiOnboardingObservation(host); + await observation.RefreshAsync(); + Assert.Equal(LocalAiOnboardingState.Unknown, observation.Snapshot.State); + await observation.RefreshAsync(); + Assert.Equal(LocalAiOnboardingState.SetUp, observation.Snapshot.State); + Assert.Equal(0, host.Mutations); + } + + [Theory] + [InlineData("gateway", "provider/model", 1)] + [InlineData("different", "provider/model", 2)] + [InlineData("gateway", "provider/other-case", 2)] + [InlineData(null, "provider/model", 2)] + public void DuplicateSuppression_RequiresExactGatewayAndModel(string? localGateway, string model, int count) + { + var detection = new GatewayAiSetupDetection + { + Candidates = [new("existing-model", "Managed", "", "provider/model", true), + new("saved-auth:other", "Other", "", "provider/other", false)], + ManualProviders = [], Workspace = "workspace", SetupComplete = true, + }; + var view = AiSetupPresentationModel.Create(detection, Enum.GetValues().ToHashSet(), + gatewayId: "gateway", localGatewayId: localGateway, localModelRef: model); + Assert.Equal(count, view.Candidates.Count); + Assert.Equal(2, detection.Candidates.Length); + } + + [Fact] + public void UtilityChoices_AreNotMainChoices_ButExplanationIsVisible() + { + var view = AiSetupPresentationModel.Create(new() + { + Candidates = [new("existing-model", "Utility", "", "provider/model", true, ModelTarget: "utility")], + ManualProviders = [new("key", "Utility key", ModelTarget: "utility")], + AuthOptions = [new("auth", "Utility login", Featured: true, ModelTarget: "utility")], + PrepareOptions = [new("prepare", "Utility preparation", ModelTarget: "utility")], + Workspace = "workspace", SetupComplete = false, UtilityModel = "provider/model", + }, Enum.GetValues().ToHashSet()); + Assert.True(view.HasUtilityChoices); + Assert.False(view.HasChoices); + Assert.Empty(view.Candidates); + } + + [Fact] + public async Task Host_FirstInstallWithoutReceipt_AdmitsSameGatewayWithoutRuntimeMutation() + { + using var directory = new TempDirectory(); + var registry = new GatewayRegistry(directory.Path); + registry.Load(); + var lifecycleResolver = new LocalAiGatewayDistroResolver(registry); + var runtime = new FakeRuntime(RuntimeSnapshot(Install(), LocalAiRuntimeState.Stopped)); + var resolver = new LocalAiSetupRouteResolver(() => registry, directory.Path, directory.Path, "Managed", + (distro, expectedId) => + { + var disk = new GatewayRegistry(directory.Path); + disk.Load(); + var owner = disk.GetActive(); + Assert.Equal("Managed", distro); + return new(owner is not null, owner?.Id, owner?.Url, true, true, true, distro, true, 0, []); + }); + var host = new SetupLocalAiHost(() => resolver.ResolveAsync(), () => registry, () => runtime, + _ => Task.FromResult(null), (_, _) => Task.FromResult(true), + _ => Task.FromResult(Hardware), () => throw new InvalidOperationException("No mutation during discovery.")); + host.BeginGatewaySetup(); + // PairOperator owns a separate registry during the installation pipeline. + var pairingRegistry = Registry(directory.Path); + pairingRegistry.Save(); + var savedBytes = File.ReadAllBytes(Path.Combine(directory.Path, "gateways.json")); + Assert.Equal(LocalAiOnboardingState.UnsupportedGateway, (await host.ObserveAsync(default)).State); + Assert.False(lifecycleResolver.Resolve().Success); + var registryEvents = 0; + registry.Changed += (_, _) => registryEvents++; + await host.ReconcileGatewaySetupAsync(pairingRegistry.GetSnapshot(), "gateway"); + var observation = await host.ObserveAsync(CancellationToken.None); + Assert.Equal(LocalAiOnboardingState.SetUp, observation.State); + var target = await host.RevalidateReviewAsync(observation, CancellationToken.None); + Assert.Equal("gateway", target.GatewayId); + Assert.Equal(0, runtime.Calls); + Assert.Equal("gateway", registry.GetActive()?.Id); + Assert.True(lifecycleResolver.Resolve().Success); + Assert.Equal("Managed", lifecycleResolver.Resolve().DistroName); + var commands = new ReadOnlyGateway(Install(), false); + var canonicalLifecycle = new LocalAiGatewayProviderCoordinator(commands, lifecycleResolver, NullLogger.Instance); + Assert.True((await canonicalLifecycle.ValidatePublicationAsync(Install())).Success); + Assert.Equal(2, commands.Reads); + Assert.Equal(1, registryEvents); + Assert.Equal(savedBytes, File.ReadAllBytes(Path.Combine(directory.Path, "gateways.json"))); + registry.Remove("gateway"); + registry.AddOrUpdate(new GatewayRecord + { Id = "replacement", Url = "ws://127.0.0.1:18789", IsLocal = true, SetupManagedDistroName = "Other" }); + Assert.False(lifecycleResolver.Resolve().Success); + } + + [Theory] + [InlineData("none")] + [InlineData("url")] + [InlineData("token")] + [InlineData("ssh")] + [InlineData("addition")] + [InlineData("removal")] + [InlineData("other-config")] + public void CompletedSetup_AdoptsOnlyTheOwningPipelineOutput(string drift) + { + using var directory = new TempDirectory(); + var canonical = Registry(directory.Path); + canonical.Save(); + var baseline = canonical.CapturePersistedSnapshot(); + var logger = new SetupLogger(filePath: null); + var context = new SetupContext(new SetupConfig(), logger, new TransactionJournal(filePath: null), + new CommandRunner(logger), CancellationToken.None, directory.Path, directory.Path) + { ExpectedGatewayRegistry = baseline }; + var writer = context.LoadSetupRegistry(); + writer.AddOrUpdate(new GatewayRecord { Id = "installed", Url = "wss://installed.example", SharedGatewayToken = "owned-output" }); + writer.SetActive("installed"); + context.SaveSetupRegistry(writer); + var expected = context.ExpectedGatewayRegistry!; + var external = new GatewayRegistry(directory.Path); + external.Load(); + switch (drift) + { + case "url": external.Update("installed", record => record with { Url = "wss://changed.example" }); break; + case "token": external.Update("installed", record => record with { SharedGatewayToken = "external" }); break; + case "ssh": external.Update("installed", record => record with { SshTunnel = new("other", "ssh.example", 18789, 19001) }); break; + case "addition": external.AddOrUpdate(new() { Id = "external", Url = "wss://external.example" }); break; + case "removal": external.Remove("gateway"); break; + case "other-config": external.Update("gateway", record => record with { FriendlyName = "external" }); break; + } + external.Save(); + var saved = File.ReadAllBytes(Path.Combine(directory.Path, "gateways.json")); + if (drift == "none") + { + Assert.Equal("installed", canonical.ReconcileCompletedSetup(baseline, expected, "installed").ActiveId); + } + else + { + Assert.Throws(() => canonical.ReconcileCompletedSetup(baseline, expected, "installed")); + Assert.True(GatewayRegistry.HasSameSetupAuthority(baseline, canonical.GetSnapshot())); + Assert.Throws(() => context.LoadSetupRegistry()); + Assert.Throws(() => context.SaveSetupRegistry(writer)); + } + Assert.Equal(saved, File.ReadAllBytes(Path.Combine(directory.Path, "gateways.json"))); + } + + [Fact] + public void CompletedSetup_ReconciliationDoesNotDiscardUnsavedCanonicalEdits() + { + using var directory = new TempDirectory(); + var registry = new GatewayRegistry(directory.Path); + var baseline = registry.GetSnapshot(); + Registry(directory.Path).Save(); + registry.AddOrUpdate(new GatewayRecord { Id = "unsaved", Url = "wss://remote.example" }); + Assert.Throws(() => registry.ReconcileCompletedSetup(baseline, baseline, "gateway")); + Assert.Equal("unsaved", Assert.Single(registry.GetAll()).Id); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CompletedRecovery_PreservesReconnectBookkeepingWithoutRejectingSuccess(bool saved) + { + using var directory = new TempDirectory(); + var registry = Registry(directory.Path); + registry.Save(); + var baseline = registry.CapturePersistedSnapshot(); + var connected = new DateTime(2026, 9, 24, 2, 0, 0, DateTimeKind.Utc); + if (saved) + registry.UpdateAndSave("gateway", record => record with { LastConnected = connected }); + else + registry.Update("gateway", record => record with { LastConnected = connected }); + var savedBytes = File.ReadAllBytes(Path.Combine(directory.Path, "gateways.json")); + var events = 0; + registry.Changed += (_, _) => events++; + + var result = registry.ReconcileCompletedSetup(baseline, baseline, "gateway"); + + Assert.Equal("gateway", result.ActiveId); + Assert.Equal(connected, Assert.Single(result.Records).LastConnected); + Assert.Equal(connected, registry.GetActive()!.LastConnected); + Assert.Equal(0, events); + Assert.Equal(savedBytes, File.ReadAllBytes(Path.Combine(directory.Path, "gateways.json"))); + } + + [Fact] + public void CompletedRecovery_StillRejectsAuthorityChangesAlongsideReconnectBookkeeping() + { + using var directory = new TempDirectory(); + var registry = Registry(directory.Path); + registry.Save(); + var baseline = registry.CapturePersistedSnapshot(); + registry.UpdateAndSave("gateway", record => record with + { + LastConnected = DateTime.UtcNow, + Url = "ws://127.0.0.1:19999" + }); + Assert.Throws(() => registry.ReconcileCompletedSetup(baseline, baseline, "gateway")); + Assert.Equal("ws://127.0.0.1:19999", registry.GetActive()!.Url); + } + + [Fact] + public void GatewaySetupCannotCaptureAnAlreadyUnsavedBaseline() + { + using var directory = new TempDirectory(); + var registry = Registry(directory.Path); + registry.Save(); + var savedBytes = File.ReadAllBytes(Path.Combine(directory.Path, "gateways.json")); + registry.Update("gateway", record => record with { FriendlyName = "Unsaved edit" }); + var host = Host(registry, new FakeRuntime(RuntimeSnapshot(Install(), LocalAiRuntimeState.Stopped)), () => null); + Assert.Throws(host.BeginGatewaySetup); + Assert.Equal("Unsaved edit", registry.GetActive()?.FriendlyName); + Assert.Equal(savedBytes, File.ReadAllBytes(Path.Combine(directory.Path, "gateways.json"))); + } + + [Theory] + [InlineData("{invalid")] + [InlineData("{\"activeId\":\"other\",\"gateways\":[]}")] + public void CompletedSetup_InvalidDiskNeverAdoptsStaleState(string json) + { + using var directory = new TempDirectory(); + var registry = Registry(directory.Path); + var baseline = registry.GetSnapshot(); + File.WriteAllText(Path.Combine(directory.Path, "gateways.json"), json); + Assert.ThrowsAny(() => registry.ReconcileCompletedSetup(baseline, baseline, "gateway")); + Assert.Equal(baseline, registry.GetSnapshot() with { Records = baseline.Records }); + Assert.Equal("gateway", Assert.Single(registry.GetAll()).Id); + } + + [Theory] + [InlineData(LocalAiRuntimeState.Failed, false)] + [InlineData(LocalAiRuntimeState.Conflict, false)] + [InlineData(LocalAiRuntimeState.Failed, true)] + public async Task Host_StartFailureClearsBindingOnlyWithRuntimeCleanupEvidence( + LocalAiRuntimeState failure, bool unresolved) + { + using var directory = new TempDirectory(); + var registry = Registry(directory.Path); + var install = Install(); + var runtime = new FakeRuntime(RuntimeSnapshot(install, LocalAiRuntimeState.Stopped)) + { + StartResult = RuntimeSnapshot(install, failure) with + { GatewayRouteRequiresResolution = unresolved, Detail = "Synthetic startup failure." }, + }; + var commands = new ReadOnlyGateway(install, false); + var host = Host(registry, runtime, () => install, + () => new(commands, new LocalAiGatewayDistroResolver(registry), NullLogger.Instance)); + var selected = await host.ObserveAsync(default); + var use = new LocalAiOnboardingUse(host); + var error = await Assert.ThrowsAnyAsync(() => use.UseAsync(selected, default)); + Assert.Equal(!unresolved, error is LocalAiStartFailedException); + Assert.Equal(unresolved, use.Expected is not null); + Assert.Equal(LocalAiOnboardingState.Repair, (await host.ObserveAsync(default)).State); + Assert.Equal(1, runtime.Calls); + Assert.Equal(2, commands.Reads); // Admission only, no second publication after failure. + } + + [Fact] + public async Task Host_SwitchedGatewayOrModel_RejectsBeforeStarting() + { + using var directory = new TempDirectory(); + var registry = Registry(directory.Path); + var install = Install(); + var runtime = new FakeRuntime(RuntimeSnapshot(install, LocalAiRuntimeState.Stopped)); + var host = Host(registry, runtime, () => install); + var selected = await host.ObserveAsync(CancellationToken.None); + Assert.Equal(LocalAiOnboardingState.StartAndUse, selected.State); + install = install with { Manifest = install.Manifest with { RequestedPort = 18808 } }; + await Assert.ThrowsAsync(() => host.UseAsync(selected, CancellationToken.None)); + registry.AddOrUpdate(new GatewayRecord { Id = "remote", Url = "wss://gateway.example" }); + registry.SetActive("remote"); + await Assert.ThrowsAsync(() => host.UseAsync(selected, CancellationToken.None)); + Assert.Equal(0, runtime.Calls); + } + + [Fact] + public async Task Host_GatewaySwitchDuringAdmissionRejectsWithoutMutationOrUncertainBinding() + { + using var directory = new TempDirectory(); + var registry = Registry(directory.Path); + registry.AddOrUpdate(new GatewayRecord { Id = "remote", Url = "wss://other.example" }); + var install = Install(); + var runtime = new FakeRuntime(RuntimeSnapshot(install, LocalAiRuntimeState.Stopped)); + var admissionRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseAdmission = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var commands = new ReadOnlyGateway(install, false) + { + AfterRead = async reads => + { + if (reads != 2) return; + admissionRead.SetResult(); + await releaseAdmission.Task; + }, + }; + var host = Host(registry, runtime, () => install, + () => new(commands, new LocalAiGatewayDistroResolver(registry), NullLogger.Instance)); + var selected = await host.ObserveAsync(default); + var use = new LocalAiOnboardingUse(host); + var pending = use.UseAsync(selected, default); + try + { + await admissionRead.Task.WaitAsync(TimeSpan.FromSeconds(5)); + registry.SetActive("remote"); + } + finally { releaseAdmission.TrySetResult(); } + await Assert.ThrowsAsync(() => pending); + Assert.Null(use.Expected); + Assert.Equal(0, runtime.Calls); + Assert.Equal(2, commands.Reads); + Assert.Equal(LocalAiOnboardingState.UnsupportedGateway, (await host.ObserveAsync(default)).State); + } + + [Fact] + public async Task Host_GatewaySwitchAfterStartRetainsUncertainExactBinding() + { + using var directory = new TempDirectory(); + var registry = Registry(directory.Path); + registry.AddOrUpdate(new GatewayRecord { Id = "remote", Url = "wss://other.example" }); + var install = Install(); + var runtime = new FakeRuntime(RuntimeSnapshot(install, LocalAiRuntimeState.Healthy)) + { OnStart = () => registry.SetActive("remote") }; + var commands = new ReadOnlyGateway(install, false); + var host = Host(registry, runtime, () => install, + () => new(commands, new LocalAiGatewayDistroResolver(registry), NullLogger.Instance)); + var selected = await host.ObserveAsync(default); + var use = new LocalAiOnboardingUse(host); + await Assert.ThrowsAsync(() => use.UseAsync(selected, default)); + Assert.Equal(new SetupLocalAiUseResult("gateway", selected.ModelRef!), use.Expected); + Assert.Equal(1, runtime.Calls); + Assert.Equal(2, commands.Reads); + } + + [Fact] + public void RecoveryDraft_PreservesCapabilitiesAndExternalRouteWithoutTailscaleInstallation() + { + var config = new SetupConfig { LocalAiRecoveryGatewayId = "gateway" }; + var draft = new SetupAccessDraft(config); + draft.SelectRoute(SetupGatewayRoute.Existing, true); + draft.ApplyProfile(SetupCapabilityProfile.ReadOnly); + config.LocalAi.Enabled = true; + config.Tailscale.Enabled = true; + draft.LocalAiReady = true; + Assert.True(draft.CanInstall(localAiRecovery: true)); + Assert.False(draft.CanInstall()); + Assert.Equal(SetupCapabilityProfile.ReadOnly, draft.Profile); + Assert.Equal(SetupGatewayRoute.Existing, draft.Route); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Host_ExplicitUseRequiresPublicationAdmissionAndReturnsExactIdentity(bool drifted) + { + using var directory = new TempDirectory(); + var registry = Registry(directory.Path); + var install = Install(); + var runtime = new FakeRuntime(RuntimeSnapshot(install, LocalAiRuntimeState.Healthy)); + var commands = new ReadOnlyGateway(install, drifted); + var host = Host(registry, runtime, () => install, + () => new(commands, new LocalAiGatewayDistroResolver(registry), NullLogger.Instance)); + var selected = await host.ObserveAsync(CancellationToken.None); + Assert.Equal(0, commands.Reads); + if (drifted) + { + await Assert.ThrowsAsync(() => host.UseAsync(selected, CancellationToken.None)); + Assert.Equal(0, runtime.Calls); + } + else + { + var result = await host.UseAsync(selected, CancellationToken.None); + Assert.Equal("gateway", result.GatewayId); + Assert.Equal(selected.ModelRef, result.ModelRef); + Assert.Equal(1, runtime.Calls); + Assert.Equal(4, commands.Reads); + } + } + + private static SetupLocalAiHost Host(GatewayRegistry registry, FakeRuntime runtime, + Func install, Func? provider = null) => + new(() => Task.FromResult(new LocalAiSetupResolution(LocalAiSetupRoute.Recovery, + new("gateway", "Managed", 18789, install()?.Manifest.ModelCatalogId, install()?.Manifest.RequestedPort))), + () => registry, () => runtime, _ => Task.FromResult(install()), (_, _) => Task.FromResult(true), + _ => Task.FromResult(Hardware), provider ?? (() => throw new InvalidOperationException("No route mutation expected."))); + + private sealed class ReadOnlyGateway(LocalAiResolvedInstall install, bool drifted) : IWslCommandRunner + { + public int Reads { get; private set; } + public Func? AfterRead { get; init; } + public async Task RunInDistroAsync(string name, IReadOnlyList command, + CancellationToken cancellationToken = default, IReadOnlyDictionary? environment = null, + string? standardInput = null) + { + Assert.Equal("Managed", name); + Assert.Contains("get", command); + Assert.Null(standardInput); + Reads++; + if (AfterRead is not null) await AfterRead(Reads); + return new WslCommandResult(0, + command.Contains(LocalAiGatewayProviderDefinition.ProviderPath) + ? LocalAiGatewayProviderDefinition.BuildProviderJson(install) + : System.Text.Json.JsonSerializer.Serialize(drifted + ? "other/model" : LocalAiGatewayProviderDefinition.BuildPrimaryModel(install)), ""); + } + public Task RunAsync(IReadOnlyList arguments, CancellationToken cancellationToken = default, + IReadOnlyDictionary? environment = null) => throw new InvalidOperationException(); + public Task> ListDistrosAsync(CancellationToken cancellationToken = default) => + throw new InvalidOperationException(); + public Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default) => + throw new InvalidOperationException(); + public Task UnregisterDistroAsync(string name, CancellationToken cancellationToken = default) => + throw new InvalidOperationException(); + } + + private static GatewayRegistry Registry(string directory) + { + var registry = new GatewayRegistry(directory); + registry.AddOrUpdate(new GatewayRecord + { Id = "gateway", Url = "ws://127.0.0.1:18789", IsLocal = true, SetupManagedDistroName = "Managed" }); + registry.SetActive("gateway"); + return registry; + } + + internal static LocalAiResolvedInstall Install() + { + var model = LocalModelCatalog.FindInstalled(LocalModelCatalog.Qwen35BModelId)!; + var endpoint = new Uri("http://127.0.0.1:18803/v1"); + return new(new LocalAiInstallManifest + { + EngineVersion = "test", Architecture = "x64", RuntimeId = "test", ModelCatalogId = model.Id, + SelectedGpuId = "GPU-test", ExecutablePath = "engines\\llama-server.exe", + RuntimeAssets = ImmutableArray.Empty, ModelPath = "models\\test.gguf", + ModelId = "test/source", ModelAlias = model.Id, ContextLength = LocalModelCatalog.NativeContextTokens, + ModelAsset = new() { FileName = "test.gguf", SourceUrl = "https://example.com/test", + SizeBytes = 1, Sha256 = new string('a', 64) }, + Endpoint = endpoint.AbsoluteUri, + }, "llama-server.exe", "test.gguf", endpoint); + } + + private static LocalAiRuntimeSnapshot RuntimeSnapshot(LocalAiResolvedInstall install, LocalAiRuntimeState state) => + new(state, state == LocalAiRuntimeState.Healthy ? LocalAiOwnership.CompanionManaged : LocalAiOwnership.None, + install.Endpoint!, "test", install.Manifest.ModelCatalogId, + new(LocalAiModelAvailabilityState.Verified, DateTimeOffset.UtcNow, new string('a', 64), 1), + null, null, null, DateTimeOffset.UtcNow); + + private sealed class FakeRuntime(LocalAiRuntimeSnapshot snapshot) : ILocalAiRuntime + { + public int Calls { get; private set; } + public Action? OnStart { get; init; } + public LocalAiRuntimeSnapshot? StartResult { get; init; } + public LocalAiRuntimeSnapshot Snapshot { get; private set; } = snapshot; + public event EventHandler? StateChanged { add { } remove { } } + public Task EnsureStartedAsync(CancellationToken cancellationToken = default) + { Calls++; OnStart?.Invoke(); Snapshot = StartResult ?? Snapshot; return Task.FromResult(Snapshot); } + public Task StopAsync(CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Observation must not stop a runtime."); + public Task RestartAsync(CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Observation must not restart a runtime."); + public Task RefreshAsync(CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Runtime refresh can mutate a Gateway route."); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class ObservationHost(params Task[] results) : ISetupLocalAiHost + { + public GatewayRegistrySnapshot BeginGatewaySetup() => throw new InvalidOperationException(); + public Task ReconcileGatewaySetupAsync(GatewayRegistrySnapshot expectedOutput, string? completedGatewayId) => throw new InvalidOperationException(); + private readonly Queue> _results = new(results); + public List Tokens { get; } = []; + public int Mutations { get; private set; } + public Task ObserveAsync(CancellationToken ct) + { Tokens.Add(ct); return _results.Dequeue(); } + public Task RevalidateReviewAsync(LocalAiOnboardingSnapshot selected, CancellationToken ct) + { Mutations++; throw new InvalidOperationException(); } + public Task UseAsync(LocalAiOnboardingSnapshot selected, CancellationToken ct) + { Mutations++; throw new InvalidOperationException(); } + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingUseTests.cs b/tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingUseTests.cs new file mode 100644 index 000000000..d94fd29a2 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/LocalAiOnboardingUseTests.cs @@ -0,0 +1,124 @@ +using OpenClaw.Connection; +using OpenClaw.TestSupport; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class LocalAiOnboardingUseTests +{ + private static readonly LocalAiOnboardingSnapshot Selection = new(LocalAiOnboardingState.StartAndUse, + new("selected", "Managed", 18789, null, null), "provider/model"); + + [Fact] + public async Task CancelledUse_DrainRetainsOwnershipUntilActualRollbackEnds() + { + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var trace = new List(); + var host = new UseHost(async ct => + { + trace.Add("mutation"); + using var registration = ct.Register(() => cancelled.SetResult()); + await cancelled.Task; + trace.Add("rollback-start"); + await release.Task; + trace.Add("rollback-end"); + ct.ThrowIfCancellationRequested(); + return new("selected", "provider/model"); + }); + using var cancellation = new CancellationTokenSource(); + var use = new LocalAiOnboardingUse(host); + var operation = use.UseAsync(Selection, cancellation.Token); + cancellation.Cancel(); + await cancelled.Task; + var drain = use.DrainAsync(); + Assert.False(drain.IsCompleted); + Assert.False(operation.IsCompleted); + release.SetResult(); + await drain; + trace.Add("owner-released"); + await Assert.ThrowsAnyAsync(() => operation); + Assert.Equal(["mutation", "rollback-start", "rollback-end", "owner-released"], trace); + } + + [Fact] + public async Task ConfirmedFailureAllowsOnlyAnotherExplicitAdmission() + { + var host = new UseHost(_ => Task.FromException( + new LocalAiStartFailedException("Cleaned failed start."))); + var use = new LocalAiOnboardingUse(host); + await Assert.ThrowsAsync(() => use.UseAsync(Selection, default)); + Assert.Null(use.Expected); + await use.DrainAsync(); + Assert.Equal(1, host.Actions); + await Assert.ThrowsAsync(() => use.UseAsync(Selection, default)); + Assert.Equal(2, host.Actions); + } + + [Fact] + public async Task UncertainFailureRetainsExactPairAndNeverReplaysUse() + { + var host = new UseHost(_ => Task.FromException(new IOException("Lost publication reply."))); + var use = new LocalAiOnboardingUse(host); + await Assert.ThrowsAsync(() => use.UseAsync(Selection, default)); + Assert.Equal(new("selected", "provider/model"), use.Expected); + Assert.Equal(SetupCompletionIntent.CustodianOnboarding, use.Expected!.CompletionIntent); + await use.DrainAsync(); + await Assert.ThrowsAsync(() => use.UseAsync(Selection, default)); + Assert.Throws(() => + LocalAiOnboardingUse.RequireGateway(use.Expected!.GatewayId, "other")); + Assert.Equal(1, host.Actions); + } + + [Fact] + public async Task ReconnectMismatchRejectsBeforeLookingForCredentialsOrOpeningTransport() + { + using var directory = new TempDirectory(); + var registry = new GatewayRegistry(directory.Path); + registry.AddOrUpdate(new GatewayRecord { Id = "other", Url = "wss://remote.invalid" }); + registry.SetActive("other"); + registry.Save(); + await Assert.ThrowsAsync(() => + SetupGatewaySession.ConnectAsync(directory.Path, expectedGatewayId: "selected")); + } + + [Fact] + public void CompletionGatewayGuard_IsReadOnlyAndRejectsSavedGatewaySwitch() + { + using var directory = new TempDirectory(); + var registry = new GatewayRegistry(directory.Path); + var selected = new GatewayRecord { Id = "selected", Url = "wss://selected.invalid/control" }; + registry.AddOrUpdate(selected); + registry.SetActive(selected.Id); + registry.Save(); + var identity = new OpenClaw.Shared.DeviceIdentity(registry.GetIdentityDirectory(selected.Id)); + identity.Initialize(); + var receipt = new GatewayAiSetupCompletion(SetupCompletionIntent.CustodianOnboarding, + selected.Id, GatewayDashboardBinding.Capture(selected), "provider/model", "primary", 1, + IdentityBinding: SetupCompletionAuthority.CaptureIdentity(registry.GetIdentityDirectory(selected.Id), identity.DeviceId), + SessionKey: "agent:primary:main"); + var path = Path.Combine(directory.Path, "gateways.json"); + var before = File.ReadAllBytes(path); + SetupGatewaySession.RequireCompletionGateway(directory.Path, receipt); + Assert.Equal(before, File.ReadAllBytes(path)); + registry.AddOrUpdate(new GatewayRecord { Id = "other", Url = "wss://other.invalid" }); + registry.SetActive("other"); + registry.Save(); + Assert.Throws(() => + SetupGatewaySession.RequireCompletionGateway(directory.Path, receipt)); + } + + private sealed class UseHost(Func> action) : ISetupLocalAiHost + { + public int Actions { get; private set; } + public GatewayRegistrySnapshot BeginGatewaySetup() => throw new InvalidOperationException(); + public Task ReconcileGatewaySetupAsync(GatewayRegistrySnapshot expectedOutput, string? completedGatewayId) => throw new InvalidOperationException(); + public Task ObserveAsync(CancellationToken ct) => throw new InvalidOperationException(); + public Task RevalidateReviewAsync(LocalAiOnboardingSnapshot selected, CancellationToken ct) => + throw new InvalidOperationException(); + public Task UseAsync(LocalAiOnboardingSnapshot selected, CancellationToken ct) + { + Actions++; + return action(ct); + } + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/OnboardingFlowPolicyTests.cs b/tests/OpenClaw.SetupEngine.Tests/OnboardingFlowPolicyTests.cs new file mode 100644 index 000000000..dd03e9679 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/OnboardingFlowPolicyTests.cs @@ -0,0 +1,154 @@ +using OpenClaw.SetupEngine; + +namespace OpenClaw.SetupEngine.Tests; + +public class OnboardingFlowPolicyTests +{ + [Fact] + public void ExistingGateway_ReviewsAccessThenAiWithoutWslInstallation() + { + Assert.Equal( + [OnboardingStage.Welcome, OnboardingStage.Gateway, OnboardingStage.Capabilities, OnboardingStage.AiSetup, OnboardingStage.Ready], + OnboardingFlowPolicy.GetStages(false, new SetupConfig())); + } + + [Theory] + [InlineData(SetupGatewayRoute.ManagedWsl, "chat")] + [InlineData(SetupGatewayRoute.Existing, "chat")] + [InlineData(SetupGatewayRoute.Remote, "chat")] + [InlineData(SetupGatewayRoute.McpOnly, "settings")] + [InlineData(SetupGatewayRoute.Deferred, "connection")] + public void CompletionLaunchTarget_UsesTheSelectedSetupRoute(SetupGatewayRoute route, string target) + { + Assert.Equal(target, OnboardingFlowPolicy.GetCompletionLaunchTarget(route)); + } + + [Theory] + [InlineData(SetupGatewayRoute.ManagedWsl, OnboardingAccessDestination.GatewayReview, true)] + [InlineData(SetupGatewayRoute.Existing, OnboardingAccessDestination.AiSetup, false)] + [InlineData(SetupGatewayRoute.Remote, OnboardingAccessDestination.AiSetup, false)] + [InlineData(SetupGatewayRoute.McpOnly, OnboardingAccessDestination.CompleteWithoutGateway, false)] + [InlineData(SetupGatewayRoute.Deferred, OnboardingAccessDestination.CompleteWithoutGateway, false)] + public void BranchDestination_SeparatesNativeAiFromWslAndGatewayFreeCompletion( + SetupGatewayRoute route, OnboardingAccessDestination destination, bool usesWsl) + { + Assert.Equal(destination, OnboardingFlowPolicy.GetAccessDestination(route)); + Assert.Equal(usesWsl, OnboardingFlowPolicy.UsesWslWorkspaceFinalization(route)); + } + + [Theory] + [InlineData(SetupGatewayRoute.Existing)] + [InlineData(SetupGatewayRoute.Remote)] + public void NativeGatewayRoutes_AlwaysReachFocusedAiDespiteManagedWizardSkip(SetupGatewayRoute route) + { + var config = new SetupConfig { SkipWizard = true }; + Assert.True(OnboardingFlowPolicy.RequiresAiSetup(route, config)); + var stages = OnboardingFlowPolicy.GetStages(route, config); + Assert.Equal(OnboardingStage.Ready, stages[^1]); + Assert.Contains(OnboardingStage.Capabilities, stages); + Assert.DoesNotContain(OnboardingStage.GatewayReview, stages); + Assert.DoesNotContain(OnboardingStage.Install, stages); + } + + [Theory] + [InlineData(SetupGatewayRoute.McpOnly)] + [InlineData(SetupGatewayRoute.Deferred)] + public void GatewayFreeRoutes_SkipAiEvenWhenManagedLocalAiSelectionIsRetained(SetupGatewayRoute route) + { + var config = new SetupConfig(); + config.LocalAi.Enabled = true; + Assert.False(OnboardingFlowPolicy.RequiresAiSetup(route, config)); + var stages = OnboardingFlowPolicy.GetStages(route, config); + Assert.Equal(OnboardingStage.Capabilities, stages[^1]); + Assert.DoesNotContain(OnboardingStage.AiSetup, stages); + Assert.DoesNotContain(OnboardingStage.Install, stages); + Assert.True(config.LocalAi.Enabled); + } + + [Fact] + public void FreshSetup_HasCombinedAccessAndAnExplicitVerifiedDestinationStage() + { + Assert.Equal( + [OnboardingStage.Welcome, OnboardingStage.Gateway, OnboardingStage.Capabilities, OnboardingStage.GatewayReview, + OnboardingStage.Install, OnboardingStage.AiSetup, OnboardingStage.Ready], + OnboardingFlowPolicy.GetStages(true, new SetupConfig())); + } + + [Theory] + [InlineData(SetupGatewayRoute.ManagedWsl, 7)] + [InlineData(SetupGatewayRoute.Existing, 5)] + [InlineData(SetupGatewayRoute.Remote, 5)] + [InlineData(SetupGatewayRoute.McpOnly, 3)] + [InlineData(SetupGatewayRoute.Deferred, 3)] + public void CombinedAccess_IsOneStageRegardlessOfHeadlessSkipPermissions(SetupGatewayRoute route, int count) + { + foreach (var skip in new[] { false, true }) + { + var config = new SetupConfig { SkipPermissions = skip }; + var stages = OnboardingFlowPolicy.GetStages(route, config); + Assert.Equal(count, stages.Count); + Assert.Single(stages, stage => stage == OnboardingStage.Capabilities); + Assert.Equal(skip, config.SkipPermissions); + } + } + + [Fact] + public void ExplicitWizardSkip_OmitsAiConfiguration() + { + var config = new SetupConfig { SkipWizard = true }; + Assert.False(OnboardingFlowPolicy.RequiresAiSetup(config)); + Assert.DoesNotContain(OnboardingStage.AiSetup, OnboardingFlowPolicy.GetStages(true, config)); + } + + [Fact] + public void LocalAiStillRequiresVerification_WhenClassicWizardIsSkipped() + { + var config = new SetupConfig { SkipWizard = true }; + config.LocalAi.Enabled = true; + Assert.True(OnboardingFlowPolicy.RequiresAiSetup(config)); + Assert.Contains(OnboardingStage.AiSetup, OnboardingFlowPolicy.GetStages(true, config)); + } + + [Fact] + public void LocalAiRecovery_DoesNotReplayFirstRunIntroduction() + { + var config = new SetupConfig { SkipWizard = true }; + config.LocalAi.Enabled = true; + Assert.Equal( + [OnboardingStage.GatewayReview, OnboardingStage.Install, OnboardingStage.AiSetup, OnboardingStage.Ready], + OnboardingFlowPolicy.GetStages(true, config, localAiRecovery: true)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InteractiveInstallation_DefersWizardAndWorkspaceFinalization(bool recovery) + { + var steps = OnboardingFlowPolicy.BuildInstallationSteps(recovery); + Assert.NotEmpty(steps); + Assert.DoesNotContain(steps, step => step is RunGatewayWizardStep or WindowsNodeBootstrapContextStep); + if (recovery) + Assert.DoesNotContain(steps, step => step is CreateWslInstanceStep or CleanupStaleDistroStep); + else + { + Assert.Contains(steps, step => step is PairOperatorStep); + Assert.Contains(steps, step => step is PairNodeStep); + Assert.Contains(steps, step => step is VerifyEndToEndStep); + } + } + + [Fact] + public void ClassicCompatibilityWizard_DoesNotPromiseTheNativeVerifiedChooser() + { + Assert.DoesNotContain(OnboardingStage.Ready, OnboardingFlowPolicy.GetStages( + SetupGatewayRoute.ManagedWsl, new SetupConfig(), includeReadyChoice: false)); + } + + [Fact] + public void HeadlessPipeline_KeepsItsWizardAndWorkspaceFinalization() + { + var steps = SetupStepFactory.BuildDefaultSteps(); + Assert.Contains(steps, step => step is RunGatewayWizardStep); + Assert.Contains(steps, step => step is WindowsNodeBootstrapContextStep); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/OnboardingMascotAnimatorTests.cs b/tests/OpenClaw.SetupEngine.Tests/OnboardingMascotAnimatorTests.cs new file mode 100644 index 000000000..67974fabb --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/OnboardingMascotAnimatorTests.cs @@ -0,0 +1,380 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class OnboardingMascotAnimatorTests +{ + public static TheoryData Moods => new(Enum.GetValues()); + public static TheoryData Gestures => new(Enum.GetValues().Select(gesture => (int)gesture)); + + [Theory] + [MemberData(nameof(Moods))] + public void ReducedMotion_IsStaticAndIgnoresAllInteractions(OnboardingMascotMood mood) + { + var animator = New(); + animator.SetMood(mood); + var expected = OnboardingMascotPose.Static(mood); + for (var i = 0; i < 20; i++) + { + Assert.Equal(expected, animator.Advance(TimeSpan.FromDays(1), false)); + animator.HandleTap(); + animator.SetPointer(new(1, -1)); + } + Assert.Equal(0, animator.ElapsedSeconds); + Assert.Null(animator.ActiveGesture); + Assert.Equal(0, expected.FloatOffset); + Assert.Equal(OnboardingMascotEffect.None, expected.Effect); + } + + [Fact] + public void StaticExpressions_MatchSourceIncludingThreeIdenticalNeutralMoods() + { + Assert.Equal(9, Enum.GetValues().Length); + Assert.Equal(OnboardingMascotPose.Static(OnboardingMascotMood.Idle), OnboardingMascotPose.Static(OnboardingMascotMood.Curious)); + Assert.Equal(OnboardingMascotPose.Static(OnboardingMascotMood.Idle), OnboardingMascotPose.Static(OnboardingMascotMood.Attentive)); + Assert.Equal(7, Enum.GetValues().Select(OnboardingMascotPose.Static).Distinct().Count()); + Assert.Equal(1, OnboardingMascotPose.Static(OnboardingMascotMood.Working).HardHat); + Assert.Equal(-0.55, OnboardingMascotPose.Static(OnboardingMascotMood.Sad).MouthCurve); + Assert.Equal(0.6, OnboardingMascotPose.Static(OnboardingMascotMood.Happy).MouthCurve); + Assert.Equal(OnboardingMascotAccessory.Nightcap, OnboardingMascotPose.Static(OnboardingMascotMood.Sleepy).Accessory); + Assert.Equal(30, OnboardingMascotPose.Static(OnboardingMascotMood.Celebrating).LeftClawDegrees); + Assert.Equal(-30, OnboardingMascotPose.Static(OnboardingMascotMood.Celebrating).RightClawDegrees); + } + + [Theory] + [MemberData(nameof(Moods))] + public void SeededSchedules_AreDeterministicAndAllChannelsStayBounded(OnboardingMascotMood mood) + { + var first = New(); + var second = New(); + first.SetMood(mood); + second.SetMood(mood); + for (var i = 0; i < 6000; i++) + { + if (i % 191 == 0) + { + first.HandleTap(); + second.HandleTap(); + } + if (i % 163 == 0) + { + first.SetPointer(new(1, -1)); + second.SetPointer(new(1, -1)); + } + if (i % 163 == 40) + { + first.SetPointer(null); + second.SetPointer(null); + } + var pose = first.Advance(TimeSpan.FromSeconds(1d / 12), true); + Assert.Equal(pose, second.Advance(TimeSpan.FromSeconds(1d / 12), true)); + AssertBounds(pose); + for (var index = 0; index < OnboardingMascotParticles.Capacity; index++) + { + var particle = OnboardingMascotParticles.Sample(pose, index); + Assert.InRange(particle.Opacity, 0, 1); + Assert.InRange(particle.X - particle.Size, -24, 144); + Assert.InRange(particle.Y - particle.Size, -24, 144); + } + } + } + + [Fact] + public void DifferentSeeds_ProduceDifferentMicrobehaviorSchedules() + { + var first = New(1); + var second = New(2); + var differences = 0; + for (var i = 0; i < 600; i++) + if (first.Advance(TimeSpan.FromSeconds(0.1), true) != second.Advance(TimeSpan.FromSeconds(0.1), true)) + differences++; + Assert.True(differences > 100); + } + + [Fact] + public void Clock_ClampsLongFramesAndDoesNotAdvanceWhilePausedOrResetForMoodChanges() + { + var animator = New(); + Assert.Equal(12, OnboardingMascotAnimator.FramesPerSecond); + animator.Advance(TimeSpan.FromHours(2), true); + Assert.Equal(0.1, animator.ElapsedSeconds); + animator.Advance(TimeSpan.FromDays(1), false); + Assert.Equal(0.1, animator.ElapsedSeconds); + animator.SetMood(OnboardingMascotMood.Celebrating); + animator.SetMood(OnboardingMascotMood.Sad); + Assert.Equal(0.1, animator.ElapsedSeconds); + animator.Advance(TimeSpan.FromSeconds(1d / 12), true); + Assert.InRange(animator.ElapsedSeconds, 0.1833, 0.1834); + } + + [Fact] + public void Working_HatDropAndHammerStrikeMatchPinnedSourceTiming() + { + var animator = New(); + animator.SetMood(OnboardingMascotMood.Working); + Assert.Equal(0, animator.Advance(TimeSpan.Zero, true).HardHat); + Assert.Equal(0.5, Advance(animator, 0.275).HardHat, 8); + Assert.Equal(1, Advance(animator, 0.275).HardHat); + Assert.Equal(-34, OnboardingMascotAnimator.BasePose(OnboardingMascotMood.Working, 0.95 * 2.60).RightClawDegrees, 8); + Assert.Equal(12, OnboardingMascotAnimator.BasePose(OnboardingMascotMood.Working, 0.95 * 2.72).RightClawDegrees, 8); + var sparks = OnboardingMascotAnimator.BasePose(OnboardingMascotMood.Working, 0.95 * 2.80); + Assert.Equal(OnboardingMascotEffect.Sparks, sparks.Effect); + Assert.True(OnboardingMascotParticles.Sample(sparks, 0).Opacity > 0.99); + } + + [Fact] + public void Working_BrowWipeIsScheduledBetweenSixAndTwelveSecondsAndLastsTwo() + { + var animator = New(); + animator.SetMood(OnboardingMascotMood.Working); + while (animator.ActiveGesture != OnboardingMascotGesture.WipeBrow && animator.ElapsedSeconds < 13) + animator.Advance(TimeSpan.FromSeconds(0.1), true); + Assert.Equal(OnboardingMascotGesture.WipeBrow, animator.ActiveGesture); + Assert.InRange(animator.ElapsedSeconds, 6, 12.1); + var wiping = Advance(animator, 1); + Assert.Equal(OnboardingMascotEffect.Sweat, wiping.Effect); + Assert.True(wiping.HappyEyes >= 0.7); + Assert.Equal(OnboardingMascotEffect.Sparks, Advance(animator, 1.1).Effect); + } + + [Fact] + public void WorkingExit_TipsOnlyASeatedHatAndSuppressesRequestedHeadwear() + { + var animator = New(); + animator.SetAccessory(OnboardingMascotAccessory.GradCap); + animator.SetMood(OnboardingMascotMood.Working); + var pose = Advance(animator, 0.8); + Assert.Equal(1, pose.HardHat); + Assert.Equal(0, pose.AccessoryAmount); + animator.SetMood(OnboardingMascotMood.Happy); + Assert.Equal(OnboardingMascotGesture.HatTip, animator.ActiveGesture); + Assert.Equal(1, animator.Advance(TimeSpan.Zero, true).HardHat); + Assert.Equal(0, Advance(animator, 0.5).AccessoryAmount); + pose = Advance(animator, 0.5); + Assert.Equal(0, pose.HardHat); + Assert.Equal(1, pose.AccessoryAmount); + animator.SetMood(OnboardingMascotMood.Working); + Advance(animator, 0.1); + animator.SetMood(OnboardingMascotMood.Sad); + Assert.Equal(OnboardingMascotGesture.Sigh, animator.ActiveGesture); + Assert.Equal(0, animator.Advance(TimeSpan.Zero, true).HardHat); + } + + [Fact] + public void Headwear_UsesHalfSecondCubicEntranceAndStaticWorkingWins() + { + var animator = New(); + animator.SetAccessory(OnboardingMascotAccessory.GradCap); + Assert.Equal(0, animator.Advance(TimeSpan.Zero, true).AccessoryAmount); + Assert.Equal(0.875, Advance(animator, 0.25).AccessoryAmount, 8); + Assert.Equal(1, Advance(animator, 0.25).AccessoryAmount); + animator.SetAccessory(OnboardingMascotAccessory.None); + Assert.Equal(OnboardingMascotAccessory.None, animator.Advance(TimeSpan.Zero, true).Accessory); + animator.SetAccessory(OnboardingMascotAccessory.Nightcap); + animator.SetMood(OnboardingMascotMood.Working); + var pose = animator.Advance(TimeSpan.Zero, false); + Assert.Equal(1, pose.HardHat); + Assert.Equal(0, pose.AccessoryAmount); + animator.SetMood(OnboardingMascotMood.Celebrating); + pose = animator.Advance(TimeSpan.Zero, false); + Assert.Equal(0, pose.HardHat); + Assert.Equal(OnboardingMascotAccessory.Nightcap, pose.Accessory); + Assert.Equal(1, pose.AccessoryAmount); + Assert.Equal(0, animator.Advance(TimeSpan.FromSeconds(0.1), true).HardHat); + } + + [Fact] + public void Pointer_UsesExponentialBlendingAndBoundedDirection() + { + var animator = New(); + animator.SetPointer(new(1000, -1000)); + var pose = animator.Advance(TimeSpan.FromSeconds(0.1), true); + Assert.Equal(1 - Math.Exp(-0.9), pose.Gaze.X, 10); + Assert.Equal(-(1 - Math.Exp(-0.9)), pose.Gaze.Y, 10); + animator.SetPointer(null); + var next = animator.Advance(TimeSpan.FromSeconds(0.1), true); + Assert.Equal(pose.Gaze.X * Math.Exp(-0.9), next.Gaze.X, 10); + } + + [Fact] + public void EntranceWave_StartsAfterPointNineSeconds() + { + var animator = New(); + Advance(animator, 0.8); + Assert.Null(animator.ActiveGesture); + Advance(animator, 0.11); + Assert.Equal(OnboardingMascotGesture.Wave, animator.ActiveGesture); + } + + [Fact] + public void ClickLadder_NonrepeatingSinglesHeartsDizzyExtensionThenRecovery() + { + var animator = New(); + animator.Advance(TimeSpan.Zero, true); + animator.HandleTap(); + var first = animator.ActiveGesture; + Assert.Contains(first!.Value, new[] { OnboardingMascotGesture.Hop, OnboardingMascotGesture.Wave, OnboardingMascotGesture.Wink }); + animator.HandleTap(); + Assert.NotEqual(first, animator.ActiveGesture); + animator.HandleTap(); + Assert.Equal(OnboardingMascotGesture.HeartBurst, animator.ActiveGesture); + var love = Advance(animator, 0.4); + Assert.Equal(OnboardingMascotEffect.Hearts, love.Effect); + Assert.True(love.Blush > 0.8); + animator.HandleTap(); + animator.HandleTap(); + animator.HandleTap(); + Assert.Null(animator.ActiveGesture); + Assert.Equal(1, Advance(animator, 0.3).Dizzy); + Advance(animator, 1); + animator.HandleTap(); + Assert.Equal(1, animator.Advance(TimeSpan.Zero, true).Dizzy); + Assert.True(Advance(animator, 1.5).Dizzy > 0); + Advance(animator, 1); + Assert.Equal(OnboardingMascotGesture.Shake, animator.ActiveGesture); + } + + [Theory] + [InlineData(12, 45, 80)] + [InlineData(23, 24.75, 44)] + [InlineData(4, 24.75, 44)] + [InlineData(5, 45, 80)] + public void IdleSleepsOnlyWithWakePathAndPointerNeverWakesIt(int hour, double minimum, double maximum) + { + var animator = new OnboardingMascotAnimator(123, hour, true); + while (!animator.IsDozing && animator.ElapsedSeconds <= maximum + 0.1) + animator.Advance(TimeSpan.FromSeconds(0.1), true); + Assert.True(animator.IsDozing); + Assert.InRange(animator.ElapsedSeconds, minimum, maximum + 0.1); + animator.SetPointer(new(1, 1)); + var pose = Advance(animator, 0.3); + Assert.True(animator.IsDozing); + Assert.Equal(OnboardingMascotAccessory.Nightcap, pose.Accessory); + Assert.Equal(OnboardingMascotEffect.Zzz, pose.Effect); + animator.HandleTap(); + Assert.False(animator.IsDozing); + Assert.Equal(OnboardingMascotGesture.Startle, animator.ActiveGesture); + Assert.Equal(OnboardingMascotAccessory.None, Advance(animator, 0.1).Accessory); + var passive = new OnboardingMascotAnimator(123, hour, false); + Advance(passive, 100); + Assert.False(passive.IsDozing); + passive.HandleTap(); + Assert.NotEqual(OnboardingMascotGesture.Startle, passive.ActiveGesture); + } + + [Fact] + public void PointerActivityDefersSleepWithoutChangingTheIdleMood() + { + var animator = New(); + for (var i = 0; i < 1000; i++) + { + animator.SetPointer(new(0.2, 0.1)); + animator.Advance(TimeSpan.FromSeconds(0.1), true); + } + Assert.False(animator.IsDozing); + Assert.Equal(OnboardingMascotMood.Idle, animator.Mood); + } + + [Theory] + [MemberData(nameof(Gestures))] + public void EveryClip_IsNontrivialAndClampsEveryChannel(int gestureValue) + { + var gesture = (OnboardingMascotGesture)gestureValue; + var changes = 0; + foreach (var mood in Enum.GetValues()) + { + for (var i = 0; i <= 100; i++) + { + var baseline = OnboardingMascotAnimator.BasePose(mood, i * 0.1); + var pose = baseline; + gesture.Apply(ref pose, i / 100d); + if (pose != baseline) + changes++; + AssertBounds(pose.Clamp()); + } + } + Assert.True(changes > 0); + Assert.InRange(gesture.Duration(), 0.6, 2.4); + } + + [Fact] + public void GestureLandmarks_MatchAllSixteenPinnedClips() + { + Assert.Equal(16, Enum.GetValues().Length); + Assert.Equal(-28, Clip(OnboardingMascotGesture.Wave, 0.5).RightClawDegrees, 8); + Assert.Equal(-9, Clip(OnboardingMascotGesture.Hop, 0.5).FloatOffset, 8); + Assert.Equal(0, Clip(OnboardingMascotGesture.Wink, 0.5).RightEyeOpenness); + Assert.Equal(38, Clip(OnboardingMascotGesture.Celebrate, 0.5).LeftClawDegrees); + Assert.Equal(0.9, Clip(OnboardingMascotGesture.HeartBurst, 0.5).Blush); + Assert.Equal(-6, Clip(OnboardingMascotGesture.Peek, 0.25).BodyTilt); + Assert.Equal(0.87, Clip(OnboardingMascotGesture.Sneeze, 0.5).BodyStretch, 8); + Assert.Equal(1.55, Clip(OnboardingMascotGesture.AntennaZap, 0.5).GlowScale); + Assert.Equal(0.945, Clip(OnboardingMascotGesture.Sigh, 0.75).BodyStretch, 8); + Assert.Equal(0.9, Clip(OnboardingMascotGesture.Yawn, 0.5).MouthRound); + Assert.Equal(-5, Clip(OnboardingMascotGesture.Startle, 0.2).FloatOffset); + Assert.Equal(5 * (1 - 1d / 12), Clip(OnboardingMascotGesture.Shake, 1d / 12).BodyTilt, 8); + Assert.Equal(-8, Clip(OnboardingMascotGesture.ClawSnap, 0.35).LeftClawDegrees); + var don = new OnboardingMascotPose { HardHat = 1 }; + OnboardingMascotGesture.DonHardHat.Apply(ref don, 0.275); + Assert.Equal(0.5, don.HardHat, 8); + Assert.Equal(38, Clip(OnboardingMascotGesture.WipeBrow, 0.5).LeftClawDegrees, 8); + Assert.Equal(0.5, Clip(OnboardingMascotGesture.HatTip, 0.775).HardHat, 8); + } + + [Fact] + public void InvalidInputs_FailExplicitly() + { + var animator = New(); + Assert.Throws(() => animator.SetMood((OnboardingMascotMood)999)); + Assert.Throws(() => animator.SetAccessory((OnboardingMascotAccessory)999)); + Assert.Throws(() => animator.SetPointer(new(double.NaN, 0))); + Assert.Throws(() => animator.Advance(TimeSpan.FromSeconds(-1), true)); + Assert.Throws(() => OnboardingMascotPose.Static((OnboardingMascotMood)999)); + Assert.Throws(() => OnboardingMascotParticles.Sample(new(), 6)); + } + + private static OnboardingMascotAnimator New(ulong seed = 123) => new(seed, 12, true); + private static OnboardingMascotPose Clip(OnboardingMascotGesture gesture, double progress) + { + var pose = new OnboardingMascotPose(); + gesture.Apply(ref pose, progress); + return pose; + } + + private static OnboardingMascotPose Advance(OnboardingMascotAnimator animator, double seconds) + { + var pose = animator.Advance(TimeSpan.Zero, true); + while (seconds > 0.0000001) + { + var dt = Math.Min(seconds, 0.1); + pose = animator.Advance(TimeSpan.FromTicks((long)Math.Round(dt * TimeSpan.TicksPerSecond)), true); + seconds -= dt; + } + return pose; + } + + private static void AssertBounds(OnboardingMascotPose pose) + { + Assert.InRange(pose.FloatOffset, -12, 2); + Assert.InRange(pose.AntennaDegrees, -14, 14); + Assert.InRange(pose.AntennaDroop, 0, 1); + Assert.InRange(pose.LeftClawDegrees, -45, 45); + Assert.InRange(pose.RightClawDegrees, -45, 45); + Assert.InRange(pose.EyeGlowOpacity, 0, 1); + Assert.InRange(pose.GlowScale, 0.5, 1.6); + Assert.InRange(pose.LeftEyeOpenness, 0, 1); + Assert.InRange(pose.RightEyeOpenness, 0, 1); + Assert.InRange(pose.HappyEyes, 0, 1); + Assert.InRange(pose.Gaze.X, -1.2, 1.2); + Assert.InRange(pose.Gaze.Y, -1.2, 1.2); + Assert.InRange(pose.MouthCurve, -1, 1); + Assert.InRange(pose.MouthOpen, 0, 1); + Assert.InRange(pose.MouthRound, 0, 1); + Assert.InRange(pose.Blush, 0, 1); + Assert.InRange(pose.HardHat, 0, 1); + Assert.InRange(pose.AccessoryAmount, 0, 1); + Assert.InRange(pose.BodyTilt, -8, 8); + Assert.InRange(pose.BodyStretch, 0.86, 1.05); + Assert.InRange(pose.Dizzy, 0, 1); + Assert.InRange(pose.DizzyPhase, 0, 1); + Assert.InRange(pose.EffectPhase, 0, 1); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/OnboardingMascotAssetTests.cs b/tests/OpenClaw.SetupEngine.Tests/OnboardingMascotAssetTests.cs new file mode 100644 index 000000000..cf31da03c --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/OnboardingMascotAssetTests.cs @@ -0,0 +1,54 @@ +using System.Security.Cryptography; +using System.Xml.Linq; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class OnboardingMascotAssetTests +{ + public static TheoryData UpstreamAssets => new() + { + { "ProviderIcon-claude.svg", "497A88A780831512317B6DD061285E2EC2608ABF09A94A5F24EE9019A4D3EF8B" }, + { "ProviderIcon-codex.svg", "5C999792F6D26C9D14A197AA6E6C20F5AF3DEF433473F7B66BD0D8C8A41CD8CF" }, + { "ProviderIcon-gemini.svg", "DFC7A86AD243030737BF66D316996743D8C4209FADEEE5A782CCAF4F84E3C7E9" }, + { "ProviderIcon-kimi.svg", "B99F95983147C4CA1A72F61E1E22E172027CCC30E72E88C48DC7280A555C3E9A" }, + { "ProviderIcon-lmstudio.svg", "02DC67068DAA4865D2836D06464DBD853320556812F7B64465F69AFA98EF7DFE" }, + { "ProviderIcon-ollama.svg", "167D65056565611212CBA72F70E582032F92F3B6A1BD35FFC26C2B5014F90794" }, + { "ProviderIcon-opencode.svg", "3A20ABB3D62D9A1FF7AE1395A17CC2D1A7105597AC028E968A6A3ED73EE1D82F" }, + { "ProviderIcon-pi.svg", "10F6335CD4F9B5E8CFAA5408C2AA9C75362D1AD5BCF62B11CBD00B4EF9B23AE5" }, + { "ProviderIcon-xai.svg", "FF019BBAA756CCB2F3F31D9B86BE17ED9EC2FD43D24B0EF56EE42AE7AB691985" }, + { "ATTRIBUTION.md", "08F98F79BE36D70B334E2361A4105AA0E2F0A95635FF8C93CEBBFE745F87B473" }, + { "NOTICE.md", "359646742269AB493AF0B2D62F3F5420D4320143E9F23DCD4A4CA89CF5799D91" }, + }; + + [Theory] + [MemberData(nameof(UpstreamAssets))] + public void ProviderArtworkAndNotices_AreExactPinnedUpstreamBytes(string name, string hash) + { + var path = OnboardingMascotSourceContractTests.RepoPath( + "src", "OpenClaw.Tray.WinUI", "Assets", "Setup", "ProviderIcons", name); + Assert.Equal(hash, Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))); + if (!name.EndsWith(".svg", StringComparison.Ordinal)) + return; + + var document = XDocument.Load(path); + XNamespace svg = "http://www.w3.org/2000/svg"; + Assert.Equal(svg + "svg", document.Root!.Name); + Assert.False(string.IsNullOrWhiteSpace(document.Root.Attribute("viewBox")?.Value)); + Assert.NotEmpty(document.Descendants(svg + "path")); + // These path-only assets avoid unsupported SVG filters, embedded fonts, scripts, and external resources. + Assert.All(document.Descendants(), element => Assert.Contains(element.Name.LocalName, new[] { "svg", "path" })); + Assert.DoesNotContain(document.Descendants().Attributes(), + attribute => attribute.Name.LocalName is "href" or "style" || attribute.Value.Contains("url(", StringComparison.Ordinal)); + } + + [Fact] + public void MascotNotice_PreservesLicenseAndPinnedSourceProvenance() + { + var source = File.ReadAllText(OnboardingMascotSourceContractTests.RepoPath( + "src", "OpenClaw.Tray.WinUI", "Assets", "Setup", "Mascot-NOTICE.txt")); + Assert.Contains("0fd603a6fece58d60c010e565df9e26c6601db8b", source); + Assert.Contains("Copyright (c) 2026 OpenClaw Foundation", source); + Assert.Contains("Permission is hereby granted, free of charge", source); + Assert.Contains("THE SOFTWARE IS PROVIDED \"AS IS\"", source); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/OnboardingMascotSourceContractTests.cs b/tests/OpenClaw.SetupEngine.Tests/OnboardingMascotSourceContractTests.cs new file mode 100644 index 000000000..610ec539f --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/OnboardingMascotSourceContractTests.cs @@ -0,0 +1,134 @@ +namespace OpenClaw.SetupEngine.Tests; + +/// Retire these lifecycle wiring guards when an in-process WinUI control test host covers them. +public sealed class OnboardingMascotSourceContractTests +{ + [Fact] + public void Control_ExposesNativeMoodAndNeverStartsAConstructorTimer() + { + var source = Control(); + Assert.Contains("typeof(OnboardingMascotMood), typeof(OnboardingMascot)", source); + Assert.Contains("public OnboardingMascotMood Mood", source); + Assert.Contains("public bool IsAnimationEnabled", source); + Assert.Contains("IsTabStop = false;", source); + Assert.Contains("PointerMoved += OnPointerMoved;", source); + Assert.Contains("Tapped += OnTapped;", source); + Assert.Contains("if (!CanAnimate || !IsInteractive)", source); + Assert.Contains("AccessibilityView.Raw", source); + var constructor = source[source.IndexOf("public OnboardingMascot()", StringComparison.Ordinal).. + source.IndexOf("public void SetPointerGaze", StringComparison.Ordinal)]; + Assert.DoesNotContain("new DispatcherTimer", constructor); + Assert.DoesNotContain(".Start()", constructor); + Assert.Contains("FramesPerSecond", source); + } + + [Fact] + public void Control_StopsForReducedMotionUnloadAncestorAndHostVisibility() + { + var source = Control(); + Assert.Contains("_uiSettings?.AnimationsEnabled == true", source); + Assert.Contains("_root?.IsHostVisible == true", source); + Assert.Contains("subscription.Element.Visibility == Visibility.Visible", source); + Assert.Contains("EffectiveViewportChanged += OnEffectiveViewportChanged;", source); + Assert.Contains("AnimationsEnabledChanged += OnAnimationsEnabledChanged;", source); + Assert.Contains("AnimationsEnabledChanged -= OnAnimationsEnabledChanged;", source); + Assert.Contains("ColorValuesChanged += OnColorsChanged;", source); + Assert.Contains("ColorValuesChanged -= OnColorsChanged;", source); + Assert.DoesNotContain(".HighContrastChanged +=", source); + Assert.Contains("_root.Changed -= OnRootChanged;", source); + Assert.Contains("UnregisterPropertyChangedCallback(VisibilityProperty, token)", source); + Assert.Contains("_timer.Tick -= OnTick;", source); + Assert.Contains("_timer.Stop();", source); + Assert.Contains("_clock.Reset();", source); + Assert.Contains("_animator.Advance(elapsed, CanAnimate)", source); + Assert.Contains("_heroGlow?.Dispose();", source); + } + + [Fact] + public void Glow_CapturesTheActualArtworkAndUsesPinnedThemeConstants() + { + var source = File.ReadAllText(RepoPath("src", "OpenClaw.SetupEngine.UI", "Controls", "OnboardingMascotGlow.cs")); + Assert.Contains("GetElementVisual(drawing.Artwork)", source); + Assert.Contains("CreateVisualSurface()", source); + Assert.Contains("_shadow.Mask = _mask", source); + Assert.Contains("_shadow.BlurRadius = 12", source); + Assert.Contains("239, 75, 88", source); + Assert.Contains("255, 77, 77", source); + Assert.Contains("light ? 0.2f : 0.4f", source); + Assert.Contains("_visual.IsVisible = !highContrast", source); + Assert.Contains("_surface.SourceVisual = null", source); + Assert.Contains("_surface.Dispose()", source); + Assert.DoesNotContain("Ellipse", source); + Assert.DoesNotContain("Bitmap", source); + } + + [Fact] + public void PageHeroes_UseSharedOpticalSizeAndCompletionHeadwear() + { + var pages = RepoPath("src", "OpenClaw.SetupEngine.UI", "Pages"); + foreach (var file in Directory.EnumerateFiles(pages, "*.xaml")) + { + var document = System.Xml.Linq.XDocument.Load(file); + foreach (var mascot in document.Descendants().Where(element => element.Name.LocalName == "OnboardingMascot")) + { + Assert.Null(mascot.Attribute("Width")); + Assert.Null(mascot.Attribute("Height")); + } + } + var match = System.Text.RegularExpressions.Regex.Match(Control(), @"public const double HeroSize = (\d+)"); + Assert.True(match.Success); + var frame = int.Parse(match.Groups[1].Value); + Assert.Equal(0, frame % 4); + Assert.InRange(frame * 120d / 168, 124, 132); + Assert.Contains("Accessory=\"GradCap\"", File.ReadAllText(Path.Combine(pages, "CompletePage.xaml"))); + Assert.Contains("Mood=\"Idle\"", File.ReadAllText(Path.Combine(pages, "SecurityNoticePage.xaml"))); + Assert.Contains("ReadinessError.IsOpen ? OnboardingMascotMood.Sad : OnboardingMascotMood.Happy", + File.ReadAllText(Path.Combine(pages, "WelcomePage.xaml.cs"))); + } + + [Fact] + public void Drawing_PreservesCanonicalBodyGeometryAndNativeVectorParts() + { + var source = File.ReadAllText(RepoPath("src", "OpenClaw.SetupEngine.UI", "Controls", "OnboardingMascotDrawing.cs")); + Assert.Contains("Curve(30, 10, 15, 35, 15, 55)", source); + Assert.Contains("Curve(55, 100, 60, 102, 65, 100)", source); + Assert.Contains("Curve(105, 35, 90, 10, 60, 10)", source); + Assert.Contains("CenterX = 26, CenterY = 53", source); + Assert.Contains("CenterX = 94, CenterY = 53", source); + Assert.Contains("Quad(35, 5, 30, 8)", source); + Assert.Contains("Quad(85, 5, 90, 8)", source); + Assert.Contains("Rgb(255, 112, 121)", source); + Assert.Contains("Rgb(153, 27, 27)", source); + Assert.Contains("UIElementType.WindowText", source); + Assert.DoesNotContain("BitmapImage", source); + Assert.DoesNotContain("XamlReader", source); + } + + [Fact] + public void Drawing_FrameChangesDoNotReparentGeometryAcrossParticles() + { + var source = File.ReadAllText(RepoPath("src", "OpenClaw.SetupEngine.UI", "Controls", "OnboardingMascotDrawing.cs")); + var render = source[source.IndexOf("public void Render(", StringComparison.Ordinal).. + source.IndexOf("private Eye AddEye(", StringComparison.Ordinal)]; + Assert.DoesNotContain(".Data =", render); + Assert.DoesNotContain("new PathGeometry", render); + Assert.Contains("_hearts[i].Opacity", render); + Assert.Contains("_particles[i].Opacity", render); + Assert.Contains("_hearts[i] = heart", source); + } + + internal static string RepoPath(params string[] segments) + { + var root = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (string.IsNullOrWhiteSpace(root)) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx"))) + directory = directory.Parent; + root = directory?.FullName ?? throw new DirectoryNotFoundException("Could not locate repository root."); + } + return Path.Combine([root, .. segments]); + } + + private static string Control() => File.ReadAllText(RepoPath("src", "OpenClaw.SetupEngine.UI", "Controls", "OnboardingMascot.cs")); +} diff --git a/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj b/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj index 19de49efd..0eb56ad65 100644 --- a/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj +++ b/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj @@ -9,4 +9,11 @@ CopyToOutputDirectory="PreserveNewest" /> + + + + + + + diff --git a/tests/OpenClaw.SetupEngine.Tests/ProviderArtworkSourceContractTests.cs b/tests/OpenClaw.SetupEngine.Tests/ProviderArtworkSourceContractTests.cs new file mode 100644 index 000000000..f410b921a --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/ProviderArtworkSourceContractTests.cs @@ -0,0 +1,72 @@ +namespace OpenClaw.SetupEngine.Tests; + +/// Retire when a mounted WinUI test host covers image decode and recycled-control lifetimes. +public sealed class ProviderArtworkSourceContractTests +{ + [Fact] + public void Page_ForwardsAllMetadataAndClosesArtworkBeforeConnectionCleanup() + { + var page = Read("src", "OpenClaw.SetupEngine.UI", "Pages", "AiSetupPage.xaml.cs"); + Assert.Contains("candidate.BrandId, candidate.Icon, candidate.Kind", page); + Assert.Contains("MetadataActionLabel: option.ActionLabel", page); + Assert.Contains("Icon: option.Icon, ProviderKind: option.Kind", page); + Assert.Contains("_artworkSession.Dispose();", page); + Assert.DoesNotContain("new HttpClient", page); + Assert.DoesNotContain("IconVisibility", page); + var xaml = Read("src", "OpenClaw.SetupEngine.UI", "Pages", "AiSetupPage.xaml"); + Assert.Contains("Descriptor=\"{Binding Artwork}\" Session=\"{Binding ArtworkSession}\"", xaml); + Assert.Contains("Text=\"{Binding ActionLabel}\"", xaml); + } + + [Fact] + public void Control_UsesWinUiBundledResourceResolutionAndCancelsEveryReplacementAndUnload() + { + var source = Read("src", "OpenClaw.SetupEngine.UI", "Controls", "ProviderArtwork.xaml.cs"); + Assert.Contains("ArtworkImage.Source = null;", source); + Assert.Contains("_generation.IsCurrent(generation)", source); + Assert.Contains("_generation.Stop();", source); + Assert.Contains("Unloaded += OnUnloaded;", source); + Assert.Contains("session.Closed += OnSessionClosed;", source); + Assert.Contains("_observedSession.Closed -= OnSessionClosed;", source); + Assert.Contains("await session.Loader.LoadAsync(remote, ct)", source); + Assert.Contains("source.UriSource = uri", source); + var loading = source[source.IndexOf("private async Task LoadBundledAsync", StringComparison.Ordinal)..]; + Assert.True(loading.IndexOf("ArtworkImage.Source = source", StringComparison.Ordinal) < + loading.IndexOf("source.UriSource = uri", StringComparison.Ordinal)); + Assert.True(loading.IndexOf("ArtworkImage.Visibility = Visibility.Visible", StringComparison.Ordinal) < + loading.IndexOf("await completion.Task", StringComparison.Ordinal)); + Assert.Contains("_generation.Matches(generation) && ReferenceEquals(ArtworkImage.Source, source)", loading); + Assert.Contains("source.Opened -= Opened", source); + Assert.Contains("source.OpenFailed -= Failed", source); + Assert.DoesNotContain("RandomAccessStreamReference", source); + Assert.DoesNotContain("new BitmapImage", source); + Assert.DoesNotContain("Trace.", source); + Assert.DoesNotContain("ex.Message", source); + Assert.Contains("AutomationProperties.AccessibilityView=\"Raw\"", + Read("src", "OpenClaw.SetupEngine.UI", "Controls", "ProviderArtwork.xaml")); + } + + [Fact] + public void Decoder_ChecksCodecAndDimensionsBeforeAllocatingPixelsAndRetainsNativeSlot() + { + var source = Read("src", "OpenClaw.SetupEngine.UI", "Controls", "ProviderArtworkDecoder.cs"); + Assert.Contains("decoder.DecoderInformation.CodecId != expected", source); + Assert.Contains("decoder.FrameCount != 1", source); + Assert.True(source.IndexOf("AreDimensionsAllowed", StringComparison.Ordinal) < + source.IndexOf("GetPixelDataAsync", StringComparison.Ordinal)); + Assert.Contains("BitmapPixelFormat.Bgra8", source); + Assert.Contains("ExifOrientationMode.IgnoreExifOrientation", source); + Assert.Contains("ColorManagementMode.DoNotColorManage", source); + Assert.Contains("Slots.WaitAsync(0, ct)", source); + Assert.Contains("await decode.WaitAsync(ct)", source); + Assert.Contains("finally { Slots.Release(); }", source); + Assert.DoesNotContain("BitmapImage", source); + } + + private static string Read(params string[] parts) + { + var root = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT") ?? + throw new InvalidOperationException("Set OPENCLAW_REPO_ROOT for linked-worktree source contracts."); + return File.ReadAllText(Path.Combine([root, .. parts])); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/ProviderArtworkTests.cs b/tests/OpenClaw.SetupEngine.Tests/ProviderArtworkTests.cs new file mode 100644 index 000000000..94c11c22f --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/ProviderArtworkTests.cs @@ -0,0 +1,438 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Xml.Linq; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class ProviderArtworkTests +{ + public static TheoryData Aliases => new() + { + { "claude-cli", "claude" }, { "claude-code", "claude" }, { "claude", "claude" }, { "anthropic", "claude" }, + { "codex-cli", "codex" }, { "codex", "codex" }, { "openai", "codex" }, { "chatgpt", "codex" }, + { "gemini-cli", "gemini" }, { "gemini", "gemini" }, { "googlegemini", "gemini" }, { "google", "gemini" }, + { "ollama", "ollama" }, { "lmstudio", "lmstudio" }, { "lm-studio", "lmstudio" }, + { "pi", "pi" }, { "opencode", "opencode" }, { "kimi-code", "kimi" }, { "kimi", "kimi" }, + { "moonshot", "kimi" }, { "grok-build", "xai" }, { "grok", "xai" }, { "xai", "xai" }, + { " xAI-OAuth \n", "xai" }, { "anthropic-vertex", "claude" }, { "google-gemini-cli", "gemini" }, + { "-xai-oauth", "xai" } + }; + + [Theory, MemberData(nameof(Aliases))] + public void PinnedMacAliases_MapOnlyToNineBundledNames(string value, string resource) => + Assert.Equal($"ProviderIcon-{resource}.svg", GatewayAiSetupPresentation.GetBundledProviderIconFileName(value)); + + [Theory] + [InlineData(null)] + [InlineData("unknown")] + [InlineData("../claude")] + [InlineData("https://host/claude")] + [InlineData("lm-studio-oauth")] + public void UnrecognizedNames_NeverBecomeResourcePaths(string? value) => + Assert.Null(GatewayAiSetupPresentation.GetBundledProviderIconFileName(value)); + + [Fact] + public void Artwork_PrefersBrandThenIdThenRemoteThenFallback() + { + var brand = GatewayAiSetupPresentation.GetProviderArtwork("google", "xai", "https://icons.example/logo", ProviderArtworkFallback.Key); + Assert.Equal("ProviderIcon-gemini.svg", brand.BundledFileName); + Assert.Null(brand.RemoteUri); + var id = GatewayAiSetupPresentation.GetProviderArtwork("unknown", "xai-oauth", "http://localhost/logo", ProviderArtworkFallback.Pair); + Assert.Equal("ProviderIcon-xai.svg", id.BundledFileName); + Assert.False(id.RemoteRejected); + var remote = GatewayAiSetupPresentation.GetProviderArtwork(null, "unknown", "https://icons.example/logo", ProviderArtworkFallback.Account); + Assert.NotNull(remote.RemoteUri); + var fallback = GatewayAiSetupPresentation.GetProviderArtwork(null, "unknown", "http://localhost/logo", ProviderArtworkFallback.Account); + Assert.Null(fallback.RemoteUri); + Assert.True(fallback.RemoteRejected); + Assert.Equal(ProviderArtworkFallback.Account, fallback.Fallback); + } + + [Theory] + [InlineData("device-code", "Pair")] + [InlineData("install", "Set up…")] + [InlineData("custom", "Configure…")] + [InlineData("oauth", "Sign in")] + [InlineData(null, "Sign in")] + public void MetadataActionLabel_WinsOverKindDefault(string? kind, string expected) + { + Assert.Equal(expected, GatewayAiSetupPresentation.GetProviderActionLabel(null, kind)); + Assert.Equal(expected, GatewayAiSetupPresentation.GetProviderActionLabel(" ", kind)); + Assert.Equal("Use provider", GatewayAiSetupPresentation.GetProviderActionLabel("Use provider", kind)); + Assert.Equal("Connect / Set up", + GatewayAiSetupPresentation.GetProviderActionLabel(null, kind, GatewayAiSetupChoiceKind.Prepare)); + Assert.Equal("Prepare exact model", + GatewayAiSetupPresentation.GetProviderActionLabel("Prepare exact model", kind, GatewayAiSetupChoiceKind.Prepare)); + } + + [Theory] + [InlineData("https://icons.example/logo", true)] + [InlineData("https://8.8.8.8/logo", true)] + [InlineData("https://[2606:4700:4700::1111]/logo", true)] + [InlineData("https://user:password@icons.example/logo", false)] + [InlineData("https://user@icons.example/logo", false)] + [InlineData("https://icons.example:8443/logo", false)] + [InlineData("http://icons.example/logo", false)] + [InlineData("file:///C:/logo", false)] + [InlineData("data:image/svg+xml,", false)] + [InlineData("https://localhost/logo", false)] + [InlineData("https://machine/logo", false)] + [InlineData("https://machine.local/logo", false)] + [InlineData("https://machine.internal./logo", false)] + [InlineData("https://127.1/logo", false)] + [InlineData("https://2130706433/logo", false)] + [InlineData("https://[::1]/logo", false)] + [InlineData("https://[::ffff:127.0.0.1]/logo", false)] + [InlineData("https://icons.example/logo#fragment", false)] + public void UriPolicy_IsHttpsPublicOnly(string value, bool expected) => + Assert.Equal(expected, ProviderArtworkNetworkPolicy.TryGetUri(value, out _)); + + [Theory] + [InlineData("0.0.0.0")] + [InlineData("10.1.2.3")] + [InlineData("100.64.1.1")] + [InlineData("127.0.0.1")] + [InlineData("169.254.169.254")] + [InlineData("172.16.0.1")] + [InlineData("192.168.0.1")] + [InlineData("192.0.0.8")] + [InlineData("192.0.2.1")] + [InlineData("192.88.99.1")] + [InlineData("198.18.0.1")] + [InlineData("198.51.100.1")] + [InlineData("203.0.113.1")] + [InlineData("224.0.0.1")] + [InlineData("240.0.0.1")] + [InlineData("255.255.255.255")] + [InlineData("::")] + [InlineData("::1")] + [InlineData("::ffff:192.168.1.1")] + [InlineData("fe80::1")] + [InlineData("fec0::1")] + [InlineData("fc00::1")] + [InlineData("ff02::1")] + [InlineData("64:ff9b::a00:1")] + [InlineData("100::1")] + [InlineData("2001:db8::1")] + [InlineData("2001:20::1")] + [InlineData("2002:0808:0808::1")] + [InlineData("3fff::1")] + [InlineData("4000::1")] + public void AddressPolicy_BlocksNonPublicAndSpecialUse(string value) + { + var ip = IPAddress.Parse(value); + Assert.False(ProviderArtworkNetworkPolicy.IsPublicAddress(ip)); + if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + Assert.False(ProviderArtworkNetworkPolicy.IsPublicAddress(ip.MapToIPv6())); + } + + [Fact] + public async Task SocketConnection_UsesTheValidatedAddressWithoutSecondDnsLookup() + { + var resolves = 0; + var connects = 0; + using var stream = await ProviderArtworkNetworkPolicy.ConnectPublicAsync("icons.example", 443, + (_, _) => { ++resolves; return Task.FromResult(new[] { IPAddress.Parse("8.8.8.8") }); }, + (ip, port, _) => + { + ++connects; + Assert.Equal(IPAddress.Parse("8.8.8.8"), ip); + Assert.Equal(443, port); + return ValueTask.FromResult(new MemoryStream()); + }, default); + Assert.Equal(1, resolves); + Assert.Equal(1, connects); + } + + [Fact] + public async Task SocketConnection_RejectsMixedDnsAnswersBeforeAnyConnect() + { + await Assert.ThrowsAsync(async () => + await ProviderArtworkNetworkPolicy.ConnectPublicAsync("icons.example", 443, + (_, _) => Task.FromResult(new[] { IPAddress.Parse("8.8.8.8"), IPAddress.Loopback }), + (_, _, _) => throw new InvalidOperationException("Must not connect."), default)); + } + + [Fact] + public async Task SocketConnection_LiteralIpv6AndCancelledResolution() + { + using var stream = await ProviderArtworkNetworkPolicy.ConnectPublicAsync("2606:4700:4700::1111", 443, + (_, _) => throw new InvalidOperationException("Must not resolve a literal."), + (ip, _, _) => + { + Assert.Equal(IPAddress.Parse("2606:4700:4700::1111"), ip); + return ValueTask.FromResult(new MemoryStream()); + }, default); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + await Assert.ThrowsAnyAsync(async () => + await ProviderArtworkNetworkPolicy.ConnectPublicAsync("icons.example", 443, + (_, ct) => Task.FromCanceled(ct), + (_, _, _) => throw new InvalidOperationException("Must not connect."), cancelled.Token)); + } + + [Fact] + public void Handler_DisablesCredentialRedirectProxyAndTelemetryPropagation() + { + using var handler = ProviderArtworkNetworkPolicy.CreateHandler(); + Assert.False(handler.AllowAutoRedirect); + Assert.False(handler.UseCookies); + Assert.False(handler.PreAuthenticate); + Assert.False(handler.UseProxy); + Assert.Null(handler.Credentials); + Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback); + Assert.Null(handler.ActivityHeadersPropagator); + Assert.Equal(DecompressionMethods.None, handler.AutomaticDecompression); + Assert.NotNull(handler.ConnectCallback); + } + + [Theory] + [InlineData(HttpStatusCode.Redirect)] + [InlineData(HttpStatusCode.TemporaryRedirect)] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.ProxyAuthenticationRequired)] + public async Task Responses_NeverFollowOrAuthenticate(HttpStatusCode status) + { + using var handler = new Handler((request, _) => + { + Assert.Null(request.Headers.Authorization); + Assert.Null(request.Headers.Referrer); + Assert.False(request.Headers.Contains("Cookie")); + Assert.False(request.Headers.Contains("Proxy-Authorization")); + var response = new HttpResponseMessage(status); + response.Headers.Location = new Uri("https://127.0.0.1/secret"); + response.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Negotiate")); + return Task.FromResult(response); + }); + using var loader = new ProviderArtworkLoader(handler); + Assert.Equal(ProviderArtworkStatus.Http, (await loader.LoadAsync(IconUri(), default)).Status); + Assert.Equal(1, handler.Calls); + } + + [Fact] + public async Task InvalidUri_NeverReachesHandler() + { + using var handler = new Handler((_, _) => throw new InvalidOperationException()); + using var loader = new ProviderArtworkLoader(handler); + Assert.Equal(ProviderArtworkStatus.Blocked, (await loader.LoadAsync(new Uri("http://127.0.0.1/logo"), default)).Status); + Assert.Equal(0, handler.Calls); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Download_EnforcesAdvertisedAndStreamedByteLimits(bool advertise) + { + using var handler = new Handler((_, _) => + { + var response = ImageResponse(new byte[ProviderArtworkLoader.MaxBytes + 1]); + if (!advertise) + response.Content.Headers.ContentLength = 1; + response.Content.Headers.ContentType = new("image/png"); + return Task.FromResult(response); + }); + using var loader = new ProviderArtworkLoader(handler); + Assert.Equal(ProviderArtworkStatus.TooLarge, (await loader.LoadAsync(IconUri(), default)).Status); + Assert.Equal((0, 0), loader.CacheSize); + } + + [Fact] + public async Task Download_CancellationAndDeadlineAreDistinct() + { + using var handler = new Handler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + return ImageResponse(Png()); + }); + using var loader = new ProviderArtworkLoader(handler, TimeSpan.FromMilliseconds(30)); + Assert.Equal(ProviderArtworkStatus.TimedOut, (await loader.LoadAsync(IconUri(), default)).Status); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + await Assert.ThrowsAnyAsync(() => loader.LoadAsync(IconUri(), cancelled.Token)); + } + + [Fact] + public async Task Cache_IsFiniteAndAvoidsRepeatDownload() + { + using var handler = new Handler((_, _) => Task.FromResult(ImageResponse(Png()))); + using var loader = new ProviderArtworkLoader(handler); + await loader.LoadAsync(IconUri(), default); + await loader.LoadAsync(IconUri(), default); + Assert.Equal(1, handler.Calls); + for (var i = 0; i < 30; ++i) + await loader.LoadAsync(IconUri(i + 1), default); + Assert.Equal(ProviderArtworkLoader.MaxCacheEntries, loader.CacheSize.Entries); + Assert.InRange(loader.CacheSize.Bytes, 1, ProviderArtworkLoader.MaxCacheBytes); + loader.Dispose(); + Assert.Equal((0, 0), loader.CacheSize); + } + + [Fact] + public async Task Cache_ByteBudgetEvictsBeforeCountBudget() + { + var bytes = new byte[ProviderArtworkLoader.MaxBytes]; + Png().CopyTo(bytes, 0); + using var handler = new Handler((_, _) => Task.FromResult(ImageResponse(bytes))); + using var loader = new ProviderArtworkLoader(handler); + for (var i = 0; i < 12; ++i) + await loader.LoadAsync(IconUri(i), default); + Assert.Equal(8, loader.CacheSize.Entries); + Assert.Equal(ProviderArtworkLoader.MaxCacheBytes, loader.CacheSize.Bytes); + } + + [Theory] + [InlineData("text/html", false)] + [InlineData("application/octet-stream", false)] + [InlineData("image/gif", false)] + [InlineData("image/png", true)] + public async Task UntrustedMimeAndCompression_AreRejected(string mime, bool compressed) + { + using var handler = new Handler((_, _) => + { + var response = ImageResponse(Png()); + response.Content.Headers.ContentType = new(mime); + if (compressed) + response.Content.Headers.ContentEncoding.Add("gzip"); + return Task.FromResult(response); + }); + using var loader = new ProviderArtworkLoader(handler); + Assert.Equal(ProviderArtworkStatus.Unsupported, (await loader.LoadAsync(IconUri(), default)).Status); + } + + [Fact] + public async Task PageDisposal_CancelsActiveAndQueuedRequestsAndDropsCache() + { + using var handler = new Handler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + return ImageResponse(Png()); + }); + using var loader = new ProviderArtworkLoader(handler); + var pending = Enumerable.Range(0, 8).Select(i => loader.LoadAsync(IconUri(i), default)).ToArray(); + loader.Dispose(); + foreach (var task in pending) + await Assert.ThrowsAnyAsync(() => task); + Assert.Equal((0, 0), loader.CacheSize); + } + + [Fact] + public async Task NetworkFailures_OnlyReturnCoarseCategories() + { + using var handler = new Handler((_, _) => throw new HttpRequestException("https://sensitive.example/logo?secret=123")); + using var loader = new ProviderArtworkLoader(handler); + var result = await loader.LoadAsync(IconUri(), default); + Assert.Equal(ProviderArtworkStatus.Network, result.Status); + Assert.DoesNotContain("sensitive", result.ToString()); + Assert.DoesNotContain("123", result.ToString()); + Assert.Null(result.Data); + } + + [Fact] + public async Task RequestConcurrencyAndAdmission_AreBounded() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var running = 0; + var peak = 0; + using var handler = new Handler(async (_, ct) => + { + var count = Interlocked.Increment(ref running); + peak = Math.Max(peak, count); + try { await release.Task.WaitAsync(ct); return ImageResponse(Png()); } + finally { Interlocked.Decrement(ref running); } + }); + using var loader = new ProviderArtworkLoader(handler); + var requests = Enumerable.Range(0, 40).Select(i => loader.LoadAsync(IconUri(i), default)).ToArray(); + Assert.Equal(ProviderArtworkLoader.MaxConcurrentRequests, running); + release.SetResult(); + var results = await Task.WhenAll(requests); + Assert.Equal(40 - ProviderArtworkLoader.MaxPendingRequests, results.Count(r => r.Status == ProviderArtworkStatus.Busy)); + Assert.InRange(peak, 1, ProviderArtworkLoader.MaxConcurrentRequests); + } + + [Fact] + public void Generation_RejectsCompletionAfterRebindUnloadAndPageClose() + { + var lifetime = new ProviderArtworkGeneration(); + using var page = new CancellationTokenSource(); + var first = lifetime.Begin(page.Token); + var second = lifetime.Begin(page.Token); + Assert.True(first.Token.IsCancellationRequested); + Assert.False(lifetime.IsCurrent(first.Generation)); + Assert.False(lifetime.Matches(first.Generation)); + Assert.True(lifetime.IsCurrent(second.Generation)); + page.Cancel(); + Assert.False(lifetime.IsCurrent(second.Generation)); + lifetime.Stop(); + Assert.False(lifetime.Matches(second.Generation)); + } + + [Fact] + public void Content_RequiresMatchingSignatureAndSupportedMime() + { + Assert.Equal(ProviderArtworkStatus.Loaded, ProviderArtworkContent.Validate(Png(), "image/png").Status); + Assert.Equal(ProviderArtworkStatus.InvalidImage, ProviderArtworkContent.Validate(Png(), "image/jpeg").Status); + Assert.Equal(ProviderArtworkStatus.InvalidImage, ProviderArtworkContent.Validate(Encoding.UTF8.GetBytes(""), "image/png").Status); + Assert.False(ProviderArtworkContent.AreDimensionsAllowed(0, 1)); + Assert.False(ProviderArtworkContent.AreDimensionsAllowed(65535, 65535)); + Assert.False(ProviderArtworkContent.AreDimensionsAllowed(1025, 1)); + Assert.True(ProviderArtworkContent.AreDimensionsAllowed(1024, 1024)); + } + + [Fact] + public void Svg_StaticSubsetIsReconstructedWithBoundedRenderExtent() + { + var result = Svg(""); + Assert.Equal(ProviderArtworkStatus.Loaded, result.Status); + var root = XElement.Parse(Encoding.UTF8.GetString(result.Data!.Bytes)); + Assert.Equal("24", root.Attribute("width")!.Value); + Assert.Equal("24", root.Attribute("height")!.Value); + Assert.Equal("#FFFFFF", root.Attribute("fill")!.Value); + } + + [Theory] + [InlineData("]>&x;")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + public void Svg_ActiveExternalOrUnboundedContentNeverReachesNativeDecode(string svg) => + Assert.Null(Svg(svg).Data); + + [Fact] + public void Svg_NodeDepthAndPathBudgetsAreEnforced() + { + const string root = ""; + Assert.Null(Svg(root + string.Concat(Enumerable.Repeat("", 257)) + "").Data); + Assert.Null(Svg(root + string.Concat(Enumerable.Repeat("", 18)) + + string.Concat(Enumerable.Repeat("", 18)) + "").Data); + Assert.Null(Svg(root + "").Data); + } + + private static ProviderArtworkResult Svg(string text) => ProviderArtworkContent.Validate(Encoding.UTF8.GetBytes(text), "image/svg+xml"); + private static Uri IconUri(int id = 0) => new($"https://icons.example/logo-{id}"); + private static byte[] Png() => Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWZkAAAAASUVORK5CYII="); + private static HttpResponseMessage ImageResponse(byte[] bytes) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }; + response.Content.Headers.ContentType = new("image/png"); + return response; + } + + private sealed class Handler(Func> send) : HttpMessageHandler + { + internal int Calls; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Interlocked.Increment(ref Calls); + return send(request, ct); + } + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupAccessDraftTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupAccessDraftTests.cs new file mode 100644 index 000000000..29e2ecd77 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupAccessDraftTests.cs @@ -0,0 +1,332 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupAccessDraftTests +{ + [Theory] + [InlineData(SetupGatewayRoute.Existing)] + [InlineData(SetupGatewayRoute.Remote)] + public void NativeCommit_UpdatesTheSameConfigAndRetainsTheEditor(SetupGatewayRoute route) + { + var draft = new SetupAccessDraft(new SetupConfig()); + var config = draft.Config; + var request = new SetupNativeConnectionRequest("wss://native.example", EditingGatewayId: "committed-id"); + draft.NativeConnectionRequest = request; + Assert.True(draft.TryAcceptNativeConnection(route, + new(true, true, "committed-id", "wss://native.example"))); + Assert.Same(config, draft.Config); + Assert.Equal("wss://native.example", config.GatewayUrl); + Assert.Equal("committed-id", draft.NativeGatewayId); + Assert.True(draft.GatewayAvailable); + Assert.Equal(route, draft.Route); + draft.ApplyProfile(SetupCapabilityProfile.ReadOnly); + Assert.Same(request, draft.NativeConnectionRequest); + Assert.Equal("committed-id", draft.NativeGatewayId); + draft.SelectRoute(SetupGatewayRoute.ManagedWsl); + Assert.Null(config.GatewayUrl); + Assert.Same(request, draft.NativeConnectionRequest); + } + + [Theory] + [InlineData(false, false, "id", "wss://native.example")] + [InlineData(true, false, "id", "wss://native.example")] + [InlineData(true, true, null, "wss://native.example")] + [InlineData(true, true, "id", null)] + public void NativeCheckOrInvalidCommit_CannotAdvanceOrChangeConfig( + bool success, bool committed, string? id, string? url) + { + var draft = new SetupAccessDraft(new SetupConfig()); + draft.SelectRoute(SetupGatewayRoute.Remote); + Assert.False(draft.TryAcceptNativeConnection(SetupGatewayRoute.Remote, + new(success, committed, id, url))); + Assert.Null(draft.Config.GatewayUrl); + Assert.False(draft.GatewayAvailable); + Assert.Null(draft.NativeGatewayId); + } + + [Fact] + public void NativeDraftSecrets_AreNotSetupConfigAndAreClearedOnlyOnExplicitCleanup() + { + var draft = new SetupAccessDraft(new SetupConfig()); + draft.NativeConnectionRequest = new("wss://native.example", "setup-secret", "shared-secret"); + draft.SelectRoute(SetupGatewayRoute.Deferred); + Assert.Equal("setup-secret", draft.NativeConnectionRequest.SetupCode); + var configJson = System.Text.Json.JsonSerializer.Serialize(draft.Config); + Assert.DoesNotContain("setup-secret", configJson); + Assert.DoesNotContain("shared-secret", configJson); + draft.ClearNativeConnectionSecrets(); + Assert.Null(draft.NativeConnectionRequest.SetupCode); + Assert.Null(draft.NativeConnectionRequest.SharedToken); + Assert.Equal("wss://native.example", draft.NativeConnectionRequest.GatewayUrl); + } + + [Theory] + [InlineData("""{"CurrentTailnet":{"MagicDNSEnabled":true}}""", true)] + [InlineData("""{"CurrentTailnet":{"MagicDNSEnabled":false}}""", false)] + [InlineData("""{"CurrentTailnet":null}""", false)] + [InlineData("""{"Self":{"DNSName":"pc.tailnet.ts.net."}}""", false)] + [InlineData("invalid", false)] + public void Tailscale_MagicDnsMustBeConfirmedNotInferredFromAHostname(string json, bool ready) => + Assert.Equal(ready, SetupTailscaleReadiness.IsMagicDnsEnabled(json)); + + [Fact] + public void ProfileCatalog_ContainsExactlyEightInStableOrder() + { + Assert.Equal( + new[] { SetupCapability.System, SetupCapability.Canvas, SetupCapability.Screen, + SetupCapability.Camera, SetupCapability.Location, SetupCapability.Browser, + SetupCapability.Tts, SetupCapability.Stt }, + SetupCapabilityProfiles.Ordered); + } + + [Fact] + public void Draft_ProjectsExplicitTransportDefaultsWithoutChangingHeadlessDefaults() + { + var config = new SetupConfig(); + Assert.Null(config.Settings.EnableMcpServer); + Assert.Null(config.Settings.NodeOllamaInferenceEnabled); + var draft = new SetupAccessDraft(config); + Assert.False(draft.Config.Settings.EnableMcpServer); + Assert.False(draft.Config.Settings.NodeOllamaInferenceEnabled); + Assert.True(draft.Config.Settings.EnableNodeMode); + } + + [Theory] + [InlineData(SetupCapabilityProfile.ReadOnly, "Canvas,Screen")] + [InlineData(SetupCapabilityProfile.Standard, "System,Canvas,Screen,Tts,Stt")] + [InlineData(SetupCapabilityProfile.Full, "System,Canvas,Screen,Camera,Location,Browser,Tts,Stt")] + public void Profiles_HaveExactMembership(SetupCapabilityProfile profile, string expected) + { + var draft = new SetupAccessDraft(new SetupConfig()); + draft.ApplyProfile(profile); + Assert.Equal(expected, string.Join(",", SetupCapabilityProfiles.Ordered.Where(draft.GetCapability))); + Assert.Equal(profile, draft.Profile); + Assert.True(draft.Config.Capabilities.Device); + Assert.Equal(draft.Config.Capabilities.System, draft.Config.Settings.NodeSystemRunEnabled); + } + + [Fact] + public void BundledPlaceholder_DefaultsOnlyAtDraftCreation() + { + var config = new SetupConfig { UsesBundledDefaultConfig = true }; + var draft = new SetupAccessDraft(config); + Assert.Same(config, draft.Config); + Assert.Equal(SetupCapabilityProfile.Standard, draft.Profile); + draft.ApplyProfile(SetupCapabilityProfile.Full); + draft.SelectRoute(SetupGatewayRoute.Remote); + draft.SelectRoute(SetupGatewayRoute.ManagedWsl); + Assert.Equal(SetupCapabilityProfile.Full, draft.Profile); + Assert.Equal(SetupCapabilityProfile.Full, new SetupAccessDraft(new SetupConfig()).Profile); + } + + [Fact] + public void CustomSelection_IsNeverWidenedByTransportOrRouteChanges() + { + var config = new SetupConfig { UsesBundledDefaultConfig = true }; + config.Capabilities.Camera = false; + var draft = new SetupAccessDraft(config); + Assert.Equal(SetupCapabilityProfile.Custom, draft.Profile); + var selection = SetupCapabilityProfiles.Ordered.Select(draft.GetCapability).ToArray(); + draft.SetNodeMode(false); + draft.SetMcpServer(false); + Assert.False(draft.CapabilityControlsEnabled); + draft.SelectRoute(SetupGatewayRoute.McpOnly); + draft.SetMcpServer(true); + Assert.True(draft.CapabilityControlsEnabled); + Assert.False(draft.BrowserAvailable); + Assert.Equal(selection, SetupCapabilityProfiles.Ordered.Select(draft.GetCapability)); + } + + [Fact] + public void Browser_RequiresNodeAndAnActualGateway() + { + var draft = new SetupAccessDraft(new SetupConfig()); + Assert.False(draft.BrowserAvailable); + draft.SelectRoute(SetupGatewayRoute.Remote, gatewayAvailable: true); + Assert.True(draft.BrowserAvailable); + draft.SetNodeMode(false); + draft.SetMcpServer(true); + Assert.False(draft.BrowserAvailable); + Assert.True(draft.GetCapability(SetupCapability.Browser)); + } + + [Fact] + public void ProfileChanges_DoNotChangeIndependentSettingsOrConsent() + { + var config = new SetupConfig(); + config.Settings.EnableMcpServer = true; + config.Settings.NodeOllamaInferenceEnabled = true; + config.LocalAi.WslMirroredNetworkingConsent = true; + config.LocalAi.SelectedModelId = "pinned"; + config.Tailscale.AuthKey = "session-only"; + config.Tailscale.TrustTailscaleAuth = true; + var draft = new SetupAccessDraft(config); + draft.ApplyProfile(SetupCapabilityProfile.ReadOnly); + draft.SetNodeMode(false); + Assert.True(config.Settings.EnableMcpServer); + Assert.True(config.Settings.NodeOllamaInferenceEnabled); + Assert.True(config.LocalAi.WslMirroredNetworkingConsent); + Assert.Equal("pinned", config.LocalAi.SelectedModelId); + Assert.Equal("session-only", config.Tailscale.AuthKey); + Assert.True(config.Tailscale.TrustTailscaleAuth); + } + + [Fact] + public void EveryCapabilityCombination_HasOnlyAnExactPresetMatch() + { + for (var flags = 0; flags < 256; flags++) + { + var config = new SetupConfig(); + foreach (var capability in SetupCapabilityProfiles.Ordered) + SetupCapabilityProfiles.Set(config.Capabilities, capability, (flags & (1 << (int)capability)) != 0); + var draft = new SetupAccessDraft(config); + var expected = flags switch + { + 6 => SetupCapabilityProfile.ReadOnly, + 199 => SetupCapabilityProfile.Standard, + 255 => SetupCapabilityProfile.Full, + _ => SetupCapabilityProfile.Custom, + }; + Assert.Equal(expected, draft.Profile); + } + } + + [Fact] + public void CustomIntent_IsDraftOnlyPreservesValuesAndSurvivesIndependentChoices() + { + var draft = FreshDraft(); + Assert.False(draft.IsCustomizingCapabilities); + draft.ApplyProfile(SetupCapabilityProfile.ReadOnly); + var before = System.Text.Json.JsonSerializer.Serialize(draft.Config); + draft.ApplyProfile(SetupCapabilityProfile.Custom); + Assert.Equal(before, System.Text.Json.JsonSerializer.Serialize(draft.Config)); + Assert.Equal(SetupCapabilityProfile.Custom, draft.Profile); + draft.SetCapability(SetupCapability.Camera, true); + draft.SetCapability(SetupCapability.Camera, false); + Assert.Equal(SetupCapabilityProfile.ReadOnly, SetupCapabilityProfiles.Detect(draft.Config.Capabilities)); + Assert.Equal(SetupCapabilityProfile.Custom, draft.Profile); + draft.SetNodeMode(false); + draft.SetMcpServer(true); + draft.SetOllamaSharing(true); + draft.SelectRoute(SetupGatewayRoute.Existing, gatewayAvailable: true); + Assert.True(draft.IsCustomizingCapabilities); + Assert.Equal(SetupCapabilityProfile.Custom, draft.Profile); + Assert.DoesNotContain("IsCustomizingCapabilities", System.Text.Json.JsonSerializer.Serialize(draft.Config)); + draft.ApplyProfile(SetupCapabilityProfile.Standard); + draft.SelectRoute(SetupGatewayRoute.ManagedWsl); + Assert.False(draft.IsCustomizingCapabilities); + Assert.Equal(SetupCapabilityProfile.Standard, draft.Profile); + Assert.False(draft.GetCapability(SetupCapability.Camera)); + Assert.False(draft.Config.Settings.EnableNodeMode); + Assert.True(draft.Config.Settings.EnableMcpServer); + Assert.True(draft.Config.Settings.NodeOllamaInferenceEnabled); + } + + [Theory] + [InlineData(SetupCapabilityProfile.ReadOnly)] + [InlineData(SetupCapabilityProfile.Standard)] + [InlineData(SetupCapabilityProfile.Full)] + public void FineTuneInspection_IsDraftOnlyAndNeverSelectsCustom(SetupCapabilityProfile profile) + { + var draft = FreshDraft(); + draft.ApplyProfile(profile); + var before = System.Text.Json.JsonSerializer.Serialize(draft.Config); + draft.FineTuneExpanded = true; + Assert.Equal(profile, draft.Profile); + Assert.False(draft.IsCustomizingCapabilities); + Assert.Equal(before, System.Text.Json.JsonSerializer.Serialize(draft.Config)); + draft.SetNodeMode(false); + draft.SetMcpServer(false); + Assert.True(draft.FineTuneExpanded); + draft.ApplyProfile(SetupCapabilityProfile.Full); + Assert.True(draft.FineTuneExpanded); + draft.FineTuneExpanded = false; + Assert.Equal(SetupCapabilityProfile.Full, draft.Profile); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LocalAiToggle_RestoresOriginalWizardChoiceAndRetainsConsent(bool originalSkip) + { + var config = new SetupConfig { SkipWizard = originalSkip }; + var draft = new SetupAccessDraft(config); + config.LocalAi.WslMirroredNetworkingConsent = true; + draft.SetLocalAiEnabled(true); + Assert.True(config.SkipWizard); + draft.SetLocalAiEnabled(false); + Assert.Equal(originalSkip, config.SkipWizard); + Assert.True(config.LocalAi.WslMirroredNetworkingConsent); + } + + [Theory] + [InlineData(SetupGatewayRoute.Existing)] + [InlineData(SetupGatewayRoute.Remote)] + [InlineData(SetupGatewayRoute.McpOnly)] + [InlineData(SetupGatewayRoute.Deferred)] + public void AlternateRoutes_NeverInstallWsl(SetupGatewayRoute route) + { + var draft = FreshDraft(); + draft.SelectRoute(route, gatewayAvailable: true); + Assert.False(draft.CanInstall()); + Assert.False(draft.CanInstall(localAiRecovery: true)); + Assert.DoesNotContain(OnboardingStage.Install, OnboardingFlowPolicy.GetStages(route, draft.Config)); + } + + [Fact] + public void Replacement_RequiresExplicitConsentBoundToExactDistro() + { + var draft = new SetupAccessDraft(new SetupConfig()); + draft.RecordWslInspection(Existing(draft.Config.DistroName)); + Assert.False(draft.CanInstall()); + draft.ConfirmReplacement(true); + Assert.True(draft.CanInstall()); + Assert.Equal(draft.Config.DistroName, draft.Config.ConfirmedDestructiveDistroName); + draft.Config.DistroName = "OtherGateway"; + Assert.False(draft.CanInstall()); + draft.ConfirmReplacement(true); + Assert.False(draft.CanInstall()); + } + + [Fact] + public void LocalAiUnknownAndUnsupportedRecovery_FailClosedButCanOptOutOutsideRecovery() + { + var draft = FreshDraft(); + draft.SetLocalAiEnabled(true); + Assert.False(draft.CanInstall()); + Assert.False(draft.CanInstall(localAiRecovery: true)); + draft.SetLocalAiEnabled(false); + Assert.True(draft.CanInstall()); + Assert.False(draft.CanInstall(localAiRecovery: true)); + } + + [Fact] + public void LocalAiAndTailscale_RequireIndependentReadinessAndExplicitConsent() + { + var draft = FreshDraft(); + draft.SetLocalAiEnabled(true); + draft.LocalAiReady = true; + draft.LocalAiNetworkingConsentRequired = true; + Assert.False(draft.CanInstall()); + draft.Config.LocalAi.WslMirroredNetworkingConsent = true; + Assert.True(draft.CanInstall()); + draft.Config.Tailscale.Enabled = true; + Assert.False(draft.CanInstall()); + draft.TailscaleReady = true; + Assert.True(draft.CanInstall()); + draft.Config.Tailscale.AuthMode = TailscaleAuthMode.AuthKey; + Assert.False(draft.CanInstall()); + draft.Config.Tailscale.AuthKey = "one-off"; + Assert.True(draft.CanInstall()); + Assert.False(draft.Config.Tailscale.TrustTailscaleAuth); + } + + private static SetupAccessDraft FreshDraft() + { + var draft = new SetupAccessDraft(new SetupConfig()); + draft.RecordWslInspection(new(false, null, null, false, false, false, null, false, 0, [])); + return draft; + } + + private static ExistingConfigDetector.ExistingConfig Existing(string name) => + new(false, null, null, true, true, false, name, false, 1, ["Preserved remote"]); +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupCompletionAuthorityTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupCompletionAuthorityTests.cs new file mode 100644 index 000000000..c3cb7b21b --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupCompletionAuthorityTests.cs @@ -0,0 +1,118 @@ +using OpenClaw.Connection; +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupCompletionAuthorityTests +{ + [Theory] + [InlineData("rotated")] + [InlineData("missing")] + [InlineData("corrupt")] + public void PersistedIdentityCannotRelabelAnAlreadyVerifiedClient(string change) + { + using var directory = new TempDirectory(); + var identity = new DeviceIdentity(directory.Path); + identity.Initialize(); + var captured = SetupCompletionAuthority.CaptureIdentity(directory.Path, identity.DeviceId); + SetupCompletionAuthority.RequirePersistedIdentity(directory.Path, captured); + var file = Path.Combine(directory.Path, "device-key-ed25519.json"); + if (change == "rotated") + { + using var replacement = new TempDirectory(); + new DeviceIdentity(replacement.Path).Initialize(); + File.Copy(Path.Combine(replacement.Path, "device-key-ed25519.json"), file, true); + } + else if (change == "missing") File.Delete(file); + else File.WriteAllText(file, "{invalid"); + var before = File.Exists(file) ? File.ReadAllText(file) : null; + Assert.Throws(() => + SetupCompletionAuthority.RequirePersistedIdentity(directory.Path, captured)); + Assert.Equal(captured, SetupCompletionAuthority.CaptureIdentity(directory.Path, identity.DeviceId)); + Assert.Equal(before, File.Exists(file) ? File.ReadAllText(file) : null); + } + + [Fact] + public void VerifiedReconnectNeverGeneratesAMissingIdentity() + { + using var directory = new TempDirectory(); + Assert.Throws(() => new OpenClawGatewayClient( + "wss://not-contacted.invalid", "synthetic", identityPath: directory.Path, requireExistingIdentity: true)); + Assert.False(File.Exists(Path.Combine(directory.Path, "device-key-ed25519.json"))); + } + + [Fact] + public void NoAuthenticatedSigningIdentityCannotProduceValidAuthority() + { + var record = new GatewayRecord { Id = "gateway", Url = "wss://not-contacted.invalid" }; + var binding = new SetupGatewaySessionBinding(record); + var route = binding.GetRoute(record, "identity", "agent:primary:main", null); + Assert.Null(route.IdentityBinding); + Assert.False(SetupCompletionAuthority.IsValid(route.IdentityBinding, route.SessionKey, route.AgentId)); + } + + private static GatewayAiSetupCompletion Proof => new(SetupCompletionIntent.CustodianOnboarding, + "gateway", new string('A', 64), "provider/model", "primary", 1, + IdentityBinding: SetupCompletionAuthority.CaptureIdentity("identity", "device-a"), + SessionKey: "agent:primary:main"); + + [Theory] + [InlineData("device")] + [InlineData("identity")] + [InlineData("session")] + [InlineData("missing")] + public void CrossSessionVerification_RejectsChangedOrMissingStableAuthority(string change) + { + var current = Proof with + { + VerifiedGeneration = 7, + IdentityBinding = change switch + { + "device" => SetupCompletionAuthority.CaptureIdentity("identity", "device-b"), + "identity" => SetupCompletionAuthority.CaptureIdentity("other", "device-a"), + "missing" => null, + _ => Proof.IdentityBinding, + }, + SessionKey = change == "session" ? "agent:primary:alternate" : Proof.SessionKey, + }; + Assert.Throws(() => SetupNativeVerification.RequireSame( + Proof, new(current, current.SessionKey!))); + } + + [Fact] + public void FreshGenerationWithSameIdentityAndFullSession_IsAcceptedWithoutLeakingIdentityPath() + { + SetupNativeVerification.RequireSame(Proof, new(Proof with { VerifiedGeneration = 19 }, Proof.SessionKey!)); + var json = System.Text.Json.JsonSerializer.Serialize(Proof); + Assert.DoesNotContain(Path.GetFullPath("identity"), json); + Assert.DoesNotContain("device-a", json); + Assert.NotEqual(SetupCompletionAuthority.CaptureIdentity("a|b", "c"), + SetupCompletionAuthority.CaptureIdentity("a", "b|c")); + } + + [Fact] + public async Task PersistedPortDrift_IsRejectedBeforeFreshSessionCanConnect() + { + using var temp = new TempDirectory(); + var registry = new GatewayRegistry(temp.Path); + var record = registry.AddOrUpdate(new() + { + Id = "gateway", Url = "wss://not-contacted.invalid", + SshTunnel = new("user", "not-contacted.invalid", 18789, 19001), + }); + registry.SetActive(record.Id); + registry.Save(); + var path = registry.GetIdentityDirectory(record.Id); + var device = new DeviceIdentity(path); + device.Initialize(); + var proof = Proof with { EndpointBinding = GatewayDashboardBinding.Capture(record), + IdentityBinding = SetupCompletionAuthority.CaptureIdentity(path, device.DeviceId) }; + SetupGatewaySession.RequireCompletionGateway(temp.Path, proof); + registry.Update(record.Id, value => value with { SshTunnel = value.SshTunnel! with { LocalPort = 19002 } }); + registry.Save(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await Assert.ThrowsAnyAsync(() => + SetupNativeCompletionVerifier.VerifyAsync(temp.Path, proof, timeout.Token)); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupGatewaySessionBindingTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupGatewaySessionBindingTests.cs new file mode 100644 index 000000000..131728276 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupGatewaySessionBindingTests.cs @@ -0,0 +1,102 @@ +using OpenClaw.Connection; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupGatewaySessionBindingTests +{ + private static GatewayRecord Original => new() + { + Id = "gateway-a", Url = "wss://gateway.example/control/", + SshTunnel = new("user", "ssh.example", 18789, 19001), + }; + + [Theory] + [InlineData("id")] + [InlineData("url")] + [InlineData("ssh-host")] + [InlineData("ssh-local-port")] + [InlineData("ssh-user")] + public async Task ChangedRegistryDuringConnect_CannotRelabelAlreadyCreatedClient(string change) + { + GatewayRecord active = Original; + var captured = new SetupGatewaySessionBinding(active); + var connecting = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var route = AfterConnectionAsync(); + active = change switch + { + "id" => active with { Id = "gateway-b" }, // Same URL is still a different Gateway. + "url" => active with { Url = "wss://different.example/" }, + "ssh-host" => active with { SshTunnel = active.SshTunnel! with { Host = "different.example" } }, + "ssh-local-port" => active with { SshTunnel = active.SshTunnel! with { LocalPort = 19002 } }, + _ => active with { SshTunnel = active.SshTunnel! with { User = "different-user" } }, + }; + connecting.SetResult(); + await Assert.ThrowsAsync(() => route); + Assert.Equal("gateway-a", captured.GatewayId); + Assert.Equal("ws://localhost:19001", captured.Endpoint); + + async Task AfterConnectionAsync() + { + captured.RequireCurrent(active); + await connecting.Task; + // This is the post-connect/GetRoute seam, before GatewayAiSetupClient captures its route. + return captured.GetRoute(active, "identity-a", "agent:primary:main", "device-a"); + } + } + + [Fact] + public void PersistedBinding_RejectsOnlyLocalForwardPortDrift() + { + var changed = Original with { SshTunnel = Original.SshTunnel! with { LocalPort = 19002 } }; + Assert.NotEqual(GatewayDashboardBinding.Capture(Original), GatewayDashboardBinding.Capture(changed)); + } + + [Fact] + public void LastConnectedAndRotatedCredentials_DoNotChangeCapturedAuthority() + { + var binding = new SetupGatewaySessionBinding(Original); + var current = Original with { LastConnected = DateTime.UtcNow, SharedGatewayToken = "rotated" }; + var route = binding.GetRoute(current, "identity-a", "agent:primary:main", "device-a"); + Assert.Equal("gateway-a", route.GatewayId); + Assert.Equal("primary", route.AgentId); + Assert.DoesNotContain("identity-a", route.AuthorityId); + Assert.Equal(SetupCompletionAuthority.CaptureIdentity("identity-a", "device-a"), route.IdentityBinding); + Assert.Equal("agent:primary:main", route.SessionKey); + Assert.Equal(GatewayDashboardBinding.Capture(Original), route.EndpointBinding); + } + + [Fact] + public void MissingActiveGateway_FailsAdmissionAndRouteProjection() + { + var binding = new SetupGatewaySessionBinding(Original); + Assert.Throws(() => binding.RequireCurrent(null)); + Assert.Throws(() => binding.GetRoute(null, "identity-a", null, null)); + } + + [Fact] + public void ProductionSession_GuardsAdmissionHandshakePostConnectAndRequests() + { + var root = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); root is null && directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx"))) + root = directory.FullName; + } + Assert.NotNull(root); + var session = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "SetupGatewaySession.cs")) + .Replace("\r\n", "\n"); + Assert.Contains("var binding = new SetupGatewaySessionBinding(record);", session); + Assert.Contains("client.ReconnectAuthorizationAsync = AuthorizeHandshakeAsync;", session); + Assert.Contains("client.HandshakeAuthorizationAsync = AuthorizeHandshakeAsync;", session); + Assert.Contains("RequireCurrentGateway();\n var client = new OpenClawGatewayClient", session); + Assert.Contains("RequireCurrentGateway();\n await client.ConnectAsync()", session); + Assert.Contains("RequireCurrentGateway();\n return new(dataDir, record, binding, identityPath, client);", session); + Assert.Contains("_binding.GetRoute(registry.GetActive()", session); + Assert.Contains("catch\n {\n client.Dispose();\n throw;", session); + var transport = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "GatewayAiSetupTransport.cs")); + Assert.True(transport.IndexOf("var route = routeProvider();", StringComparison.Ordinal) < + transport.IndexOf("await client.SendWizardRequestAsync", StringComparison.Ordinal)); + Assert.Contains("if (routeProvider() != route)", transport); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupInstallReadinessTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupInstallReadinessTests.cs new file mode 100644 index 000000000..9c2e8962c --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupInstallReadinessTests.cs @@ -0,0 +1,116 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupInstallReadinessTests +{ + [Fact] + public void Admission_MatchesOriginalConditionsAcrossRoutesInspectionConsentAndOptionalFeatures() + { + foreach (var route in Enum.GetValues()) + for (var inspection = 0; inspection < 4; inspection++) + for (var flags = 0; flags < 2048; flags++) + { + bool Flag(int bit) => (flags & (1 << bit)) != 0; + var recovery = Flag(0); + var draft = new SetupAccessDraft(new SetupConfig()); + draft.SelectRoute(route); + if (inspection != 0) + { + draft.RecordWslInspection(new(false, null, null, inspection == 2, inspection == 2, + false, draft.Config.DistroName, false, 0, [])); + if (Flag(9)) draft.ConfirmReplacement(true); + if (Flag(10)) draft.Config.ConfirmedDestructiveDistroName = "DifferentGateway"; + if (inspection == 3) draft.Config.DistroName = "DifferentGateway"; + } + draft.SetLocalAiEnabled(Flag(1)); + draft.LocalAiReady = Flag(2); + draft.LocalAiNetworkingConsentRequired = Flag(3); + draft.Config.LocalAi.WslMirroredNetworkingConsent = Flag(4); + draft.Config.Tailscale.Enabled = Flag(5); + draft.TailscaleReady = Flag(6); + draft.Config.Tailscale.AuthMode = Flag(7) ? TailscaleAuthMode.AuthKey : TailscaleAuthMode.Browser; + draft.Config.Tailscale.AuthKey = Flag(8) ? "test-only" : " "; + + var expected = route == SetupGatewayRoute.ManagedWsl && + (recovery || (inspection is 1 or 2 && + (inspection != 2 || Flag(9) && !Flag(10)))) && + (!recovery || Flag(1)) && + (!Flag(1) || Flag(2) && (!Flag(3) || Flag(4))) && + (!Flag(5) || Flag(6) && (!Flag(7) || Flag(8))); + Assert.True(expected == draft.CanInstall(recovery), + $"Admission mismatch: route={route}, inspection={inspection}, flags={flags}"); + } + } + + [Fact] + public void Requirements_IdentifyEveryBlockerWithoutChangingTheDraft() + { + var draft = new SetupAccessDraft(new SetupConfig()); + Assert.Equal([SetupInstallRequirement.WslInspection], draft.GetInstallRequirements()); + draft.RecordWslInspection(new(false, null, null, true, true, false, + draft.Config.DistroName, false, 0, [])); + draft.SetLocalAiEnabled(true); + draft.LocalAiNetworkingConsentRequired = true; + draft.Config.Tailscale.Enabled = true; + var before = System.Text.Json.JsonSerializer.Serialize(draft.Config); + Assert.Equal( + [SetupInstallRequirement.Replacement, SetupInstallRequirement.LocalAi, + SetupInstallRequirement.NetworkingConsent, SetupInstallRequirement.Tailscale], + draft.GetInstallRequirements()); + Assert.Equal(before, System.Text.Json.JsonSerializer.Serialize(draft.Config)); + Assert.False(draft.ReplacementConfirmed); + + draft.ConfirmReplacement(true); + Assert.Equal(SetupInstallRequirement.LocalAi, draft.GetInstallRequirements()[0]); + draft.LocalAiReady = true; + Assert.Equal(SetupInstallRequirement.NetworkingConsent, draft.GetInstallRequirements()[0]); + draft.Config.LocalAi.WslMirroredNetworkingConsent = true; + Assert.Equal([SetupInstallRequirement.Tailscale], draft.GetInstallRequirements()); + draft.TailscaleReady = true; + draft.Config.Tailscale.AuthMode = TailscaleAuthMode.AuthKey; + Assert.Equal([SetupInstallRequirement.Tailscale], draft.GetInstallRequirements()); + draft.Config.Tailscale.AuthKey = "test-only"; + Assert.Empty(draft.GetInstallRequirements()); + Assert.True(draft.CanInstall()); + draft.ConfirmReplacement(false); + Assert.Equal([SetupInstallRequirement.Replacement], draft.GetInstallRequirements()); + Assert.False(draft.CanInstall()); + } + + [Fact] + public void ReplacementConsent_CannotSubstituteForAnExactTargetInspection() + { + var draft = new SetupAccessDraft(new SetupConfig()); + draft.ConfirmReplacement(true); + Assert.Equal([SetupInstallRequirement.WslInspection], draft.GetInstallRequirements()); + draft.RecordWslInspection(new(false, null, null, true, true, false, + draft.Config.DistroName, false, 0, [])); + draft.ConfirmReplacement(true); + draft.Config.ConfirmedDestructiveDistroName = "DifferentGateway"; + Assert.Equal([SetupInstallRequirement.Replacement], draft.GetInstallRequirements()); + draft.Config.DistroName += "-different"; + Assert.Equal([SetupInstallRequirement.WslInspection], draft.GetInstallRequirements()); + draft.ConfirmReplacement(true); + Assert.Equal([SetupInstallRequirement.WslInspection], draft.GetInstallRequirements()); + } + + [Fact] + public void OptionalOff_IsNotABlockerAndRecoveryRetainsPinnedRequirements() + { + var draft = new SetupAccessDraft(new SetupConfig()); + draft.Config.LocalAi.SelectedModelId = "pinned-model"; + draft.LocalAiNetworkingConsentRequired = true; + Assert.Equal([SetupInstallRequirement.LocalAi], draft.GetInstallRequirements(localAiRecovery: true)); + draft.SetLocalAiEnabled(true); + Assert.Equal([SetupInstallRequirement.LocalAi, SetupInstallRequirement.NetworkingConsent], + draft.GetInstallRequirements(localAiRecovery: true)); + draft.LocalAiReady = true; + draft.Config.LocalAi.WslMirroredNetworkingConsent = true; + Assert.Empty(draft.GetInstallRequirements(localAiRecovery: true)); + Assert.Equal("pinned-model", draft.Config.LocalAi.SelectedModelId); + Assert.False(draft.WslInspectionComplete); + Assert.Equal([SetupInstallRequirement.WslInspection], draft.GetInstallRequirements()); + draft.SetLocalAiEnabled(false); + draft.SelectRoute(SetupGatewayRoute.Existing); + Assert.Equal(SetupInstallRequirement.ManagedWslRoute, draft.GetInstallRequirements()[0]); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupInstallationProgressTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupInstallationProgressTests.cs new file mode 100644 index 000000000..179ce904d --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupInstallationProgressTests.cs @@ -0,0 +1,116 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupInstallationProgressTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void EveryActualStep_HasAnExplicitPhaseInPipelineOrder(bool recovery) + { + var steps = OnboardingFlowPolicy.BuildInstallationSteps(recovery); + var phases = steps.Select(step => SetupInstallationProgress.PhaseFor(step.Id, recovery)).ToArray(); + Assert.Equal(phases.Order(), phases); + var progress = new SetupInstallationProgress(steps, recovery); + Assert.Equal(steps.Count, progress.TotalSteps); + Assert.Equal(0, progress.CompletedSteps); + Assert.All(progress.Phases, phase => Assert.Equal(SetupInstallationStatus.Pending, phase.Status)); + foreach (var step in steps) + { + progress.Apply(new(step.Id, step.DisplayName, null, null)); + Assert.Equal(step.DisplayName, progress.CurrentActivity); + Assert.True(progress.IsRunning); + progress.Apply(new(step.Id, step.DisplayName, StepOutcome.Success, TimeSpan.Zero)); + } + Assert.Equal(steps.Count, progress.CompletedSteps); + Assert.Null(progress.CurrentActivity); + Assert.False(progress.IsRunning); + Assert.All(progress.Phases, phase => Assert.Equal(SetupInstallationStatus.Complete, phase.Status)); + } + + [Fact] + public void AllFactorySteps_IncludingProofAndLegacyWizardHaveAnOwner() + { + var steps = SetupStepFactory.BuildDefaultSteps().Concat(SetupStepFactory.BuildLocalAiRecoverySteps()) + .Concat(SetupStepFactory.BuildLocalAiInferenceProofSteps()).Concat(SetupStepFactory.BuildWizardOnlySteps()); + foreach (var step in steps) + Assert.True(Enum.IsDefined(SetupInstallationProgress.PhaseFor(step.Id, false))); + Assert.Throws(() => SetupInstallationProgress.PhaseFor("future-step", false)); + } + + [Fact] + public void SkippedSteps_CountAsFinishedWithoutClaimingTheyWereInstalled() + { + var steps = OnboardingFlowPolicy.BuildInstallationSteps(false); + var progress = new SetupInstallationProgress(steps, false); + foreach (var step in steps) + progress.Apply(new(step.Id, step.DisplayName, StepOutcome.Skipped, null)); + Assert.Equal(steps.Count, progress.CompletedSteps); + Assert.All(progress.Phases, phase => Assert.Equal(SetupInstallationStatus.Skipped, phase.Status)); + progress.Apply(new(steps[0].Id, steps[0].DisplayName, StepOutcome.Success, null)); + Assert.Equal(SetupInstallationStatus.Complete, progress.Phases[0].Status); + } + + [Theory] + [InlineData(StepOutcome.Failed)] + [InlineData(StepOutcome.FailedTerminal)] + public void Failure_TakesPriorityOverRunningAndCompletedWork(StepOutcome outcome) + { + var steps = OnboardingFlowPolicy.BuildInstallationSteps(false); + var progress = new SetupInstallationProgress(steps, false); + progress.Apply(new(steps[0].Id, steps[0].DisplayName, StepOutcome.Success, null)); + progress.Apply(new(steps[1].Id, steps[1].DisplayName, outcome, null)); + progress.Apply(new(steps[2].Id, steps[2].DisplayName, null, null)); + Assert.Equal(SetupInstallationStatus.Failed, progress.Phases[0].Status); + Assert.Equal(steps[1].DisplayName, progress.CurrentActivity); + Assert.False(progress.IsRunning); + Assert.Equal(1, progress.CompletedSteps); + } + + [Fact] + public void ForeignEventsAndDuplicateSteps_AreRejected() + { + var steps = OnboardingFlowPolicy.BuildInstallationSteps(false); + var progress = new SetupInstallationProgress(steps, false); + Assert.Throws(() => progress.Apply(new("restart-gateway", "Foreign", null, null))); + Assert.Throws(() => new SetupInstallationProgress([steps[0], steps[0]], false)); + Assert.Equal(0, progress.CompletedSteps); + } + + [Fact] + public void Cancellation_StopsTheCurrentPhaseWithoutCompletingPendingSteps() + { + var steps = OnboardingFlowPolicy.BuildInstallationSteps(false); + var progress = new SetupInstallationProgress(steps, false); + progress.Apply(new(steps[0].Id, steps[0].DisplayName, StepOutcome.Success, null)); + progress.Apply(new(steps[1].Id, steps[1].DisplayName, null, null)); + progress.Cancel(); + Assert.Equal(SetupInstallationStatus.Cancelled, progress.Phases[0].Status); + Assert.False(progress.IsRunning); + Assert.Equal(steps[1].DisplayName, progress.CurrentActivity); + Assert.Equal(1, progress.CompletedSteps); + Assert.All(progress.Phases.Skip(1), phase => Assert.Equal(SetupInstallationStatus.Pending, phase.Status)); + } + + [Fact] + public void Cancellation_BetweenStepsDoesNotOverwriteFailureOrSuccess() + { + var steps = OnboardingFlowPolicy.BuildInstallationSteps(false); + var progress = new SetupInstallationProgress(steps, false); + progress.Apply(new(steps[0].Id, steps[0].DisplayName, StepOutcome.Failed, null)); + progress.Cancel(); + Assert.Equal(SetupInstallationStatus.Failed, progress.Phases[0].Status); + Assert.Equal(steps[0].DisplayName, progress.CurrentActivity); + var complete = new SetupInstallationProgress(steps, false); + foreach (var step in steps) complete.Apply(new(step.Id, step.DisplayName, StepOutcome.Success, null)); + complete.Cancel(); + Assert.All(complete.Phases, phase => Assert.Equal(SetupInstallationStatus.Complete, phase.Status)); + } + + [Fact] + public void Recovery_PreparesExistingGatewayThenLocalAiWithoutGatewayInstallClaim() + { + Assert.Equal(SetupInstallationPhase.Prepare, SetupInstallationProgress.PhaseFor("preserve-local-ai-recovery-gateway", true)); + Assert.Equal(SetupInstallationPhase.Install, SetupInstallationProgress.PhaseFor("acquire-local-ai-model", true)); + Assert.Equal(SetupInstallationPhase.Connect, SetupInstallationProgress.PhaseFor("restart-gateway", true)); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupNativeCompletionCoordinatorTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupNativeCompletionCoordinatorTests.cs new file mode 100644 index 000000000..79e1f4411 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupNativeCompletionCoordinatorTests.cs @@ -0,0 +1,116 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupNativeCompletionCoordinatorTests +{ + private static GatewayAiSetupCompletion Proof => new(SetupCompletionIntent.CustodianOnboarding, + "gateway-a", new string('A', 64), "provider/model", "primary", 2, + IdentityBinding: new string('B', 64), SessionKey: "agent:primary:main"); + private static SetupVerifiedNativeRoute Route => new(Proof, "agent:primary:main"); + + [Theory] + [InlineData(SetupNativeDestination.Chat)] + [InlineData(SetupNativeDestination.Channels)] + [InlineData(SetupNativeDestination.Skills)] + [InlineData(SetupNativeDestination.WhatsApp)] + [InlineData(SetupNativeDestination.Telegram)] + public async Task ShowingChooserDoesNothing_ExplicitChoiceVerifiesThenFinalizesAndPublishes(SetupNativeDestination destination) + { + var calls = new List(); + using var owner = new SetupNativeCompletionCoordinator(Proof, + _ => Task.CompletedTask, + (_, _) => { calls.Add("verify"); return Task.FromResult(Route); }, + (_, _) => { calls.Add("finalize"); return Task.CompletedTask; }, + (choice, _) => { calls.Add(choice.Target.Destination.ToString()); return Task.CompletedTask; }); + Assert.Empty(calls); + Assert.False(owner.IsCompleted); + await owner.SelectAsync(destination); + Assert.Equal(["verify", "finalize", destination.ToString()], calls); + Assert.True(owner.IsCompleted); + } + + [Fact] + public async Task DoubleClickIsGatedBeforeAwait_AndPublicationRetryDoesNotRepeatFinalization() + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finalized = 0; + var launched = 0; + using var owner = new SetupNativeCompletionCoordinator(Proof, _ => Task.CompletedTask, + (_, _) => pending.Task, (_, _) => { finalized++; return Task.CompletedTask; }, + (_, _) => ++launched == 1 ? Task.FromException(new IOException("Synthetic launch failure")) : Task.CompletedTask); + var first = owner.SelectAsync(SetupNativeDestination.Chat); + await Assert.ThrowsAsync(() => owner.SelectAsync(SetupNativeDestination.Telegram)); + pending.SetResult(Route); + await Assert.ThrowsAsync(() => first); + Assert.False(owner.IsCompleted); + await owner.SelectAsync(SetupNativeDestination.Chat); + Assert.Equal(1, finalized); + Assert.Equal(2, launched); + } + + [Theory] + [InlineData("gateway")] + [InlineData("endpoint")] + [InlineData("agent")] + [InlineData("model")] + [InlineData("role")] + [InlineData("session")] + public async Task ChangedProofCannotFinalizeOrPublish(string changed) + { + var proof = Proof with + { + GatewayId = changed == "gateway" ? "other" : Proof.GatewayId, + EndpointBinding = changed == "endpoint" ? new string('B', 64) : Proof.EndpointBinding, + AgentId = changed == "agent" ? "other" : Proof.AgentId, + ModelRef = changed == "model" ? "other/model" : Proof.ModelRef, + ModelTarget = changed == "role" ? "utility" : null, + }; + using var owner = new SetupNativeCompletionCoordinator(Proof, _ => Task.CompletedTask, + (_, _) => Task.FromResult(new SetupVerifiedNativeRoute(proof, + changed == "session" ? "agent:other:main" : Route.SessionKey)), + (_, _) => throw new InvalidOperationException("Must not finalize"), + (_, _) => throw new InvalidOperationException("Must not publish")); + await Assert.ThrowsAsync(() => owner.SelectAsync(SetupNativeDestination.Chat)); + Assert.False(owner.IsCompleted); + } + + [Fact] + public async Task SameAgentDifferentSession_CannotFinalizeOriginalVerification() + { + var finalized = false; + using var owner = new SetupNativeCompletionCoordinator(Proof, _ => Task.CompletedTask, + (_, _) => Task.FromResult(new SetupVerifiedNativeRoute(Proof, "agent:primary:alternate")), + (_, _) => { finalized = true; return Task.CompletedTask; }, + (_, _) => Task.CompletedTask); + await Assert.ThrowsAsync(() => owner.SelectAsync(SetupNativeDestination.Chat)); + Assert.False(finalized); + } + + [Fact] + public void UnavailableVerificationIsRetryableRatherThanAnOwnershipChange() + { + Assert.Throws(() => SetupNativeCompletionVerifier.RequireAvailable( + new() { Ok = false, Status = "unavailable", Error = "Synthetic model not ready" })); + SetupNativeCompletionVerifier.RequireAvailable(new() { Ok = true }); + } + + [Fact] + public async Task ClosingDuringVerificationCancelsAndNeverFinalizesOrPublishes() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var owner = new SetupNativeCompletionCoordinator(Proof, _ => Task.CompletedTask, + async (_, ct) => + { + entered.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return Route; + }, + (_, _) => throw new InvalidOperationException("Must not finalize"), + (_, _) => throw new InvalidOperationException("Must not publish")); + var task = owner.SelectAsync(SetupNativeDestination.Chat); + await entered.Task; + owner.Dispose(); + await Assert.ThrowsAnyAsync(() => task); + Assert.False(owner.IsCompleted); + Assert.False(owner.IsBusy); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupNativeConnectionPageContractTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupNativeConnectionPageContractTests.cs new file mode 100644 index 000000000..4ceb14e28 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupNativeConnectionPageContractTests.cs @@ -0,0 +1,47 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupNativeConnectionPageContractTests +{ + [Fact] + public void Editor_RevalidatesNextAndRejectsStaleOrPartialCommitSuccess() + { + var page = ReadPage(); + Assert.Contains("await args.Host.ConnectAsync(request, operation.Token)", page); + Assert.Contains("await args.Host.CheckAsync(request, operation.Token)", page); + Assert.Contains("if (_closed || generation != _generation)", page); + Assert.Contains("_blocked = result.GatewayCommitted || result.RequiresAttention;", page); + Assert.True(page.IndexOf("_incompleteCommitError = result.Error", StringComparison.Ordinal) < + page.IndexOf("if (_closed || generation != _generation)", StringComparison.Ordinal)); + Assert.Contains("throw new InvalidOperationException(_incompleteCommitError);", page); + Assert.Contains("if (connect && result.GatewayCommitted)", page); + Assert.Contains("GatewayUrl = SetupNativeConnectionInputResolver.Resolve(request).GatewayUrl,", page); + Assert.True(page.IndexOf("if (!result.Success)", StringComparison.Ordinal) < + page.IndexOf("args.Connected(result)", StringComparison.Ordinal)); + } + + [Fact] + public void Editor_CloseAndDraftChangeDiscardStagingAfterCancellationDrains() + { + var page = ReadPage(); + Assert.Contains("Page, IAsyncDisposable", page); + Assert.Contains("OnNavigatedFrom(NavigationEventArgs e) => CloseInBackground()", page); + Assert.Contains("_operation?.Cancel();", page); + Assert.Contains("AsyncEventHandlerGuard.Run(host.DiscardCheckAsync", page); + Assert.Contains("_closeTask ??= CloseAsync()", page); + Assert.Contains("var host = _host;", page); + Assert.True(page.IndexOf("await _pending;", StringComparison.Ordinal) < + page.IndexOf("await host.DiscardCheckAsync();", StringComparison.Ordinal)); + Assert.Contains("CodeInput.Password = TokenInput.Password = \"\";", page); + } + + private static string ReadPage() + { + var root = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); root is null && directory is not null; + directory = directory.Parent) + if (Directory.Exists(Path.Combine(directory.FullName, "src", "OpenClaw.SetupEngine.UI"))) + root = directory.FullName; + return File.ReadAllText(Path.Combine(root ?? throw new DirectoryNotFoundException("Repository root not found."), + "src", "OpenClaw.SetupEngine.UI", "Pages", "SetupNativeConnectionPage.xaml.cs")); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupNativeConnectionTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupNativeConnectionTests.cs new file mode 100644 index 000000000..956187a0c --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupNativeConnectionTests.cs @@ -0,0 +1,82 @@ +using System.Text; +using OpenClaw.Connection; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupNativeConnectionTests +{ + [Fact] + public void SetupCode_PreservesBootstrapKindAndResolvesMissingAddress() + { + var result = SetupNativeConnectionInputResolver.Resolve(new(SetupCode: Code( + """{"url":"wss://gateway.example","bootstrapToken":"bootstrap"}"""))); + Assert.Equal("wss://gateway.example", result.GatewayUrl); + Assert.Equal("bootstrap", result.BootstrapToken); + Assert.Null(result.SharedToken); + } + + [Fact] + public void EditedAddress_MustMatchSetupCode() + { + Assert.Throws(() => SetupNativeConnectionInputResolver.Resolve(new( + "wss://changed.example", Code("""{"url":"wss://original.example","bootstrapToken":"bootstrap"}""")))); + } + + [Theory] + [InlineData("""{"bootstrapToken":"bootstrap"}""", "wss://gateway.example")] + [InlineData("""{"url":"wss://gateway.example"}""", "wss://gateway.example")] + [InlineData("""{"url":"https://gateway.example"}""", "wss://gateway.example")] + public void PartialSetupCode_IsResolvedAgainstCurrentAddress(string json, string url) + { + var result = SetupNativeConnectionInputResolver.Resolve(new(url, Code(json))); + Assert.Equal(url, result.GatewayUrl); + } + + [Theory] + [InlineData("file:///tmp/gateway")] + [InlineData("wss://user:password@gateway.example")] + [InlineData("wss://gateway.example?token=secret")] + [InlineData("wss://gateway.example#secret")] + public void UnsafeOrInvalidAddress_IsRejected(string url) => + Assert.Throws(() => SetupNativeConnectionInputResolver.Resolve(new(url))); + + [Fact] + public void SetupCodeAndSharedToken_AreMutuallyExclusive() => + Assert.Throws(() => SetupNativeConnectionInputResolver.Resolve(new( + "wss://gateway.example", Code("""{"bootstrapToken":"bootstrap"}"""), "shared"))); + + [Fact] + public void Ssh_UsesExistingArgumentValidation() + { + var ssh = new SshTunnelConfig("user", "host.example", 18789, 45678, SshPort: 2222); + Assert.NotNull(SetupNativeConnectionInputResolver.Resolve(new("ws://127.0.0.1:18789", SshTunnel: ssh))); + Assert.Throws(() => SetupNativeConnectionInputResolver.Resolve(new( + "ws://127.0.0.1:18789", SshTunnel: ssh with { Host = "host -oProxyCommand=bad" }))); + } + + [Fact] + public void Ssh_SavedPublicAddressKeepsExistingHostEndpointSemantics() + { + var ssh = new SshTunnelConfig("user", "host.example", 18789, 45678); + var result = SetupNativeConnectionInputResolver.Resolve(new( + "wss://gateway.example/gateway", SshTunnel: ssh, EditingGatewayId: "saved-ssh")); + Assert.Equal("wss://gateway.example/gateway", result.GatewayUrl); + Assert.Equal("ws://localhost:45678", GatewayClientEndpointResolver.Resolve( + new GatewayRecord { Url = result.GatewayUrl, SshTunnel = ssh })); + } + + [Fact] + public void Ssh_PublicSetupCodeAddressStillMustMatchTheEditedAddress() + { + var ssh = new SshTunnelConfig("user", "host.example", 18789, 45678); + var code = Code("""{"url":"wss://gateway.example","bootstrapToken":"bootstrap"}"""); + var result = SetupNativeConnectionInputResolver.Resolve(new(SetupCode: code, SshTunnel: ssh)); + Assert.Equal("wss://gateway.example", result.GatewayUrl); + Assert.Equal("bootstrap", result.BootstrapToken); + Assert.Null(result.SharedToken); + Assert.Throws(() => SetupNativeConnectionInputResolver.Resolve( + new("wss://different.example", code, SshTunnel: ssh))); + } + + private static string Code(string json) => Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupPipelineSettlementTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupPipelineSettlementTests.cs new file mode 100644 index 000000000..5b1614ac9 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupPipelineSettlementTests.cs @@ -0,0 +1,118 @@ +using OpenClaw.Connection; +using OpenClaw.TestSupport; +using OpenClawTray.Services; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupPipelineSettlementTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task FailedOrCancelledPipelineSettlesBeforeClosedOwnerFinishes(bool cancel, bool rollbackFails) + { + using var temp = new TempDirectory(); + using var cancellation = new CancellationTokenSource(); + var registry = new GatewayRegistry(temp.Path); + registry.AddOrUpdate(new() { Id = "old", Url = "wss://old.example", IsLocal = true, SetupManagedDistroName = "Managed" }); + registry.SetActive("old"); + registry.Save(); + var callbacks = 0; + var host = Host(registry, (_, _) => { callbacks++; return Task.CompletedTask; }); + using var logger = new SetupLogger(filePath: null); + using var journal = new TransactionJournal(filePath: null); + var ctx = new SetupContext(new SetupConfig { RollbackOnFailure = true, DistroName = "Managed" }, logger, journal, + new CommandRunner(logger), cancellation.Token, temp.Path, temp.Path) + { ExpectedGatewayRegistry = host.BeginGatewaySetup() }; + var closed = false; + var pipeline = new SetupPipeline([new WriterStep(rollbackFails), new FailureStep(() => + { + closed = true; + if (cancel) cancellation.Cancel(); + })]); + var changed = 0; + registry.Changed += (_, _) => changed++; + var result = await SetupPipeline.RunWithSettlementAsync(() => pipeline.RunAsync(ctx), + _ => + { + Assert.True(closed); + return host.ReconcileGatewaySetupAsync(ctx.ExpectedGatewayRegistry!, null); + }); + Assert.NotEqual(PipelineOutcome.Success, result.Outcome); + Assert.Equal(1, callbacks); + Assert.Equal(1, changed); + Assert.Equal(rollbackFails ? "created" : null, registry.ActiveGatewayId); + registry.AddOrUpdate(new() { Id = "normal", Url = "wss://normal.example" }); + registry.SetActive("normal"); + registry.Save(); + Assert.Equal("normal", registry.CapturePersistedSnapshot().ActiveId); + } + + [Fact] + public async Task ExternalConflictPreservesLiveEditsAndExplicitReloadRecoversSaving() + { + using var temp = new TempDirectory(); + var registry = new GatewayRegistry(temp.Path); + registry.AddOrUpdate(new() { Id = "old", Url = "wss://old.example" }); + registry.SetActive("old"); + registry.Save(); + var failures = 0; + var host = Host(registry, failure: () => failures++); + var baseline = host.BeginGatewaySetup(); + var external = new GatewayRegistry(temp.Path); + external.Load(); + external.UpdateAndSave("old", value => value with { Url = "wss://new.example" }); + registry.Update("old", value => value with { FriendlyName = "unsaved-live-edit" }); + await Assert.ThrowsAsync(() => host.ReconcileGatewaySetupAsync(baseline, null)); + Assert.Equal("unsaved-live-edit", registry.GetActive()!.FriendlyName); + Assert.Equal(1, failures); + // Explicit user recovery can discard that admitted draft and load the saved selection. + registry.AdoptPersistedSnapshot(registry.GetSnapshot()); + registry.UpdateAndSave("old", value => value with { FriendlyName = "after-reload" }); + Assert.Equal("wss://new.example", registry.CapturePersistedSnapshot().Records[0].Url); + } + + private static SetupLocalAiHost Host(GatewayRegistry registry, + Func? reconcile = null, Action? failure = null) => + new(() => throw new NotSupportedException(), () => registry, () => null, + _ => throw new NotSupportedException(), (_, _) => throw new NotSupportedException(), + _ => throw new NotSupportedException(), () => throw new NotSupportedException(), reconcile, failure); + + private sealed class WriterStep(bool rollbackFails) : SetupStep + { + public override string Id => "writer"; + public override string DisplayName => Id; + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var registry = ctx.LoadSetupRegistry(); + registry.AddOrUpdate(new() { Id = "created", Url = "wss://created.example", IsLocal = true, SetupManagedDistroName = ctx.DistroName }); + registry.SetActive("created"); + ctx.SaveSetupRegistry(registry); + ctx.GatewayRecordId = "created"; + return Task.FromResult(StepResult.Ok("Saved owned output")); + } + public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + var registry = ctx.LoadSetupRegistry(); + if (rollbackFails) throw new IOException("Identity deletion failed before rollback save"); + foreach (var record in registry.GetAll().Where(record => PairOperatorStep.IsSetupManagedLocalRecord(record, ctx))) + registry.Remove(record.Id); + ctx.SaveSetupRegistry(registry); + return Task.CompletedTask; + } + } + + private sealed class FailureStep(Action fail) : SetupStep + { + public override string Id => "failure"; + public override string DisplayName => Id; + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + fail(); + ct.ThrowIfCancellationRequested(); + return Task.FromResult(StepResult.Terminal("Synthetic pipeline failure")); + } + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupReviewOwnershipTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupReviewOwnershipTests.cs new file mode 100644 index 000000000..c6c0fc360 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupReviewOwnershipTests.cs @@ -0,0 +1,41 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupReviewOwnershipTests +{ + [Fact] + public void LocalAiReview_PreservesGenerationEligibilityAndPinnedRecovery() + { + var source = Read("LocalAiSetupControl.xaml.cs"); + Assert.Contains("LocalAiSetupAvailabilityCoordinator", source); + Assert.Contains("CanApplyLocalAiAvailability(checking.Generation, setupWindow)", source); + Assert.Contains("LocalInferenceEligibilityFailureCode.HardwareFactsIncomplete", source); + Assert.Contains("TryApplyProbeFailure", source); + Assert.Contains("if (_localAiRecoveryModelPinned)", source); + Assert.Contains("LocalAiToggle.IsEnabled = !_localAiRecoveryOnly", source); + Assert.Contains("_localAiAvailability.CancelCurrent()", source); + } + + [Fact] + public void TailscaleReview_PreservesBoundedReadOnlyProbeAndGenerationFence() + { + var source = Read("TailscaleSetupControl.xaml.cs"); + Assert.Contains("psi.ArgumentList.Add(\"status\")", source); + Assert.Contains("psi.ArgumentList.Add(\"--json\")", source); + Assert.Contains("BoundedProcessOutput.ReadAsync", source); + Assert.Contains("TailscaleSetupPolicy.GetTailnetDnsSuffix(dnsName)", source); + Assert.Contains("IsCurrentTailscaleStatusProbe(generation, cancellation)", source); + Assert.DoesNotContain("psi.ArgumentList.Add(\"up\")", source); + } + + private static string Read(string name) => File.ReadAllText(Path.Combine( + RepositoryRoot(), "src", "OpenClaw.SetupEngine.UI", "Controls", name)); + + private static string RepositoryRoot() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT") is { Length: > 0 } root) return root; + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + if (Directory.Exists(Path.Combine(directory.FullName, "src", "OpenClaw.SetupEngine.UI"))) + return directory.FullName; + throw new InvalidOperationException("Repository root not found."); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupSettingsPersistenceTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupSettingsPersistenceTests.cs new file mode 100644 index 000000000..47ebcfac3 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupSettingsPersistenceTests.cs @@ -0,0 +1,40 @@ +using OpenClaw.Shared; +using OpenClaw.TestSupport; +using System.Text.Json; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupSettingsPersistenceTests +{ + [Fact] + public async Task StandaloneSetupWritersShareThePathLeaseAndPreserveUnrelatedFields() + { + using var temp = new TempDirectory(); + var path = temp.Combine("settings.json"); + File.WriteAllText(path, """{"Unrelated":"keep","AutoStart":false}"""); + using var started = new CountdownEvent(2); + var lease = PersistenceFileLease.Acquire(path); + var settings = Task.Run(() => + { + started.Signal(); + new TraySettingsConfig { NodeCameraEnabled = false }.MergeIntoSettingsFile(path, includeAutoStart: false); + }); + var startup = Task.Run(() => + { + started.Signal(); + TraySettingsConfig.UpdateAutoStartInSettingsFile(path, true); + }); + try + { + Assert.True(started.Wait(TimeSpan.FromSeconds(5))); + Assert.False(settings.IsCompleted); + Assert.False(startup.IsCompleted); + } + finally { lease.Dispose(); } + await Task.WhenAll(settings, startup).WaitAsync(TimeSpan.FromSeconds(5)); + using var actual = JsonDocument.Parse(File.ReadAllText(path)); + Assert.Equal("keep", actual.RootElement.GetProperty("Unrelated").GetString()); + Assert.True(actual.RootElement.GetProperty("AutoStart").GetBoolean()); + Assert.False(actual.RootElement.GetProperty("NodeCameraEnabled").GetBoolean()); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 415c53185..00bfe8659 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -16,8 +16,10 @@ public class SetupStepsTests : IDisposable { private readonly string _tempDir; private readonly string _localTempDir; + private readonly string _localTempRoot; private readonly string? _prevDataDir; private readonly string? _prevLocalDataDir; + private readonly string? _prevLocalAppDataRoot; 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"; @@ -27,23 +29,27 @@ public SetupStepsTests(ITestOutputHelper output) { _output = output; _tempDir = Path.Combine(Path.GetTempPath(), $"steps-test-{Guid.NewGuid():N}"); - _localTempDir = Path.Combine(Path.GetTempPath(), $"steps-local-test-{Guid.NewGuid():N}"); + _localTempRoot = Path.Combine(Path.GetTempPath(), $"steps-local-test-{Guid.NewGuid():N}"); + _localTempDir = Path.Combine(_localTempRoot, "OpenClawTray"); Directory.CreateDirectory(_tempDir); Directory.CreateDirectory(_localTempDir); _prevDataDir = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR"); _prevLocalDataDir = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCAL_DATA_DIR"); + _prevLocalAppDataRoot = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR"); Environment.SetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR", _tempDir); Environment.SetEnvironmentVariable("OPENCLAW_TRAY_LOCAL_DATA_DIR", _localTempDir); + Environment.SetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR", _localTempRoot); } public void Dispose() { Environment.SetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR", _prevDataDir); Environment.SetEnvironmentVariable("OPENCLAW_TRAY_LOCAL_DATA_DIR", _prevLocalDataDir); + Environment.SetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR", _prevLocalAppDataRoot); // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. try { Directory.Delete(_tempDir, recursive: true); } catch { } // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. - try { Directory.Delete(_localTempDir, recursive: true); } catch { } + try { Directory.Delete(_localTempRoot, recursive: true); } catch { } } private SetupContext CreateContext(SetupConfig? config = null, ICommandRunner? commands = null) diff --git a/tests/OpenClaw.Shared.Tests/NativeProofLayoutTests.cs b/tests/OpenClaw.Shared.Tests/NativeProofLayoutTests.cs new file mode 100644 index 000000000..c07d74ec8 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/NativeProofLayoutTests.cs @@ -0,0 +1,112 @@ +using OpenClaw.TestSupport; + +namespace OpenClaw.Shared.Tests; + +public sealed class NativeProofLayoutTests +{ + [Theory] + [InlineData(1, 258)] + [InlineData(1.25, 257)] + [InlineData(1.5, 257)] + [InlineData(1.75, 257)] + [InlineData(2, 258)] + public void Centering_RoundsOffsetInsideCommonParent_NotIndependentCenters(double scale, int heroLeft) + { + var parent = 48 / scale; + var width = 601 / scale; + var hero = NativeProofLayout.CenteredPhysicalEdges(parent, 0, width, 182 / scale, scale); + var text = NativeProofLayout.CenteredPhysicalEdges(parent, 0, width, 301 / scale, scale); + // Float subtraction before double-precision LayoutRound can put the half-pixel + // just below its midpoint on fractional plateaus. These are exact, not tolerances. + Assert.Equal((heroLeft, heroLeft + 182), hero); + Assert.Equal((198, 499), text); + Assert.NotEqual((hero.Left + hero.Right) / 2d, (text.Left + text.Right) / 2d); + Assert.NotEqual(text, (text.Left + 1, text.Right + 1)); + Assert.NotEqual(text, (text.Left - 1, text.Right - 1)); + } + + [Theory] + [InlineData(1, 182, 182)] + [InlineData(1.25, 182.39999389648438, 228)] + [InlineData(1.5, 182, 273)] + [InlineData(1.75, 182.28572082519531, 319)] + [InlineData(2, 182, 364)] + public void HeroSize_UsesExactPhysicalRoundingAndWinUiFloatStorage(double scale, double expectedView, int physical) + { + Assert.Equal(expectedView, NativeProofLayout.RoundedViewSize(182, scale)); + Assert.Equal(physical, NativeProofLayout.PhysicalPixels(182, scale)); + Assert.Equal(physical, NativeProofLayout.PhysicalPixels(expectedView, scale)); + } + + [Fact] + public void ContentScale_IsIndependentOfTheWindowDpiValue() + { + Assert.Equal(876, NativeProofLayout.PhysicalPixels(876, 1)); + Assert.Equal(876, NativeProofLayout.PhysicalPixels((float)(876 / 1.75), 1.75)); + Assert.Equal(1533, NativeProofLayout.PhysicalPixels(876, 1.75)); + } + + [Theory] + [InlineData(false, false, 0)] + [InlineData(false, true, 0)] + [InlineData(true, false, 1)] + [InlineData(true, true, 0)] + public void ChromeInset_IsConditionalAndPhysical(bool extended, bool maximized, int expected) => + Assert.Equal(expected, NativeProofLayout.ContentTopInset(extended, maximized)); + + [Fact] + public void RecordedSetupContent_FillsExactClientBelowChromeBorder() + { + const double scale = 1.75; + Assert.Equal(1236, NativeProofLayout.PhysicalPixels(706.2857055664062, scale)); + Assert.Equal(1422, NativeProofLayout.PhysicalPixels(812.5714111328125, scale)); + Assert.Equal(1423, NativeProofLayout.PhysicalPixels(812.5714111328125, scale) + + NativeProofLayout.ContentTopInset(extendsContentIntoTitleBar: true, maximized: false)); + } + + [Fact] + public void RecordedConsentEdges_SnapToTheSamePixelWithoutDipTolerance() + { + const double scale = 1.5; + var viewport = (Left: 0d, Top: 0d, Right: 600d, Bottom: 424.6666564941406); + var consent = (Left: 0d, Top: 382.0000305175781, Right: 488.6666564941406, + Bottom: 382.0000305175781 + 42.666656494140625); + Assert.Equal(637, NativeProofLayout.PhysicalEdge(viewport.Bottom, scale)); + Assert.Equal(637, NativeProofLayout.PhysicalEdge(consent.Bottom, scale)); + Assert.True(NativeProofLayout.ContainsPhysicalEdges(viewport, consent, scale)); + Assert.False(NativeProofLayout.ContainsPhysicalEdges(viewport, consent with { Bottom = consent.Bottom + 1 / scale }, scale)); + } + + [Theory] + [InlineData(-1, 0, 30, 30)] + [InlineData(0, -1, 30, 30)] + [InlineData(0, 0, 31, 30)] + [InlineData(0, 0, 30, 31)] + public void Containment_RejectsOnePhysicalPixelOverflowOnEveryEdge(double left, double top, double right, double bottom) + { + const double scale = 1.5; + Assert.False(NativeProofLayout.ContainsPhysicalEdges((0, 0, 20, 20), + (left / scale, top / scale, right / scale, bottom / scale), scale)); + } + + [Fact] + public void Containment_RoundsEdgesInOneSharedCoordinateSpace() + { + const double scale = 1.75; + var viewport = (Left: 12.285714149475098, Top: 18.85714340209961, Right: 132.2857141494751, Bottom: 88.285717); + Assert.True(NativeProofLayout.ContainsPhysicalEdges(viewport, viewport, scale)); + Assert.False(NativeProofLayout.ContainsPhysicalEdges(viewport, + viewport with { Left = viewport.Left - 1 / scale }, scale)); + } + + [Theory] + [InlineData(-1, 1)] + [InlineData(182, 0)] + [InlineData(182, double.NaN)] + [InlineData(double.PositiveInfinity, 1.75)] + public void InvalidGeometryFailsExplicitly(double size, double scale) + { + Assert.Throws(() => NativeProofLayout.RoundedViewSize(size, scale)); + Assert.Throws(() => NativeProofLayout.PhysicalPixels(size, scale)); + } +} diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index ed1d274a8..624fbcbaa 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -24,7 +24,9 @@ public GatewayClientTestHelper( bool tokenIsBootstrapToken = false, bool bootstrapPairAsNode = false, string gatewayUrl = "ws://localhost:18789", - string? identityPath = null) + string? identityPath = null, + bool persistHandshakeDeviceTokens = true, + DeviceTokenReceivedEventArgs? ephemeralOperatorCredential = null) { // Isolate test identities because other test classes can construct // gateway clients concurrently under the same AppData root. @@ -36,7 +38,9 @@ public GatewayClientTestHelper( new TestLogger(), tokenIsBootstrapToken, bootstrapPairAsNode, - identityPath); + identityPath, + persistHandshakeDeviceTokens: persistHandshakeDeviceTokens, + ephemeralOperatorCredential: ephemeralOperatorCredential); } public GatewayClientTestHelper(IOpenClawLogger logger) @@ -1398,6 +1402,7 @@ await server.SendTextAsync( "nonce": "old-socket", "ts": 1785824000000 } + } """); await authorizationStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); @@ -1417,6 +1422,56 @@ await server.SendTextAsync( Assert.True(isConnected); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AuthenticatedSigningIdentity_ComesFromConnectNotOptionalHelloEcho(bool spoofEcho) + { + using var server = new LoopbackWebSocketServer(); + await server.StartAsync(); + var identityPath = CreateTempIdentityPath(); + using var client = new OpenClawGatewayClient(server.WebSocketUrl, "synthetic", + new TestLogger(), identityPath: identityPath); + var handshake = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.HandshakeSucceeded += (_, _) => handshake.TrySetResult(); + await client.ConnectAsync(); + await server.WaitForAcceptedCountAsync(1, TimeSpan.FromSeconds(2)); + await server.SendTextAsync("""{"type":"event","event":"connect.challenge","payload":{"nonce":"schema-fixture","ts":1785824000000}}"""); + using var connect = JsonDocument.Parse(await server.ReceiveTextAsync().WaitAsync(TimeSpan.FromSeconds(2))); + var signedId = connect.RootElement.GetProperty("params").GetProperty("device").GetProperty("id").GetString(); + Assert.Null(client.AuthenticatedSigningDeviceId); + // HelloOkSchema + SnapshotSchema at upstream 63f1dec2 declare no device/deviceId echo. + var payload = System.Text.Json.Nodes.JsonNode.Parse(""" + {"type":"hello-ok","protocol":4,"server":{"version":"2026.9.25","connId":"fixture"}, + "features":{"methods":[],"events":[]},"snapshot":{"presence":[],"health":{}, + "stateVersion":{"presence":0,"health":0},"uptimeMs":0, + "sessionDefaults":{"defaultAgentId":"main","mainKey":"main","mainSessionKey":"agent:main:main"}}, + "auth":{"deviceToken":"authenticated-token","role":"operator","scopes":["operator.admin"]}, + "policy":{"maxPayload":1048576,"maxBufferedBytes":1048576,"tickIntervalMs":30000}} + """)!; + if (spoofEcho) payload["deviceId"] = "not-the-signing-device"; + await server.SendTextAsync(new System.Text.Json.Nodes.JsonObject + { + ["type"] = "res", ["id"] = connect.RootElement.GetProperty("id").GetString(), + ["ok"] = true, ["payload"] = payload, + }.ToJsonString()); + await handshake.Task.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.True(client.IsConnectedToGateway); + Assert.Equal("agent:main:main", client.MainSessionKey); + Assert.Equal(signedId, client.AuthenticatedSigningDeviceId); + if (!spoofEcho) Assert.Null(client.OperatorDeviceId); + else Assert.Equal("not-the-signing-device", client.OperatorDeviceId); + var replacementPath = CreateTempIdentityPath(); + var replacement = new DeviceIdentity(replacementPath); + replacement.Initialize(); + Assert.NotEqual(signedId, replacement.DeviceId); + File.Copy(Path.Combine(replacementPath, "device-key-ed25519.json"), + Path.Combine(identityPath, "device-key-ed25519.json"), true); + Assert.Equal(signedId, client.AuthenticatedSigningDeviceId); + await client.DisconnectAsync(); + Assert.Null(client.AuthenticatedSigningDeviceId); + } + [Fact] public void OperatorBootstrap_HelloOkWithNodeHandoffToken_StoresNodeToken() { @@ -1495,6 +1550,55 @@ public void HelloOkWhenTokenWriteFails_CompletesHandshakeAndPublishesToken() Assert.Equal("operator", receivedToken?.Role); } + [Fact] + public void NonpersistentBootstrapHandshake_PublishesCredentialWithoutWritingIdentity() + { + using var directory = new TempDirectory(); + var helper = new GatewayClientTestHelper( + tokenIsBootstrapToken: true, identityPath: directory.Path, + persistHandshakeDeviceTokens: false); + using var client = helper.Client; + var path = Path.Combine(directory.Path, "device-key-ed25519.json"); + var before = File.ReadAllBytes(path); + DeviceTokenReceivedEventArgs? received = null; + client.DeviceTokenReceived += (_, token) => received = token; + helper.TrackPendingRequest("ephemeral-bootstrap", "connect"); + helper.ProcessRawMessage(""" + {"type":"res","id":"ephemeral-bootstrap","payload":{ + "type":"hello-ok","protocol":4, + "auth":{"deviceToken":"issued-operator","role":"operator","scopes":["operator.read"]} + }} + """); + Assert.True(client.HasHandshakeSnapshot); + Assert.Equal("issued-operator", received?.Token); + Assert.Equal(before, File.ReadAllBytes(path)); + Assert.Null(DeviceIdentity.TryReadStoredDeviceToken(directory.Path)); + } + + [Fact] + public void EphemeralOperatorCredential_UsesDeviceAuthAndScopesWithoutWritingIdentity() + { + using var directory = new TempDirectory(); + var identity = new DeviceIdentity(directory.Path); + identity.Initialize(); + var path = Path.Combine(directory.Path, "device-key-ed25519.json"); + var before = File.ReadAllBytes(path); + var helper = new GatewayClientTestHelper( + tokenIsBootstrapToken: true, identityPath: directory.Path, + persistHandshakeDeviceTokens: false, + ephemeralOperatorCredential: new("issued-operator", ["operator.read"], "operator")); + using var client = helper.Client; + var auth = helper.BuildAuthPayload(); + Assert.Equal("issued-operator", auth["deviceToken"]); + Assert.False(auth.ContainsKey("bootstrapToken")); + Assert.False(auth.ContainsKey("token")); + var scopesMethod = typeof(OpenClawGatewayClient).GetMethod("GetRequestedScopes", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var scopes = Assert.IsType(scopesMethod!.Invoke(client, ["operator"])); + Assert.Equal(["operator.read"], scopes); + Assert.Equal(before, File.ReadAllBytes(path)); + } + [Theory] [InlineData(3)] [InlineData(4)] diff --git a/tests/OpenClaw.Shared.Tests/StartupTaskInspectionTests.cs b/tests/OpenClaw.Shared.Tests/StartupTaskInspectionTests.cs new file mode 100644 index 000000000..475c1ff9d --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/StartupTaskInspectionTests.cs @@ -0,0 +1,28 @@ +namespace OpenClaw.Shared.Tests; + +public sealed class StartupTaskInspectionTests +{ + [Theory] + [InlineData("expected", true)] + [InlineData("disabled", false)] + [InlineData("path", false)] + [InlineData("executable", false)] + [InlineData("arguments", false)] + [InlineData("working-directory", false)] + [InlineData("principal", false)] + [InlineData("trigger", false)] + public void OnlyExactEnabledStartupTaskCountsAsCompletedRegistration(string changed, bool matches) + { + var executable = Path.Combine(Path.GetTempPath(), "expected.exe"); + var description = new StartupTaskDescription( + changed != "disabled", + changed == "path" ? "\\other" : "\\OpenClaw", + changed == "executable" ? Path.Combine(Path.GetTempPath(), "old.exe") : executable, + changed == "arguments" ? "--other-profile" : "", + changed == "working-directory" ? Path.GetTempPath() : "", + changed == "principal" ? "S-1-5-18" : "S-1-5-21-123", + changed != "trigger"); + Assert.Equal(matches, WindowsStartupTaskRegistration.MatchesExpectedTask( + description, "OpenClaw", executable, "S-1-5-21-123")); + } +} diff --git a/tests/OpenClaw.TestSupport/NativeProofLayout.cs b/tests/OpenClaw.TestSupport/NativeProofLayout.cs new file mode 100644 index 000000000..d229d9628 --- /dev/null +++ b/tests/OpenClaw.TestSupport/NativeProofLayout.cs @@ -0,0 +1,57 @@ +namespace OpenClaw.TestSupport; + +/// WinUI layout rounds physical pixels, then stores its view size as a single-precision value. +public static class NativeProofLayout +{ + // CWindowChrome reserves one physical pixel, independent of DPI, above restored + // custom-titlebar content. Maximized windows have no such border. + public static int ContentTopInset(bool extendsContentIntoTitleBar, bool maximized) => + extendsContentIntoTitleBar && !maximized ? 1 : 0; + + public static double RoundedViewSize(double configuredSize, double rasterizationScale) + { + Validate(configuredSize, rasterizationScale); + return (float)(Math.Round((float)configuredSize * rasterizationScale, MidpointRounding.AwayFromZero) / + rasterizationScale); + } + + public static int PhysicalPixels(double viewSize, double rasterizationScale) + { + Validate(viewSize, rasterizationScale); + return checked((int)Math.Round(viewSize * rasterizationScale, MidpointRounding.AwayFromZero)); + } + + public static int PhysicalEdge(double viewCoordinate, double rasterizationScale) + { + if (!double.IsFinite(viewCoordinate)) throw new ArgumentOutOfRangeException(nameof(viewCoordinate)); + Validate(0, rasterizationScale); + return checked((int)Math.Round(viewCoordinate * rasterizationScale, MidpointRounding.AwayFromZero)); + } + + public static (int Left, int Right) CenteredPhysicalEdges( + double parentOrigin, double slotLeft, double slotWidth, double renderedWidth, double scale) + { + Validate(slotWidth, scale); + Validate(renderedWidth, scale); + // microsoft-ui-xaml 948461c2, framework.cpp: ComputeAlignmentOffset then + // LayoutRound(VisualOffset). Round the parent-relative offset, not two centers. + var offset = (float)(((float)slotWidth - (float)renderedWidth) * 0.5f + (float)slotLeft); + var roundedOffset = (float)(PhysicalEdge(offset, scale) / scale); + return (PhysicalEdge(parentOrigin + roundedOffset, scale), + PhysicalEdge(parentOrigin + roundedOffset + renderedWidth, scale)); + } + + public static bool ContainsPhysicalEdges( + (double Left, double Top, double Right, double Bottom) viewport, + (double Left, double Top, double Right, double Bottom) content, double scale) => + PhysicalEdge(content.Left, scale) >= PhysicalEdge(viewport.Left, scale) && + PhysicalEdge(content.Top, scale) >= PhysicalEdge(viewport.Top, scale) && + PhysicalEdge(content.Right, scale) <= PhysicalEdge(viewport.Right, scale) && + PhysicalEdge(content.Bottom, scale) <= PhysicalEdge(viewport.Bottom, scale); + + private static void Validate(double size, double scale) + { + if (!double.IsFinite(size) || size < 0) throw new ArgumentOutOfRangeException(nameof(size)); + if (!double.IsFinite(scale) || scale <= 0) throw new ArgumentOutOfRangeException(nameof(scale)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ActivationRouterTests.cs b/tests/OpenClaw.Tray.Tests/ActivationRouterTests.cs index a828a415d..8947e0dea 100644 --- a/tests/OpenClaw.Tray.Tests/ActivationRouterTests.cs +++ b/tests/OpenClaw.Tray.Tests/ActivationRouterTests.cs @@ -162,6 +162,25 @@ public void PlanLaunch_PostSetupChatFallback_WhenNoOtherCandidate() Assert.Equal("chat", route.Page); } + [Theory] + [InlineData("settings", "settings")] + [InlineData("SETTINGS", "settings")] + [InlineData("connection", "connection")] + [InlineData("CONNECTION", "connection")] + public void PlanLaunch_PostSetupWithoutGateway_OpensSettings(string target, string page) + { + var plan = CreateRouter().PlanLaunch(Input(postSetupLaunch: target)); + var dispatch = Assert.IsType(plan); + Assert.Equal(page, Assert.IsType(dispatch.Route).Page); + } + + [Fact] + public void PlanLaunch_UnknownPostSetupTarget_IsNotAnActivation() + { + Assert.IsType( + CreateRouter().PlanLaunch(Input(postSetupLaunch: "browser"))); + } + [Fact] public void PlanLaunch_ReturnsIgnore_WhenNoCandidatePresent() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index f3ff06ab7..961873cbf 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -54,7 +54,7 @@ public void Startup_Order_PreservesInitializationInvariants() "await ShowOnboardingAsync();", "EnsureNodeService(_settings);", "InitializeGatewayClient();", - "CheckForUpdatesAsync();", + "CheckOrdinaryStartupUpdateAsync(", "await _activationRouter.StartForwardedActivationListenerAsync(this, CancellationToken.None);"); } @@ -64,7 +64,7 @@ public void ExtendedStableUpdatePolicy_RemainsOutsideAppCompositionRoot() var source = ReadAppSources(); Assert.Contains("() => _connectionManager?.OperatorClient,", source); - AssertInOrder(source, "InitializeGatewayClient();", "CheckForUpdatesAsync();"); + AssertInOrder(source, "InitializeGatewayClient();", "CheckOrdinaryStartupUpdateAsync("); Assert.DoesNotContain("extended-stable", source); Assert.DoesNotContain("GetUpdateStatusAsync", source); } @@ -167,7 +167,7 @@ public void GatewayRecordEdits_HoldSharedLifecycleLease() directConnectService, "BeginManualGatewayLifecycleOperationAsync", "DisconnectAsync", - "_registry.AddOrUpdate(candidate)"); + "_registry.ReplaceSnapshotAndSave(previousRegistry"); Assert.Contains("GatewayDirectConnectService", windowEdit); Assert.Contains("directConnectService.ConnectAsync(", windowEdit); Assert.DoesNotContain("BeginManualGatewayLifecycleOperationAsync", windowEdit); @@ -305,7 +305,7 @@ public void DirectConnectRollback_RestoresNullActiveGatewayExactly() "GatewayDirectConnectService.cs")); var rollback = ExtractMethod(directConnectService, "Rollback"); - Assert.Contains("_registry.SetActive(previousActiveId);", rollback); + Assert.Contains("_registry.ReplaceSnapshotAndSave(admittedRegistry, previousRegistry)", rollback); Assert.DoesNotContain("if (previousActiveId != null)", rollback); } @@ -345,20 +345,22 @@ public void DirectConnectRollback_UsesTransactionalTokenClearAfterLifecycleLease "await _connectionManager.DisconnectAsync()", "Rollback("); Assert.Contains("if (!clearResult.Success)", serviceConnect); - Assert.Contains("candidateRegistryCommitted", serviceConnect); + Assert.Contains("committedRegistry", serviceConnect); AssertInOrder( serviceConnect, - "_registry.Save();", - "candidateRegistryCommitted = true", + "committedRegistry = _registry.ReplaceSnapshotAndSave", "BeginTransactionalTokenClear(identityDir, _logger)"); AssertInOrder( rollback, - "_registry.Save();", + "_registry.ReplaceSnapshotAndSave(admittedRegistry, previousRegistry)", "RestoreTransactionalTokenClear("); Assert.Contains("RestoreTransactionalTokenClear(", rollback); Assert.Contains("DeviceTokenRestoreOutcome.Superseded", rollback); Assert.Contains("DeviceTokenRestoreOutcome.Failed", rollback); - Assert.Contains("ReconcileSettings(candidate)", rollback); + Assert.Contains("ReconcileSupersedingSnapshot(persisted, candidate)", rollback); + Assert.Contains("ReconcileSettings(active)", directConnectService); + Assert.Contains("_registry.AdoptPersistedSnapshot(admittedRegistry)", rollback); + Assert.DoesNotContain("_registry.SetActive(candidate.Id)", rollback); Assert.Contains("previousSettings.Restore(_settings)", rollback); Assert.Contains("_reconcileRuntimeTunnel()", rollback); } @@ -472,9 +474,11 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() var source = ReadAppSources(); var method = ExtractMethod(source, "OpenDashboard"); - Assert.Contains("if (!EnsureSshTunnelConfigured())", method); - Assert.Contains("_toastService?.ShowToast", method); - Assert.Contains("Check SSH tunnel settings and logs.", method); + Assert.Contains("new GatewayDashboardLauncher(", method); + Assert.Contains("EnsureSshTunnelConfigured,", method); + Assert.Contains("ShowDashboardLaunchFailureAsync", method); + Assert.DoesNotContain("GatewayDashboardUrlBuilder.Build", method); + Assert.DoesNotContain("Process.Start", method); } [Fact] @@ -1114,11 +1118,12 @@ public void Setup_IsHostedInTrayAndUsesSelfRestartAfterCompletion() Assert.Contains("Environment.ProcessId)", source); Assert.Contains("SetupRunLock.TryAcquire(_dataDir", setupWindow); Assert.Contains("new SetupContext(", progressPage); - Assert.Contains("step is not RunGatewayWizardStep", progressPage); - Assert.Contains("config.SkipWizard || step is not WindowsNodeBootstrapContextStep", progressPage); + Assert.Contains("OnboardingFlowPolicy.BuildInstallationSteps(localAiRecoveryOnly)", progressPage); + var flowPolicy = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "OnboardingFlowPolicy.cs")); + Assert.Contains("step is not RunGatewayWizardStep and not WindowsNodeBootstrapContextStep", flowPolicy); Assert.Contains("_dataDir,", progressPage); Assert.Contains("_localDataDir);", progressPage); - Assert.Contains("var dataDir = setupWindow.DataDir", welcomePage); + Assert.Contains("setupWindow.DataDir, config.DistroName, setupWindow.LocalDataDir", welcomePage); Assert.Contains("SetupWindow.Active?.DataDir ?? SetupContext.ResolveDataDir()", wizardPage); Assert.Contains("await CompleteSetupAsync(generation)", wizardPage); Assert.Contains("ApplyWindowsNodeContextAsync", wizardPage); @@ -1169,8 +1174,8 @@ public void Setup_IsHostedInTrayAndUsesSelfRestartAfterCompletion() Assert.Contains("\"--wait-for-pid\"", source); Assert.Contains("\"--post-setup-launch\"", source); var activationRouterSource = ReadActivationRouterServiceSource(); - Assert.Contains("$\"{_protocolScheme}://chat\"", activationRouterSource); - Assert.Contains("input.PostSetupLaunch, \"chat\"", activationRouterSource); + Assert.Contains("GetPostSetupLaunchPath(input.PostSetupLaunch)", activationRouterSource); + Assert.Contains("\"chat\" => \"chat\"", activationRouterSource); Assert.Contains("WaitForRestartSourceIfRequested(Environment.GetCommandLineArgs())", source); AssertInOrder(source, "WaitForRestartSourceIfRequested(Environment.GetCommandLineArgs())", "_mutex = new Mutex"); Assert.DoesNotContain("setupWindow.TryNavigateToWizard()", source); @@ -1200,15 +1205,15 @@ public void SetupProgress_PreparesWslBeforeLocalAiDownloads() { var root = TestRepositoryPaths.GetRepositoryRoot(); var code = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "ProgressPage.xaml.cs")); - + var pipeline = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "SetupPipeline.cs")); + var defaults = pipeline[pipeline.IndexOf("public static List BuildDefaultSteps()", StringComparison.Ordinal)..]; AssertInOrder( - code, - "(\"wsl-platform\", \"Prepare WSL\", [\"ensure-wsl-platform\"])", - "(\"local-ai-engine\", \"Install Local AI\"", - "(\"local-ai-model\", \"Download AI model\""); - Assert.Contains( - "(\"wsl-networking\", \"Connect WSL to Local AI\", [\"configure-local-ai-wsl-networking\"])", - code); + defaults, + "new EnsureWslPlatformStep", + "new AcquireLocalAiRuntimeStep", + "new AcquireLocalAiModelStep", + "new ConfigureLocalAiWslNetworkingStep"); + Assert.Contains("OnboardingFlowPolicy.BuildInstallationSteps(localAiRecoveryOnly)", code); Assert.DoesNotContain("Verify Local AI before WSL setup", code); } @@ -1234,32 +1239,32 @@ public void SetupWelcome_BlocksOnWslReadinessBeforeLocalAiDecisionUi() AssertInOrder( startInstall, "GetWslViabilityAsync(refresh: true)", - "if (wslViability.BlocksSetup)", - "Title = \"WSL2 is not ready\"", - "PrimaryButtonText = \"Try again\"", + "if (viability.BlocksSetup)", + "ReadinessError.Title = SetupLocalization.GetString(\"Onboarding_V2_WslNotReady\")", + "ReadinessError.IsOpen = true", "ExistingConfigDetector.Detect", "NavigateToCapabilities()"); AssertInOrder( detectLocalAi, "GetWslViabilityAsync()", - "if (wslViability.BlocksSetup)", + "wslViability.BlocksSetup", "GetLocalAiHardwareAsync()"); Assert.DoesNotContain("GetWslViabilityAsync", capabilities); Assert.DoesNotContain("WslViabilityKind", capabilities); } [Fact] - public void SetupProgress_HidesEveryLocalAiOnlyGroupAndKeepsNonLocalPreviewActive() + public void SetupProgress_HasOnePhaseProjectionAndKeepsNonLocalPreviewActive() { var root = TestRepositoryPaths.GetRepositoryRoot(); var code = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "ProgressPage.xaml.cs")); - var buildRows = ExtractMethod(code, "BuildStepRows"); var preview = ExtractMethod(code, "RenderProgressPreview"); - Assert.Contains("IsLocalAiOnlyGroup(stepIds)", buildRows); - Assert.Contains("stepIds.All(stepId => stepId.Contains(\"local-ai\"", code); - Assert.Contains("localAiPreview ? \"local-ai-model\" : \"wsl-create\"", preview); - Assert.DoesNotContain("groupId.StartsWith(\"local-ai\"", buildRows); + Assert.Contains("_installationProgress = new(steps, _localAiRecoveryOnly)", code); + Assert.DoesNotContain("BuildStepRows", code); + Assert.DoesNotContain("StepGroups", code); + Assert.Contains("localAiPreview ? \"acquire-local-ai-model\" : \"wsl-create\"", preview); + Assert.Contains("_installationProgress!.Apply", preview); Assert.DoesNotContain(": 3;", preview); } @@ -1269,17 +1274,19 @@ public void SetupCompletion_PersistsStartupChoiceBeforeRestart() var root = TestRepositoryPaths.GetRepositoryRoot(); var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "SetupWindow.xaml.cs")); var method = ExtractMethod(source, "RequestSetupCompleted"); + var save = ExtractMethod(source, "SaveSetupChoices"); - Assert.Contains("if (_persistStartupPreferenceOnComplete && !preserveStartupPreference)", method); - Assert.Contains("bool preserveStartupPreference = false", method); - Assert.Contains("_config.Settings.AutoStart = enableAutoStart", method); - Assert.Contains("TraySettingsConfig.UpdateAutoStartInSettingsFile", method); + Assert.Contains("if (_persistStartupPreferenceOnComplete || !_startupRegistrationAllowed)", save); + Assert.Contains("_config.Settings.AutoStart = enableAutoStart", save); + Assert.Contains("TraySettingsConfig.UpdateAutoStartInSettingsFile", save); AssertInOrder( - method, - "if (_persistStartupPreferenceOnComplete && !preserveStartupPreference)", + save, + "if (_persistStartupPreferenceOnComplete || !_startupRegistrationAllowed)", "_config.Settings.AutoStart = enableAutoStart", - "TraySettingsConfig.UpdateAutoStartInSettingsFile", - "handler.Invoke"); + "TraySettingsConfig.UpdateAutoStartInSettingsFile"); + AssertInOrder(method, "SaveSetupChoices(enableAutoStart)", "handler.Invoke"); + var native = ExtractMethod(source, "FinalizeNativeChoiceAsync"); + AssertInOrder(native, "if (!_nativeSettingsSaved)", "SaveSetupChoices(startup)", "_nativeSettingsSaved = true"); } [Fact] @@ -1287,12 +1294,12 @@ public void SetupCompletion_PreservesStartupRegistrationWhenRequested() { var root = TestRepositoryPaths.GetRepositoryRoot(); var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); - Assert.Contains("RestartAfterSetupAsync(e.EnableAutoStart, e.PreserveStartupPreference)", source); + Assert.Contains("e.ApplyStartupPreference ? e.EnableAutoStart : null", source); var restart = ExtractMethod(source, "RestartAfterSetupAsync"); AssertInOrder( restart, - "if (enableAutoStart && !preserveStartupPreference)", - "AutoStartManager.SetAutoStartAsync(true)", + "if (nativeCompletion is null)", + "SetupStartupPolicy.ApplyClassicPreferenceAsync(enableAutoStart", "Process.Start(psi)"); } @@ -1336,8 +1343,10 @@ public void CompletePage_UsesCompletionArgsForStartupPreference() var complete = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CompletePage.xaml.cs")); var navigate = ExtractMethod(setupWindow, "NavigateToComplete"); - Assert.Contains("DefaultAutoStart: true", navigate); - Assert.Contains("ShowStartupPreference: _showStartupPreferenceOnComplete", navigate); + Assert.Contains("DefaultAutoStart: AutoStartAfterSetup", navigate); + Assert.Contains("_autoStartAfterSetup = true", setupWindow); + Assert.Contains("get => _startupRegistrationAllowed && _autoStartAfterSetup", setupWindow); + Assert.Contains("ShowStartupPreference: ShowStartupPreference", navigate); Assert.Contains("StartupToggle.IsOn = args.DefaultAutoStart", complete); Assert.Contains("StartupRow.Visibility = args.ShowStartupPreference ? Visibility.Visible : Visibility.Collapsed", complete); Assert.Contains("StartupRow.Visibility == Visibility.Visible && StartupToggle.IsOn", complete); @@ -1409,18 +1418,12 @@ public void CompletePage_OffersTypedRestartChoiceWithoutForcingApplicationsClose public void CapabilitiesPage_PersistsSelectedProfileIntoRuntimeNodeSettings() { var root = TestRepositoryPaths.GetRepositoryRoot(); - var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); - var method = ExtractMethod(source, "WriteCapabilities"); - - Assert.Contains("config.Settings.ApplyCapabilities(caps)", method); - Assert.Contains("config.Tailscale.TrustTailscaleAuth = TailscaleTrustAuthToggle.IsOn == true", method); - AssertInOrder( - method, - "prop?.SetValue(caps, toggle.IsOn)", - "config.Settings.ApplyCapabilities(caps)"); - Assert.Contains("_config.UsesBundledDefaultConfig", source); - Assert.Contains("_treatBundledAllOnAsPlaceholder ? 1 : 2", source); - Assert.Contains("return -1", source); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "SetupAccessDraft.cs")); + var setter = ExtractMethod(source, "SetCapability"); + Assert.Contains("SetupCapabilityProfiles.Set(Config.Capabilities, capability, enabled)", setter); + Assert.Contains("Config.Settings.ApplyCapabilities(Config.Capabilities)", setter); + Assert.DoesNotContain("MergeIntoSettingsFile", source); + Assert.DoesNotContain("GetProperty", source); } [Fact] @@ -1428,9 +1431,9 @@ public void CapabilitiesPage_InstallerReviewUsesGeneratedExactCommands() { var root = TestRepositoryPaths.GetRepositoryRoot(); var xaml = File.ReadAllText( - Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml")); + Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "GatewaySetupPage.xaml")); var source = File.ReadAllText( - Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); + Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "GatewaySetupPage.xaml.cs")); Assert.Contains("x:Name=\"ExactCommandsText\"", xaml); Assert.Contains("ExactCommandsText.Text = summary.ExactCommands", source); @@ -1439,85 +1442,39 @@ public void CapabilitiesPage_InstallerReviewUsesGeneratedExactCommands() Assert.DoesNotContain("| bash", xaml); } - [Fact] - public void CapabilitiesPage_PermissionProbeFaultsShowInlineWarning() - { - var root = TestRepositoryPaths.GetRepositoryRoot(); - var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); - var click = ExtractMethod(source, "PrimaryClickAsync"); - var build = ExtractMethod(source, "BuildPermissionRows"); - - Assert.Contains("!permissionsTask.IsCompletedSuccessfully", click); - Assert.Contains("catch (Exception ex)", build); - Assert.Contains("new InfoBar", build); - Assert.Contains("Couldn't read Windows permission status", build); - Assert.Contains("Review permissions later in Settings", build); - } - - [Fact] - public void CapabilitiesPage_RefreshesPermissionStateWhenSetupIsReactivated() - { - var root = TestRepositoryPaths.GetRepositoryRoot(); - var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); - var activated = ExtractMethod(source, "SetupWindow_Activated"); - var refresh = ExtractMethod(source, "RefreshPermissionRowsAsync"); - - Assert.Contains("_setupWindow.Activated += SetupWindow_Activated", source); - Assert.Contains("_setupWindow.Activated -= SetupWindow_Activated", source); - Assert.Contains("WindowActivationState.Deactivated", activated); - Assert.Contains("RefreshPermissionRowsAsync(_permissionsTask)", activated); - AssertInOrder(refresh, "await previousRefresh", "await BuildPermissionRows()"); - } - [Fact] public void CapabilitiesPage_ExposesExplicitCustomCapabilitySetsForReview() { var root = TestRepositoryPaths.GetRepositoryRoot(); var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); var xaml = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml")); - var detectProfile = ExtractMethod(source, "DetectProfileIndex"); - - Assert.Contains("x:Name=\"CapabilityExpander\"", xaml); - Assert.Contains("\"Custom capabilities (review)\"", source); - Assert.Contains("CapabilityExpander.IsExpanded = true", source); - Assert.Contains("_treatBundledAllOnAsPlaceholder ? 1 : 2", detectProfile); - Assert.Contains("return -1", detectProfile); - Assert.Contains("toggle.Toggled += Capability_Toggled", source); - AssertInOrder( - source, - "_treatBundledAllOnAsPlaceholder = _config.UsesBundledDefaultConfig", - "_suppressProfile = true", - "ApplyProfile(1)", - "_suppressProfile = false", - "_treatBundledAllOnAsPlaceholder = false"); - AssertInOrder( - ExtractMethod(source, "Capability_Toggled"), - "DetectProfileIndex()", - "ProfileRadio.SelectedIndex = profileIndex", - "UpdateCapabilityProfilePresentation(profileIndex)"); + Assert.Contains("x:Name=\"CustomProfileText\"", xaml); + Assert.Contains("_draft.Profile == SetupCapabilityProfile.Custom", source); + Assert.Contains("_draft.SetCapability(capability, toggle.IsOn)", source); + Assert.Contains("ProfileSelector.SelectedIndex = _draft.Profile == SetupCapabilityProfile.Custom ? -1 : (int)_draft.Profile", source); + Assert.DoesNotContain("UsesBundledDefaultConfig", source); + Assert.DoesNotContain("new SetupAccessDraft", source); } [Fact] public void CapabilitiesPage_DisclosesAlwaysOnDeviceStatusWithoutOfferingFalseToggle() { var root = TestRepositoryPaths.GetRepositoryRoot(); - var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "SetupAccessDraft.cs")); var xaml = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml")); - - Assert.DoesNotContain("(\"Device\", \"Device\"", source); - Assert.DoesNotContain("[\"Canvas\", \"Screen\", \"Device\"]", source); - Assert.Contains("_config.Capabilities.Device = true", source); - Assert.Contains("Basic device info and status stay available while Node Mode is on.", xaml); + Assert.Contains("Config.Capabilities.Device = true", source); + Assert.Contains("Onboarding_V2_DeviceFixed", xaml); + Assert.DoesNotContain("DeviceToggle", xaml); } [Fact] public void CapabilitiesPage_AggregatesOnlyLocalAiHardwareAndNetworkingDiagnosis() { var root = TestRepositoryPaths.GetRepositoryRoot(); - var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); - var xaml = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml")); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Controls", "LocalAiSetupControl.xaml.cs")); + var xaml = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Controls", "LocalAiSetupControl.xaml")); Assert.Contains("LocalAiUnavailableDetailsButton", xaml); - Assert.Contains("SetupLocalization.GetString(\"Onboarding_LocalAi_UnavailableDetailsDialogTitle\")", source); + Assert.Contains("LocalAiUnavailableReasonText.Text = _localAiUnavailableReason", source); Assert.Contains("LocalAiInstallReviewCard.Visibility = Visibility.Visible", ExtractMethod(source, "ShowLocalAiUnavailable")); Assert.Contains("LocalAiAvailabilityReasons.Build", source); Assert.DoesNotContain("WslViability", source); @@ -1535,7 +1492,7 @@ public void CapabilitiesPage_AggregatesOnlyLocalAiHardwareAndNetworkingDiagnosis public void CapabilitiesPage_FiltersModelsBySelectedGpuCapacityAndShowsMemoryEvidence() { var root = TestRepositoryPaths.GetRepositoryRoot(); - var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Controls", "LocalAiSetupControl.xaml.cs")); var diagnostics = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Shared", "Inference", "Catalog", "LocalInferenceEligibilityDiagnostics.cs")); var resources = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw")); @@ -1544,7 +1501,7 @@ public void CapabilitiesPage_FiltersModelsBySelectedGpuCapacityAndShowsMemoryEvi Assert.Contains("eligibility.DetectedTotalMemoryBytes", diagnostics); Assert.Contains("model weights, KV cache, and runtime workspace", resources); Assert.Contains("SetupReviewSummaryBuilder.DisplayModelName(model)", source); - Assert.Contains("(isRecommended ? \" (Recommended)\" : string.Empty)", source); + Assert.Contains("Onboarding_V2_Recommended", source); Assert.Contains("SetupReviewSummaryBuilder.DisplayModelName(plan.Model)", source); Assert.Contains("bytes / (1024d * 1024d * 1024d)", diagnostics); Assert.Contains("GiB", resources); @@ -1678,9 +1635,15 @@ public void WizardConnect_UsesActiveGatewayRecordUrl() var root = TestRepositoryPaths.GetRepositoryRoot(); var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WizardPage.xaml.cs")); var method = ExtractMethod(source, "ConnectClientAsync"); - - Assert.Contains("GatewayClientEndpointResolver.Resolve(record)", method); - Assert.Contains("new OpenClawGatewayClient(gatewayUrl, token", method); + var session = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "SetupGatewaySession.cs")); + Assert.Contains("SetupGatewaySession.ConnectAsync", method); + Assert.Contains("var gatewayUrl = binding.Endpoint;", session); + var binding = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "SetupGatewaySessionBinding.cs")); + Assert.Contains("GatewayClientEndpointResolver.Resolve(record)", binding); + Assert.Contains("active.Id != GatewayId", binding); + Assert.Contains("_binding.GetRoute(registry.GetActive()", session); + Assert.Contains("new OpenClawGatewayClient(gatewayUrl, token", session); + Assert.DoesNotContain("new OpenClawGatewayClient", method); Assert.DoesNotContain("config.EffectiveGatewayUrl", method); } @@ -1690,12 +1653,14 @@ public void WizardTerminalRestartRecovery_IsExactVersionManagedLocalAndFailClose var root = TestRepositoryPaths.GetRepositoryRoot(); var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WizardPage.xaml.cs")); var connect = ExtractMethod(source, "ConnectClientAsync"); + var session = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "SetupGatewaySession.cs")); var statusChanged = ExtractMethod(source, "OnWizardClientStatusChanged"); var sendAnswer = ExtractMethod(source, "SendCurrentAnswerAsync"); Assert.Contains( "GatewayWizardRestartRecoveryPolicy.WaitForExpectedManagedGatewayAsync", - connect); + session); + Assert.Contains("expectedRestart?.Invoke() == true", session); Assert.Contains("_expectedTerminalRestart", connect); Assert.Contains("_expectedTerminalRestart", statusChanged); Assert.Contains("_hostAccessPlan.CanControlWslGateway", sendAnswer); @@ -1786,7 +1751,7 @@ public void SetupUiImages_UseLibraryQualifiedAssetUris() .OrderBy(Path.GetFileName) .Select(File.ReadAllText)); - Assert.Contains("ms-appx:///OpenClaw.SetupEngine.UI/Assets/Setup/OpenClawMascot.png", xaml); + Assert.Contains("controls:OnboardingMascot", xaml); Assert.DoesNotContain("ms-appx:///Assets/Setup/", xaml); } @@ -1802,11 +1767,11 @@ public void SetupWelcomePage_RunsExistingConfigDetectionOffUiThread() Assert.Contains("InstallCheckProgress.Visibility = Visibility.Visible", method); Assert.Contains("var setupWindow = SetupWindow.Active", method); Assert.Contains("await Task.Run(() => ExistingConfigDetector.Detect", method); - Assert.Contains("setupWindow.IsClosed || xamlRoot is null", method); + Assert.Contains("!IsLoaded || setupWindow.IsClosed", method); Assert.Contains("!setupWindow.IsClosed", method); Assert.Contains("InstallCheckProgress.IsActive = false", method); Assert.Contains("InstallCheckProgress.Visibility = Visibility.Collapsed", method); - Assert.Contains("NextButton.IsEnabled = true", method); + Assert.Contains("ApplySelection()", method); // The busy state is carried by the progress ring alone. Overwriting the option // title hid which option was being acted on for as long as the check ran. @@ -1814,7 +1779,7 @@ public void SetupWelcomePage_RunsExistingConfigDetectionOffUiThread() // A failed inspection must stay recoverable instead of ending the flow on the // recommended option with no way forward. - Assert.Contains("PrimaryButtonText = \"Try again\"", method); + Assert.Contains("ReadinessError.IsOpen = true", method); Assert.Contains("AutomationProperties.AutomationId=\"WelcomeInstallCheckProgress\"", File.ReadAllText( Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WelcomePage.xaml"))); @@ -1822,8 +1787,8 @@ public void SetupWelcomePage_RunsExistingConfigDetectionOffUiThread() method, "NextButton.IsEnabled = false", "await Task.Run(() => ExistingConfigDetector.Detect", - "setupWindow.IsClosed || xamlRoot is null", - "dialog.ShowAsync()", + "!IsLoaded || setupWindow.IsClosed", + "setupWindow.AccessDraft.RecordWslInspection(existing)", "setupWindow.NavigateToCapabilities()"); } @@ -1831,30 +1796,14 @@ public void SetupWelcomePage_RunsExistingConfigDetectionOffUiThread() public void SetupWelcomePage_RetriesWslReadinessWithFreshInspection() { var root = TestRepositoryPaths.GetRepositoryRoot(); - var welcome = File.ReadAllText(Path.Combine( - root, - "src", - "OpenClaw.SetupEngine.UI", - "Pages", - "WelcomePage.xaml.cs")); - var setupWindow = File.ReadAllText(Path.Combine( - root, - "src", - "OpenClaw.SetupEngine.UI", - "SetupWindow.xaml.cs")); - var method = ExtractMethod(welcome, "StartInstallAsync"); - - Assert.Contains("GetWslViabilityAsync(bool refresh = false)", setupWindow); - Assert.Contains("GetWslViabilityAsync(refresh: true)", method); - Assert.Contains("PrimaryButtonText = \"Try again\"", method); - Assert.Contains("if (retry != ContentDialogResult.Primary)", method); - AssertInOrder( - method, - "while (true)", - "GetWslViabilityAsync(refresh: true)", - "if (wslViability.BlocksSetup)", - "PrimaryButtonText = \"Try again\"", - "if (retry != ContentDialogResult.Primary)"); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WelcomePage.xaml.cs")); + var xaml = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WelcomePage.xaml")); + var check = ExtractMethod(source, "StartInstallAsync"); + Assert.Contains("GetWslViabilityAsync(refresh: true)", check); + Assert.Contains("ReadinessError.IsOpen = true", check); + Assert.Contains("Onboarding_V2_Retry", xaml); + Assert.Contains("Click=\"Next_Click\"", xaml); + Assert.DoesNotContain("ContentDialog", check); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs index 6d494f63f..d69fc3913 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs @@ -5,10 +5,10 @@ public sealed class ConnectionRegressionSourceTests [Fact] public void Dashboard_TokenQuery_IsLimitedToSharedGatewayToken() { - var appSource = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); + var launcherSource = ReadSource("src", "OpenClaw.Tray.WinUI", "Services", "GatewayDashboardLauncher.cs"); - Assert.Contains("credentialSource == CredentialResolver.SourceSharedGatewayToken", appSource); - Assert.DoesNotContain("if (!isBootstrapToken && !string.IsNullOrEmpty(token))", appSource); + Assert.Contains("!credential.IsBootstrapToken && credential.Source == CredentialResolver.SourceSharedGatewayToken", launcherSource); + Assert.DoesNotContain("if (!isBootstrapToken && !string.IsNullOrEmpty(token))", launcherSource); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs index c885f4c28..757561f95 100644 --- a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs +++ b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs @@ -21,7 +21,7 @@ public sealed class FluentIconCatalogTests "Browser", "Camera", "Canvas", "Screen", "Location", "Voice", "Speech", "System", "Terminal", "Operator", "Dashboard", "OpenInBrowser", "Chat", "CanvasAct", "VoiceAct", "Settings", "Setup", "About", "Notifications", "Exit", - "Add", "Back", "Sync", "Lock", "Plug", "MoreOverflow", + "Add", "Back", "Sync", "Lock", "Key", "Plug", "MoreOverflow", "People", "Money", "ServerEnvironment", "CapabilityOff", "Channels", "ChevronR", "Check", // Diagnostics surface (see src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml). diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs index 367c8c2ff..3e020a50f 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs @@ -4,6 +4,23 @@ namespace OpenClaw.Tray.Tests; public sealed class GatewayDashboardUrlBuilderTests { + [Theory] + [InlineData("wss://gateway.example/mount/", "https://gateway.example/mount/custodian?onboarding=1#token=synthetic")] + [InlineData("wss://gateway.example/mount/#token=synthetic", "https://gateway.example/mount/custodian?onboarding=1#token=synthetic")] + public void Build_CustodianPreservesMountAndAuthFragment(string gateway, string expected) + { + Assert.Equal(expected, GatewayDashboardUrlBuilder.Build( + gateway, "custodian?onboarding=1", gateway.Contains('#') ? null : "synthetic", true)); + } + + [Fact] + public void Build_RotatedSharedTokenReplacesOldFragmentTokenWithoutQueryExport() + { + Assert.Equal("https://gateway.example/mount/custodian?onboarding=1#view=compact&token=current", + GatewayDashboardUrlBuilder.Build("wss://gateway.example/mount/#token=old&view=compact", + "custodian?onboarding=1", "current", true)); + } + [Fact] public void Build_AppendsSharedTokenToDashboardRoot() { diff --git a/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs index 5378c90f6..1b10e4406 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs @@ -6,6 +6,170 @@ namespace OpenClaw.Tray.Tests; public sealed class GatewayDirectConnectServiceTests : IDisposable { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InitialCommitCasConflictCleansOnlyUnadoptedNewRealmIdentity(bool adoptCandidate) + { + var previous = AddPreviousGateway(); + var previousIdentity = CreateIdentity(previous.Id); + var beforeIdentity = File.ReadAllBytes(Path.Combine(_registry.GetIdentityDirectory(previous.Id), "device-key-ed25519.json")); + var fs = new BeforeRegistryReadFileSystem(); + var registry = new GatewayRegistry(_tempDir, fs); + registry.Load(); + string? candidateId = null; + fs.BeforeRead = () => + { + var candidate = Directory.GetDirectories(Path.Combine(_tempDir, "gateways")) + .SingleOrDefault(path => Path.GetFileName(path) != previous.Id); + if (candidate is null) return; + Assert.True(File.Exists(Path.Combine(candidate, "device-key-ed25519.json"))); + candidateId = Path.GetFileName(candidate); + fs.BeforeRead = null; + var external = new GatewayRegistry(_tempDir); + external.Load(); + external.AddOrUpdate(new() { Id = adoptCandidate ? candidateId : "newer", + Url = "wss://newer.example", SharedGatewayToken = "newer-token" }); + external.SetActive(adoptCandidate ? candidateId : "newer"); + external.Save(); + }; + var service = new GatewayDirectConnectService(_manager, registry, _settings, + () => _tunnelReconcileCount++, NullLogger.Instance, TimeSpan.FromMilliseconds(250)); + var result = await service.ConnectAsync(new("wss://replacement-realm.example", "candidate", null, null, + EditingGatewayId: previous.Id, NativeSetup: true)); + Assert.NotNull(candidateId); + Assert.False(result.GatewayCommitted); + Assert.True(result.RollbackIncomplete); + Assert.Equal(0, _manager.ConnectCount); + Assert.Equal(adoptCandidate, Directory.Exists(registry.GetIdentityDirectory(candidateId!))); + Assert.Equal(adoptCandidate ? candidateId : "newer", registry.ActiveGatewayId); + Assert.Equal("newer-token", registry.GetActive()!.SharedGatewayToken); + Assert.Equal(beforeIdentity, File.ReadAllBytes(Path.Combine(registry.GetIdentityDirectory(previous.Id), "device-key-ed25519.json"))); + Assert.Equal(previousIdentity.DeviceId, CreateIdentity(previous.Id).DeviceId); + registry.UpdateAndSave(registry.ActiveGatewayId!, value => value with { FriendlyName = "still-saveable" }); + } + + private sealed class BeforeRegistryReadFileSystem : IFileSystem + { + public Action? BeforeRead { get; set; } + public bool FileExists(string path) => File.Exists(path); + public bool DirectoryExists(string path) => Directory.Exists(path); + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + public string ReadAllText(string path) { BeforeRead?.Invoke(); return File.ReadAllText(path); } + public void WriteAllText(string path, string content) => File.WriteAllText(path, content); + public void CopyFile(string source, string destination, bool overwrite) => File.Copy(source, destination, overwrite); + public void DeleteFile(string path) => File.Delete(path); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task RollbackConflictAdoptsActualNewerSavedSelectionNotStaleCandidate(bool sameId, bool sameInstance) + { + var previous = AddPreviousGateway(); + _manager.SetCurrentSnapshot(Connected(previous.Id)); + _manager.NextSnapshot = Failed("candidate", "rejected"); + string? candidateId = null; + _manager.BeforeSnapshot = () => + { + candidateId = _manager.LastGatewayId; + var external = sameInstance ? _registry : new GatewayRegistry(_tempDir); + if (!sameInstance) external.Load(); + var current = external.GetActive()!; + var newer = current with { Id = sameId ? current.Id : "external", + Url = "wss://external.example", SharedGatewayToken = "external-token", + SshTunnel = new("user", "ssh.example", 18789, 19001) }; + external.AddOrUpdate(newer); + external.SetActive(newer.Id); + external.Save(); + }; + var result = await CreateService().ConnectAsync(new("wss://candidate.example", "candidate-token", null, null, + NativeSetup: true)); + Assert.False(result.GatewayCommitted); + Assert.True(result.RollbackIncomplete); + Assert.Equal(sameId ? candidateId : "external", _registry.ActiveGatewayId); + Assert.Equal("wss://external.example", _registry.GetActive()!.Url); + Assert.Equal("wss://external.example", _settings.GatewayUrl); + Assert.Equal("ssh.example", _settings.SshTunnelHost); + Assert.Equal(1, _manager.ConnectCount); + _registry.UpdateAndSave(_registry.ActiveGatewayId!, value => value with { FriendlyName = "normal-save-after-failure" }); + Assert.Equal("external-token", _registry.CapturePersistedSnapshot().Records.Single(r => r.Id == _registry.ActiveGatewayId).SharedGatewayToken); + } + + [Fact] + public async Task RollbackConflictWithNoSavedActiveDoesNotInventCandidateSettings() + { + var previous = AddPreviousGateway(); + _manager.SetCurrentSnapshot(Connected(previous.Id)); + _manager.NextSnapshot = Failed("candidate", "rejected"); + _manager.BeforeSnapshot = () => + { + var external = new GatewayRegistry(_tempDir); + external.Load(); + external.SetActive(null); + external.Save(); + }; + var result = await CreateService().ConnectAsync(new("wss://candidate.example", "candidate", null, null, NativeSetup: true)); + Assert.False(result.GatewayCommitted); + Assert.True(result.RollbackIncomplete); + Assert.Null(_registry.ActiveGatewayId); + Assert.Equal("", _settings.GatewayUrl); + Assert.False(_settings.UseSshTunnel); + Assert.Equal(1, _manager.ConnectCount); + _registry.SetActive(previous.Id); + _registry.Save(); + } + + [Fact] + public async Task UnreadableRollbackStateReportsUnknownWithoutSettingsOrReconnectGuesses() + { + var previous = AddPreviousGateway(); + _manager.SetCurrentSnapshot(Connected(previous.Id)); + _manager.NextSnapshot = Failed("candidate", "rejected"); + FileStream? held = null; + _manager.BeforeSnapshot = () => held = new FileStream(Path.Combine(_tempDir, "gateways.json"), + FileMode.Open, FileAccess.Read, FileShare.None); + try + { + var result = await CreateService().ConnectAsync(new("wss://candidate.example", "candidate", null, null, NativeSetup: true)); + Assert.False(result.GatewayCommitted); + Assert.True(result.RollbackIncomplete); + Assert.Contains("could not be confirmed", result.Error); + Assert.Equal(1, _tunnelReconcileCount); + Assert.Equal(1, _manager.ConnectCount); + } + finally { held?.Dispose(); } + _registry.AdoptPersistedSnapshot(_registry.GetSnapshot()); + _registry.Save(); + } + + [Fact] + public async Task NativePreflightIdentityReadFailureIsRetryableAndNotCommitted() + { + var previous = AddPreviousGateway(); + CreateIdentity(previous.Id); + var before = CaptureFiles(); + var host = new SetupNativeConnectionHost(CreateService(), _registry, NullLogger.Instance); + var request = new OpenClaw.SetupEngine.SetupNativeConnectionRequest(previous.Url, EditingGatewayId: previous.Id); + using (var blocked = new FileStream(Path.Combine(_registry.GetIdentityDirectory(previous.Id), "device-key-ed25519.json"), + FileMode.Open, FileAccess.Read, FileShare.None)) + { + var failed = await host.ConnectAsync(request, CancellationToken.None); + Assert.False(failed.Success); + Assert.False(failed.GatewayCommitted); + Assert.False(failed.RequiresAttention); + Assert.Contains("preparation", failed.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, _manager.ValidationCount); + Assert.Equal(0, _manager.ConnectCount); + } + Assert.Equal(before, CaptureFiles()); + _manager.NextSnapshot = Connected(previous.Id); + Assert.True((await host.ConnectAsync(request, CancellationToken.None)).Success); + await host.DiscardCheckAsync(); + } + private readonly string _tempDir = Path.Combine( Path.GetTempPath(), "openclaw-direct-connect-" + Guid.NewGuid().ToString("N")); @@ -215,6 +379,36 @@ public void BuildCandidate_UnchangedSharedToken_KeepsStoredBootstrapToken() Assert.Equal(existing.Id, candidate.Id); } + [Theory] + [InlineData(false, true, null, null, "bootstrap-old")] + [InlineData(false, true, "shared-new", null, null)] + [InlineData(true, true, null, null, "bootstrap-old")] + [InlineData(true, true, "shared-new", null, "bootstrap-old")] + [InlineData(true, false, null, null, null)] + [InlineData(true, true, null, "bootstrap-new", "bootstrap-new")] + [InlineData(true, false, null, "bootstrap-new", "bootstrap-new")] + public void BuildCandidate_BootstrapPreservation_CombinesNativeAndExistingEditPolicies( + bool nativeSetup, bool preserveExisting, string? sharedToken, + string? bootstrapToken, string? expectedBootstrapToken) + { + var existing = new GatewayRecord + { + Id = "gw-existing", + Url = "wss://gateway.example", + SharedGatewayToken = "shared-old", + BootstrapToken = "bootstrap-old", + }; + var request = new GatewayDirectConnectRequest( + existing.Url, sharedToken, null, null, + BootstrapToken: bootstrapToken, NativeSetup: nativeSetup); + + var candidate = GatewayDirectConnectService.BuildCandidate( + request, existing, existing.Id, preserveExisting); + + Assert.Equal(expectedBootstrapToken, candidate.BootstrapToken); + Assert.Equal(sharedToken ?? (preserveExisting ? "shared-old" : null), candidate.SharedGatewayToken); + } + [Fact] public async Task Connect_UnchangedSharedToken_KeepsDeviceTokensAndBootstrap() { @@ -441,6 +635,435 @@ public void SynchronizeSettingsWithActiveGateway_PersistsCommittedGateway() Assert.Equal(1, _tunnelReconcileCount); } + [Fact] + public async Task NativeCheck_DoesNotChangeSavedStateOrLiveConnection() + { + var previous = AddPreviousGateway(); + CreateIdentity(previous.Id).StoreDeviceTokenForRole("operator", "operator-old"); + var before = CaptureFiles(); + _manager.SetCurrentSnapshot(Connected(previous.Id)); + + var result = await CreateService().CheckAsync(new(previous.Url, null, null, null, + EditingGatewayId: previous.Id, BootstrapToken: "bootstrap-new", NativeSetup: true), CancellationToken.None); + + Assert.Equal(SetupCodeOutcome.Success, result.Outcome); + Assert.Equal(before, CaptureFiles()); + Assert.Equal(previous.Id, _manager.CurrentSnapshot.GatewayId); + Assert.Equal(0, _manager.LeaseCount); + Assert.Equal(0, _manager.DisconnectCount); + Assert.Equal(0, _tunnelReconcileCount); + Assert.Equal("operator-old", _manager.ValidatedCredential?.Token); + } + + [Fact] + public async Task NativeNext_RevalidatesAndPreservesSameRealmPairingInStagedIdentity() + { + var previous = AddPreviousGateway(); + var identity = CreateIdentity(previous.Id); + identity.StoreDeviceTokenForRole("operator", "operator-old"); + identity.StoreDeviceTokenForRole("node", "node-old"); + var originalIdentity = File.ReadAllBytes(Path.Combine(_registry.GetIdentityDirectory(previous.Id), "device-key-ed25519.json")); + _manager.NextSnapshot = Connected(previous.Id); + _manager.ValidationRequiresV2 = true; + var service = CreateService(); + var request = new GatewayDirectConnectRequest(previous.Url, "new-shared", null, null, + EditingGatewayId: previous.Id, NativeSetup: true); + await service.CheckAsync(request, CancellationToken.None); + var result = await service.ConnectAsync(request); + + Assert.Equal(GatewayDirectConnectOutcome.Connected, result.Outcome); + Assert.Equal(2, _manager.ValidationCount); + Assert.Equal("operator-old", _manager.ValidatedCredential?.Token); + var active = Assert.Single(_registry.GetAll()); + Assert.Equal(previous.Id, active.Id); + Assert.True(active.RequiresV2Signature); + Assert.Equal(originalIdentity, File.ReadAllBytes(Path.Combine(_registry.GetIdentityDirectory(active.Id), "device-key-ed25519.json"))); + Assert.True(Directory.Exists(_registry.GetIdentityDirectory(previous.Id))); + } + + [Fact] + public async Task NativeNext_CancelDuringValidationLeavesEverythingUnchanged() + { + var previous = AddPreviousGateway(); + var before = CaptureFiles(); + _manager.SetCurrentSnapshot(Connected(previous.Id)); + using var cancellation = new CancellationTokenSource(); + _manager.DuringValidation = () => cancellation.Cancel(); + + var result = await CreateService().ConnectAsync(new(previous.Url, "shared", null, null, + NativeSetup: true), cancellation.Token); + + Assert.False(result.GatewayCommitted); + Assert.Equal(GatewayDirectConnectOutcome.Failed, result.Outcome); + Assert.Equal(before, CaptureFiles()); + Assert.Equal(0, _manager.DisconnectCount); + Assert.Equal(0, _manager.ConnectCount); + } + + [Fact] + public async Task NativeNext_CancelAfterHandshakeRestoresPreviousIdentityAndLiveConnection() + { + var previous = AddPreviousGateway(); + CreateIdentity(previous.Id).StoreDeviceTokenForRole("operator", "operator-old"); + var before = CaptureFiles(); + _manager.SetCurrentSnapshot(Connected(previous.Id)); + _manager.NextSnapshot = Connected(previous.Id); + _manager.RestoreSnapshot = Connected(previous.Id); + using var cancellation = new CancellationTokenSource(); + _manager.BeforeSnapshot = () => + { + if (_manager.ConnectCount == 1) + { + cancellation.Cancel(); + } + }; + var result = await CreateService().ConnectAsync(new(previous.Url, null, null, null, + NativeSetup: true), cancellation.Token); + + Assert.False(result.GatewayCommitted); + Assert.Equal(GatewayDirectConnectOutcome.Failed, result.Outcome); + Assert.Equal(before, CaptureFiles()); + Assert.Equal(previous.Id, _manager.CurrentSnapshot.GatewayId); + Assert.Equal(2, _manager.ConnectCount); + } + + [Fact] + public async Task NativeHost_CancelledNextWithConfirmedRollbackRetainsStagedIdentity() + { + var previous = AddPreviousGateway(); + CreateIdentity(previous.Id).StoreDeviceTokenForRole("operator", "paired"); + _manager.SetCurrentSnapshot(Connected(previous.Id)); + _manager.NextSnapshot = Connected(previous.Id); + _manager.RestoreSnapshot = Connected(previous.Id); + using var cancel = new CancellationTokenSource(); + _manager.BeforeSnapshot = () => { if (_manager.ConnectCount == 1) cancel.Cancel(); }; + var host = new SetupNativeConnectionHost(CreateService(), _registry, NullLogger.Instance); + var request = new OpenClaw.SetupEngine.SetupNativeConnectionRequest(previous.Url, EditingGatewayId: previous.Id); + var result = await host.ConnectAsync(request, cancel.Token); + Assert.False(result.Success); + Assert.False(result.GatewayCommitted); + Assert.False(result.RequiresAttention); + Assert.True(Directory.Exists(_manager.ValidationPaths[0])); + _manager.BeforeSnapshot = null; + Assert.True((await host.CheckAsync(request, CancellationToken.None)).Success); + Assert.Single(_manager.ValidationPaths.Distinct()); + Assert.Single(_manager.ValidationDeviceIds.Distinct()); + await host.DiscardCheckAsync(); + Assert.False(Directory.Exists(_manager.ValidationPaths[0])); + } + + [Fact] + public async Task NativeNext_PairingPendingIsNotAiReadyAndRollsBack() + { + var previous = AddPreviousGateway(); + _manager.NextSnapshot = Connected(previous.Id) with + { + OperatorState = RoleConnectionState.PairingRequired, + OverallState = OverallConnectionState.PairingRequired + }; + var result = await CreateService().ConnectAsync(new(previous.Url, "shared", null, null, + NativeSetup: true)); + Assert.Equal(GatewayDirectConnectOutcome.Failed, result.Outcome); + Assert.False(result.GatewayCommitted); + Assert.Contains("approval", result.Error); + Assert.Equal(previous.Id, _registry.ActiveGatewayId); + } + + [Fact] + public async Task NativeNext_ValidationFailureDoesNotStartTransaction() + { + var previous = AddPreviousGateway(); + _manager.SetCurrentSnapshot(Connected(previous.Id)); + var before = CaptureFiles(); + _manager.ValidationResult = new(SetupCodeOutcome.ConnectionFailed, "rejected"); + var result = await CreateService().ConnectAsync(new(previous.Url, "shared", null, null, NativeSetup: true)); + Assert.False(result.GatewayCommitted); + Assert.Equal(before, CaptureFiles()); + Assert.Equal(0, _manager.DisconnectCount); + Assert.Equal(0, _manager.ConnectCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NativeCheck_ChangedRealmDoesNotCarrySavedCredentials(bool changeSsh) + { + var previous = AddPreviousGateway(); + CreateIdentity(previous.Id).StoreDeviceTokenForRole("operator", "operator-old"); + _registry.AddOrUpdate(previous with { SharedGatewayToken = "shared-old", BootstrapToken = "bootstrap-old" }); + var ssh = changeSsh ? new SshTunnelConfig("user", "host.example", 18789, 45678) : null; + await CreateService().CheckAsync(new(changeSsh ? previous.Url : "wss://other.example", + null, null, ssh, EditingGatewayId: previous.Id, NativeSetup: true), CancellationToken.None); + Assert.Null(_manager.ValidatedCredential); + } + + [Fact] + public async Task NativeNext_BootstrapRemainsBootstrapAndLaterCancellationDoesNotUndoSuccess() + { + using var cancellation = new CancellationTokenSource(); + _manager.NextSnapshot = Connected("new"); + var result = await CreateService().ConnectAsync(new("wss://gateway.example", null, null, null, + BootstrapToken: "bootstrap", NativeSetup: true), cancellation.Token); + cancellation.Cancel(); + var active = Assert.Single(_registry.GetAll()); + Assert.Equal("bootstrap", active.BootstrapToken); + Assert.Null(active.SharedGatewayToken); + Assert.True(_manager.ValidatedCredential!.IsBootstrapToken); + Assert.True(result.GatewayCommitted); + Assert.Equal(active.Id, _registry.ActiveGatewayId); + Assert.False(_manager.LastConnectCancellation.IsCancellationRequested); + } + + [Fact] + public async Task NativeHost_RollbackPersistenceFailureRemainsExplicitAndCannotAdvance() + { + var previous = AddPreviousGateway(); + _manager.NextSnapshot = Failed(previous.Id, "rejected"); + FileStream? registryLock = null; + _manager.BeforeSnapshot = () => registryLock = new FileStream( + Path.Combine(_tempDir, "gateways.json"), FileMode.Open, FileAccess.Read, FileShare.Read); + try + { + var notifications = 0; + var host = new SetupNativeConnectionHost(CreateService(), _registry, NullLogger.Instance, + () => notifications++); + var result = await host.ConnectAsync(new("wss://replacement.example", SharedToken: "shared", + EditingGatewayId: previous.Id), CancellationToken.None); + Assert.False(result.Success); + Assert.True(result.GatewayCommitted); + Assert.True(result.RequiresAttention); + Assert.Equal(1, notifications); + Assert.Contains("rollback failed", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(previous.Id, result.GatewayId); + Assert.NotNull(_registry.GetById(previous.Id)); + } + finally { registryLock?.Dispose(); } + } + + [Fact] + public async Task NativeHost_PreviousConnectionRestoreFailureRequiresAttentionWithoutNewCommit() + { + var previous = AddPreviousGateway(); + _manager.SetCurrentSnapshot(Connected(previous.Id)); + _manager.NextSnapshot = Failed(previous.Id, "rejected"); + _manager.RestoreSnapshot = Failed(previous.Id, "previous connection unavailable"); + var notifications = 0; + var host = new SetupNativeConnectionHost(CreateService(), _registry, NullLogger.Instance, + () => notifications++); + var result = await host.ConnectAsync(new("wss://new.example", SharedToken: "shared"), + CancellationToken.None); + Assert.False(result.Success); + Assert.False(result.GatewayCommitted); + Assert.True(result.RequiresAttention); + Assert.Equal(1, notifications); + Assert.Equal(previous.Id, _registry.ActiveGatewayId); + Assert.NotEmpty(_manager.ValidationPaths); + Assert.All(_manager.ValidationPaths, path => Assert.False(Directory.Exists(path))); + await host.DiscardCheckAsync(); + } + + [Fact] + public async Task NativeNext_ManagedGatewayRetainsLogicalIdAndLocalAiOwnership() + { + var previous = AddPreviousGateway() with + { + Url = "ws://127.0.0.1:18789", + IsLocal = true, + SetupManagedDistroName = "OpenClawGateway", + BrowserControlPort = 18791 + }; + _registry.AddOrUpdate(previous); + _registry.Save(); + CreateIdentity(previous.Id).StoreDeviceTokenForRole("operator", "paired"); + var sidecar = Path.Combine(_registry.GetIdentityDirectory(previous.Id), "retained-state.txt"); + File.WriteAllText(sidecar, "existing gateway state"); + _manager.NextSnapshot = Connected(previous.Id); + var result = await CreateService().ConnectAsync(new(previous.Url, null, null, null, + EditingGatewayId: previous.Id, NativeSetup: true)); + Assert.Equal(GatewayDirectConnectOutcome.Connected, result.Outcome); + var active = Assert.Single(_registry.GetAll()); + Assert.Equal(previous.Id, active.Id); + Assert.Equal(previous.Id, Assert.Single(LocalAiGatewayDistroResolver.FindOwners(_registry.GetAll())).Id); + Assert.Equal("OpenClawGateway", active.SetupManagedDistroName); + Assert.Equal(18791, active.BrowserControlPort); + Assert.Equal("existing gateway state", File.ReadAllText(sidecar)); + } + + [Fact] + public async Task NativeNext_ChangedRealmAddsGatewayWithoutRemovingPreviousRecordOrIdentity() + { + var previous = AddPreviousGateway(); + CreateIdentity(previous.Id).StoreDeviceTokenForRole("operator", "paired"); + var previousIdentity = File.ReadAllBytes(Path.Combine(_registry.GetIdentityDirectory(previous.Id), "device-key-ed25519.json")); + _manager.NextSnapshot = Connected("new"); + var result = await CreateService().ConnectAsync(new("wss://other.example", "shared", null, null, + EditingGatewayId: previous.Id, NativeSetup: true)); + Assert.Equal(GatewayDirectConnectOutcome.Connected, result.Outcome); + Assert.Equal(2, _registry.GetAll().Count); + Assert.NotEqual(previous.Id, _registry.ActiveGatewayId); + Assert.Equal(previous, _registry.GetById(previous.Id)); + Assert.Equal(previousIdentity, File.ReadAllBytes(Path.Combine(_registry.GetIdentityDirectory(previous.Id), "device-key-ed25519.json"))); + } + + [Fact] + public async Task NativeNext_NewerIdentityWriterWinsAndRollbackRequiresAttention() + { + var previous = AddPreviousGateway(); + CreateIdentity(previous.Id).StoreDeviceTokenForRole("operator", "paired"); + _manager.BeforeSnapshot = () => CreateIdentity(previous.Id).StoreDeviceTokenForRole("operator", "newer-token"); + _manager.NextSnapshot = Failed(previous.Id, "rejected"); + var notifications = 0; + var host = new SetupNativeConnectionHost(CreateService(), _registry, NullLogger.Instance, + () => notifications++); + var result = await host.ConnectAsync(new(previous.Url, EditingGatewayId: previous.Id), + CancellationToken.None); + Assert.False(result.Success); + Assert.False(result.GatewayCommitted); + Assert.True(result.RequiresAttention); + Assert.Equal(1, notifications); + Assert.Equal(previous.Id, _registry.ActiveGatewayId); + Assert.Equal("newer-token", DeviceIdentity.TryReadStoredDeviceToken(_registry.GetIdentityDirectory(previous.Id))); + await host.DiscardCheckAsync(); + } + + [Fact] + public async Task NativeNext_SameUrlDifferentSshRealmsRetainsSelectedGatewayIdAndCredentials() + { + var first = AddPreviousGateway() with + { + Url = "ws://127.0.0.1:18789", + SshTunnel = new("user", "first.example", 18789, 45678) + }; + var selected = first with + { + Id = "selected-gateway", + SshTunnel = new("user", "second.example", 18789, 45679) + }; + _registry.AddOrUpdate(first); + _registry.AddOrUpdate(selected); + _registry.Save(); + CreateIdentity(first.Id).StoreDeviceTokenForRole("operator", "first-token"); + CreateIdentity(selected.Id).StoreDeviceTokenForRole("operator", "selected-token"); + _manager.NextSnapshot = Connected(selected.Id); + var result = await CreateService().ConnectAsync(new(selected.Url, null, null, selected.SshTunnel, + EditingGatewayId: selected.Id, NativeSetup: true)); + Assert.Equal(GatewayDirectConnectOutcome.Connected, result.Outcome); + Assert.Equal("selected-token", _manager.ValidatedCredential?.Token); + Assert.Equal(selected.Id, _registry.ActiveGatewayId); + Assert.Equal(2, _registry.GetAll().Count); + Assert.NotNull(_registry.GetById(first.Id)); + } + + [Fact] + public async Task NativeHost_NoEditingId_CheckAndNextSelectSecondSavedSshRealm() + { + var first = AddPreviousGateway() with + { + Url = "wss://gateway.example", + SshTunnel = new("user", "first.example", 18789, 45678) + }; + var selected = first with + { + Id = "selected-gateway", + SshTunnel = new("user", "second.example", 18789, 45679) + }; + _registry.AddOrUpdate(first); + _registry.AddOrUpdate(selected); + _registry.Save(); + CreateIdentity(first.Id).StoreDeviceTokenForRole("operator", "first-token"); + var selectedIdentity = CreateIdentity(selected.Id); + selectedIdentity.StoreDeviceTokenForRole("operator", "selected-token"); + _manager.DuringValidation = () => _manager.ValidationResult = + _manager.ValidatedCredential is null + ? new(SetupCodeOutcome.ConnectionFailed, "missing credential") + : new(SetupCodeOutcome.Success); + _manager.NextSnapshot = Connected(selected.Id); + var before = CaptureFiles(); + var host = new SetupNativeConnectionHost(CreateService(), _registry, NullLogger.Instance); + var request = new OpenClaw.SetupEngine.SetupNativeConnectionRequest( + selected.Url, SshTunnel: selected.SshTunnel); + + try + { + var check = await host.CheckAsync(request, CancellationToken.None); + + Assert.True(check.Success, check.Error); + Assert.Equal("selected-token", _manager.ValidatedCredential?.Token); + Assert.Equal(before, CaptureFiles()); + Assert.Equal(first.Id, _registry.ActiveGatewayId); + Assert.Equal(0, _manager.DisconnectCount); + var connected = await host.ConnectAsync(request, CancellationToken.None); + Assert.True(connected.Success, connected.Error); + Assert.Equal(selected.Id, connected.GatewayId); + Assert.Equal(selected.Id, _registry.ActiveGatewayId); + Assert.Equal(2, _registry.GetAll().Count); + Assert.All(_manager.ValidationDeviceIds, id => Assert.Equal(selectedIdentity.DeviceId, id)); + Assert.Equal(2, _manager.ValidationCount); + } + finally { await host.DiscardCheckAsync(); } + } + + private string CaptureFiles() => string.Join("\n", + Directory.GetFiles(_tempDir, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal) + .Select(path => Path.GetRelativePath(_tempDir, path) + ":" + + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(path))))); + + [Fact] + public async Task NativeHost_CheckRetryNextRetainsPublicKeyAndEphemeralBootstrapUpgrade() + { + var host = new SetupNativeConnectionHost(CreateService(), _registry, NullLogger.Instance); + var code = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes( + """{"url":"wss://gateway.example","bootstrapToken":"bootstrap"}""")); + var request = new OpenClaw.SetupEngine.SetupNativeConnectionRequest(SetupCode: code); + _manager.IssueValidationToken = true; + _manager.NextSnapshot = Connected("new"); + + Assert.True((await host.CheckAsync(request, CancellationToken.None)).Success); + Assert.True((await host.CheckAsync(request, CancellationToken.None)).Success); + Assert.Empty(_registry.GetAll()); + Assert.Null(DeviceIdentity.TryReadStoredDeviceToken(_manager.ValidationPaths[0])); + var result = await host.ConnectAsync(request, CancellationToken.None); + Assert.True(result.Success); + Assert.Single(_manager.ValidationDeviceIds.Distinct()); + Assert.Equal(3, _manager.ValidationCount); + Assert.Equal(CredentialResolver.SourceDeviceToken, _manager.ValidatedCredential!.Source); + Assert.Equal("issued-token", DeviceIdentity.TryReadStoredDeviceToken(_registry.GetIdentityDirectory(result.GatewayId!))); + Assert.False(Directory.Exists(_manager.ValidationPaths[0])); + } + + [Fact] + public async Task NativeHost_CancelledCheckRetryKeepsStagedIdentityUntilClose() + { + var host = new SetupNativeConnectionHost(CreateService(), _registry, NullLogger.Instance); + var request = new OpenClaw.SetupEngine.SetupNativeConnectionRequest("wss://gateway.example", SharedToken: "shared"); + using var cancel = new CancellationTokenSource(); + _manager.DuringValidation = cancel.Cancel; + await Assert.ThrowsAnyAsync(() => host.CheckAsync(request, cancel.Token)); + Assert.True(Directory.Exists(_manager.ValidationPaths[0])); + _manager.DuringValidation = null; + Assert.True((await host.CheckAsync(request, CancellationToken.None)).Success); + Assert.Single(_manager.ValidationDeviceIds.Distinct()); + await host.DiscardCheckAsync(); + Assert.False(Directory.Exists(_manager.ValidationPaths[0])); + } + + [Fact] + public async Task NativeHost_DraftChangeAndCloseDiscardStagedIdentity() + { + var host = new SetupNativeConnectionHost(CreateService(), _registry, NullLogger.Instance); + var first = new OpenClaw.SetupEngine.SetupNativeConnectionRequest("wss://one.example", SharedToken: "token"); + Assert.True((await host.CheckAsync(first, CancellationToken.None)).Success); + var firstPath = _manager.ValidationPaths[0]; + Assert.True(Directory.Exists(firstPath)); + Assert.True((await host.CheckAsync(first with { GatewayUrl = "wss://two.example" }, CancellationToken.None)).Success); + Assert.False(Directory.Exists(firstPath)); + Assert.Equal(2, _manager.ValidationDeviceIds.Distinct().Count()); + await host.DiscardCheckAsync(); + Assert.All(_manager.ValidationPaths, path => Assert.False(Directory.Exists(path))); + Assert.Empty(_registry.GetAll()); + Assert.Equal(0, _manager.DisconnectCount); + } + private GatewayDirectConnectService CreateService() => new( _manager, @@ -511,6 +1134,34 @@ private sealed class FakeConnectionManager : IGatewayConnectionManager GatewayConnectionSnapshot.Idle; public GatewayConnectionSnapshot? RestoreSnapshot { get; set; } public Action? BeforeSnapshot { get; set; } + public Action? DuringValidation { get; set; } + public int ValidationCount { get; private set; } + public GatewayCredential? ValidatedCredential { get; private set; } + public SetupCodeResult ValidationResult { get; set; } = new(SetupCodeOutcome.Success); + public bool IssueValidationToken { get; set; } + public bool ValidationRequiresV2 { get; set; } + public List ValidationDeviceIds { get; } = []; + public List ValidationPaths { get; } = []; + public CancellationToken LastConnectCancellation { get; private set; } + + public Task ValidateConnectionAsync( + GatewayRecord candidate, GatewayValidationIdentity identity, CancellationToken cancellationToken = default) + { + ValidationCount++; + var device = new DeviceIdentity(identity.DirectoryPath); + device.Initialize(); + ValidationDeviceIds.Add(device.DeviceId); + ValidationPaths.Add(identity.DirectoryPath); + ValidatedCredential = identity.OperatorCredential is { } ephemeral + ? new GatewayCredential(ephemeral.Token, false, CredentialResolver.SourceDeviceToken) + : new CredentialResolver(DeviceIdentityFileReader.Instance).ResolveOperator(candidate, identity.DirectoryPath); + if (IssueValidationToken) + identity.CaptureToken(new("issued-token", ["operator.read"], "operator")); + identity.UseV2Signature = ValidationRequiresV2; + DuringValidation?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(ValidationResult); + } public event EventHandler? StateChanged; #pragma warning disable CS0067 @@ -534,6 +1185,13 @@ public Task ConnectAsync(string? gatewayId = null) return Task.CompletedTask; } + public Task ConnectAsync(string? gatewayId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + LastConnectCancellation = cancellationToken; + return ConnectAsync(gatewayId); + } + public void SetCurrentSnapshot(GatewayConnectionSnapshot snapshot) => CurrentSnapshot = snapshot; diff --git a/tests/OpenClaw.Tray.Tests/GatewayFixtureIsolationContractTests.cs b/tests/OpenClaw.Tray.Tests/GatewayFixtureIsolationContractTests.cs index c605f5eaf..168a1ccc4 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayFixtureIsolationContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayFixtureIsolationContractTests.cs @@ -102,10 +102,10 @@ public void AutoStart_FixtureRollbackAvoidsInstalledRegistrationReads() public void ToastBoundaries_DoNotInitializeInstalledRegistrationInFixtureMode() { Assert.Matches( - @"if \(!GatewayFixtureIsolation\.IsEnabled\)\s+ToastNotificationManagerCompat\.OnActivated \+= OnToastActivated;", + @"if \(!GatewayFixtureIsolation\.IsEnabled && !AppIdentity\.IsIsolated\)\s+ToastNotificationManagerCompat\.OnActivated \+= OnToastActivated;", ReadTraySource("App.xaml.cs")); Assert.Matches( - @"if \(!GatewayFixtureIsolation\.IsEnabled\)\s+ToastNotificationManagerCompat\.OnActivated -= OnToastActivated;", + @"if \(!GatewayFixtureIsolation\.IsEnabled && !AppIdentity\.IsIsolated\)\s+ToastNotificationManagerCompat\.OnActivated -= OnToastActivated;", ReadTraySource("App.AppShutdownCoordinator.cs")); var body = BodyAfter(ReadTraySource("Services", "ToastService.cs"), "public void ShowToast(ToastContentBuilder builder, string? toastTag = null, string? deviceId = null)"); diff --git a/tests/OpenClaw.Tray.Tests/IsolatedWindowsRegistrationTests.cs b/tests/OpenClaw.Tray.Tests/IsolatedWindowsRegistrationTests.cs new file mode 100644 index 000000000..99a719210 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/IsolatedWindowsRegistrationTests.cs @@ -0,0 +1,69 @@ +using System.Text.RegularExpressions; +using OpenClaw.TestSupport; +using OpenClawTray; + +namespace OpenClaw.Tray.Tests; + +[CollectionDefinition("Windows registration isolation", DisableParallelization = true)] +public sealed class WindowsRegistrationIsolationCollection; + +[Collection("Windows registration isolation")] +public sealed class IsolatedWindowsRegistrationTests +{ + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(@"C:\scenario\data", true)] + [InlineData(" ", true)] + public void IsolationMatchesTheTrayDataOverrideContract(string? dataDirectory, bool expected) + { + using var environment = new EnvironmentScope().Set("OPENCLAW_TRAY_DATA_DIR", dataDirectory); + Assert.Equal(expected, AppIdentity.IsIsolated); + } + + // These native entry points cannot be safely exercised against the developer's + // HKCU. Retire these guards when a disposable-user registration suite covers them. + [Theory] + [InlineData("App.xaml.cs", @"if \(!GatewayFixtureIsolation\.IsEnabled && !AppIdentity\.IsIsolated\)\s+ToastNotificationManagerCompat.OnActivated \+=")] + [InlineData("App.AppShutdownCoordinator.cs", @"if \(!GatewayFixtureIsolation\.IsEnabled && !AppIdentity\.IsIsolated\)\s+ToastNotificationManagerCompat.OnActivated -=")] + [InlineData(@"Services\ToastService.cs", @"if \(AppIdentity.IsIsolated\)\s*\{\s*Logger.Info\([^;]+;\s*return;\s*\}")] + [InlineData(@"Services\DeepLinkHandler.cs", @"if \(AppIdentity.IsIsolated\)\s*\{\s*Logger.Info\([^;]+;\s*return;\s*\}")] + public void NativeRegistrationAndNotificationBoundaries_AreGuarded(string path, string guard) + { + Assert.Matches(new Regex(guard), ReadTraySource(path)); + } + + [Theory] + [InlineData("void SetAutoStart")] + [InlineData("Task SetAutoStartAsync")] + public void StartupMutators_RejectBeforeAnyWindowsAccess(string signature) + { + var source = ReadTraySource(@"Services\AutoStartManager.cs"); + Assert.Contains($"public static {signature}(bool enable)\n {{\n ThrowIfFixtureMutation();\n EnsureRegistrationAllowed();", + source.Replace("\r\n", "\n")); + Assert.Matches(@"if \(AppIdentity.IsIsolated\)\s+throw new AutoStartRefusedException", source); + Assert.Matches(@"IsAutoStartEnabled\(\)\s*\{\s*if \(AppIdentity.IsIsolated\)\s+return false;", source); + } + + [Fact] + public void KeepaliveIsolationGate_PrecedesLegacyDiscoveryAndCleanup() + { + var source = ReadTraySource(@"Services\WslGatewayKeepAliveService.cs"); + Assert.Matches(@"if \(!WslKeepAlivePolicy.CanManageGateway\(activeRecord, AppIdentity.IsIsolated\)\)\s*\{[^}]+return;\s*\}", source); + Assert.True(source.IndexOf("WslKeepAlivePolicy.CanManageGateway", StringComparison.Ordinal) + < source.IndexOf("WslKeepAlivePolicy.ShouldStart", StringComparison.Ordinal)); + } + + private static string ReadTraySource(string path) + { + var root = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (string.IsNullOrEmpty(root)) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx"))) + directory = directory.Parent; + root = directory?.FullName ?? throw new DirectoryNotFoundException("Could not locate this test's source worktree."); + } + return File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", path)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/LocalAiOnboardingOwnershipTests.cs b/tests/OpenClaw.Tray.Tests/LocalAiOnboardingOwnershipTests.cs new file mode 100644 index 000000000..ba4cea51d --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/LocalAiOnboardingOwnershipTests.cs @@ -0,0 +1,105 @@ +namespace OpenClaw.Tray.Tests; + +public sealed class LocalAiOnboardingOwnershipTests +{ + private static string Read(string path) => + File.ReadAllText(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), path)); + + [Fact] + public void AiPage_UsesTypedSameWindowHostAndNeverOwnsRuntimeOrGatewayRegistration() + { + var page = Read(@"src\OpenClaw.SetupEngine.UI\Pages\AiSetupPage.xaml.cs"); + var window = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + Assert.Contains("ISetupLocalAiHost? LocalAiHost", page); + Assert.Contains("ReviewLocalAi: ReviewLocalAiAsync", window); + Assert.Contains("await _localAiHost.RevalidateReviewAsync(selection, _lifetimeCts.Token)", window); + Assert.Contains("_localObservation = new(host)", page); + Assert.DoesNotContain("ShowLocalAiSetupAsync", page + window); + Assert.DoesNotContain("new SetupWindow(", page + window); + Assert.DoesNotContain("new LlamaServerRuntimeService", page + window); + Assert.DoesNotContain("new LocalAiManifestStore", page); + Assert.DoesNotContain("Process.Start", page); + } + + [Fact] + public void Observation_DoesNotCallMutatingRuntimeRefreshOrAnySetupAction() + { + var source = Read(@"src\OpenClaw.Tray.WinUI\Services\SetupLocalAiHost.cs"); + var observation = source[source.IndexOf("public async Task ObserveAsync", StringComparison.Ordinal).. + source.IndexOf("public async Task RevalidateReviewAsync", StringComparison.Ordinal)]; + Assert.DoesNotContain("RefreshAsync", observation); + Assert.DoesNotContain("EnsureStartedAsync", observation); + Assert.DoesNotContain("PublishAsync", observation); + Assert.DoesNotContain("SaveAsync", observation); + Assert.Contains("getRuntime()?.Snapshot", observation); + var reconciler = Read(@"src\OpenClaw.SetupEngine\LocalAiInstallReconciler.cs"); + var inspection = reconciler[reconciler.IndexOf("public async Task InspectAsync", StringComparison.Ordinal).. + reconciler.IndexOf("private static LlamaRuntimeInstallResult CreateRuntimeInstall", StringComparison.Ordinal)]; + Assert.DoesNotContain("Migrate", inspection); + Assert.DoesNotContain("SaveAsync", inspection); + } + + [Fact] + public void SetupWindow_DrainsDepartedAiPagesAndCancelledPipelineBeforeUnlock() + { + var window = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + var progress = Read(@"src\OpenClaw.SetupEngine.UI\Pages\ProgressPage.xaml.cs"); + Assert.Contains("RootFrame.Content is AiSetupPage aiPage", window); + Assert.Contains("Task.WhenAll(_aiPageCleanupTask, aiPage.CloseAsync())", window); + Assert.True(window.IndexOf("await _aiPageCleanupTask", StringComparison.Ordinal) < + window.IndexOf("_setupLock?.Dispose()", StringComparison.Ordinal)); + Assert.Contains("Page, IAsyncDisposable", progress); + Assert.Contains("await _pipelineTask", progress); + Assert.Contains("if (_closed || _window?.IsClosed == true)", progress); + } + + [Fact] + public void NormalGatewayReview_DoesNotOfferACompetingLocalAiSelector() + { + var source = Read(@"src\OpenClaw.SetupEngine.UI\Pages\GatewaySetupPage.xaml.cs"); + Assert.Contains("LocalAiCard.Visibility = _draft.Config.LocalAi.Enabled || _window.IsLocalAiRecovery", source); + var xaml = Read(@"src\OpenClaw.SetupEngine.UI\Pages\AiSetupPage.xaml"); + Assert.Contains("", xaml); + Assert.DoesNotContain("", xaml); + Assert.Contains("Click=\"ChoiceAction_Click\"", xaml); + var page = Read(@"src\OpenClaw.SetupEngine.UI\Pages\AiSetupPage.xaml.cs"); + var selection = page[page.IndexOf("private void Choice_Changed", StringComparison.Ordinal).. + page.IndexOf("private void ChoiceAction_Click", StringComparison.Ordinal)]; + Assert.DoesNotContain("StartSelectedAsync", selection); + Assert.DoesNotContain("ContinueAsync", selection); + } + + [Fact] + public void AiHeaderBand_UsesSharedCenteredArtworkAndDoesNotPromiseAContinueGate() + { + var document = System.Xml.Linq.XDocument.Parse(Read(@"src\OpenClaw.SetupEngine.UI\Pages\AiSetupPage.xaml")); + System.Xml.Linq.XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + foreach (var name in new[] { "TitleText", "StatusText" }) + { + var text = Assert.Single(document.Descendants(), element => (string?)element.Attribute(x + "Name") == name); + Assert.Equal("Wrap", (string?)text.Attribute("TextWrapping")); + Assert.Equal("Center", (string?)text.Attribute("TextAlignment")); + Assert.Contains(text.Ancestors(), element => (string?)element.Attribute(x + "Name") == "AiHeader"); + } + var header = Assert.Single(document.Descendants(), element => (string?)element.Attribute(x + "Name") == "AiHeader"); + Assert.Equal("StackPanel", header.Name.LocalName); + Assert.Null(header.Attribute("Height")); + var mascot = Assert.Single(header.Descendants(), element => element.Name.LocalName == "OnboardingMascot"); + Assert.Equal("Center", (string?)mascot.Attribute("HorizontalAlignment")); + Assert.Null(mascot.Attribute("Width")); + Assert.Null(mascot.Attribute("Height")); + foreach (var locale in new[] { "en-us", "fr-fr", "nl-nl", "pt-br", "zh-cn", "zh-tw" }) + { + var resources = System.Xml.Linq.XDocument.Parse(Read($@"src\OpenClaw.Tray.WinUI\Strings\{locale}\Resources.resw")); + var text = Assert.Single(resources.Descendants("data"), + element => (string?)element.Attribute("name") == "Onboarding_AiSetup_Choose").Element("value")!.Value; + Assert.False(string.IsNullOrWhiteSpace(text)); + Assert.DoesNotContain("—", text); + } + Assert.Contains("Choose a model or connect an AI provider.", + Read(@"src\OpenClaw.Tray.WinUI\Strings\en-us\Resources.resw")); + Assert.DoesNotContain("Nothing is tested or changed until you continue.", + Read(@"src\OpenClaw.Tray.WinUI\Strings\en-us\Resources.resw")); + } +} diff --git a/tests/OpenClaw.Tray.Tests/LocalAiSetupAvailabilityCoordinatorTests.cs b/tests/OpenClaw.Tray.Tests/LocalAiSetupAvailabilityCoordinatorTests.cs index 155e87cc0..b3e78bd6b 100644 --- a/tests/OpenClaw.Tray.Tests/LocalAiSetupAvailabilityCoordinatorTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalAiSetupAvailabilityCoordinatorTests.cs @@ -93,6 +93,21 @@ public void ConfirmedUnsupported_RemainsDefinitiveAndNotRetryable() Assert.Equal("No qualified NVIDIA GPU was detected.", unsupported.Reason); } + [Fact] + public void StartingANewProbeClearsAvailableStateAndRejectsOldSuccess() + { + var coordinator = new LocalAiSetupAvailabilityCoordinator(); + var old = coordinator.StartProbe(); + Assert.True(coordinator.TryApplyAvailable(old.Generation, out _)); + var current = coordinator.StartProbe(); + Assert.False(coordinator.Current.IsAvailable); + Assert.True(coordinator.Current.IsChecking); + Assert.False(coordinator.TryApplyAvailable(old.Generation, out _)); + Assert.True(coordinator.TryApplyProbeFailure(current.Generation, "Unknown", out var unknown)); + Assert.True(unknown.IsUnknown); + Assert.False(unknown.IsAvailable); + } + [Fact] public void RecheckAfterConfirmedUnsupported_DoesNotStartProbe() { diff --git a/tests/OpenClaw.Tray.Tests/LocalAiSetupUxContractTests.cs b/tests/OpenClaw.Tray.Tests/LocalAiSetupUxContractTests.cs index 8b5642033..5d5e504bb 100644 --- a/tests/OpenClaw.Tray.Tests/LocalAiSetupUxContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalAiSetupUxContractTests.cs @@ -31,10 +31,16 @@ public void LocalAiSetupProgressAndCompletion_DoNotPromiseInferenceVerification( string complete = File.ReadAllText(Path.Combine(pages, "CompletePage.xaml.cs")); string completeXaml = File.ReadAllText(Path.Combine(pages, "CompletePage.xaml")); - Assert.Contains("Prepare Local AI router", progress); - Assert.DoesNotContain("capture-local-ai-gpu-baseline", progress); - Assert.DoesNotContain("verify-local-ai-inference", progress); - Assert.DoesNotContain("verify-local-ai-gpu-load", progress); + Assert.Contains("SetupInstallationProgress", progress); + // Recovery can show real inference steps. Ordinary installation must not schedule them. + var factory = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "SetupPipeline.cs")); + var defaults = factory[factory.IndexOf("public static List BuildDefaultSteps()", StringComparison.Ordinal).. + factory.IndexOf("public sealed class SetupPipeline", StringComparison.Ordinal)]; + Assert.DoesNotContain("new CaptureLocalAiGpuBaselineStep", defaults); + Assert.DoesNotContain("new VerifyLocalAiInferenceStep", defaults); + Assert.DoesNotContain("new VerifyLocalAiGpuLoadStep", defaults); + Assert.Contains("OnboardingFlowPolicy.BuildInstallationSteps(localAiRecoveryOnly)", progress); + Assert.DoesNotContain("Local AI verified", progress); Assert.Contains("Local AI installed", complete); Assert.Contains("Local AI installed", completeXaml); Assert.Contains("The model loads on the first request.", complete); @@ -61,7 +67,7 @@ public void WelcomePage_ShowsLocalAiCompatibilityDetails() Assert.Contains("WelcomeLocalAiAvailable", xaml); Assert.Contains("Glyph=\"\"", xaml); Assert.Contains("x:Uid=\"Onboarding_Welcome_LocalAiAvailableBadge\"", xaml); - Assert.Contains("Local AI supported", xaml); + Assert.Contains("Your PC supports Local AI", xaml); Assert.Contains("AutomationProperties.AccessibilityView=\"Raw\"", xaml); AssertInOrder( xaml, @@ -112,7 +118,7 @@ public void WelcomePage_LocalAiCompatibilityPanel_GatesOnDeviceEligibilityNotSel string root = TestRepositoryPaths.GetRepositoryRoot(); string source = File.ReadAllText(Path.Combine( root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WelcomePage.xaml.cs")); - string method = ExtractMethod(source, "private async Task DetectLocalAiAvailabilityAsync"); + string method = ExtractMethod(source, "DetectLocalAiAvailabilityAsync"); Assert.Contains("LocalInferenceEligibility.Evaluate(hardware);", method); Assert.DoesNotContain("config.LocalAi.SelectedModelId", method); @@ -126,16 +132,17 @@ public void CapabilitiesReview_SeparatesReasonActionFromDisabledOptions() root, "src", "OpenClaw.SetupEngine.UI", - "Pages", - "CapabilitiesPage.xaml")); + "Controls", + "LocalAiSetupControl.xaml")); string source = File.ReadAllText(Path.Combine( root, "src", "OpenClaw.SetupEngine.UI", - "Pages", - "CapabilitiesPage.xaml.cs")); + "Controls", + "LocalAiSetupControl.xaml.cs")); string infoBar = ExtractElement(xaml, "LocalAiUnavailablePanel", ""); - string networkingInfoBar = ExtractElement(xaml, "LocalAiNetworkingConsentPanel", ""); + string networkingSource = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "GatewaySetupDetailPage.xaml.cs")); + string networkingXaml = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "GatewaySetupDetailPage.xaml")); Assert.Contains("Title=\"Local AI is not available\"", xaml); Assert.Contains("Severity=\"Informational\"", xaml); @@ -155,8 +162,8 @@ public void CapabilitiesReview_SeparatesReasonActionFromDisabledOptions() "x:Name=\"LocalAiUnavailableDetailsButton\"", "x:Name=\"LocalAiAvailabilityRecoveryPanel\"", "x:Name=\"LocalAiRecheckAvailabilityButton\"", - "x:Name=\"LocalAiInstallReviewCard\"", - "x:Name=\"LocalAiOptionContent\""); + "x:Name=\"LocalAiOptionContent\"", + "x:Name=\"LocalAiInstallReviewCard\""); Assert.Contains("LocalAiSetupAvailabilityCoordinator", source); Assert.Contains("TryApplyProbeFailure", source); Assert.Contains("ShowLocalAiProbeUnknown", source); @@ -183,22 +190,19 @@ public void CapabilitiesReview_SeparatesReasonActionFromDisabledOptions() Assert.Contains("LocalAiOptionContent.Opacity = isAvailable ? 1 : 0.55", source); Assert.Contains("LocalAiToggle.IsEnabled = isAvailable", source); Assert.Contains("LocalAiModelSelector.IsEnabled = isAvailable", source); - Assert.Contains("LocalAiNetworkingConsentCheckBox.IsEnabled = isAvailable", source); - Assert.Contains("Title=\"WSL networking change required\"", networkingInfoBar); + Assert.Contains("Title=\"WSL networking change required\"", xaml); // Enabling Local AI must never imply consent on its own: the user has to // affirmatively accept the global .wslconfig rewrite and one-time WSL shutdown. - Assert.Contains(" @@ -266,7 +265,7 @@ public void CapabilitiesReview_GatesOnDeviceEligibilityAndReconcilesStaleSelecte { string root = TestRepositoryPaths.GetRepositoryRoot(); string source = File.ReadAllText(Path.Combine( - root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); + root, "src", "OpenClaw.SetupEngine.UI", "Controls", "LocalAiSetupControl.xaml.cs")); // "InitializeLocalAiReviewAsync" also appears at its earlier call site // (AsyncEventHandlerGuard.Run(() => InitializeLocalAiReviewAsync(...))), so search for // the method's declaration specifically; otherwise ExtractMethod would grab the body of @@ -300,8 +299,8 @@ public void CapabilitiesReview_RecheckAffordance_HasLocalizedResourceKeys() root, "src", "OpenClaw.SetupEngine.UI", - "Pages", - "CapabilitiesPage.xaml")); + "Controls", + "LocalAiSetupControl.xaml")); Assert.Contains("x:Uid=\"Onboarding_LocalAi_RecheckAvailabilityButton\"", xaml); @@ -329,9 +328,9 @@ public void CapabilitiesReview_UnavailableAndProbeErrorCopy_IsLocalizedInEverySu { string root = TestRepositoryPaths.GetRepositoryRoot(); string xaml = File.ReadAllText(Path.Combine( - root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml")); + root, "src", "OpenClaw.SetupEngine.UI", "Controls", "LocalAiSetupControl.xaml")); string source = File.ReadAllText(Path.Combine( - root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); + root, "src", "OpenClaw.SetupEngine.UI", "Controls", "LocalAiSetupControl.xaml.cs")); Assert.Contains("x:Uid=\"Onboarding_LocalAi_UnavailableDetailsButton\"", xaml); @@ -347,8 +346,6 @@ public void CapabilitiesReview_UnavailableAndProbeErrorCopy_IsLocalizedInEverySu "SetupLocalization.GetString(\"Onboarding_LocalAi_UnavailableMessage\")", "SetupLocalization.GetString(\"Onboarding_LocalAi_UnavailableHelpText\")", "SetupLocalization.GetString(\"Onboarding_LocalAi_ProbeFailureReason\")", - "SetupLocalization.GetString(\"Onboarding_LocalAi_UnavailableDetailsDialogTitle\")", - "SetupLocalization.GetString(\"Onboarding_LocalAi_UnavailableDetailsDialogClose\")", "SetupLocalization.GetString(\"Onboarding_LocalAi_WslConfigReadFailureReason\")", ]; foreach (string call in setupResourceCalls) @@ -401,7 +398,7 @@ public void LocalAiUnavailableReason_IsLocaleNeutralInSharedAndLocalizedInBothUi string diagnostics = File.ReadAllText(Path.Combine( root, "src", "OpenClaw.Shared", "Inference", "Catalog", "LocalInferenceEligibilityDiagnostics.cs")); string setupSource = File.ReadAllText(Path.Combine( - root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); + root, "src", "OpenClaw.SetupEngine.UI", "Controls", "LocalAiSetupControl.xaml.cs")); string viewModelSource = File.ReadAllText(Path.Combine( root, "src", "OpenClaw.Tray.WinUI", "Presentation", "LocalAiPageViewModel.cs")); string hubPageSource = File.ReadAllText(Path.Combine( @@ -574,7 +571,7 @@ public void CapabilitiesReview_PendingAvailabilityIsEscapedByToggleNotByBypassin { string root = TestRepositoryPaths.GetRepositoryRoot(); string source = File.ReadAllText(Path.Combine( - root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); + root, "src", "OpenClaw.SetupEngine.UI", "Controls", "LocalAiSetupControl.xaml.cs")); // SetLocalAiOptionAvailability(isAvailable: false) sets LocalAiOptionContent's // IsHitTestVisible to false, which blocks pointer input for its whole subtree regardless @@ -598,16 +595,12 @@ public void CapabilitiesReview_PendingAvailabilityIsEscapedByToggleNotByBypassin "SetLocalAiOptionAvailability(", "RestoreLocalAiToggleAsPendingStateEscapeHatch();"); - // Continue's gate must not special-case pending availability: it only ever short-circuits - // on the toggle being off, or requires a fully resolved, eligible, consented state. - string primaryButtonMethod = ExtractMethod(source, "private void UpdatePrimaryButtonState"); - Assert.DoesNotContain("_localAiAvailability", primaryButtonMethod); - Assert.Contains( - "(!_localAiRecoveryOnly && LocalAiToggle.IsOn != true) ||", - primaryButtonMethod); - Assert.Contains( - "(LocalAiToggle.IsOn == true &&\n _localAiSelectionEligible &&\n (!_localAiNetworkingConsentRequired || LocalAiNetworkingConsentCheckBox.IsChecked == true));", - primaryButtonMethod.Replace("\r\n", "\n")); + string draft = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "SetupAccessDraft.cs")); + Assert.Contains("CanInstall(bool localAiRecovery = false) => GetInstallRequirements(localAiRecovery).Count == 0", draft); + Assert.Contains("Config.LocalAi.Enabled && !LocalAiReady", draft); + Assert.Contains("Config.LocalAi.Enabled && LocalAiNetworkingConsentRequired && !Config.LocalAi.WslMirroredNetworkingConsent", draft); + Assert.Contains("localAiRecovery && !Config.LocalAi.Enabled", draft); + Assert.DoesNotContain("LocalAi.Enabled = false", source); } /// diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index 20447079a..def5b609e 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -27,6 +27,17 @@ public class LocalizationValidationTests private static readonly HashSet InvariantOrDeferredResourceKeys = new(StringComparer.Ordinal) { + // Canonical setup product/transport names, not untranslated prose. + "Onboarding_V2_NodeMode.Header", + "Onboarding_V2_NodeModeToggle.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name", + "Onboarding_V2_CliCard.Header", + "Onboarding_V2_LocalAiReview.Header", + "Onboarding_V2_LocalAiCard.Header", + "Onboarding_V2_DetailLocalAi", + "Onboarding_V2_TailscaleReview.Header", + "Onboarding_V4_TailscaleDisclosure.Header", + "Onboarding_V2_TailscaleCard.Header", + "Onboarding_V2_DetailTailscale", "CanvasWindow_TextBlock_31.Text", "CanvasWindow_winexWindowEx_2.Title", "ChatWindow_winexWindowEx_2.Title", @@ -44,6 +55,8 @@ public class LocalizationValidationTests "VoiceOverlayWindow_winexWindowEx_2.Title", // Brand name — identical across all locales. "ConnectionPage_TopologyTailscale", + // Canonical product surface name, shared with the existing Dashboard action. + "ChatDashboardButton.Content", // Native engine executable/product name. Keep the exact llama-server // spelling in every locale so it matches diagnostics and process names. "LocalAiPage_EngineHeading.Text", @@ -359,6 +372,7 @@ public class LocalizationValidationTests private static readonly string[] RequiredLocalizedAccessibilityKeys = [ + "Onboarding_Ready_Chat.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name", "SandboxPage_UnavailablePrimaryButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name", "SandboxPage_PresetLockedButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name", "SandboxPage_PresetBalancedButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name", @@ -1028,6 +1042,20 @@ public void Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales() if (identicalLocales.Count != localeResw.Count) { + // These ordinary words are spelled identically in these languages. + string[] naturalLoanwordLocales = key switch + { + "Onboarding_V2_LocalAiModel.Header" => ["nl-nl"], + "Onboarding_V2_ProfileReadOnlyTitle.Text" => ["fr-fr"], + "Onboarding_V2_ProfileFullTitle.Text" => ["nl-nl"], + "Onboarding_V2_Microphone" or "Onboarding_V2_Notifications" => ["fr-fr"], + "Onboarding_V2_Tokens" => ["nl-nl", "pt-br"], + _ => [], + }; + if (naturalLoanwordLocales.Length > 0 && + identicalLocales.Order().SequenceEqual(naturalLoanwordLocales.Order())) + continue; + // Allow Latin-script loanwords (e.g. "OK") to be identical // across en-us/fr-fr/nl-nl while still being translated for // non-Latin-script locales (zh-CN, zh-TW). diff --git a/tests/OpenClaw.Tray.Tests/NativeCompletionPresentationTests.cs b/tests/OpenClaw.Tray.Tests/NativeCompletionPresentationTests.cs new file mode 100644 index 000000000..b78b8e9aa --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/NativeCompletionPresentationTests.cs @@ -0,0 +1,150 @@ +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +public sealed class NativeCompletionPresentationTests +{ + [Fact] + public void CandidateCreationReturnsItsBaselineWithoutAnUnlockedPostCopyRead() + { + var source = Read(@"src\OpenClaw.Tray.WinUI\Services\GatewayDirectConnectService.cs"); + Assert.Contains("candidateIdentityCreation = validationIdentity.CopyTo(", source); + Assert.DoesNotContain("candidateIdentityHash", source); + Assert.DoesNotContain("File.ReadAllBytes", source); + var identity = Read(@"src\OpenClaw.Connection\GatewayValidationIdentity.cs"); + Assert.Contains("return DeviceIdentity.ReplaceValidatedIdentity(destinationDirectory, null, CreateCommittedJson())", identity); + var registry = Read(@"src\OpenClaw.Connection\GatewayRegistry.cs"); + Assert.Contains("return DeviceIdentity.RemoveCreatedIdentity(creation)", registry); + } + + private static string Read(string path) => File.ReadAllText(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), path)); + + [Fact] + public void ChooserHasThreeNativeActions_RecommendationAndErrorOnlyRecovery_NoFooterButtons() + { + var page = XDocument.Parse(Read(@"src\OpenClaw.SetupEngine.UI\Pages\AiReadyPage.xaml")); + var actions = page.Descendants().Where(element => element.Name.LocalName == "SettingsCard").ToArray(); + Assert.Equal(["Chat", "Channels", "Skills"], actions.Select(element => element.Attribute("Tag")?.Value)); + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + Assert.DoesNotContain(page.Descendants(), element => (string?)element.Attribute(x + "Name") is "SkipButton" or "ReturnButton"); + var recovery = Assert.Single(page.Descendants(), element => (string?)element.Attribute(x + "Name") == "RecoveryButton"); + Assert.Contains(recovery.Ancestors(), element => element.Name.LocalName == "InfoBar"); + var badge = Assert.Single(actions[0].Descendants(), element => (string?)element.Attribute(x + "Name") == "RecommendedBadge"); + Assert.Equal("Recommended", (string?)badge.Attribute("Text")); + Assert.Equal("{ThemeResource AccentTextFillColorPrimaryBrush}", (string?)badge.Attribute("Foreground")); + Assert.Contains(page.Descendants(), element => element.Name.LocalName == "SetupProgressIndicator"); + Assert.DoesNotContain(page.Descendants(), element => (string?)element.Attribute(x + "Name") == "FinishButton"); + var source = Read(@"src\OpenClaw.SetupEngine.UI\Pages\AiReadyPage.xaml.cs"); + Assert.Contains("args.Owner.OwnsReadyChoice(args.Coordinator)", source); + Assert.Contains("args.Coordinator.SelectAsync(destination)", source); + Assert.DoesNotContain("Launcher.LaunchUri", source); + } + + [Fact] + public void NativeConnectionProgress_HasItsOwnRowAboveWrappableActions() + { + var page = XDocument.Parse(Read(@"src\OpenClaw.SetupEngine.UI\Pages\SetupNativeConnectionPage.xaml")); + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var progress = Assert.Single(page.Descendants(), item => (string?)item.Attribute(x + "Name") == "FlowProgress"); + Assert.Equal("Auto,Auto", (string?)progress.Parent!.Attribute("RowDefinitions")); + foreach (var button in progress.Parent.Elements().Where(item => item.Name.LocalName == "Button")) + { + Assert.Equal("1", (string?)button.Attribute("Grid.Row")); + Assert.Equal("0", (string?)button.Attribute("MinWidth")); + Assert.Equal("{StaticResource WrappedFooterAction}", (string?)button.Attribute("ContentTemplate")); + } + } + + [Fact] + public void CompletionActivation_HasNoSupersededBrowserFallback() + { + Assert.Contains("OpenNativeSetupCompletion(r.Handle ?? \"invalid\")", + Read(@"src\OpenClaw.Tray.WinUI\App.ActivationRouter.cs")); + var app = Read(@"src\OpenClaw.Tray.WinUI\App.xaml.cs"); + Assert.DoesNotContain("handoffHandle", app); + Assert.Contains("launchTarget = store.Issue(nativeCompletion)", app); + var dashboard = Read(@"src\OpenClaw.Tray.WinUI\Services\GatewayDashboardLauncher.cs"); + Assert.DoesNotContain("GatewayAiSetupCompletion", dashboard); + Assert.DoesNotContain("OpenPendingAsync", dashboard); + Assert.DoesNotContain("Issue(GatewayAiSetupCompletion", Read(@"src\OpenClaw.Tray.WinUI\Services\SetupDashboardHandoffStore.cs")); + } + + [Fact] + public void NativeRestartWaitsBeforeServicesAndNeverFallsThroughToForwardingOnFailure() + { + var app = Read(@"src\OpenClaw.Tray.WinUI\App.xaml.cs"); + Assert.Contains("NativeRestartAdmission.Acquire(_postSetupLaunch!", app); + Assert.Contains("if (nativeRestart) { Exit(); return; }", app); + var admission = app.IndexOf("NativeRestartAdmission.Acquire(_postSetupLaunch!", StringComparison.Ordinal); + var forwarding = app.IndexOf("await _activationRouter.ForwardLaunchToPrimaryAsync", admission, StringComparison.Ordinal); + Assert.True(admission < forwarding); + Assert.True(forwarding < app.IndexOf("_settings = new SettingsManager();", forwarding, StringComparison.Ordinal)); + Assert.Contains("_postSetupLaunch = _nativeRestartRecovery.Read()", app); + } + + [Fact] + public void SkillsEntryIsBoundReadOnlyAndWaitedBeforeReceiptConsumption() + { + var source = Read(@"src\OpenClaw.Tray.WinUI\Pages\SkillsPage.xaml.cs"); + Assert.Contains("_nativeSetupRequest = e.Parameter as SetupNativeNavigationRequest", source); + Assert.Contains("SetupNativeSkills.LoadAsync(request, RequireNativeClient, ct)", source); + Assert.Contains("if (_nativeSetupRequest is not null) return;", source); + Assert.Contains("CurrentAgentId != request.Completion.Verification.AgentId", source); + Assert.Contains("AgentFilterCombo.IsEnabled = false", source); + Assert.DoesNotContain("InstallSkillAsync", source); + Assert.Contains("await skills.WaitForNativeSetupAsync(request, ct)", + Read(@"src\OpenClaw.Tray.WinUI\Windows\HubWindow.xaml.cs")); + } + + [Fact] + public void IsolatedHostDisablesStartupWithoutWeakeningRegistrationGuard() + { + Assert.Contains("startupRegistrationAllowed: !AppIdentity.IsIsolated", + Read(@"src\OpenClaw.Tray.WinUI\Services\WindowManager.cs")); + var window = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + Assert.Contains("ShowStartupPreference => _startupRegistrationAllowed &&", window); + Assert.Contains("get => _startupRegistrationAllowed && _autoStartAfterSetup", window); + Assert.Contains("enableAutoStart &= _startupRegistrationAllowed", window); + Assert.Contains("ShowStartupPreference: ShowStartupPreference", window); + Assert.Contains("if (_startupRegistrationAllowed && _persistStartupPreferenceOnComplete)", window); + var app = Read(@"src\OpenClaw.Tray.WinUI\App.xaml.cs"); + Assert.Contains("AutoStartManager.ApplySetupPreferenceAsync", app); + Assert.Contains("AutoStartSettingsApplier.ApplyExplicitAsync", app); + Assert.Contains("e.ApplyStartupPreference ? e.EnableAutoStart : null", app); + Assert.DoesNotContain("if (enabled) await AutoStartManager.SetAutoStartAsync(true)", app); + } + + [Fact] + public void HostedSetupUsesSettingsOwnerAndClassicStartupFailureIsSeparateFromRestartFailure() + { + var window = Read(@"src\OpenClaw.Tray.WinUI\Services\WindowManager.cs"); + Assert.Contains("new SetupSettingsWriter", window); + Assert.Contains("persistChoices:", window); + var setup = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + Assert.Contains("_persistChoices(_config.Settings", setup); + var app = Read(@"src\OpenClaw.Tray.WinUI\App.xaml.cs"); + var start = app.IndexOf("private async Task RestartAfterSetupAsync", StringComparison.Ordinal); + var end = app.IndexOf("private async Task ShowSetupRestartErrorAsync", start, StringComparison.Ordinal); + var restart = app[start..end]; + Assert.True(restart.IndexOf("SetupStartupPolicy.ApplyClassicPreferenceAsync", StringComparison.Ordinal) < + restart.IndexOf("Process.Start(psi)", StringComparison.Ordinal)); + Assert.Contains("Onboarding_StartupWarning_Message", restart); + } + + [Fact] + public void NativeChannelFocusUsesBoundFreshMetadataAndNeverStartsAuthentication() + { + var source = Read(@"src\OpenClaw.Tray.WinUI\Pages\ChannelsPage.xaml.cs"); + var focus = source[source.IndexOf("private void ApplyNativeChannelFocus", StringComparison.Ordinal).. + source.IndexOf("private Expander BuildExpander", StringComparison.Ordinal)]; + Assert.Contains("ReferenceEquals(snapshot, _nativeSnapshot)", focus); + Assert.Contains("SetupChannelFocusPolicy.GetAvailability", focus); + Assert.Contains("row.IsExpanded = true", focus); + Assert.Contains("row.Loaded += loaded", focus); + Assert.DoesNotContain("StartLinkingAsync", focus); + Assert.DoesNotContain("StartChannelAsync", focus); + Assert.DoesNotContain("SaveAsync", focus); + Assert.Contains("useBuiltInFallback: _nativeSetupRequest is null", source); + Assert.Contains("!ReferenceEquals(client, BoundClient)", source); + } +} diff --git a/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs b/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs index 1674195a8..124aa8096 100644 --- a/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs @@ -91,7 +91,7 @@ public void NativeSetup_RetryRechecksDraftPortAndRendersAggregateLaunchFailure() var failure = source[catchStart..finallyStart]; Assert.Contains("or AggregateException", failure); Assert.Contains("Trace.TraceError", failure); - Assert.Contains("SetStatus(StepStatus.Failed)", failure); + Assert.Contains("Apply(SetupInstallationStatus.Failed)", failure); Assert.Contains("SetupLogger.Sanitize(ex.Message)", failure); Assert.Contains("RetryButton.Visibility = Visibility.Visible", failure); } @@ -102,7 +102,8 @@ public void NativeSetup_IsDistinctFromWslAndRechecksCapabilityWithoutIsolationWa var root = TestRepositoryPaths.GetRepositoryRoot(); var pages = Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages"); var welcome = File.ReadAllText(Path.Combine(pages, "WelcomePage.xaml.cs")); - Assert.Contains("NavigateToNativeCapabilities()", welcome); + Assert.Contains("SelectGatewayRoute(SetupGatewayRoute.Native)", welcome); + Assert.Contains("NavigateToCapabilities()", welcome); var xaml = File.ReadAllText(Path.Combine(pages, "NativeGatewaySetupPage.xaml")); Assert.DoesNotContain("Not isolated", xaml); Assert.DoesNotContain("ConsentCheck", xaml); @@ -116,9 +117,9 @@ public void NativeSetup_IsDistinctFromWslAndRechecksCapabilityWithoutIsolationWa Assert.Contains("eligibility != NativeGatewayEligibility.Available", source); Assert.Contains("Loaded += (_, _) => StartOperation()", source); Assert.Contains("await NativeGatewayPackageAcquisition.EnsureAsync(", source); - Assert.Contains("new StepRow(", source); - Assert.Contains("StepStatus.Done", source); - Assert.Contains("StepStatus.Failed", source); + Assert.Contains("new SetupPhaseStatus()", source); + Assert.Contains("SetupInstallationStatus.Complete", source); + Assert.Contains("SetupInstallationStatus.Failed", source); Assert.Contains("NativeGatewaySetupService", source); Assert.Contains("_installer.InstallAsync(new CommandRunner(logger), cancellationToken)", source); Assert.DoesNotContain("LaunchUriAsync", source); @@ -155,15 +156,15 @@ public void Welcome_RecommendsProbedNativeFirstWithWslAlwaysVisibleSecond() var native = primary.Elements().First(); Assert.Equal("False", (string?)native.Attribute("IsEnabled")); Assert.Contains(native.Descendants(), element => - (string?)element.Attribute(names + "Name") == "NativeRecommendedBadge"); + (string?)element.Attribute(names + "Uid") == "Onboarding_Native_Recommended"); Assert.Empty(document.Descendants(xaml + "Expander")); Assert.DoesNotContain(document.Descendants(), element => (string?)element.Attribute("Content") == "Check again"); var wsl = primary.Elements(xaml + "ListViewItem").ElementAt(1); Assert.Null(wsl.Attribute("Visibility")); Assert.Null(wsl.Attribute("IsEnabled")); - Assert.DoesNotContain(wsl.Descendants(), element => - (string?)element.Attribute("Text") == "Recommended"); + Assert.Contains(wsl.Descendants(), element => + (string?)element.Attribute(names + "Name") == "WslRecommendedBadge"); var source = File.ReadAllText(Path.Combine(pages, "WelcomePage.xaml.cs")); Assert.Contains("window.GetNativeGatewayEligibilityAsync()", source); Assert.Contains("NativeGatewaySetupEligibility.ResolveSelection", source); @@ -172,6 +173,7 @@ public void Welcome_RecommendsProbedNativeFirstWithWslAlwaysVisibleSecond() Assert.Contains("ms-settings:windowsupdate", source); Assert.Contains("ShowWindowsUpdateError()", source); Assert.Contains("NativeGatewayEligibility.Available", source); + Assert.Contains("WslRecommendedBadge.Visibility = available ? Visibility.Collapsed : Visibility.Visible", source); Assert.Contains("GatewaySetupChoice.Wsl => InstallChoice", source); Assert.Contains("ReferenceEquals(GatewayChoiceSelector.SelectedItem, InstallChoice)", source); Assert.Contains("_selectedChoice is GatewaySetupChoice.Existing or GatewaySetupChoice.Wsl", source); @@ -193,20 +195,9 @@ public void Welcome_DisabledNativeKeepsRecommendationAndSeparateSupportCard() XElement Named(string name) => document.Descendants().Single( element => (string?)element.Attribute(names + "Name") == name); var native = Named("NativeChoice"); - var badge = Named("NativeRecommendedBadge"); Assert.Equal("False", (string?)native.Attribute("IsEnabled")); - Assert.Contains(native, badge.Ancestors()); - Assert.Null(badge.Attribute("Visibility")); - Assert.Equal("{ThemeResource TextFillColorDisabledBrush}", (string?)badge.Attribute("BorderBrush")); - foreach (var name in new[] { "NativeTitle", "NativeDescription", "NativeRecommendedText" }) - Assert.Equal("{ThemeResource TextFillColorDisabledBrush}", (string?)Named(name).Attribute("Foreground")); - Assert.Equal("{ThemeResource AccentFillColorDisabledBrush}", (string?)Named("NativeIconBackground").Attribute("Background")); - Assert.Equal("{ThemeResource TextOnAccentFillColorDisabledBrush}", (string?)Named("NativeIcon").Attribute("Foreground")); - var enabledSetters = Named("NativeEnabled").Descendants().Where(element => element.Name.LocalName == "Setter").ToArray(); - Assert.Equal(6, enabledSetters.Length); - Assert.Contains(enabledSetters, setter => - (string?)setter.Attribute("Target") == "NativeRecommendedText.Foreground" && - (string?)setter.Attribute("Value") == "{ThemeResource AccentTextFillColorPrimaryBrush}"); + Assert.Contains(native.Descendants(), element => + (string?)element.Attribute(names + "Uid") == "Onboarding_Native_Recommended"); var card = Named("NativeSupportCard"); var selector = Named("GatewayChoiceSelector"); Assert.Contains(card, selector.ElementsAfterSelf()); @@ -216,14 +207,123 @@ XElement Named(string name) => document.Descendants().Single( Assert.Contains(card, Named("WindowsUpdateButton").Ancestors()); Assert.DoesNotContain(native, card.Ancestors()); var source = File.ReadAllText(Path.Combine(pages, "WelcomePage.xaml.cs")); - Assert.DoesNotContain("NativeRecommendedBadge.Visibility", source); Assert.Contains("NativeChoice.IsEnabled = available", source); - Assert.Contains("VisualStateManager.GoToState(this, \"NativeDisabled\", false)", source); - Assert.Contains("VisualStateManager.GoToState(this, available ? \"NativeEnabled\" : \"NativeDisabled\", false)", source); + Assert.Contains("WslRecommendedBadge.Visibility = available ? Visibility.Collapsed : Visibility.Visible", source); Assert.Contains("NativeSupportCard.Visibility = available ? Visibility.Collapsed : Visibility.Visible", source); Assert.Contains("\", \" + SetupLocalization.GetString(\"Onboarding_Native_Recommended.Text\")", source); } + [Fact] + public void Welcome_GatewayLabelsMatchEnglishResourcesAndAccessibleNames() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var pages = Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages"); + var document = XDocument.Load(Path.Combine(pages, "WelcomePage.xaml")); + XNamespace names = "http://schemas.microsoft.com/winfx/2006/xaml"; + var resources = XDocument.Load(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw")) + .Descendants("data").ToDictionary( + element => (string)element.Attribute("name")!, element => element.Element("value")?.Value); + var choice = document.Descendants().Single(element => (string?)element.Attribute(names + "Name") == "NativeChoice"); + var title = choice.Descendants().Single(element => + (string?)element.Attribute(names + "Uid") == "Onboarding_Native_Title"); + Assert.Equal("Install a local native gateway", (string?)title.Attribute("Text")); + Assert.Equal("Install a local native gateway", resources["Onboarding_Native_Title.Text"]); + var wsl = document.Descendants().Single(element => (string?)element.Attribute(names + "Name") == "InstallChoice"); + Assert.Equal("Install a local Gateway (WSL), recommended", (string?)wsl.Attribute("AutomationProperties.Name")); + Assert.Equal("Install a local Gateway (WSL)", resources["Onboarding_Copy_GatewayLocalTitle.Text"]); + Assert.Equal("Install a local, MXC contained OpenClaw gateway", resources["Onboarding_Native_Description.Text"]); + var source = File.ReadAllText(Path.Combine(pages, "WelcomePage.xaml.cs")); + Assert.Contains("AutomationProperties.SetName(InstallChoice, SetupLocalization.GetString(\"Onboarding_Wsl_Title.Text\"))", source); + } + + [Fact] + public void Welcome_NativeBadgeSharesHeadingAndSuccessFollowsDescription() + { + var pages = Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.SetupEngine.UI", "Pages"); + var document = XDocument.Load(Path.Combine(pages, "WelcomePage.xaml")); + XNamespace xaml = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace names = "http://schemas.microsoft.com/winfx/2006/xaml"; + var native = document.Descendants(xaml + "ListViewItem") + .Single(element => (string?)element.Attribute(names + "Name") == "NativeChoice"); + var heading = native.Descendants(xaml + "StackPanel") + .Single(element => (string?)element.Attribute("Orientation") == "Horizontal" && + element.Elements().Any(child => (string?)child.Attribute(names + "Uid") == "Onboarding_Native_Title")); + Assert.Contains(heading.Elements(), element => + (string?)element.Attribute(names + "Uid") == "Onboarding_Native_Title"); + Assert.Contains(heading.Descendants(), element => + (string?)element.Attribute(names + "Uid") == "Onboarding_Native_Recommended"); + var description = heading.ElementsAfterSelf().First(); + Assert.Equal("Onboarding_Native_Description", (string?)description.Attribute(names + "Uid")); + Assert.Equal("Install a local, MXC contained OpenClaw gateway", (string?)description.Attribute("Text")); + var success = description.ElementsAfterSelf().First(); + Assert.Equal("NativeSupportAvailablePanel", (string?)success.Attribute(names + "Name")); + Assert.Equal("Collapsed", (string?)success.Attribute("Visibility")); + var checkmark = Assert.Single(success.Elements(xaml + "FontIcon")); + Assert.Equal("\uE73E", (string?)checkmark.Attribute("Glyph")); + Assert.Equal("Raw", (string?)checkmark.Attribute("AutomationProperties.AccessibilityView")); + var text = Assert.Single(success.Elements(xaml + "TextBlock")); + Assert.Equal("Polite", (string?)text.Attribute("AutomationProperties.LiveSetting")); + var source = File.ReadAllText(Path.Combine(pages, "WelcomePage.xaml.cs")); + Assert.Contains("NativeSupportAvailablePanel.Visibility = Visibility.Collapsed", source); + Assert.Contains("NativeSupportAvailablePanel.Visibility = available ? Visibility.Visible : Visibility.Collapsed", source); + Assert.Contains("NativeSupportStatusPanel.Visibility = available ? Visibility.Collapsed : Visibility.Visible", source); + Assert.Contains("available ? NativeSupportAvailableText : NativeSupportStatus", source); + Assert.Contains("CreatePeerForElement(supportText)", source); + } + + [Fact] + public void NativeWizard_UsesSharedPageAndRpc_WithFailClosedStagedAuthorization() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var window = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "SetupWindow.xaml.cs")); + var wizard = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WizardPage.xaml.cs")); + var host = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "NativeGatewaySetupHost.cs")); + Assert.Contains("NativeSetupSession = session;", window); + Assert.Contains("NavigateTo(typeof(WizardPage), _config)", window); + Assert.Contains("ApplyStartupPreference: _startupRegistrationAllowed && _persistStartupPreferenceOnComplete", window); + Assert.Contains("await wizardPage.CancelAndWaitAsync()", window); + Assert.Contains("await ReleaseNativeSetupAsync()", window); + Assert.Contains("await native.PrepareWizardAsync(native.LifetimeToken)", wizard); + Assert.Contains("await native.AuthorizeAsync(cancellationToken)", wizard); + Assert.Contains("reportNativePairing: true", wizard); + Assert.Contains("NativeGatewaySetupSession.GetPairingGuidance(requestId)", wizard); + Assert.Contains("await native.ApproveWizardPairingAsync(ex.RequestId, native.LifetimeToken)", wizard); + Assert.Contains("when (attempt == 0)", wizard); + var nativeConnection = wizard[ + wizard.IndexOf("private async Task ConnectNativeClientAsync", StringComparison.Ordinal).. + wizard.IndexOf("private sealed class NativePairingRequiredException", StringComparison.Ordinal)]; + Assert.True(nativeConnection.IndexOf("for (var attempt", StringComparison.Ordinal) < + nativeConnection.IndexOf("new OpenClawGatewayClient", StringComparison.Ordinal)); + Assert.Contains("client.Dispose();", nativeConnection); + Assert.Contains("client.HandshakeAuthorizationAsync = client.ReconnectAuthorizationAsync;", nativeConnection); + Assert.True(nativeConnection.IndexOf("client.HandshakeAuthorizationAsync =", StringComparison.Ordinal) < + nativeConnection.IndexOf("await WaitForConnectAsync", StringComparison.Ordinal)); + Assert.DoesNotContain("client.DisconnectAsync()", nativeConnection); + Assert.Contains("[\"devices\", \"list\", \"--json\"]", host); + Assert.Contains("[\"devices\", \"approve\", requestId, \"--json\"]", host); + Assert.Contains("PairingCommandTimeout = TimeSpan.FromMinutes(2)", host); + Assert.Equal(2, host.Split("environment, PairingCommandTimeout,").Length - 1); + Assert.Contains("WizardPayloadHelpers.GetNativeTerminalError(payload)", wizard); + Assert.Contains("ShowError(SetupLogger.Sanitize(nativeError))", wizard); + Assert.DoesNotContain("--url", host); + Assert.DoesNotContain("--latest", host); + Assert.Contains("new { mode = \"local\", installDaemon = false }", wizard); + Assert.Contains("SendWizardRequestAsync(\"wizard.start\"", wizard); + Assert.Contains("SendWizardRequestAsync(\"wizard.next\"", wizard); + Assert.Contains("SendWizardRequestAsync(\"wizard.cancel\"", wizard); + Assert.Contains("_nativeSession?.MarkWizardCompleted()", wizard); + Assert.Contains("await native.CompleteAsync(native.LifetimeToken, _config!.Capabilities)", wizard); + Assert.Contains("await native.RestartAsync(native.LifetimeToken)", wizard); + Assert.Contains("nativeLogPath: _nativeSession?.ConsoleLogPath", wizard); + Assert.DoesNotContain("onboard", host); + Assert.DoesNotContain("wsl.exe", host); + Assert.DoesNotContain("RunInWslAsync", host); + Assert.Contains("[\"setup\"]", host); + Assert.Contains("[\"config\", \"validate\", \"--json\"]", host); + Assert.Contains("[\"gateway\", \"health\", \"--json\"]", host); + Assert.False(File.Exists(Path.Combine(root, "src", "OpenClaw.SetupEngine", "NativeGatewayTerminalCommand.cs"))); + } + [Fact] public void OptionalSetup_UsesOnePolicyAndValidatedHandoffForNativeWslAndHeadless() { @@ -271,22 +371,14 @@ public void NativeCapabilities_ReusePermissionsButDoNotProbeOrInstallWslAddons() { var root = TestRepositoryPaths.GetRepositoryRoot(); var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); - var nativeStart = source.IndexOf("if (_nativeGateway)", StringComparison.Ordinal); - var nativeEnd = source.IndexOf("TailscaleToggle.IsOn =", nativeStart, StringComparison.Ordinal); - var native = source[nativeStart..nativeEnd]; - Assert.Contains("WslReviewContent.Visibility = Visibility.Collapsed", native); - Assert.Contains("NativeReviewContent.Visibility = Visibility.Visible", native); - Assert.Contains("return;", native); - Assert.True(source.IndexOf("_permissionsTask = BuildPermissionRows()", StringComparison.Ordinal) < nativeStart); - Assert.True(nativeEnd < source.IndexOf("() => InitializeLocalAiReviewAsync(", StringComparison.Ordinal)); - Assert.Contains("if (_nativeGateway)\n SetupWindow.Active?.NavigateToNativeGatewaySetup();", - source.Replace("\r\n", "\n")); - var writeStart = source.IndexOf("private void WriteCapabilities()", StringComparison.Ordinal); - var writeEnd = source.IndexOf("private void ApplySetupReviewSummary", writeStart, StringComparison.Ordinal); - var write = source[writeStart..writeEnd]; - Assert.Contains("config.Settings.ApplyCapabilities(caps)", write); - Assert.Contains("config.Settings.EnableNodeMode = true", write); - Assert.True(write.IndexOf("return;", StringComparison.Ordinal) < write.IndexOf("config.Tailscale.Enabled", StringComparison.Ordinal)); + var window = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "SetupWindow.xaml.cs")); + var policy = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "OnboardingFlowPolicy.cs")); + Assert.Contains("SetupWindow.Active?.AccessDraft", source); + Assert.Contains("_draft.SetCapability(capability, toggle.IsOn)", source); + Assert.DoesNotContain("TailscaleToggle", source); + Assert.Contains("OnboardingAccessDestination.NativeGatewaySetup", window); + Assert.Contains("NavigateToNativeGatewaySetup()", window); + Assert.Contains("SetupGatewayRoute.Native => OnboardingAccessDestination.NativeGatewaySetup", policy); } [Fact] @@ -314,8 +406,9 @@ public void NativeFinalization_SavesCapabilityChoicesBeforeReleasingSessionAndSh Assert.True(completion.IndexOf("await setupWindow.ReleaseNativeSetupAsync()", StringComparison.Ordinal) < completion.IndexOf("setupWindow.NavigateToNativeComplete", StringComparison.Ordinal)); var window = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "SetupWindow.xaml.cs")); - Assert.Contains("MergeCapabilitiesIntoSettingsFile(Path.Combine(_dataDir, \"settings.json\"))", window); - Assert.Contains("new CapabilitiesPageArgs(_config, false, false, NativeGateway: true)", window); + Assert.Contains("_persistStartupPreferenceOnComplete = false;", window); + Assert.Contains("SaveSetupChoices(AutoStartAfterSetup)", window); + Assert.Contains("NavigateToCapabilities(back: true)", window); var cancelStart = wizard.IndexOf("window.NavigateToNativeCapabilities()", StringComparison.Ordinal); Assert.True(cancelStart >= 0); Assert.DoesNotContain("window.NavigateToNativeGatewaySetup()", wizard); diff --git a/tests/OpenClaw.Tray.Tests/NativeRestartAdmissionTests.cs b/tests/OpenClaw.Tray.Tests/NativeRestartAdmissionTests.cs new file mode 100644 index 000000000..cf4529219 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/NativeRestartAdmissionTests.cs @@ -0,0 +1,167 @@ +using OpenClaw.TestSupport; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class NativeRestartAdmissionTests +{ + [Theory] + [InlineData("\"ai-v3:truncated")] + [InlineData("\"not-a-handle\"")] + [InlineData("oversized")] + public void InvalidRestartIsReportedOnceAndOnlyItsRegularFileIsRemoved(string contents) + { + using var temp = new TempDirectory(); + var directory = temp.Combine("setup-dashboard-handoff"); + Directory.CreateDirectory(directory); + var file = Path.Combine(directory, "restart.json"); + File.WriteAllText(file, contents == "oversized" ? new string('x', 2048) : contents); + var sentinel = Path.Combine(directory, "pending.json"); + File.WriteAllText(sentinel, "keep"); + var store = new NativeRestartRecoveryStore(temp.Path); + Assert.Throws(() => store.Read()); + Assert.Null(store.Read()); + Assert.False(File.Exists(file)); + Assert.Equal("keep", File.ReadAllText(sentinel)); + } + + [Fact] + public async Task InvalidRestartCleanupCannotDeleteNewerCooperatingHandle() + { + using var temp = new TempDirectory(); + var directory = temp.Combine("setup-dashboard-handoff"); + Directory.CreateDirectory(directory); + var file = Path.Combine(directory, "restart.json"); + File.WriteAllText(file, "{"); + var handle = "ai-v3:" + new string('d', 64); + var store = new NativeRestartRecoveryStore(temp.Path); + using var started = new ManualResetEventSlim(); + var lease = OpenClaw.Shared.PersistenceFileLease.Acquire(file); + var writer = Task.Run(() => { started.Set(); store.Save(handle); }); + try + { + Assert.True(started.Wait(TimeSpan.FromSeconds(5))); + Assert.Throws(() => store.Read()); + } + finally { lease.Dispose(); } + await writer.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(handle, store.Read()); + store.Clear("ai-v3:" + new string('e', 64)); + Assert.Equal(handle, store.Read()); + } + + [Fact] + public void ReparseRestartIsRejectedWithoutDeletingLinkOrTarget() + { + using var temp = new TempDirectory(); + var directory = temp.Combine("setup-dashboard-handoff"); + Directory.CreateDirectory(directory); + var target = temp.Combine("outside.json"); + File.WriteAllText(target, "{"); + var file = Path.Combine(directory, "restart.json"); + File.CreateSymbolicLink(file, target); + try + { + var store = new NativeRestartRecoveryStore(temp.Path); + Assert.Throws(() => store.Read()); + Assert.True(File.GetAttributes(file).HasFlag(FileAttributes.ReparsePoint)); + Assert.Equal("{", File.ReadAllText(target)); + } + finally { File.Delete(file); } + } + + [Fact] + public async Task DelayedOldOwnerIsNeverForwardedToAndMutexAcquisitionStaysOnOneThread() + { + using var temp = new TempDirectory(); + var store = new NativeRestartRecoveryStore(temp.Path); + var handle = "ai-v3:" + new string('a', 64); + var ownerThread = Environment.CurrentManagedThreadId; + var waits = 0; + var prompts = 0; + var admitted = NativeRestartAdmission.Acquire(handle, store.Save, budget => + { + Assert.Equal(ownerThread, Environment.CurrentManagedThreadId); + Assert.Equal(TimeSpan.FromSeconds(60), budget); + Assert.Equal(handle, store.Read()); + return ++waits == 2; + }, reason => + { + Assert.Equal(ownerThread, Environment.CurrentManagedThreadId); + Assert.Equal(NativeRestartWaitFailure.PreviousInstance, reason); + prompts++; + return true; + }); + Assert.True(admitted); + Assert.Equal(1, prompts); + var router = new ActivationRouter("openclaw", "unused-restart-test"); + var sink = new Sink(); + var input = new LaunchActivationInput(null, [], handle, false); + await router.DispatchPlanAsync(router.PlanLaunch(input), sink, CancellationToken.None); + Assert.Equal(handle, Assert.IsType(Assert.Single(sink.Routes)).Handle); + store.Clear(handle); + Assert.Null(store.Read()); + } + + [Fact] + public void RealMutexIsReleasedByTheSameThreadThatAdmitsTheRestart() + { + var name = "OpenClaw.TestRestart." + Guid.NewGuid().ToString("N"); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var oldOwner = new Thread(() => + { + using var oldMutex = new Mutex(true, name); + entered.Set(); + release.Wait(TimeSpan.FromSeconds(5)); + oldMutex.ReleaseMutex(); + }); + oldOwner.Start(); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + using var mutex = new Mutex(false, name); + try + { + Assert.True(NativeRestartAdmission.Acquire("ai-v3:" + new string('c', 64), + _ => release.Set(), timeout => mutex.WaitOne(timeout), _ => false)); + mutex.ReleaseMutex(); + } + finally { release.Set(); oldOwner.Join(TimeSpan.FromSeconds(5)); } + } + + [Fact] + public void BoundedFailureRetainsProtectedHandleForExplicitReopen() + { + using var temp = new TempDirectory(); + var store = new NativeRestartRecoveryStore(temp.Path); + var handle = "ai-v3:" + new string('b', 64); + Assert.False(NativeRestartAdmission.Acquire(handle, store.Save, _ => false, _ => false)); + Assert.Equal(handle, new NativeRestartRecoveryStore(temp.Path).Read()); + if (OperatingSystem.IsWindows()) + { + var info = new FileInfo(Path.Combine(temp.Path, "setup-dashboard-handoff", "restart.json")); + Assert.True(System.IO.FileSystemAclExtensions.GetAccessControl(info).AreAccessRulesProtected); + } + } + + [Fact] + public void RecoveryStorageFailureIsVisibleAndCannotBeTreatedAsOwnership() + { + var prompts = new List(); + Assert.False(NativeRestartAdmission.Acquire("ai-v3:" + new string('a', 64), + _ => throw new IOException(), _ => throw new Exception("Must not wait before preserving"), + reason => { prompts.Add(reason); return false; })); + Assert.Equal([NativeRestartWaitFailure.RecoveryStorage], prompts); + } + + private sealed class Sink : IActivationPlanSink + { + public List Routes { get; } = []; + public Task DispatchAsync(ActivationRoute route, CancellationToken ct) + { + Routes.Add(route); + return Task.CompletedTask; + } + public Task ConfirmAsync(ActivationConfirmation confirmation, CancellationToken ct) => + throw new InvalidOperationException("Native setup must use its existing receipt validation route."); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OnboardingCopyRefinementTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingCopyRefinementTests.cs new file mode 100644 index 000000000..64e1f3c04 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/OnboardingCopyRefinementTests.cs @@ -0,0 +1,114 @@ +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +public sealed class OnboardingCopyRefinementTests +{ + private static string Read(string path) => File.ReadAllText(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), path)); + private static Dictionary Strings(string locale) => + XDocument.Parse(Read($@"src\OpenClaw.Tray.WinUI\Strings\{locale}\Resources.resw")) + .Descendants("data").ToDictionary(item => item.Attribute("name")!.Value, item => item.Element("value")!.Value); + + [Fact] + public void CapabilityPageHasNoWindowsAccessControlOrProbeHooks() + { + var xaml = Read(@"src\OpenClaw.SetupEngine.UI\Pages\CapabilitiesPage.xaml"); + var code = Read(@"src\OpenClaw.SetupEngine.UI\Pages\CapabilitiesPage.xaml.cs"); + Assert.DoesNotContain("SetupWindowsAccessControl", xaml); + Assert.DoesNotContain("WindowsAccess", code); + Assert.DoesNotContain("SetupPermissionHelper", code); + Assert.DoesNotContain("SetupPrivacyObservation", code); + Assert.Contains("NavigateAfterCapabilities()", code); + } + + [Fact] + public void RetiredSetupResources_AreRemovedWithoutLosingCurrentRecoveryOrSettingsCopy() + { + foreach (var locale in new[] { "en-us", "fr-fr", "nl-nl", "pt-br", "zh-cn", "zh-tw" }) + { + var strings = Strings(locale); + foreach (var prefix in new[] { "Onboarding_V2_Privacy", "Onboarding_V3_WindowsAccess", + "Onboarding_Ready_WhatsApp.", "Onboarding_Ready_Telegram." }) + Assert.DoesNotContain(strings.Keys, key => key.StartsWith(prefix, StringComparison.Ordinal)); + foreach (var key in new[] { "Onboarding_V2_Notifications", "Onboarding_V2_Microphone", + "Onboarding_V2_ScreenStatus", "Onboarding_V2_OpenSettings", + "Onboarding_V2_OpenPermissionSettings", "Onboarding_Ready_Skip.Content" }) + Assert.False(strings.ContainsKey(key), key); + foreach (var key in new[] { "Onboarding_Ready_Return.Content", "Onboarding_V2_Back.Content", + "Onboarding_V2_NativeSettings.Text", "PermissionsPage_Cap_Camera_Label", + "PermissionsPage_Cap_Location_Label", "PermissionsPage_Cap_Screen_Label" }) + Assert.False(string.IsNullOrWhiteSpace(strings[key]), key); + } + } + + [Fact] + public void PresetCopyNamesOnlySelectableCapabilities_AndKeepsStableKeys() + { + var strings = Strings("en-us"); + Assert.Equal("Choose what your agent can do", strings["Onboarding_V4_ProfileHeading.Text"]); + foreach (var (key, title, detail) in new[] + { + ("ReadOnly", "Strict", "Canvas and screen capture. Command execution is off."), + ("Standard", "Balanced (Recommended)", "Commands, Canvas, screen capture and voice."), + ("Full", "Open", "All eight capabilities, including Camera, Location and Browser control."), + }) + { + Assert.Equal(title, strings[$"Onboarding_V2_Profile{key}Title.Text"]); + Assert.Equal(detail, strings[$"Onboarding_V2_Profile{key}Description.Text"]); + Assert.StartsWith(title + ":", strings[$"Onboarding_V2_Profile{key}.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name"]); + Assert.DoesNotMatch(@"(?i)\b(folder|filesystem|workspace-only|internet|LAN|network|system folders)\b", detail); + } + var code = Read(@"src\OpenClaw.SetupEngine.UI\Pages\CapabilitiesPage.xaml.cs"); + Assert.Contains("_draft.ApplyProfile((SetupCapabilityProfile)ProfileSelector.SelectedIndex)", code); + Assert.Contains("SetupCapabilityProfiles.Ordered.Where(_draft.GetCapability)", code); + Assert.Contains("FineTuneExpander.IsExpanded = _draft.FineTuneExpanded", code); + } + + [Fact] + public void WelcomeAndGatewayCopyIsTruthfulLocalizedAndUsesRepositorySafetyUrl() + { + var strings = Strings("en-us"); + Assert.Equal("OpenClaw is your AI assistant for getting things done on your PC.", + strings["Onboarding_Flow_WelcomeDescription.Text"]); + Assert.Equal("Before you get started", strings["Onboarding_V2_WelcomeTrust.Title"]); + Assert.Contains("change or delete files", strings["Onboarding_V2_WelcomeTrust.Message"]); + Assert.Contains("expose private information", strings["Onboarding_V2_WelcomeTrust.Message"]); + Assert.Contains("control and limit", strings["Onboarding_V2_WelcomeTrust.Message"]); + Assert.Equal("Install a local Gateway (WSL)", strings["Onboarding_Copy_GatewayLocalTitle.Text"]); + Assert.Equal("Install a local Gateway (WSL), recommended", + strings["Onboarding_Copy_GatewayLocalChoice.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name"]); + Assert.Equal("Install a private OpenClaw Gateway in WSL.", strings["Onboarding_Copy_GatewayLocalDescription.Text"]); + Assert.DoesNotContain("directly on Windows", string.Join("\n", strings.Where(pair => pair.Key.StartsWith("Onboarding_Copy_")).Select(pair => pair.Value))); + Assert.Equal("Your PC supports Local AI", strings["Onboarding_Welcome_LocalAiAvailableBadge.Text"]); + var welcome = XDocument.Parse(Read(@"src\OpenClaw.SetupEngine.UI\Pages\SecurityNoticePage.xaml")); + var link = Assert.Single(welcome.Descendants(), element => element.Name.LocalName == "HyperlinkButton"); + Assert.Equal("https://trust.openclaw.ai", link.Attribute("NavigateUri")!.Value); + Assert.Contains(link.Attribute("NavigateUri")!.Value, Read("SECURITY.md")); + foreach (var locale in new[] { "en-us", "fr-fr", "nl-nl", "pt-br", "zh-cn", "zh-tw" }) + { + var localized = Strings(locale); + foreach (var key in strings.Keys.Where(key => key.StartsWith("Onboarding_Copy_", StringComparison.Ordinal))) + Assert.False(string.IsNullOrWhiteSpace(localized[key])); + Assert.Contains("{0}", localized["Onboarding_Welcome_LocalAiAvailabilityDetail"]); + Assert.Contains("WSL", localized["Onboarding_Copy_GatewayLocalDescription.Text"]); + Assert.Contains("WSL", localized["Onboarding_Copy_GatewayLocalTitle.Text"]); + } + } + + [Fact] + public void BadgeObservationClearsOldAnnouncementAndGuardsActualEligibility() + { + var code = Read(@"src\OpenClaw.SetupEngine.UI\Pages\WelcomePage.xaml.cs"); + Assert.Contains("LocalAiAvailabilityPanel.Visibility = Visibility.Collapsed", code); + Assert.Contains("LocalAiAvailabilityText.Text = \"\"", code); + Assert.Contains("AutomationProperties.SetName(InstallChoice, _installChoiceBaseAutomationName)", code); + Assert.Contains("new CancellationTokenSource(TimeSpan.FromSeconds(10))", code); + Assert.Contains("_availability.IsCurrent(generation)", code); + Assert.Contains("ReferenceEquals(_config, config)", code); + Assert.Contains("!eligibility.CanInstall || eligibility.SelectedGpu is null", code); + Assert.Contains("GetLocalAiHardwareAsync().WaitAsync(cancellation.Token)", code); + Assert.Contains("Unloaded += (_, _) => CancelAvailability()", code); + Assert.DoesNotContain("config.LocalAi.Enabled =", code); + Assert.DoesNotContain("new CudaHostHardwareProbe", code); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OnboardingMockPresentationTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingMockPresentationTests.cs new file mode 100644 index 000000000..64933f6c7 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/OnboardingMockPresentationTests.cs @@ -0,0 +1,117 @@ +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +public sealed class OnboardingMockPresentationTests +{ + private static readonly XNamespace X = "http://schemas.microsoft.com/winfx/2006/xaml"; + private static XDocument Page(string name) => XDocument.Load(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.SetupEngine.UI", "Pages", name + ".xaml")); + private static XElement Named(XDocument page, string name) => + Assert.Single(page.Descendants(), element => (string?)element.Attribute(X + "Name") == name); + + [Theory] + [InlineData("SecurityNoticePage")] + [InlineData("WelcomePage")] + [InlineData("CapabilitiesPage")] + [InlineData("GatewaySetupPage")] + [InlineData("GatewaySetupDetailPage")] + [InlineData("AdvancedSetupPage")] + [InlineData("SetupNativeConnectionPage")] + [InlineData("WizardPage")] + [InlineData("AiSetupPage")] + public void NormalHeaders_KeepFullSizeArtworkAboveWrappedCenteredText(string name) + { + var document = Page(name); + var mascot = Named(document, "MascotHero"); + Assert.Null(mascot.Attribute("Width")); + Assert.Null(mascot.Attribute("Height")); + Assert.Equal("Center", (string?)mascot.Attribute("HorizontalAlignment")); + var header = mascot.Parent!; + Assert.Equal("StackPanel", header.Name.LocalName); + Assert.Null(header.Attribute("Height")); + Assert.Null(header.Attribute("MaxHeight")); + var title = Assert.Single(header.Elements(), element => + (string?)element.Attribute("Style") == "{StaticResource TitleTextBlockStyle}"); + Assert.Contains(mascot, header.Elements().TakeWhile(element => element != title)); + Assert.Equal("Center", (string?)title.Attribute("TextAlignment")); + Assert.Equal("Wrap", (string?)title.Attribute("TextWrapping")); + Assert.Null(title.Attribute("Height")); + } + + [Fact] + public void Profile_LeadsAndKeepsFineTuneInsideItsSurface() + { + var page = Page("CapabilitiesPage"); + var selector = Named(page, "ProfileSelector"); + var fineTune = Named(page, "FineTuneExpander"); + Assert.Same(selector.Parent, fineTune.Parent); + Assert.Equal("Border", selector.Parent!.Parent!.Name.LocalName); + var order = page.Descendants().ToList(); + Assert.DoesNotContain(page.Descendants(), element => (string?)element.Attribute(X + "Name") == "WindowsAccess"); + Assert.True(order.IndexOf(selector) < order.IndexOf(Named(page, "NodeModeToggle"))); + Assert.Equal(3, selector.Elements().Count(element => element.Name.LocalName == "ListViewItem")); + Assert.DoesNotContain(selector.Descendants(), element => element.Name.LocalName == "SettingsCard"); + } + + [Fact] + public void Installation_KeepsOverviewAndActionableStatusOutsideCollapsedDetails() + { + var page = Page("ProgressPage"); + var details = Named(page, "DetailedActivity"); + Assert.Equal("False", (string?)details.Attribute("IsExpanded")); + Assert.DoesNotContain(page.Descendants(), element => (string?)element.Attribute(X + "Name") == "StepsPanel"); + Assert.Equal("48", (string?)details.Attribute("MinHeight")); + Assert.Contains(details.Descendants(), element => element == Named(page, "OpenLogButton")); + Assert.Contains(details.Descendants(), element => element == Named(page, "LogText")); + foreach (var name in new[] { "PreparePhase", "InstallPhase", "ConnectPhase", "CurrentActivity", + "StepCount", "DownloadActivity", "DownloadProgress", "TailscaleAuthorizationPanel" }) + Assert.DoesNotContain(Named(page, name).Ancestors(), element => element == details); + Assert.DoesNotContain(page.Descendants(), element => (string?)element.Attribute(X + "Name") == "ActivityProgress"); + foreach (var name in new[] { "PrepareStatus", "InstallStatus", "ConnectStatus" }) + Assert.Equal("SetupPhaseStatus", Named(page, name).Name.LocalName); + var source = File.ReadAllText(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), + "src", "OpenClaw.SetupEngine.UI", "Pages", "ProgressPage.xaml.cs")); + Assert.Contains("_installationProgress?.Apply(e)", source); + Assert.Contains("Onboarding_V4_RecoveryInstall", source); + Assert.Contains("progress.CompletedSteps, progress.TotalSteps", source); + } + + [Fact] + public void Options_AreFlatAndReplacementDoesNotDuplicateTheOtherBlockers() + { + var capabilities = Page("CapabilitiesPage"); + var ollama = Named(capabilities, "OllamaToggle"); + Assert.Equal("SettingsCard", ollama.Parent!.Name.LocalName); + Assert.DoesNotContain(ollama.Ancestors(), element => element.Name.LocalName == "SettingsExpander"); + var review = Page("GatewaySetupPage"); + Assert.Equal("TailscaleSetupControl", Named(review, "TailscaleOptions").Name.LocalName); + Assert.DoesNotContain(Named(review, "TailscaleOptions").Ancestors(), element => element.Name.LocalName == "SettingsExpander"); + Assert.Equal("InfoBar", Named(review, "ReplacementWarning").Name.LocalName); + Assert.Same(Named(review, "ReplacementWarning").Parent, Named(review, "ReplacementConsent").Parent); + var source = File.ReadAllText(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), + "src", "OpenClaw.SetupEngine.UI", "Pages", "GatewaySetupPage.xaml.cs")); + Assert.Contains("requirement != SetupInstallRequirement.Replacement", source); + Assert.Contains("_primaryRequirement = requirements.Count == 0 ? null : requirements[0]", source); + Assert.Contains("if (_draft.CanInstall(_window.IsLocalAiRecovery))", source); + Assert.Contains("ReplacementWarning.Message = _draft.ReplacementSummary", source); + Assert.Contains("CliCard.HeaderIcon = cliIcon", source); + Assert.DoesNotContain("NavigateToTailscaleSetup", source); + Assert.Equal(1, source.Split("TailscaleOptions.StateChanged +=").Length - 1); + Assert.Contains("TailscaleOptions.StateChanged -= Tailscale_StateChanged", source); + Assert.Contains("TailscaleOptions.Deactivate()", source); + } + + [Fact] + public void ToolkitExpanders_KeepOnlySettingsCardsInItems() + { + foreach (var file in Directory.EnumerateFiles(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), + "src", "OpenClaw.SetupEngine.UI"), "*.xaml", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"))) + { + var document = XDocument.Load(file); + foreach (var items in document.Descendants().Where(element => element.Name.LocalName == "SettingsExpander.Items")) + Assert.All(items.Elements(), item => Assert.Equal("SettingsCard", item.Name.LocalName)); + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/OnboardingPresentationContractTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingPresentationContractTests.cs new file mode 100644 index 000000000..7b633ac50 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/OnboardingPresentationContractTests.cs @@ -0,0 +1,410 @@ +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +public sealed class OnboardingPresentationContractTests +{ + private static string Read(string path) => + File.ReadAllText(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), path)); + + [Fact] + public void PipelineRegistryWriters_ProduceTheExpectedAdoptionSnapshot() + { + foreach (var step in new[] { "CleanupStaleGatewayStep", "PairOperatorStep", "VerifyEndToEndStep" }) + { + var source = Read($@"src\OpenClaw.SetupEngine\{step}.cs"); + Assert.Contains("ctx.LoadSetupRegistry()", source); + Assert.Contains("ctx.SaveSetupRegistry(registry)", source); + Assert.DoesNotContain("registry.Save()", source); + } + var progress = Read(@"src\OpenClaw.SetupEngine.UI\Pages\ProgressPage.xaml.cs"); + Assert.Contains("ctx.ExpectedGatewayRegistry = setupOwner?.BeginGatewaySetup()", progress); + Assert.Contains("SetupPipeline.RunWithSettlementAsync", progress); + Assert.True(progress.IndexOf("SettleGatewaySetupAsync(ctx.ExpectedGatewayRegistry", StringComparison.Ordinal) < + progress.IndexOf("if (_closed || _window?.IsClosed == true)", StringComparison.Ordinal)); + } + + [Fact] + public void AccessReview_UsesOneCombinedPageAndOneDraftWithoutEarlyPersistence() + { + var xaml = Read(@"src\OpenClaw.SetupEngine.UI\Pages\CapabilitiesPage.xaml"); + var source = Read(@"src\OpenClaw.SetupEngine.UI\Pages\CapabilitiesPage.xaml.cs"); + Assert.DoesNotContain("controls:SetupWindowsAccessControl", xaml); + Assert.DoesNotContain("LocalAiSetupControl", xaml); + Assert.DoesNotContain("TailscaleSetupControl", xaml); + Assert.Contains("NavigateAfterCapabilities()", source); + Assert.Contains("new SettingsCard", source); + Assert.DoesNotContain("GetProperty", source); + var window = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + Assert.Equal(1, window.Split("new SetupAccessDraft(_config)").Length - 1); + Assert.Contains("if (!AccessDraft.CanInstall(_startAtLocalAiRecoveryReview))", window); + Assert.DoesNotContain("AlternateSetupReady", window); + Assert.Contains("OnboardingFlowPolicy.GetAccessDestination(AccessDraft.Route)", window); + Assert.Contains("case OnboardingAccessDestination.AiSetup:", window); + Assert.Contains("case OnboardingAccessDestination.CompleteWithoutGateway:", window); + Assert.Contains("AsyncEventHandlerGuard.Run(CompleteSetupAsync", window); + Assert.DoesNotContain("NavigateToWindowsPermissions", window); + Assert.Contains("NavigateToCapabilities(back: true)", Read(@"src\OpenClaw.SetupEngine.UI\Pages\GatewaySetupPage.xaml.cs")); + Assert.Contains("x:Name=\"StartupPreferenceToggle\"", Read(@"src\OpenClaw.SetupEngine.UI\Pages\GatewaySetupPage.xaml")); + var detail = Read(@"src\OpenClaw.SetupEngine.UI\Pages\GatewaySetupDetailPage.xaml.cs"); + Assert.Contains("WslMirroredNetworkingConsent = LocalAiNetworkingConsentCheckBox.IsChecked == true", detail); + Assert.DoesNotContain("MergeIntoSettingsFile", source + detail); + } + + [Fact] + public void ExistingGateway_DoesNotImplicitlyInvokeClassicSettingsFallback() + { + var source = Read(@"src\OpenClaw.SetupEngine.UI\Pages\WelcomePage.xaml.cs"); + Assert.DoesNotContain("SetupWindow.Active?.RequestAdvancedSetup()", source); + Assert.Contains("SelectGatewayRoute(SetupGatewayRoute.Existing)", source); + Assert.DoesNotContain("new ContentDialog", source); + Assert.Contains("ClassicSettings_Click", Read(@"src\OpenClaw.SetupEngine.UI\Pages\AdvancedSetupPage.xaml.cs")); + } + + [Fact] + public void SuccessfulInstallation_UsesAiSetupWithoutAMilestoneClick() + { + var source = Read(@"src\OpenClaw.SetupEngine.UI\Pages\ProgressPage.xaml.cs"); + Assert.Contains("OnboardingFlowPolicy.RequiresAiSetup(config)", source); + Assert.Contains("_window?.TryNavigateToWizard()", source); + Assert.Contains("ctx.ResolvedLocalAiModelRef", source); + Assert.Contains("SetExpectedConfiguredModelRef(modelRef,", source); + Assert.Contains("config.LocalAiRecoveryGatewayId ?? ctx.GatewayRecordId", source); + Assert.Contains("await setupWindow.CompleteSetupAsync()", source); + Assert.Contains("OnboardingFlowPolicy.BuildInstallationSteps(localAiRecoveryOnly)", source); + Assert.DoesNotContain("SetupStepFactory.BuildDefaultSteps()", source); + } + + [Fact] + public void Completion_PreservesWorkspaceAndStartupOwnership() + { + var source = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + Assert.Contains("new AiSetupPageArgs(_config, _dataDir, _localDataDir,", source); + Assert.Contains("TryNavigateToLegacyWizard, CompleteSetupAsync, _expectedConfiguredModelRef", source); + Assert.Contains("var result = await ApplyWindowsNodeContextAsync()", source); + Assert.Contains("if (!result.IsSuccess)", source); + Assert.Contains("RequestSetupCompleted(_persistStartupPreferenceOnComplete && AutoStartAfterSetup)", source); + Assert.Contains("if (_completionDispatched || _isClosed)", source); + Assert.Contains("_completionDispatched = true", source); + Assert.DoesNotContain("static Func", source); + Assert.Contains("RootFrame.Content is IAsyncDisposable pageLifetime", source); + Assert.True(source.IndexOf("await pageCleanup", StringComparison.Ordinal) < + source.IndexOf("_setupLock?.Dispose()", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("SecurityNoticePage")] + [InlineData("WelcomePage")] + [InlineData("CapabilitiesPage")] + [InlineData("GatewaySetupPage")] + [InlineData("GatewaySetupDetailPage")] + [InlineData("ProgressPage")] + [InlineData("WizardPage")] + [InlineData("AiSetupPage")] + [InlineData("AiReadyPage")] + public void SetupPages_UseVectorMascotAndRouteDrivenProgress(string page) + { + var xaml = Read($@"src\OpenClaw.SetupEngine.UI\Pages\{page}.xaml"); + Assert.Contains("controls:OnboardingMascot", xaml); + Assert.Contains("controls:SetupProgressIndicator", xaml); + Assert.DoesNotContain("of 6", xaml); + Assert.DoesNotContain("OpenClawMascot.png", xaml); + } + + [Fact] + public void NativeChat_DashboardActionDoesNotDependOnTheWebViewToolbar() + { + var document = XDocument.Parse(Read(@"src\OpenClaw.Tray.WinUI\Pages\ChatPage.xaml")); + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var dashboard = Assert.Single(document.Descendants(), + element => (string?)element.Attribute(x + "Name") == "DashboardButton"); + Assert.Equal("OnOpenDashboard", (string?)dashboard.Attribute("Click")); + Assert.DoesNotContain(dashboard.Ancestors(), + element => (string?)element.Attribute(x + "Name") == "ToolbarBorder"); + Assert.Contains("((IAppCommands)CurrentApp).OpenDashboard()", + Read(@"src\OpenClaw.Tray.WinUI\Pages\ChatPage.xaml.cs")); + Assert.Contains("ChatFlyoutDashboardButton", + Read(@"src\OpenClaw.Tray.WinUI\Windows\ChatWindow.xaml")); + Assert.Contains("((IAppCommands)Application.Current).OpenDashboard()", + Read(@"src\OpenClaw.Tray.WinUI\Windows\ChatWindow.xaml.cs")); + } + + [Fact] + public void PageTransitions_RespectWindowsAnimationPreference() + { + var source = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + Assert.Contains("UISettings().AnimationsEnabled", source); + Assert.Contains("new SuppressNavigationTransitionInfo()", source); + } + + [Fact] + public void Completion_RestartsWithTheRouteSpecificDestination() + { + var source = Read(@"src\OpenClaw.Tray.WinUI\App.xaml.cs"); + Assert.Contains("OnboardingFlowPolicy.GetCompletionLaunchTarget(e.Route)", source); + Assert.Contains("psi.ArgumentList.Add(launchTarget)", source); + } + + [Fact] + public void ProviderProof_CapturesOwnedWindowAndVerifiesRenderedDialogText() + { + var source = Read(@"tests\OpenClaw.Tray.UITests\OnboardingArtworkRenderingTests.cs"); + var capture = Read(@"tests\OpenClaw.Tray.UITests\OnboardingNativeProof.cs"); + Assert.DoesNotContain("CopyFromScreen", source + capture); + Assert.Contains("PrintWindow(handle, dc, 2)", capture); + Assert.Contains("Assert.Equal((uint)Environment.ProcessId, processId)", capture); + Assert.Contains("[\"providerinput\", \"syntheticproviderprompt\", \"testcode\"]", source); + Assert.Contains("engine.RecognizeAsync(bitmap)", capture); + Assert.Contains("Assert.Equal(handle, GetForegroundWindow())", capture); + Assert.Contains("bounds.IntersectsWith(other.Rectangle)", capture); + Assert.Contains("Assert.Equal(bounds, AssertCaptureTarget(window))", capture); + } + + [Fact] + public void AiGroups_KeepManualInputInlineAndMoreLimitedToAuthentication() + { + var xaml = Read(@"src\OpenClaw.SetupEngine.UI\Pages\AiSetupPage.xaml"); + var source = Read(@"src\OpenClaw.SetupEngine.UI\Pages\AiSetupPage.xaml.cs"); + var groups = new[] { "LocalAiSection", "CandidatesSection", "UnavailableSection", + "PrepareSection", "SignInSection", "MoreExpander", "RecommendedSection", "ApiKeySection", "ApiKeyForm" }; + var offsets = groups.Select(name => xaml.IndexOf($"x:Name=\"{name}\"", StringComparison.Ordinal)).ToArray(); + Assert.All(offsets, offset => Assert.True(offset >= 0)); + Assert.Equal(offsets.Order(), offsets); + Assert.Contains("MoreSignInChoices.ItemsSource = ProviderRows(_presentation.MoreSignIn, GatewayAiSetupChoiceKind.Auth)", source); + Assert.Contains("ApiKeyForm.StartBringIntoView()", source); + Assert.Contains("ApiProviderPicker.SelectedIndex = -1", source); + Assert.Contains("ApiKeyForm.Visibility = Visible(_presentation.ShowApiKeyForm)", source); + Assert.Contains("RecommendedInstalls.ItemsSource = ProviderRows(_presentation.RecommendedInstalls, GatewayAiSetupChoiceKind.Prepare)", source); + Assert.Contains("SetupLocalization.GetString(\"Onboarding_AiSetup_\" + key)", source); + Assert.DoesNotContain("WizardPanel", xaml); + Assert.DoesNotContain("new ContentDialog", source); + Assert.DoesNotContain("x:Name=\"LocalAiHeading\"", xaml); + Assert.DoesNotContain("x:Name=\"LocalAiExplanation\"", xaml); + Assert.Contains("snapshot.ShowLocalChoice", source); + Assert.Contains("FluentIconCatalog.Key", source); + Assert.Contains("ApiProviderPicker.SelectedItem is ChoiceRow row && CanConnectManual()", source); + Assert.Contains("SelectChoice(row);", source); + Assert.Contains("selected.Id != row.Id || selected.ModelRef != row.ModelRef", source); + var document = XDocument.Parse(xaml); + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + XElement Named(string name) => Assert.Single(document.Descendants(), + element => (string?)element.Attribute(x + "Name") == name); + Assert.Equal("SettingsCard", Named("ApiKeysButton").Name.LocalName); + Assert.Equal("StackPanel", Named("ApiKeysButton").Parent!.Name.LocalName); + Assert.DoesNotContain(document.Descendants(), element => element.Name.LocalName == "ListView"); + Assert.DoesNotContain(document.Descendants(), element => (string?)element.Attribute(x + "Name") == "ContinueButton"); + Assert.DoesNotContain(Named("ApiKeysButton").Ancestors(), element => element == Named("SignInSection")); + Assert.DoesNotContain(Named("ApiKeyForm").Ancestors(), element => element == Named("SignInSection")); + Assert.Contains(Named("ApiKeysButton").Ancestors(), element => element == Named("ApiKeySection")); + Assert.Contains(Named("ApiKeyForm").Ancestors(), element => element == Named("ApiKeySection")); + Assert.Contains(Named("MoreExpander").Ancestors(), element => element == Named("ProviderGroup")); + Assert.Contains(Named("FeaturedSignInChoices").Ancestors(), element => element == Named("ProviderGroup")); + Assert.Equal("SettingsExpander", Named("RecommendedSection").Name.LocalName); + Assert.Equal("False", (string?)Named("RecommendedSection").Attribute("IsExpanded")); + Assert.Equal("PasswordBox", Named("ApiKeyInput").Name.LocalName); + Assert.Equal("ManualConnect_Click", (string?)Named("ApiKeyConnectButton").Attribute("Click")); + Assert.DoesNotContain(document.Descendants(), element => element.Name.LocalName == "AdaptiveTrigger"); + Assert.Equal("ApiKeyFields_SizeChanged", (string?)Named("ApiKeyFields").Attribute("SizeChanged")); + Assert.Contains("e.NewSize.Width >= 560", source); + Assert.Equal("CheckBox", Named("CatalogPreference").Name.LocalName); + Assert.Equal("False", (string?)Named("CatalogPreference").Attribute("IsChecked")); + Assert.Contains("? CatalogPreference.IsChecked == true : (bool?)null", source); + Assert.DoesNotContain(Named("ApiKeyFields").Descendants(), + element => element.Attribute("Height") is not null || element.Attribute("FontSize") is not null); + } + + [Fact] + public void SetupOwnership_ClosesCombinedCapabilitiesReview() + { + var source = Read(@"src\OpenClaw.SetupEngine.UI\Pages\CapabilitiesPage.xaml.cs"); + Assert.DoesNotContain("InitializeLocalAiReviewAsync", source); + Assert.DoesNotContain("RefreshWindowsTailscaleStatusAsync", source); + Assert.DoesNotContain("SetupPermissionHelper", source); + Assert.DoesNotContain("SetupReviewSummaryBuilder", source); + Assert.DoesNotContain("WindowsAccess", source); + Assert.Contains("LocalAiSetupAvailabilityCoordinator", Read(@"src\OpenClaw.SetupEngine.UI\Controls\LocalAiSetupControl.xaml.cs")); + Assert.Contains("BoundedProcessOutput.ReadAsync", Read(@"src\OpenClaw.SetupEngine.UI\Controls\TailscaleSetupControl.xaml.cs")); + } + + [Fact] + public void RetiredWindowsAccessPreview_CannotReintroduceProbesOrAnExtraSetupStage() + { + var window = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + Assert.DoesNotContain("WindowsPermissionsPage", window); + Assert.DoesNotContain("PrivacyProbe", window); + Assert.DoesNotContain("\"permissions\" =>", window); + Assert.DoesNotContain("Permissions,", Read(@"src\OpenClaw.SetupEngine\OnboardingFlowPolicy.cs")); + foreach (var path in new[] + { + @"src\OpenClaw.SetupEngine.UI\Pages\WindowsPermissionsPage.xaml", + @"src\OpenClaw.SetupEngine.UI\Controls\SetupWindowsAccessControl.xaml", + @"src\OpenClaw.SetupEngine.UI\Pages\SetupPermissionHelper.cs", + @"src\OpenClaw.SetupEngine\SetupPrivacyObservation.cs", + @"src\OpenClaw.SetupEngine\SetupPrivacyPolicy.cs", + }) + Assert.False(File.Exists(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), path)), path); + } + + [Fact] + public void NativeAndGatewayFreeCompletion_NeverEntersWslWorkspaceFinalization() + { + var source = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + var routeGuard = source.IndexOf("if (!OnboardingFlowPolicy.UsesWslWorkspaceFinalization(AccessDraft.Route))", StringComparison.Ordinal); + var wslContext = source.IndexOf("var context = new SetupContext(", StringComparison.Ordinal); + Assert.True(routeGuard >= 0 && routeGuard < wslContext); + Assert.Contains("StepResult.Skip(\"This setup route does not modify a WSL workspace.\")", source); + Assert.Contains("if (AccessDraft.Route != SetupGatewayRoute.ManagedWsl)", source); + Assert.Contains("_config.Settings.MergeIntoSettingsFile(Path.Combine(_dataDir, \"settings.json\"),", source); + Assert.Contains("includeAutoStart: _persistStartupPreferenceOnComplete || !_startupRegistrationAllowed", source); + Assert.Contains("new SetupCompletedEventArgs(enableAutoStart, AccessDraft.Route,", source); + Assert.Contains("x:Name=\"StartupPreferenceToggle\"", Read(@"src\OpenClaw.SetupEngine.UI\Pages\CapabilitiesPage.xaml")); + } + + [Fact] + public void NativeEditor_IsTypedAndDrainedBeforeTheRunLockIsReleased() + { + var window = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + Assert.Contains("ISetupNativeConnectionHost? nativeConnectionHost = null", window); + Assert.Contains("new SetupNativeConnectionNavigationArgs(", window); + Assert.Contains("AccessDraft.NativeConnectionRequest = request", window); + Assert.Contains("AccessDraft.TryAcceptNativeConnection(route, result)", window); + Assert.Contains("RootFrame.Content is SetupNativeConnectionPage nativePage", window); + Assert.Contains("nativePage.DisposeAsync().AsTask()", window); + Assert.True(window.IndexOf("await _nativePageCleanupTask", StringComparison.Ordinal) < + window.IndexOf("_setupLock?.Dispose()", StringComparison.Ordinal)); + Assert.Contains("AccessDraft?.ClearNativeConnectionSecrets()", window); + Assert.DoesNotContain("new GatewayConnectionManager", window); + Assert.DoesNotContain("new SetupNativeConnectionResult", window); + } + + [Fact] + public void NewSetupCopy_IsAvailableInAllSixLocales() + { + string[] locales = ["en-us", "fr-fr", "nl-nl", "pt-br", "zh-cn", "zh-tw"]; + var sets = locales.Select(locale => + XDocument.Parse(Read($@"src\OpenClaw.Tray.WinUI\Strings\{locale}\Resources.resw")) + .Descendants("data") + .Where(data => data.Attribute("name")!.Value.StartsWith("Onboarding_V2_", StringComparison.Ordinal) || + data.Attribute("name")!.Value.StartsWith("Onboarding_V3_", StringComparison.Ordinal) || + data.Attribute("name")!.Value.StartsWith("Onboarding_V4_", StringComparison.Ordinal) || + data.Attribute("name")!.Value.StartsWith("Onboarding_V5_", StringComparison.Ordinal) || + data.Attribute("name")!.Value.StartsWith("Onboarding_Ready_", StringComparison.Ordinal) || + data.Attribute("name")!.Value.StartsWith("Onboarding_Copy_", StringComparison.Ordinal) || + data.Attribute("name")!.Value.StartsWith("Onboarding_AiSetup_", StringComparison.Ordinal)) + .ToDictionary(data => data.Attribute("name")!.Value, data => data.Element("value")!.Value)) + .ToArray(); + Assert.NotEmpty(sets[0]); + foreach (var set in sets) + { + Assert.Equal(sets[0].Keys.Order(), set.Keys.Order()); + Assert.All(set.Values, value => { Assert.False(string.IsNullOrWhiteSpace(value)); Assert.DoesNotContain("—", value); }); + } + } + + [Fact] + public void CapabilityProfiles_UseThreeNativeChoicesAndLegalInlineFineTuneItems() + { + var xaml = XDocument.Parse(Read(@"src\OpenClaw.SetupEngine.UI\Pages\CapabilitiesPage.xaml")); + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var selector = Assert.Single(xaml.Descendants(), element => (string?)element.Attribute(x + "Name") == "ProfileSelector"); + Assert.Equal("ListView", selector.Name.LocalName); + Assert.Equal("Single", (string?)selector.Attribute("SelectionMode")); + Assert.Equal("Stretch", (string?)selector.Attribute("HorizontalContentAlignment")); + Assert.Equal(3, selector.Elements().Count()); + Assert.All(selector.Elements(), row => + { + Assert.Equal("ListViewItem", row.Name.LocalName); + Assert.Equal("Stretch", (string?)row.Attribute("HorizontalContentAlignment")); + }); + Assert.DoesNotContain(xaml.Descendants(), element => element.Name.LocalName is "RadioButton" or "RadioButtons"); + var editor = Assert.Single(xaml.Descendants(), element => (string?)element.Attribute(x + "Name") == "FineTuneExpander"); + Assert.Equal("SettingsExpander", editor.Name.LocalName); + Assert.Equal("False", (string?)editor.Attribute("IsExpanded")); + Assert.DoesNotContain(xaml.Descendants(), element => (string?)element.Attribute(x + "Name") == "CustomChoice"); + Assert.Contains(xaml.Descendants(), element => (string?)element.Attribute(x + "Uid") == "Onboarding_V4_ProfileHeading"); + Assert.Contains(editor.Elements(), element => element.Name.LocalName == "SettingsExpander.ItemsFooter"); + Assert.Contains(editor.Descendants(), element => (string?)element.Attribute(x + "Name") == "SelectedSummary"); + var source = Read(@"src\OpenClaw.SetupEngine.UI\Pages\CapabilitiesPage.xaml.cs"); + Assert.Contains("FineTuneExpander.Items.Add(card)", source); + Assert.Contains("new SettingsCard", source); + Assert.Contains("FineTuneExpander.IsExpanded = _draft.FineTuneExpanded", source); + Assert.Contains("CustomProfileText.Visibility = _draft.Profile == SetupCapabilityProfile.Custom", source); + Assert.Contains("? -1 : (int)_draft.Profile", source); + Assert.DoesNotContain("new Border", source); + Assert.DoesNotContain("ProfileRadio", source); + } + + [Fact] + public void Gallery_CoversCurrentCapabilitiesWithoutRetiredPrivacyPreview() + { + var catalog = Read(@"tests\OpenClaw.Tray.UITests\OnboardingSetupGalleryData.cs"); + var source = Read(@"tests\OpenClaw.Tray.UITests\OnboardingSetupGalleryTests.cs"); + Assert.DoesNotContain("\"permissions\"", catalog); + Assert.Contains("05-capabilities-standard-fine-tune", catalog); + Assert.DoesNotContain("legacy-access", catalog + source); + Assert.DoesNotContain("privacyProbe:", source); + Assert.Contains("OnboardingStage.Capabilities", source); + } + + [Fact] + public void InstallReview_UsesDraftRequirementsWithoutImplicitConsentAndPreservesDetailOrigin() + { + var source = Read(@"src\OpenClaw.SetupEngine.UI\Pages\GatewaySetupPage.xaml.cs"); + Assert.Contains("_draft.GetInstallRequirements(_window.IsLocalAiRecovery)", source); + Assert.Contains("if (presentedRequirement != _primaryRequirement) return", source); + Assert.Contains("if (_draft.CanInstall(_window.IsLocalAiRecovery))", source); + Assert.Contains("_draft.ConfirmReplacement(ReplacementConsent.IsChecked == true)", source); + Assert.DoesNotContain("NavigateToReplacementReview", source); + var review = XDocument.Parse(Read(@"src\OpenClaw.SetupEngine.UI\Pages\GatewaySetupPage.xaml")); + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var consent = Assert.Single(review.Descendants(), element => (string?)element.Attribute(x + "Name") == "ReplacementConsent"); + Assert.Equal("CheckBox", consent.Name.LocalName); + Assert.Equal("ReplacementConsent_Changed", (string?)consent.Attribute("Checked")); + Assert.DoesNotContain("LocalAiReady", source); + Assert.DoesNotContain("TailscaleReady", source); + Assert.Contains("NavigateToWslNetworking(returnToReview: true)", source); + var detail = Read(@"src\OpenClaw.SetupEngine.UI\Pages\GatewaySetupDetailPage.xaml.cs"); + Assert.Contains("Detail: GatewaySetupDetail.Networking, ReturnToReview: false", detail); + Assert.Contains("NavigateToLocalAiSetup(back: true)", detail); + Assert.Contains("NavigateToGatewaySetup(back: true)", detail); + var window = Read(@"src\OpenClaw.SetupEngine.UI\SetupWindow.xaml.cs"); + Assert.Contains("_startAtLocalAiRecoveryReview, _pinLocalAiRecoveryModel, returnToReview", window); + } + + [Fact] + public void ChangedOnboardingPages_PreserveCenteredLargeMascotsAndFixedFooters() + { + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + foreach (var page in new[] { "CapabilitiesPage", "GatewaySetupPage" }) + { + var xaml = XDocument.Parse(Read($@"src\OpenClaw.SetupEngine.UI\Pages\{page}.xaml")); + var mascot = Assert.Single(xaml.Descendants(), element => (string?)element.Attribute(x + "Name") == "MascotHero"); + Assert.Null(mascot.Attribute("Width")); + Assert.Null(mascot.Attribute("Height")); + Assert.Equal("Center", (string?)mascot.Attribute("HorizontalAlignment")); + Assert.Equal("StackPanel", mascot.Parent!.Name.LocalName); + Assert.Null(mascot.Parent.Attribute("Height")); + var progress = Assert.Single(xaml.Descendants(), element => (string?)element.Attribute(x + "Name") == "FlowProgress"); + Assert.Equal("2", (string?)progress.Parent!.Attribute("Grid.Row")); + Assert.DoesNotContain(progress.Ancestors(), element => element.Name.LocalName == "ScrollViewer"); + } + } + + [Fact] + public void EveryInstallRequirement_HasLocalizedBlockerReasonAndAction() + { + string[] requirements = ["ManagedWslRoute", "WslInspection", "Replacement", "LocalAi", "NetworkingConsent", "Tailscale"]; + foreach (var locale in new[] { "en-us", "fr-fr", "nl-nl", "pt-br", "zh-cn", "zh-tw" }) + { + var strings = XDocument.Parse(Read($@"src\OpenClaw.Tray.WinUI\Strings\{locale}\Resources.resw")) + .Descendants("data").ToDictionary(data => data.Attribute("name")!.Value, data => data.Element("value")!.Value); + foreach (var requirement in requirements) + foreach (var prefix in new[] { "Blocker", "Requirement", "Action" }) + { + var value = strings[$"Onboarding_V2_{prefix}{requirement}"]; + Assert.False(string.IsNullOrWhiteSpace(value)); + Assert.DoesNotContain("Onboarding_", string.Format(value, "TargetGateway")); + } + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 2ea998e90..96689555c 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -95,6 +95,15 @@ + + + + + + + + + @@ -113,6 +122,9 @@ + + + @@ -130,6 +142,8 @@ + + @@ -217,6 +231,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs index 12d8b49b0..24a2f9ad4 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs @@ -9,6 +9,75 @@ namespace OpenClaw.Tray.Tests.Presentation; /// public sealed class SettingsStoreTests { + [Fact] + public async Task HostedSetupSerializesWithBackgroundSettingsMutationWithoutLosingUnrelatedFields() + { + using var store = NewStore(out var settings, out _, out var temp); + using (temp) + using (var entered = new ManualResetEventSlim()) + using (var release = new ManualResetEventSlim()) + { + var path = Path.Combine(temp.Path, "settings.json"); + File.WriteAllText(path, """{"UnrelatedSentinel":"keep"}"""); + settings.Load(); + var setup = new SetupSettingsWriter(store); + var background = Task.Run(() => store.Update(null, editor => + { + editor.NotificationSound = "background"; + entered.Set(); + if (!release.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException(); + })); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + var writing = Task.Run(() => setup.Apply(new Dictionary { ["NodeCameraEnabled"] = false })); + try { Assert.NotSame(writing, await Task.WhenAny(writing, Task.Delay(150))); } + finally { release.Set(); } + await Task.WhenAll(background, writing); + var saved = new SettingsManager(temp.Path); + Assert.Equal("background", saved.NotificationSound); + Assert.False(saved.NodeCameraEnabled); + using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)); + Assert.Equal("keep", json.RootElement.GetProperty("UnrelatedSentinel").GetString()); + } + } + + [Fact] + public void HostedSetupRejectsSameFieldConflictBeforeMutatingOtherFields() + { + using var store = NewStore(out var settings, out _, out var temp); + using (temp) + { + var setup = new SetupSettingsWriter(store); + store.Update(null, editor => editor.NodeCameraEnabled = false); + var before = File.ReadAllBytes(Path.Combine(temp.Path, "settings.json")); + Assert.Throws(() => setup.Apply(new Dictionary + { ["EnableNodeMode"] = true, ["NodeCameraEnabled"] = true })); + Assert.False(settings.EnableNodeMode); + Assert.False(settings.NodeCameraEnabled); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(temp.Path, "settings.json"))); + } + } + + [Fact] + public void StaleSettingsInstanceCannotOverwriteAnotherWriterAndFailedUpdateRollsBack() + { + using var store = NewStore(out var settings, out _, out var temp); + using (temp) + { + store.Update(null, editor => editor.GlobalHotkeyEnabled = true); + var other = new SettingsManager(temp.Path); + other.NotificationSound = "other-writer"; + other.SaveOrThrow(); + var before = File.ReadAllBytes(Path.Combine(temp.Path, "settings.json")); + Assert.Throws(() => store.Update(null, editor => editor.GlobalHotkeyEnabled = false)); + Assert.True(settings.GlobalHotkeyEnabled); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(temp.Path, "settings.json"))); + settings.Load(); + store.Update(null, editor => editor.GlobalHotkeyEnabled = false); + Assert.Equal("other-writer", settings.NotificationSound); + Assert.False(settings.GlobalHotkeyEnabled); + } + } + private static SettingsStore NewStore(out SettingsManager settings, out RecordingUiDispatcher dispatcher, out TempDir temp) { temp = new TempDir(); diff --git a/tests/OpenClaw.Tray.Tests/SetupAssetPublishTests.cs b/tests/OpenClaw.Tray.Tests/SetupAssetPublishTests.cs index 24a20b67a..aa0c2febf 100644 --- a/tests/OpenClaw.Tray.Tests/SetupAssetPublishTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupAssetPublishTests.cs @@ -62,8 +62,10 @@ public async Task Publish_CopiesSetupImagesToTheirLibraryQualifiedPaths() } Assert.True(process.ExitCode == 0, $"{await stdout}\n{await stderr}"); - var images = Directory.GetFiles(sourceDirectory, "*.png", SearchOption.AllDirectories); + var images = Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories); Assert.NotEmpty(images); + Assert.Contains(images, path => path.EndsWith("ProviderIcon-claude.svg", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(images, path => path.EndsWith("ProviderIcons" + Path.DirectorySeparatorChar + "NOTICE.md", StringComparison.OrdinalIgnoreCase)); foreach (var source in images) { var relativePath = Path.GetRelativePath(sourceDirectory, source); diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffStoreTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffStoreTests.cs new file mode 100644 index 000000000..82c62d825 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffStoreTests.cs @@ -0,0 +1,170 @@ +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.SetupEngine; +using OpenClaw.TestSupport; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class SetupDashboardHandoffStoreTests +{ + private static GatewayRecord Gateway => new() { Id = "gateway-a", Url = "wss://gateway.example/control/" }; + private static GatewayAiSetupCompletion Receipt => new(SetupCompletionIntent.CustodianOnboarding, + Gateway.Id, GatewayDashboardBinding.Capture(Gateway), "provider/model", "primary", 7, + IdentityBinding: new string('B', 64), SessionKey: "agent:primary:main"); + private static SetupNativeCompletion Choice => new(Receipt, new(SetupNativeDestination.Chat, "agent:primary:main")); + + [Fact] + public void ReceiptWithoutOriginalAuthorityCannotBeIssuedOrAdmitted() + { + using var directory = new TempDirectory(); + var store = new SetupDashboardHandoffStore(directory.Path); + Assert.Throws(() => + store.Issue(Choice with { Verification = Receipt with { IdentityBinding = null } })); + var handle = store.Issue(Choice); + var path = Path.Combine(directory.Path, "setup-dashboard-handoff", "pending.json"); + var json = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(path))!; + json["Completion"]!.AsObject().Remove("IdentityBinding"); + File.WriteAllText(path, json.ToJsonString()); + Assert.Null(store.Acquire(handle)); + } + + [Fact] + public void ForgedShapeAndUnknownHandle_AreNotVerificationAuthority() + { + using var directory = new TempDirectory(); + var store = new SetupDashboardHandoffStore(directory.Path); + var forged = "ai-v1:" + Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(Receipt)); + Assert.Null(SetupDashboardHandoff.ParseHandle(forged)); + Assert.Null(store.Acquire(forged)); + var real = store.Issue(Choice); + Assert.Null(store.Acquire(SetupDashboardHandoff.NativePrefix + new string('0', 64))); + Assert.Null(store.Acquire("../pending.json")); + var router = new ActivationRouter("openclaw", "unused-source-test"); + var publicJson = "openclaw://setup-dashboard?receipt=" + Uri.EscapeDataString(forged); + var plan = Assert.IsType(router.PlanLaunch(new(publicJson, [], null, false))); + Assert.Null(Assert.IsType(plan.Route).Handle); + using var valid = store.Acquire(real); + Assert.Equal(Receipt, valid!.Completion); + valid.Consume(); + } + + [Fact] + public void LocalRecord_ContainsNoHandleOrCredentials_AndNewRunSupersedesOldRun() + { + using var directory = new TempDirectory(); + var store = new SetupDashboardHandoffStore(directory.Path); + var old = store.Issue(Choice); + var current = store.Issue(Choice with { Verification = Receipt with { Intent = SetupCompletionIntent.Dashboard } }); + Assert.NotEqual(old, current); + var text = File.ReadAllText(Path.Combine(directory.Path, "setup-dashboard-handoff", "pending.json")); + Assert.DoesNotContain(current, text); + Assert.DoesNotContain("token", text, StringComparison.OrdinalIgnoreCase); + Assert.Null(store.Acquire(old)); + using var lease = store.Acquire(current); + Assert.Equal(SetupCompletionIntent.Dashboard, lease!.Completion.Intent); + lease.Consume(); + } + + [Fact] + public void ExpiryAndBackwardClock_RejectPreviouslyVerifiedReceipt() + { + using var directory = new TempDirectory(); + var time = new Clock(); + var store = new SetupDashboardHandoffStore(directory.Path, time); + var handle = store.Issue(Choice); + time.Now += TimeSpan.FromMinutes(5); + Assert.Null(store.Acquire(handle)); + handle = store.Issue(Choice); + time.Now -= TimeSpan.FromSeconds(1); + Assert.Null(store.Acquire(handle)); + } + + [Fact] + public void ExclusiveLease_RejectsInflightDuplicateAndReplayAfterConsume() + { + using var directory = new TempDirectory(); + var firstProcess = new SetupDashboardHandoffStore(directory.Path); + var secondProcess = new SetupDashboardHandoffStore(directory.Path); + var handle = firstProcess.Issue(Choice); + using (var first = firstProcess.Acquire(handle)) + { + Assert.NotNull(first); + Assert.Null(secondProcess.Acquire(handle)); + Assert.Null(secondProcess.Acquire(handle, explicitRetry: true)); + first!.Consume(); + } + Assert.Null(secondProcess.Acquire(handle)); + Assert.Null(secondProcess.Acquire(handle, explicitRetry: true)); + } + + [Fact] + public void InterruptedLease_IsNotReplayableAfterProcessReleasesFile() + { + using var directory = new TempDirectory(); + var store = new SetupDashboardHandoffStore(directory.Path); + var handle = store.Issue(Choice); + store.Acquire(handle)!.Dispose(); + Assert.Null(store.Acquire(handle)); + Assert.Null(store.Acquire(handle, explicitRetry: true)); + } + + [Fact] + public void PublicRoute_CannotRequestExplicitRetryOrSupplyAFilePath() + { + var router = new ActivationRouter("openclaw", "unused-source-test"); + var plan = Assert.IsType(router.PlanLaunch(new( + "openclaw://setup-dashboard?handle=..%2Fpending.json&retry=true", [], null, false))); + Assert.Null(Assert.IsType(plan.Route).Handle); + Assert.Null(SetupDashboardHandoff.ParseHandle("ai-v3:" + new string('A', 64))); + } + + [Fact] + public void CurrentHandshakeFacts_DoNotInventProviderIdentityFromBareSessionDisplayId() + { + var known = SetupDashboardLiveFacts.Project(Gateway.Id, "agent:primary:main", "model", "other"); + Assert.False(known.Matches(Receipt)); + var incomplete = SetupDashboardLiveFacts.Project(Gateway.Id, "agent:primary:main", "model"); + Assert.Null(incomplete.ModelRef); + Assert.True(incomplete.Matches(Receipt)); + Assert.False((incomplete with { AgentId = "other" }).Matches(Receipt)); + } + + [Theory] + [InlineData("anthropic/claude-sonnet-4", "openrouter", "openrouter/anthropic/claude-sonnet-4")] + [InlineData("openrouter/anthropic/claude-sonnet-4", "openrouter", "openrouter/anthropic/claude-sonnet-4")] + [InlineData("model", "provider", "provider/model")] + [InlineData("anthropic/claude-sonnet-4", null, null)] + [InlineData("provider/model", "", null)] + public void CurrentHandshakeFacts_QualifyModelsUsingTheAuthenticatedProvider( + string model, string? provider, string? expected) + { + var facts = SetupDashboardLiveFacts.Project(Gateway.Id, "agent:primary:main", model, provider); + Assert.Equal(expected, facts.ModelRef); + if (expected is not null) + Assert.True(facts.Matches(Receipt with { ModelRef = expected })); + } + + [Fact] + public void PublicJsonCannotOverrideIntentModelOrGenerationOfALocalPendingRecord() + { + using var directory = new TempDirectory(); + var store = new SetupDashboardHandoffStore(directory.Path); + var handle = store.Issue(Choice); + var spoof = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes( + Receipt with { Intent = SetupCompletionIntent.Dashboard, ModelRef = "other/model", VerifiedGeneration = 999 })); + var router = new ActivationRouter("openclaw", "unused-source-test"); + var plan = Assert.IsType(router.PlanLaunch(new( + "openclaw://setup-dashboard?handle=" + handle + "&receipt=" + Uri.EscapeDataString(spoof), + [], null, false))); + using var lease = store.Acquire(Assert.IsType(plan.Route).Handle); + Assert.Equal(Receipt, lease!.Completion); + lease.Consume(); + } + + private sealed class Clock : TimeProvider + { + public DateTimeOffset Now { get; set; } = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + public override DateTimeOffset GetUtcNow() => Now; + } +} diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs new file mode 100644 index 000000000..57d3bea70 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -0,0 +1,202 @@ +using OpenClaw.Connection; +using OpenClaw.SetupEngine; +using OpenClawTray.Services; +using OpenClaw.TestSupport; + +namespace OpenClaw.Tray.Tests; + +public sealed class SetupDashboardHandoffTests +{ + private static GatewayRecord Gateway => new() + { + Id = "gateway-a", Url = "wss://gateway.example/control/", + }; + + private static GatewayAiSetupCompletion Receipt(SetupCompletionIntent intent = SetupCompletionIntent.CustodianOnboarding) => + new(intent, Gateway.Id, GatewayDashboardBinding.Capture(Gateway), "provider/model", "agent-a", 7, + IdentityBinding: new string('B', 64), SessionKey: "agent:agent-a:main"); + + private static SetupNativeCompletion Native(GatewayAiSetupCompletion receipt) => + new(receipt, new(SetupNativeDestination.Chat, "agent:agent-a:main")); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NativeStartup_DoesNotWaitForUpdatePromptOrExitForInstaller(bool acceptInstaller) + { + using var directory = new TempDirectory(); + var clock = new StartupClock(); + var store = new SetupDashboardHandoffStore(directory.Path, clock); + var handle = store.Issue(Native(Receipt())); + var router = new ActivationRouter("openclaw", "unused-source-test"); + var input = new LaunchActivationInput(null, [], handle, false); + var heldPrompt = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var updateCalls = 0; + var startup = router.CheckOrdinaryStartupUpdateAsync(input, () => + { + updateCalls++; + clock.Now += TimeSpan.FromMinutes(6); + return acceptInstaller ? Task.FromResult(false) : heldPrompt.Task; + }); + Assert.True(await startup.WaitAsync(TimeSpan.FromSeconds(1))); + Assert.Equal(0, updateCalls); + using var lease = store.Acquire(handle); + Assert.NotNull(lease); + Assert.False(lease!.IsExpired); + Assert.Equal(handle, Assert.IsType( + Assert.IsType(router.PlanLaunch(input)).Route).Handle); + } + + [Theory] + [InlineData(null)] + [InlineData("chat")] + [InlineData("ai-v3:invalid")] + public async Task OrdinaryStartup_StillRunsUpdateAndHonorsInstallerExit(string? target) + { + var router = new ActivationRouter("openclaw", "unused-source-test"); + var updates = 0; + Assert.False(await router.CheckOrdinaryStartupUpdateAsync(new(null, [], target, false), + () => { updates++; return Task.FromResult(false); })); + Assert.Equal(1, updates); + } + + [Theory] + [InlineData(SetupCompletionIntent.Dashboard)] + [InlineData(SetupCompletionIntent.CustodianOnboarding)] + public void RestartAndForwardedActivation_PreserveTypedReceipt(SetupCompletionIntent intent) + { + var receipt = Receipt(intent); + using var directory = new TempDirectory(); + var store = new SetupDashboardHandoffStore(directory.Path); + var encoded = store.Issue(Native(receipt)); + Assert.Equal(encoded, SetupDashboardHandoff.ParseHandle(encoded)); + var router = new ActivationRouter("openclaw", "unused-source-test"); + foreach (var input in new[] + { + new LaunchActivationInput(null, [], encoded, false), + new LaunchActivationInput( + "openclaw://setup-dashboard?handle=" + Uri.EscapeDataString(encoded), [], null, false), + }) + { + var plan = Assert.IsType(router.PlanLaunch(input)); + Assert.Equal(encoded, Assert.IsType(plan.Route).Handle); + } + using var lease = store.Acquire(encoded); + Assert.Equal(receipt, lease!.Completion); + lease.Consume(); + } + + [Theory] + [InlineData("chat", "chat")] + [InlineData("settings", "settings")] + [InlineData("connection", "connection")] + public void OldRestartArguments_KeepTheirRoutes(string value, string page) + { + var router = new ActivationRouter("openclaw", "unused-source-test"); + var plan = Assert.IsType( + router.PlanLaunch(new(null, [], value, false))); + Assert.Equal(page, Assert.IsType(plan.Route).Page); + } + + [Theory] + [InlineData("ai-v1:invalid")] + [InlineData("ai-v2:0000000000000000000000000000000000000000000000000000000000000000")] + public void RetiredOrInvalidHandoff_IsVisibleFailureRoute_NotGlobalDashboardFallback(string handle) + { + var router = new ActivationRouter("openclaw", "unused-source-test"); + var plan = Assert.IsType( + router.PlanLaunch(new(null, [], handle, false))); + Assert.Null(Assert.IsType(plan.Route).Handle); + using var directory = new TempDirectory(); + var store = new SetupDashboardHandoffStore(directory.Path); + Assert.Null(store.Acquire(handle)); + Assert.Throws(() => store.Issue(Native(Receipt() with { ModelTarget = "utility" }))); + Assert.Throws(() => store.Issue(Native(Receipt() with { VerifiedGeneration = 0 }))); + } + + [Theory] + [InlineData(null, "")] + [InlineData("channels", "/channels")] + public async Task OrdinaryDashboard_UsesRequestedPathAndNormalCredentials(string? path, string suffix) + { + string? launched = null; + var launcher = new GatewayDashboardLauncher(() => true, + () => new(Gateway.Url, "synthetic shared", false, CredentialResolver.SourceSharedGatewayToken), + url => { launched = url; return Task.FromResult(true); }, + () => throw new InvalidOperationException("Unexpected failure")); + Assert.True(await launcher.OpenAsync(path)); + Assert.Equal("https://gateway.example/control" + suffix + "#token=synthetic%20shared", launched); + Assert.DoesNotContain("custodian", launched); + } + + [Theory] + [InlineData(CredentialResolver.SourceDeviceToken, false)] + [InlineData(CredentialResolver.SourceBootstrapToken, true)] + public async Task DeviceAndBootstrapTokens_NeverEnterBrowserUrl(string source, bool bootstrap) + { + string? launched = null; + var launcher = new GatewayDashboardLauncher(() => true, + () => new(Gateway.Url, "do-not-export", bootstrap, source), + url => { launched = url; return Task.FromResult(true); }, + () => throw new InvalidOperationException("Unexpected failure")); + Assert.True(await launcher.OpenAsync()); + Assert.Equal("https://gateway.example/control", launched); + } + + [Theory] + [InlineData("tunnel")] + [InlineData("credential")] + [InlineData("browser")] + [InlineData("exception")] + public async Task Failure_IsReportedOnceWithoutAutomaticRetry(string failure) + { + var launches = 0; + var failures = 0; + var launcher = new GatewayDashboardLauncher(() => failure != "tunnel", + () => failure == "credential" ? null : new(Gateway.Url, "shared", false, CredentialResolver.SourceSharedGatewayToken), + _ => + { + launches++; + if (failure == "exception") throw new InvalidOperationException("synthetic-secret-url"); + return Task.FromResult(false); + }, () => failures++); + Assert.False(await launcher.OpenAsync()); + Assert.Equal(failure is "browser" or "exception" ? 1 : 0, launches); + Assert.Equal(1, failures); + } + + [Fact] + public void EndpointBinding_ExcludesRotatingTokensButIncludesSshRealm() + { + var original = Gateway with { SshTunnel = new("user", "ssh.example", 18789, 19001) }; + var binding = GatewayDashboardBinding.Capture(original); + Assert.Equal(binding, GatewayDashboardBinding.Capture(original with { SharedGatewayToken = "rotated" })); + Assert.NotEqual(binding, GatewayDashboardBinding.Capture( + original with { SshTunnel = original.SshTunnel! with { Host = "other.example" } })); + } + + [Fact] + public async Task ExplicitDashboardRetry_KeepsRequestedPathAndClearsFailureOnlyAfterOpen() + { + var urls = new List(); + var failures = 0; + var opened = 0; + var launcher = new GatewayDashboardLauncher(() => true, + () => new(Gateway.Url, "synthetic", false, CredentialResolver.SourceSharedGatewayToken), + url => { urls.Add(url); return Task.FromResult(urls.Count == 2); }, + () => failures++, () => opened++); + Assert.False(await launcher.OpenAsync("channels")); + Assert.Equal(0, opened); + Assert.Single(urls); + Assert.True(await launcher.OpenAsync("channels")); + Assert.Equal(urls[0], urls[1]); + Assert.Equal(1, failures); + Assert.Equal(1, opened); + } + + private sealed class StartupClock : TimeProvider + { + public DateTimeOffset Now { get; set; } = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero); + public override DateTimeOffset GetUtcNow() => Now; + } +} diff --git a/tests/OpenClaw.Tray.Tests/SetupNativeHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupNativeHandoffTests.cs new file mode 100644 index 000000000..9f8557273 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SetupNativeHandoffTests.cs @@ -0,0 +1,236 @@ +using OpenClaw.Connection; +using OpenClaw.SetupEngine; +using OpenClaw.Shared; +using OpenClaw.TestSupport; +using OpenClawTray.Presentation; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class SetupNativeHandoffTests +{ + [Theory] + [InlineData("contract", false)] + [InlineData("response", false)] + [InlineData("json", false)] + [InlineData("identity", false)] + [InlineData("contract", true)] + [InlineData("response", true)] + [InlineData("identity", true)] + public async Task ExpectedPostLeaseFailuresAreSettledAndReported(string kind, bool duringOpen) + { + using var temp = new TempDirectory(); + var store = new SetupDashboardHandoffStore(temp.Path); + var handle = store.Issue(Choice); + Exception error = kind switch + { + "identity" => new DeviceIdentityLoadException("synthetic", new IOException()), + "response" => new InvalidDataException("Empty verification response"), + "json" => new System.Text.Json.JsonException("Bad verification response"), + _ => new NotSupportedException("Missing verify API"), + }; + var failures = new List(); + var launcher = new SetupNativeHandoffLauncher(() => Gateway, + (_, _) => duringOpen ? Task.FromResult(new SetupVerifiedNativeRoute(Proof, Choice.Target.SessionKey)) + : Task.FromException(error), + (_, _) => Task.FromException(error), failures.Add); + Assert.False(await launcher.OpenAsync(store, handle)); + Assert.Equal([kind == "identity" ? SetupNativeLaunchFailure.Changed : SetupNativeLaunchFailure.Unavailable], failures); + Assert.Null(store.Acquire(handle)); + using var retry = store.Acquire(handle, explicitRetry: true); + if (kind == "identity") Assert.Null(retry); + else { Assert.NotNull(retry); retry!.Consume(); } + } + + [Fact] + public async Task OversizedPendingRecord_ReportsInvalidWithoutOpeningOrAutomaticRetry() + { + using var temp = new TempDirectory(); + var store = new SetupDashboardHandoffStore(temp.Path); + var handle = store.Issue(Choice); + File.WriteAllText(Path.Combine(temp.Path, "setup-dashboard-handoff", "pending.json"), new string('x', 17000)); + var failures = new List(); + var launcher = new SetupNativeHandoffLauncher(() => Gateway, + (_, _) => throw new InvalidOperationException("Must not verify"), + (_, _) => throw new InvalidOperationException("Must not open"), failures.Add); + Assert.False(await launcher.OpenAsync(store, handle)); + Assert.Equal([SetupNativeLaunchFailure.Invalid], failures); + } + + private static GatewayRecord Gateway => new() { Id = "a", Url = "wss://gateway.example/control/" }; + private static GatewayAiSetupCompletion Proof => new(SetupCompletionIntent.CustodianOnboarding, + Gateway.Id, GatewayDashboardBinding.Capture(Gateway), "provider/model", "primary", 4, + IdentityBinding: new string('B', 64), SessionKey: "agent:primary:main"); + private static SetupNativeCompletion Choice => new(Proof, new(SetupNativeDestination.Telegram, "agent:primary:main")); + + [Theory] + [InlineData(SetupNativeDestination.Chat, 0, "chat")] + [InlineData(SetupNativeDestination.WhatsApp, 1, "channels")] + [InlineData(SetupNativeDestination.Telegram, 2, "channels")] + [InlineData(SetupNativeDestination.Channels, 3, "channels")] + [InlineData(SetupNativeDestination.Skills, 4, "skills")] + public void NativeRecordIsVersionedAndCannotBecomeALegacyBrowserReceipt(SetupNativeDestination destination, int persistedValue, string page) + { + using var temp = new TempDirectory(); + var store = new SetupDashboardHandoffStore(temp.Path); + var choice = Choice with { Target = Choice.Target with { Destination = destination } }; + Assert.Equal(persistedValue, (int)destination); + Assert.Equal(page, new SetupNativeNavigationRequest(choice).PageTag); + var handle = store.Issue(choice); + Assert.NotNull(SetupDashboardHandoff.ParseHandle(handle)); + Assert.StartsWith("ai-v3:", handle); + Assert.Null(store.Acquire("ai-v2:" + handle[6..])); + using var lease = store.Acquire(handle); + Assert.Equal(choice.Target, lease!.NativeTarget); + lease.Consume(); + Assert.Null(store.Acquire(handle)); + } + + [Theory] + [InlineData("gateway")] + [InlineData("agent")] + [InlineData("endpoint")] + [InlineData("disconnected")] + public void SkillsBindingRejectsDrift(string changed) + { + var request = new SetupNativeNavigationRequest(Choice with + { Target = new(SetupNativeDestination.Skills, "agent:primary:main") }); + var gateway = Gateway with + { + Id = changed == "gateway" ? "other" : Gateway.Id, + Url = changed == "endpoint" ? "wss://other.example/" : Gateway.Url, + }; + Assert.ThrowsAny(() => request.RequireCurrent(gateway, "a", + changed == "agent" ? "agent:other:main" : "agent:primary:main", changed != "disconnected")); + } + + [Fact] + public async Task NativeOpenVerifiesBeforeNavigationAndKeepsFailedLaunchForExplicitRetryOnly() + { + using var temp = new TempDirectory(); + var store = new SetupDashboardHandoffStore(temp.Path); + var handle = store.Issue(Choice); + var calls = new List(); + var attempts = 0; + var launcher = new SetupNativeHandoffLauncher(() => Gateway, + (_, _) => { calls.Add("verify"); return Task.FromResult(new SetupVerifiedNativeRoute(Proof, Choice.Target.SessionKey)); }, + (choice, _) => + { + Assert.Equal(Choice, choice); + calls.Add("open"); + return ++attempts == 1 ? Task.FromException(new IOException("Synthetic failure")) : Task.CompletedTask; + }, _ => calls.Add("failure")); + Assert.False(await launcher.OpenAsync(store, handle)); + Assert.False(await launcher.OpenAsync(store, handle)); + Assert.Equal(1, attempts); + Assert.True(await launcher.OpenAsync(store, handle, explicitRetry: true)); + Assert.Equal(["verify", "open", "failure", "failure", "verify", "open"], calls); + Assert.Null(store.Acquire(handle, explicitRetry: true)); + } + + [Theory] + [InlineData("gateway")] + [InlineData("agent")] + [InlineData("session")] + [InlineData("model")] + [InlineData("device")] + [InlineData("same-agent-session")] + public async Task NativeDriftCannotOpenOrRemainRetryable(string drift) + { + using var temp = new TempDirectory(); + var store = new SetupDashboardHandoffStore(temp.Path); + var handle = store.Issue(Choice); + var current = Proof with { AgentId = drift == "agent" ? "other" : Proof.AgentId, + ModelRef = drift == "model" ? "other/model" : Proof.ModelRef, + IdentityBinding = drift == "device" ? new string('C', 64) : Proof.IdentityBinding, + SessionKey = drift == "same-agent-session" ? "agent:primary:alternate" : Proof.SessionKey }; + var launcher = new SetupNativeHandoffLauncher(() => drift == "gateway" ? new() { Id = "b", Url = Gateway.Url } : Gateway, + (_, _) => Task.FromResult(new SetupVerifiedNativeRoute(current, + drift == "session" ? "agent:primary:other" : current.SessionKey!)), + (_, _) => throw new InvalidOperationException("Must not open"), + failure => Assert.Equal(SetupNativeLaunchFailure.Changed, failure)); + Assert.False(await launcher.OpenAsync(store, handle)); + Assert.Null(store.Acquire(handle, explicitRetry: true)); + } + + [Fact] + public async Task ConnectionTimeoutRetainsExplicitRetryInsteadOfLeavingAnInflightRecord() + { + using var temp = new TempDirectory(); + var store = new SetupDashboardHandoffStore(temp.Path); + var handle = store.Issue(Choice); + var calls = 0; + var opened = 0; + var launcher = new SetupNativeHandoffLauncher(() => Gateway, + (_, _) => ++calls == 1 + ? Task.FromException(new TimeoutException("Synthetic connection timeout")) + : Task.FromResult(new SetupVerifiedNativeRoute(Proof, Choice.Target.SessionKey)), + (_, _) => { opened++; return Task.CompletedTask; }, + failure => Assert.Equal(SetupNativeLaunchFailure.Unavailable, failure)); + Assert.False(await launcher.OpenAsync(store, handle)); + Assert.Equal(0, opened); + Assert.True(await launcher.OpenAsync(store, handle, explicitRetry: true)); + Assert.Equal(1, opened); + } + + [Fact] + public async Task ConcurrentNativeActivation_DoesNotQueueAnotherLaunch() + { + using var temp = new TempDirectory(); + var store = new SetupDashboardHandoffStore(temp.Path); + var handle = store.Issue(Choice); + var presented = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var launches = 0; + var launcher = new SetupNativeHandoffLauncher(() => Gateway, + (_, _) => Task.FromResult(new SetupVerifiedNativeRoute(Proof, Choice.Target.SessionKey)), + (_, _) => { launches++; return presented.Task; }, _ => { }); + var first = launcher.OpenAsync(store, handle); + Assert.False(await launcher.OpenAsync(new(temp.Path), handle)); + presented.SetResult(); + Assert.True(await first); + Assert.Equal(1, launches); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExpiryOrEndpointDriftDuringVerification_CannotLaunchOrRetry(bool expiry) + { + using var temp = new TempDirectory(); + var clock = new Clock(); + var store = new SetupDashboardHandoffStore(temp.Path, clock); + var handle = store.Issue(Choice); + var gateway = Gateway; + var launcher = new SetupNativeHandoffLauncher(() => gateway, + (_, _) => + { + if (expiry) clock.Now += TimeSpan.FromMinutes(6); + else gateway = gateway with { Url = "wss://other.example/" }; + return Task.FromResult(new SetupVerifiedNativeRoute(Proof, Choice.Target.SessionKey)); + }, + (_, _) => throw new InvalidOperationException("Must not open"), + failure => Assert.Equal(SetupNativeLaunchFailure.Changed, failure)); + Assert.False(await launcher.OpenAsync(store, handle)); + Assert.Null(store.Acquire(handle, explicitRetry: true)); + } + + [Fact] + public void FocusPolicyDoesNotTreatFallbackOrConfiguredOtherChannelsAsAuthoritativeAbsence() + { + Assert.Equal(SetupChannelAvailability.Unconfirmed, + SetupChannelFocusPolicy.GetAvailability(new(), "whatsapp")); + Assert.Equal(SetupChannelAvailability.Unconfirmed, + SetupChannelFocusPolicy.GetAvailability(new() { Channels = new Dictionary + { ["telegram"] = default } }, "whatsapp")); + Assert.Equal(SetupChannelAvailability.NotOffered, + SetupChannelFocusPolicy.GetAvailability(new() { ChannelOrder = ["telegram"] }, "whatsapp")); + Assert.Equal(SetupChannelAvailability.Offered, + SetupChannelFocusPolicy.GetAvailability(new() { ChannelOrder = ["whatsapp"] }, "whatsapp")); + } + + private sealed class Clock : TimeProvider + { + public DateTimeOffset Now { get; set; } = new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); + public override DateTimeOffset GetUtcNow() => Now; + } +} diff --git a/tests/OpenClaw.Tray.Tests/SetupNativeSkillsTests.cs b/tests/OpenClaw.Tray.Tests/SetupNativeSkillsTests.cs new file mode 100644 index 000000000..6943cb646 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SetupNativeSkillsTests.cs @@ -0,0 +1,75 @@ +using System.Reflection; +using System.Text.Json; +using OpenClaw.SetupEngine; +using OpenClaw.Shared; +using OpenClawTray.Presentation; + +namespace OpenClaw.Tray.Tests; + +public sealed class SetupNativeSkillsTests +{ + private static SetupNativeNavigationRequest Request => new(new( + new(SetupCompletionIntent.CustodianOnboarding, "gateway", "binding", "provider/model", "primary", 1, + IdentityBinding: new string('B', 64), SessionKey: "agent:primary:main"), + new(SetupNativeDestination.Skills, "agent:primary:main"))); + + [Fact] + public async Task LoadsOnlyResponseBoundSkillsForVerifiedAgent() + { + var client = DispatchProxy.Create(); + var result = await SetupNativeSkills.LoadAsync(Request, () => client, CancellationToken.None); + Assert.Empty(result.GetProperty("skills").EnumerateArray()); + Assert.Equal(1, ((SkillsClient)client).Calls); + } + + [Fact] + public async Task ClientReplacementDuringLoadCannotPresentData() + { + var first = DispatchProxy.Create(); + var second = DispatchProxy.Create(); + var calls = 0; + await Assert.ThrowsAsync(() => + SetupNativeSkills.LoadAsync(Request, () => ++calls == 1 ? first : second, CancellationToken.None)); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"skills\":null}")] + [InlineData("[]")] + public async Task MalformedResponseIsNotSuccessfulPresentation(string json) + { + var client = DispatchProxy.Create(); + ((SkillsClient)client).Response = Task.FromResult(JsonDocument.Parse(json).RootElement.Clone()); + await Assert.ThrowsAsync(() => + SetupNativeSkills.LoadAsync(Request, () => client, CancellationToken.None)); + } + + [Fact] + public async Task CancelledReadDoesNotWaitForGatewayOrInstallAnything() + { + var client = DispatchProxy.Create(); + ((SkillsClient)client).Response = new TaskCompletionSource().Task; + using var cancel = new CancellationTokenSource(); + var load = SetupNativeSkills.LoadAsync(Request, () => client, cancel.Token); + cancel.Cancel(); + await Assert.ThrowsAnyAsync(() => load); + Assert.Equal(1, ((SkillsClient)client).Calls); + } + + public class SkillsClient : DispatchProxy + { + public int Calls { get; private set; } + public Task Response { get; set; } = + Task.FromResult(JsonDocument.Parse("{\"skills\":[]}").RootElement.Clone()); + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + Assert.Equal(nameof(IOperatorGatewayClient.SendWizardRequestAsync), targetMethod!.Name); + Assert.Equal("skills.status", args![0]); + Assert.Equal("primary", JsonSerializer.SerializeToElement(args[1]).GetProperty("agentId").GetString()); + Assert.Equal(12000, args[2]); + Calls++; + return Response; + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/SetupProofIsolationContractTests.cs b/tests/OpenClaw.Tray.Tests/SetupProofIsolationContractTests.cs new file mode 100644 index 000000000..4c21db413 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SetupProofIsolationContractTests.cs @@ -0,0 +1,19 @@ +namespace OpenClaw.Tray.Tests; + +public sealed class SetupProofIsolationContractTests +{ + [Fact] + public void RealSetupProof_DoesNotUseProductionStartupCleanupIdentities() + { + var source = File.ReadAllText(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), + "tests", "OpenClaw.E2ETests", "Setup", "E2ESetupFixture.cs")); + Assert.Contains("BuildUninstallArguments(_configPath, _distroName, uninstallLogPath)", source); + Assert.Contains("\"--autostart-name\", $\"{distroName}-Tray\"", source); + Assert.Contains("\"--startup-task-name\", $\"{distroName}-Startup\"", source); + Assert.Contains("\"OPENCLAW_TRAY_APPDATA_DIR\", RoamingAppDataRoot", source); + Assert.Contains("psi.Environment[\"OPENCLAW_TRAY_APPDATA_DIR\"] = RoamingAppDataRoot", source); + Assert.DoesNotContain("\"OPENCLAW_TRAY_APPDATA_DIR\", DataDir", source); + Assert.DoesNotContain("psi.Environment[\"OPENCLAW_TRAY_APPDATA_DIR\"] = DataDir", source); + Assert.Contains("Directory.Delete(RoamingAppDataRoot, recursive: true)", source); + } +} diff --git a/tests/OpenClaw.Tray.Tests/SetupStartupPolicyTests.cs b/tests/OpenClaw.Tray.Tests/SetupStartupPolicyTests.cs new file mode 100644 index 000000000..9f6fe7ce3 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SetupStartupPolicyTests.cs @@ -0,0 +1,97 @@ +using OpenClawTray.Services; +using OpenClaw.Shared; + +namespace OpenClaw.Tray.Tests; + +public sealed class SetupStartupPolicyTests +{ + [Theory] + [InlineData(StartupTaskInspection.Absent)] + [InlineData(StartupTaskInspection.Different)] + [InlineData(StartupTaskInspection.Unknown)] + public void AmbiguousRegistrationNeverCreatesRunKey(StartupTaskInspection inspection) + { + var writes = 0; + Assert.Throws(() => SetupStartupPolicy.ApplyUnpackaged(true, + () => StartupTaskRegistrationOutcome.Unknown, () => inspection, () => true, + () => writes++, () => { })); + Assert.Equal(0, writes); + } + + [Fact] + public void AmbiguousRegistrationCanCompleteOnlyWithExactEnabledTaskProof() + { + var removedRunKey = false; + SetupStartupPolicy.ApplyUnpackaged(true, () => StartupTaskRegistrationOutcome.Unknown, + () => StartupTaskInspection.ExpectedEnabled, () => true, + () => throw new Exception("Must not create duplicate startup"), () => removedRunKey = true); + Assert.True(removedRunKey); + } + + [Fact] + public void DisableRequiresBothRunKeyRemovalAndSuccessfulTaskRemoval() + { + var keyRemoved = false; + Assert.Throws(() => SetupStartupPolicy.ApplyUnpackaged(false, + () => throw new Exception(), () => StartupTaskInspection.ExpectedEnabled, () => false, () => throw new Exception(), + () => keyRemoved = true)); + Assert.True(keyRemoved); + var taskRemoved = false; + SetupStartupPolicy.ApplyUnpackaged(false, () => StartupTaskRegistrationOutcome.Rejected, () => StartupTaskInspection.ExpectedEnabled, + () => { taskRemoved = true; return true; }, () => { }, () => { }); + Assert.True(taskRemoved); + } + + [Fact] + public void MissingTaskIsIdempotentButUnknownTaskStateIsNotSuccess() + { + SetupStartupPolicy.ApplyUnpackaged(false, () => StartupTaskRegistrationOutcome.Rejected, () => StartupTaskInspection.Absent, + () => throw new Exception("Must not remove absent task"), () => { }, () => { }); + Assert.Throws(() => SetupStartupPolicy.ApplyUnpackaged(false, () => StartupTaskRegistrationOutcome.Rejected, + () => throw new IOException("Cannot query"), () => true, () => { }, () => { })); + } + + [Fact] + public void EnableConfirmsFallbackAndDoesNotSwallowRegistryFailures() + { + var fallback = false; + SetupStartupPolicy.ApplyUnpackaged(true, () => StartupTaskRegistrationOutcome.Rejected, () => StartupTaskInspection.Absent, () => true, + () => fallback = true, () => { }); + Assert.True(fallback); + Assert.Throws(() => SetupStartupPolicy.ApplyUnpackaged(true, + () => StartupTaskRegistrationOutcome.Rejected, () => StartupTaskInspection.Absent, () => true, () => throw new UnauthorizedAccessException(), () => { })); + Assert.Throws(() => SetupStartupPolicy.ApplyUnpackaged(true, + () => StartupTaskRegistrationOutcome.Registered, () => StartupTaskInspection.Absent, () => true, () => { }, () => throw new IOException())); + } + + [Fact] + public async Task ClassicFailureIsAcknowledgedBeforeRestartCanContinue() + { + var calls = new List(); + var warning = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var stage = SetupStartupPolicy.ApplyClassicPreferenceAsync(true, + _ => { calls.Add("apply"); throw new InvalidOperationException("Windows permission"); }, + () => { calls.Add("startup-warning"); return warning.Task; }); + Assert.False(stage.IsCompleted); + warning.SetResult(); + await stage; + calls.Add("restart"); + Assert.Equal(["apply", "startup-warning", "restart"], calls); + await SetupStartupPolicy.ApplyClassicPreferenceAsync(null, + _ => throw new Exception("Preserve must not apply"), () => throw new Exception("Must not warn")); + } + + [Fact] + public async Task ExplicitSetupUsesTheExistingMutationGateAndRejectsReplacedIntent() + { + using var gate = new SemaphoreSlim(0, 1); + var preference = false; + var writes = 0; + var task = AutoStartSettingsApplier.ApplyExplicitAsync(gate, false, () => preference, + _ => { writes++; return Task.CompletedTask; }, CancellationToken.None); + preference = true; + gate.Release(); + await Assert.ThrowsAsync(() => task); + Assert.Equal(0, writes); + } +} diff --git a/tests/OpenClaw.Tray.Tests/SetupWindowArgumentProjectionTests.cs b/tests/OpenClaw.Tray.Tests/SetupWindowArgumentProjectionTests.cs index 8e8e58c2b..6a80bc231 100644 --- a/tests/OpenClaw.Tray.Tests/SetupWindowArgumentProjectionTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupWindowArgumentProjectionTests.cs @@ -4,6 +4,18 @@ namespace OpenClaw.Tray.Tests; public sealed class SetupWindowArgumentProjectionTests { + [Fact] + public void Project_StripsNativeRestartHandleButPreservesConfig() + { + var handle = "ai-v3:" + new string('a', 64); + Assert.Equal(["--config", "custom.json"], SetupWindowArgumentProjection.Project( + ["app.exe", "--post-setup-restart", "--post-setup-launch", handle, "--config", "custom.json"], + _ => false, 1000)); + Assert.Equal(["--post-setup-launch", "ai-v3:invalid", "--config", "custom.json"], + SetupWindowArgumentProjection.Project( + ["app.exe", "--post-setup-launch", "ai-v3:invalid", "--config", "custom.json"], _ => false, 1000)); + } + [Fact] public void Project_RemovesHostArgumentsAndPreservesSetupAndUnknownTokens() { @@ -112,7 +124,7 @@ public void Project_PreservesInvalidWaitForPidValues(string value) } [Theory] - [InlineData("settings")] + [InlineData("permissions")] [InlineData("browser")] public void Project_PreservesUnknownPostSetupLaunchTargets(string value) { @@ -142,6 +154,10 @@ public void Project_RemovesValidWaitForPidValues(string value) [Theory] [InlineData("chat")] [InlineData("CHAT")] + [InlineData("settings")] + [InlineData("SETTINGS")] + [InlineData("connection")] + [InlineData("CONNECTION")] public void Project_RemovesRecognizedPostSetupLaunchTargets(string value) { var projected = SetupWindowArgumentProjection.Project( diff --git a/tests/OpenClaw.Tray.Tests/WindowManagerTests.cs b/tests/OpenClaw.Tray.Tests/WindowManagerTests.cs index b7d8352d2..957b16615 100644 --- a/tests/OpenClaw.Tray.Tests/WindowManagerTests.cs +++ b/tests/OpenClaw.Tray.Tests/WindowManagerTests.cs @@ -54,11 +54,14 @@ public void LocalAiSetup_ChoosesRecoveryOnlyAfterManagedGatewayProof() var manager = ReadManager(); Assert.Contains("public async Task ShowLocalAiSetupAsync()", manager); - Assert.Contains("LocalAiGatewayDistroResolver.FindOwners(", manager); - Assert.Contains("ExistingConfigDetector.Detect(", manager); - Assert.Contains("new LocalAiManifestStore(", manager); - Assert.Contains("install?.Manifest.ModelCatalogId", manager); - Assert.Contains("LocalAiSetupRoutePolicy.Decide(", manager); + var resolver = File.ReadAllText(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), "src", + "OpenClaw.Tray.WinUI", "Services", "LocalAiSetupRouteResolver.cs")); + Assert.Contains("new LocalAiSetupRouteResolver(", manager); + Assert.Contains("LocalAiGatewayDistroResolver.FindOwners(", resolver); + Assert.Contains("ExistingConfigDetector.Detect(", resolver); + Assert.Contains("new LocalAiManifestStore(", resolver); + Assert.Contains("install?.Manifest.ModelCatalogId", resolver); + Assert.Contains("LocalAiSetupRoutePolicy.Decide(", resolver); AssertInOrder( manager, "if (resolution.Route == LocalAiSetupRoute.Provision)", @@ -109,17 +112,28 @@ public void LocalAiRecoveryMode_IsNotAppliedToAnExistingSetupWindow() AssertInOrder( capabilities, "private void Back_Click(object sender, RoutedEventArgs e)", - "if (_localAiRecoveryOnly)", - "SetupWindow.Active?.NavigateToWelcome(back: true);", - "return;"); + "SetupWindow.Active?.NavigateToWelcome(back: true);"); + AssertInOrder(setupWindow, + "else if (startAtLocalAiRecoveryReview)", + "NavigateToLocalAiSetup();"); + Assert.Contains( + "new GatewaySetupDetailArgs(AccessDraft, detail, _startAtLocalAiRecoveryReview, _pinLocalAiRecoveryModel, returnToReview)", + setupWindow); + var gatewayReview = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.SetupEngine.UI", + "Pages", "GatewaySetupPage.xaml.cs")); + Assert.Contains("if (_window?.IsLocalAiRecovery == true) _window.NavigateToWelcome(back: true);", gatewayReview); + var localAi = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.SetupEngine.UI", + "Controls", "LocalAiSetupControl.xaml.cs")); Assert.Contains( "LocalAiModelSelector.IsEnabled = isAvailable && !_localAiRecoveryModelPinned;", - capabilities); + localAi); Assert.Contains( "LocalAiToggle.IsEnabled = isAvailable && !_localAiRecoveryOnly;", - capabilities); + localAi); AssertInOrder( - capabilities, + localAi, "if (_localAiRecoveryModelPinned)", "eligibility = selectedEligibility;", "else if (!selectedEligibility.CanInstall)", diff --git a/tests/OpenClaw.Tray.Tests/WslKeepAlivePolicyTests.cs b/tests/OpenClaw.Tray.Tests/WslKeepAlivePolicyTests.cs index 147245298..fc2230ddc 100644 --- a/tests/OpenClaw.Tray.Tests/WslKeepAlivePolicyTests.cs +++ b/tests/OpenClaw.Tray.Tests/WslKeepAlivePolicyTests.cs @@ -5,6 +5,19 @@ namespace OpenClaw.Tray.Tests; public class WslKeepAlivePolicyTests { + [Fact] + public void IsolatedProfile_RequiresExplicitManagedGatewayBeforeAnyLifecycleAction() + { + Assert.False(WslKeepAlivePolicy.CanManageGateway(null, isIsolated: true)); + Assert.False(WslKeepAlivePolicy.CanManageGateway( + new GatewayRecord { Id = "fake", Url = "ws://127.0.0.1:59999", IsLocal = true }, + isIsolated: true)); + Assert.True(WslKeepAlivePolicy.CanManageGateway( + new GatewayRecord { Id = "owned", SetupManagedDistroName = "ScenarioOwnedGateway" }, + isIsolated: true)); + Assert.True(WslKeepAlivePolicy.CanManageGateway(null, isIsolated: false)); + } + [Fact] public void MarkedKeepaliveIdentity_RejectsReusedPidProcessNameOrStartTime() { diff --git a/tests/OpenClaw.Tray.UITests/AiReadyPageRenderingTests.cs b/tests/OpenClaw.Tray.UITests/AiReadyPageRenderingTests.cs new file mode 100644 index 000000000..412f66836 --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/AiReadyPageRenderingTests.cs @@ -0,0 +1,196 @@ +using System.Reflection; +using System.Text.Json; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using OpenClaw.Connection; +using OpenClaw.SetupEngine; +using OpenClaw.SetupEngine.UI; +using OpenClaw.SetupEngine.UI.Pages; +using OpenClaw.TestSupport; +using Xunit.Abstractions; + +namespace OpenClaw.Tray.UITests; + +/// Synthetic rendering only. Never activates a destination, connects a Gateway, or finalizes setup. +[Collection(UICollection.Name)] +public sealed class AiReadyPageRenderingTests(UIThreadFixture ui, ITestOutputHelper output) +{ + [Theory] + [InlineData(ElementTheme.Light)] + [InlineData(ElementTheme.Dark)] + public async Task VerifiedChooser_ShowsNativeChoicesWithoutIssuingOrFinalizing(ElementTheme theme) + { + OnboardingNativeProof.AssertIsolatedRoots(); + using var temp = new TempDirectory("native-ready-proof-"); + var data = temp.Combine("data"); + var config = new SetupConfig(); + config.WindowsNodeContext.Enabled = false; + var configPath = temp.Combine("config.json"); + File.WriteAllText(configPath, JsonSerializer.Serialize(config)); + var registry = new GatewayRegistry(data); + var gateway = registry.AddOrUpdate(new() { Id = "synthetic-ready", Url = "wss://synthetic.example/control/" }); + registry.SetActive(gateway.Id); + registry.Save(); + var identity = new OpenClaw.Shared.DeviceIdentity(registry.GetIdentityDirectory(gateway.Id)); + identity.Initialize(); + var proof = new GatewayAiSetupCompletion(SetupCompletionIntent.CustodianOnboarding, + gateway.Id, GatewayDashboardBinding.Capture(gateway), "synthetic/model", "synthetic", 1, + IdentityBinding: SetupCompletionAuthority.CaptureIdentity(registry.GetIdentityDirectory(gateway.Id), identity.DeviceId), + SessionKey: "agent:synthetic:main"); + await ui.RunOnUIAsync(async () => + { + var resources = OnboardingWindowsFlowTests.LoadProgressResources( + Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT")!); + Application.Current.Resources.MergedDictionaries.Add(resources); + SetupWindow? window = null; + try + { + window = OnboardingNativeProof.CreateWindow(() => new SetupWindow(configPath: configPath, + dataDir: data, localDataDir: temp.Combine("local"), commandLineArgs: [])); + var size = window.AppWindow.Size; + var root = Assert.IsType(window.Content); + var frame = Assert.IsType(root.FindName("RootFrame")); + using var navigation = OnboardingNativeProof.TrackNavigation(frame); + // Feed only the trusted host boundary in this fixture. This is not live AI-verification proof. + var completed = typeof(SetupWindow).GetMethod("CompleteVerifiedAiSetupAsync", + BindingFlags.Instance | BindingFlags.NonPublic)!; + await Assert.IsAssignableFrom(completed.Invoke(window, [proof])); + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENCLAW_UI_PROOF_DIR"))) + window.Activate(); + else + OnboardingNativeProof.ActivateOwned(window); + await OnboardingNativeProof.ApplyThemeSurfaceAsync(root, theme); + await TestSupport.WaitForRenderedConditionAsync(() => frame.Content is AiReadyPage { IsLoaded: true }, + "native ready chooser mounted"); + var page = Assert.IsType(frame.Content); + var choices = Assert.IsType(page.FindName("Choices")); + Assert.Equal(3, choices.Children.Count); + Assert.Equal(["Chat", "Channels", "Skills"], choices.Children.Cast().Select(item => item.Tag)); + Assert.All(choices.Children, item => Assert.True(Assert.IsAssignableFrom(item).IsEnabled)); + Assert.Null(page.FindName("SkipButton")); + Assert.Null(page.FindName("ReturnButton")); + Assert.Null(page.FindName("FinishButton")); + var badge = Assert.IsType(page.FindName("RecommendedBadge")); + Assert.Equal("Recommended", badge.Text); + Assert.True(badge.ActualWidth > 0 && badge.ActualHeight > 0); + Assert.Equal("Talk to my agent, recommended", + Microsoft.UI.Xaml.Automation.AutomationProperties.GetName(choices.Children[0])); + Assert.False(Assert.IsType(page.FindName("ErrorBar")).IsOpen); + Assert.False(Directory.Exists(Path.Combine(data, "setup-dashboard-handoff"))); + Assert.False(File.Exists(Path.Combine(data, "settings.json"))); + Assert.Equal(size, window.AppWindow.Size); + await OnboardingArtworkRenderingTests.SaveNativeWindowProofAsync(window, + $"native-ready-{theme}", output, page); + // Fail at the coordinator boundary without touching a Gateway or publishing a handoff. + var argsField = typeof(AiReadyPage).GetField("_args", BindingFlags.Instance | BindingFlags.NonPublic)!; + var originalArgs = argsField.GetValue(page)!; + ((SetupNativeCompletionCoordinator)originalArgs.GetType().GetProperty("Coordinator")!.GetValue(originalArgs)!).Dispose(); + using var failed = new SetupNativeCompletionCoordinator(proof, _ => Task.CompletedTask, + (_, _) => Task.FromException(new SetupNativeOwnershipException()), + (_, _) => throw new InvalidOperationException("Must not finalize"), + (_, _) => throw new InvalidOperationException("Must not publish")); + typeof(SetupWindow).GetField("_readyChoice", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(window, failed); + var failedArgs = Activator.CreateInstance(originalArgs.GetType(), failed, window)!; + argsField.SetValue(page, failedArgs); + await Assert.IsAssignableFrom(typeof(AiReadyPage).GetMethod("ChooseAsync", + BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(page, [failedArgs, SetupNativeDestination.Skills])); + Assert.True(Assert.IsType(page.FindName("ErrorBar")).IsOpen); + Assert.True(Assert.IsType