diff --git a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs index 859692aa2..f1de9a532 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs @@ -65,6 +65,7 @@ private async System.Threading.Tasks.Task RefreshAvailabilityAsync() // await resumed us on the UI thread (DispatcherQueue sync context), so it // is safe to touch controls here. Always re-render β€” on both the happy // path and the failure path β€” so the page never stays in "Checking…". + NormalizeSandboxToggleForAvailability(); UpdateSandboxStatusCard(); UpdateControlsEnabledState(); } @@ -184,6 +185,7 @@ private void LoadState() _suppress = false; } + NormalizeSandboxToggleForAvailability(); UpdatePresetHighlight(); UpdateSandboxStatusCard(); UpdateControlsEnabledState(); @@ -191,14 +193,10 @@ private void LoadState() /// /// Drives the page header (icon + title + subtext + toggle visibility) based on - /// MXC availability AND the current sandbox toggle state. Three visual states: - /// 1. Available + ON β†’ πŸ›‘ "Sandbox is on" + toggle visible - /// 2. Available + OFF β†’ ⚠ "Sandbox is off β€” high risk" + toggle visible - /// 3. Unavailable + ON β†’ ⚠ "Sandbox unavailable β€” host fallback" or "commands blocked" + toggle visible - /// 4. Unavailable + OFF β†’ ⚠ "Sandbox is off β€” host execution" + toggle visible - /// When MXC is unavailable and sandboxing is enabled, MxcCommandRunner uses - /// compatibility host fallback by default and blocks only when strict fallback - /// blocking is explicitly enabled. + /// MXC availability AND the current sandbox toggle state. Definitively + /// unavailable MXC is normalized to OFF so the UI never claims Node Sandbox is + /// on when containment cannot run. Transient probe errors can still render the + /// enabled/strict-blocking state until retry resolves the probe. /// private void UpdateSandboxStatusCard() { @@ -347,6 +345,35 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? ava UnavailableActionBar.IsOpen = true; } + private bool IsSandboxDefinitivelyUnavailable() + { + return _cachedAvailability is { HasAnyBackend: false, ProbeErrored: false }; + } + + private bool NormalizeSandboxToggleForAvailability() + { + if (!IsSandboxDefinitivelyUnavailable()) + return false; + if (CurrentApp.Settings is not { } settings || !settings.SystemRunSandboxEnabled) + return false; + if (settings.SystemRunBlockHostFallbackWhenMxcUnavailable) + return false; + + _suppress = true; + try + { + settings.SystemRunSandboxEnabled = false; + SandboxEnabledToggle.IsOn = false; + } + finally + { + _suppress = false; + } + + Save(); + return true; + } + private void OnUnavailableActionClick(object sender, RoutedEventArgs e) => AsyncEventHandlerGuard.Run( () => OnUnavailableActionClickAsync(sender), @@ -603,6 +630,15 @@ private async Task OnSandboxEnabledToggledAsync() var newValue = SandboxEnabledToggle.IsOn; var oldValue = s.SystemRunSandboxEnabled; + if (newValue + && !oldValue + && IsSandboxDefinitivelyUnavailable() + && !s.SystemRunBlockHostFallbackWhenMxcUnavailable) + { + await RejectSandboxEnableWhenUnavailableAsync(); + return; + } + // Confirm before turning sandbox OFF β€” this is the high-risk transition. if (!newValue && oldValue) { @@ -659,6 +695,48 @@ private async Task OnSandboxEnabledToggledAsync() Save(); } + private async Task RejectSandboxEnableWhenUnavailableAsync() + { + _suppress = true; + try { SandboxEnabledToggle.IsOn = false; } + finally { _suppress = false; } + + UpdateSandboxStatusCard(); + UpdateControlsEnabledState(); + + if (_dialogOpen) + return; + + var reasonText = _cachedAvailability?.UnsupportedReasons.Count > 0 + ? string.Join("\n", _cachedAvailability.UnsupportedReasons) + : L("SandboxPage_UnavailableDefaultReason"); + var dialog = new ContentDialog + { + Title = "Node Sandbox unavailable", + Content = + "Node Sandbox can't be turned on because this PC does not currently have a usable MXC backend.\n\n" + + $"{reasonText}\n\n" + + "Agent-started commands will keep using the host execution path until MXC is available.", + CloseButtonText = "OK", + DefaultButton = ContentDialogButton.Close, + XamlRoot = this.XamlRoot, + }; + + _dialogOpen = true; + try + { + await dialog.ShowAsync(); + } + catch (System.Runtime.InteropServices.COMException) + { + // Another dialog is already open. The toggle has already been restored. + } + finally + { + _dialogOpen = false; + } + } + private void OnNetInternetToggled(object sender, RoutedEventArgs e) { if (_suppress) return; diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index bf4e50bf2..4b4fe55eb 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -358,6 +358,58 @@ public void AppNotifications_SandboxRiskMessageReflectsStrictFallbackBlocking() Assert.Contains("blocked", method); } + [Fact] + public void SandboxPage_NormalizesDefinitiveUnavailableMxcOff() + { + var source = ReadSandboxPageSource(); + var refresh = ExtractMethod(source, "RefreshAvailabilityAsync"); + var loadState = ExtractMethod(source, "LoadState"); + var definitiveUnavailable = ExtractMethod(source, "IsSandboxDefinitivelyUnavailable"); + var normalize = ExtractMethod(source, "NormalizeSandboxToggleForAvailability"); + + AssertInOrder( + refresh, + "NormalizeSandboxToggleForAvailability();", + "UpdateSandboxStatusCard();", + "UpdateControlsEnabledState();"); + AssertInOrder( + loadState, + "NormalizeSandboxToggleForAvailability();", + "UpdatePresetHighlight();", + "UpdateSandboxStatusCard();", + "UpdateControlsEnabledState();"); + Assert.Contains("HasAnyBackend: false", definitiveUnavailable); + Assert.Contains("ProbeErrored: false", definitiveUnavailable); + AssertInOrder( + normalize, + "settings.SystemRunSandboxEnabled", + "settings.SystemRunBlockHostFallbackWhenMxcUnavailable", + "settings.SystemRunSandboxEnabled = false"); + Assert.Contains("settings.SystemRunSandboxEnabled = false", normalize); + Assert.Contains("SandboxEnabledToggle.IsOn = false", normalize); + Assert.Contains("Save();", normalize); + } + + [Fact] + public void SandboxPage_RejectsTurningOnWhenMxcIsDefinitivelyUnavailable() + { + var source = ReadSandboxPageSource(); + var toggle = ExtractMethod(source, "OnSandboxEnabledToggledAsync"); + var reject = ExtractMethod(source, "RejectSandboxEnableWhenUnavailableAsync"); + + AssertInOrder( + toggle, + "newValue", + "!oldValue", + "IsSandboxDefinitivelyUnavailable()", + "!s.SystemRunBlockHostFallbackWhenMxcUnavailable", + "await RejectSandboxEnableWhenUnavailableAsync();", + "return;"); + Assert.Contains("SandboxEnabledToggle.IsOn = false", reject); + Assert.Contains("Node Sandbox unavailable", reject); + Assert.Contains("usable MXC backend", reject); + } + private static string ReadCoordinatorSource() { var root = TestRepositoryPaths.GetRepositoryRoot(); @@ -377,11 +429,18 @@ private static string ReadAppSources() .Select(File.ReadAllText)); } + private static string ReadSandboxPageSource() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + return File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Pages", "SandboxPage.xaml.cs")); + } + private static string ExtractMethod(string source, string methodName) { var match = Regex.Match( source, - $@"(?m)^\s*(?:private|protected|public|internal)\s+(?:async\s+)?(?:Task(?:<[^>]+>)?|void|bool|string\??|IntPtr|OpenClaw\.Connection\.GatewayCredential\?)\s+{Regex.Escape(methodName)}\s*\("); + $@"(?m)^\s*(?:private|protected|public|internal)\s+(?:async\s+)?(?:Task(?:<[^>]+>)?|System\.Threading\.Tasks\.Task|void|bool|string\??|IntPtr|OpenClaw\.Connection\.GatewayCredential\?)\s+{Regex.Escape(methodName)}\s*\("); Assert.True(match.Success, $"Could not find method {methodName}."); var brace = source.IndexOf('{', match.Index);