@@ -65,6 +65,7 @@ private async System.Threading.Tasks.Task RefreshAvailabilityAsync()
6565 // await resumed us on the UI thread (DispatcherQueue sync context), so it
6666 // is safe to touch controls here. Always re-render — on both the happy
6767 // path and the failure path — so the page never stays in "Checking…".
68+ NormalizeSandboxToggleForAvailability ( ) ;
6869 UpdateSandboxStatusCard ( ) ;
6970 UpdateControlsEnabledState ( ) ;
7071 }
@@ -184,21 +185,18 @@ private void LoadState()
184185 _suppress = false ;
185186 }
186187
188+ NormalizeSandboxToggleForAvailability ( ) ;
187189 UpdatePresetHighlight ( ) ;
188190 UpdateSandboxStatusCard ( ) ;
189191 UpdateControlsEnabledState ( ) ;
190192 }
191193
192194 /// <summary>
193195 /// Drives the page header (icon + title + subtext + toggle visibility) based on
194- /// MXC availability AND the current sandbox toggle state. Three visual states:
195- /// 1. Available + ON → 🛡 "Sandbox is on" + toggle visible
196- /// 2. Available + OFF → ⚠ "Sandbox is off — high risk" + toggle visible
197- /// 3. Unavailable + ON → ⚠ "Sandbox unavailable — host fallback" or "commands blocked" + toggle visible
198- /// 4. Unavailable + OFF → ⚠ "Sandbox is off — host execution" + toggle visible
199- /// When MXC is unavailable and sandboxing is enabled, MxcCommandRunner uses
200- /// compatibility host fallback by default and blocks only when strict fallback
201- /// blocking is explicitly enabled.
196+ /// MXC availability AND the current sandbox toggle state. Definitively
197+ /// unavailable MXC is normalized to OFF so the UI never claims Node Sandbox is
198+ /// on when containment cannot run. Transient probe errors can still render the
199+ /// enabled/strict-blocking state until retry resolves the probe.
202200 /// </summary>
203201 private void UpdateSandboxStatusCard ( )
204202 {
@@ -347,6 +345,35 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability? ava
347345 UnavailableActionBar . IsOpen = true ;
348346 }
349347
348+ private bool IsSandboxDefinitivelyUnavailable ( )
349+ {
350+ return _cachedAvailability is { HasAnyBackend : false , ProbeErrored : false } ;
351+ }
352+
353+ private bool NormalizeSandboxToggleForAvailability ( )
354+ {
355+ if ( ! IsSandboxDefinitivelyUnavailable ( ) )
356+ return false ;
357+ if ( CurrentApp . Settings is not { } settings || ! settings . SystemRunSandboxEnabled )
358+ return false ;
359+ if ( settings . SystemRunBlockHostFallbackWhenMxcUnavailable )
360+ return false ;
361+
362+ _suppress = true ;
363+ try
364+ {
365+ settings . SystemRunSandboxEnabled = false ;
366+ SandboxEnabledToggle . IsOn = false ;
367+ }
368+ finally
369+ {
370+ _suppress = false ;
371+ }
372+
373+ Save ( ) ;
374+ return true ;
375+ }
376+
350377 private void OnUnavailableActionClick ( object sender , RoutedEventArgs e ) =>
351378 AsyncEventHandlerGuard . Run (
352379 ( ) => OnUnavailableActionClickAsync ( sender ) ,
@@ -603,6 +630,15 @@ private async Task OnSandboxEnabledToggledAsync()
603630 var newValue = SandboxEnabledToggle . IsOn ;
604631 var oldValue = s . SystemRunSandboxEnabled ;
605632
633+ if ( newValue
634+ && ! oldValue
635+ && IsSandboxDefinitivelyUnavailable ( )
636+ && ! s . SystemRunBlockHostFallbackWhenMxcUnavailable )
637+ {
638+ await RejectSandboxEnableWhenUnavailableAsync ( ) ;
639+ return ;
640+ }
641+
606642 // Confirm before turning sandbox OFF — this is the high-risk transition.
607643 if ( ! newValue && oldValue )
608644 {
@@ -659,6 +695,48 @@ private async Task OnSandboxEnabledToggledAsync()
659695 Save ( ) ;
660696 }
661697
698+ private async Task RejectSandboxEnableWhenUnavailableAsync ( )
699+ {
700+ _suppress = true ;
701+ try { SandboxEnabledToggle . IsOn = false ; }
702+ finally { _suppress = false ; }
703+
704+ UpdateSandboxStatusCard ( ) ;
705+ UpdateControlsEnabledState ( ) ;
706+
707+ if ( _dialogOpen )
708+ return ;
709+
710+ var reasonText = _cachedAvailability ? . UnsupportedReasons . Count > 0
711+ ? string . Join ( "\n " , _cachedAvailability . UnsupportedReasons )
712+ : L ( "SandboxPage_UnavailableDefaultReason" ) ;
713+ var dialog = new ContentDialog
714+ {
715+ Title = "Node Sandbox unavailable" ,
716+ Content =
717+ "Node Sandbox can't be turned on because this PC does not currently have a usable MXC backend.\n \n " +
718+ $ "{ reasonText } \n \n " +
719+ "Agent-started commands will keep using the host execution path until MXC is available." ,
720+ CloseButtonText = "OK" ,
721+ DefaultButton = ContentDialogButton . Close ,
722+ XamlRoot = this . XamlRoot ,
723+ } ;
724+
725+ _dialogOpen = true ;
726+ try
727+ {
728+ await dialog . ShowAsync ( ) ;
729+ }
730+ catch ( System . Runtime . InteropServices . COMException )
731+ {
732+ // Another dialog is already open. The toggle has already been restored.
733+ }
734+ finally
735+ {
736+ _dialogOpen = false ;
737+ }
738+ }
739+
662740 private void OnNetInternetToggled ( object sender , RoutedEventArgs e )
663741 {
664742 if ( _suppress ) return ;
0 commit comments