Skip to content

Commit cd02def

Browse files
shanselmanCopilot
andauthored
Fix unavailable sandbox toggle state (#825)
Normalize the Sandbox page toggle off when MXC is definitively unavailable, reject turning it back on in the host-fallback mode, and preserve strict fallback blocking so users who opted into command denial remain protected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5602c96 commit cd02def

2 files changed

Lines changed: 146 additions & 9 deletions

File tree

src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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;

tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,58 @@ public void AppNotifications_SandboxRiskMessageReflectsStrictFallbackBlocking()
358358
Assert.Contains("blocked", method);
359359
}
360360

361+
[Fact]
362+
public void SandboxPage_NormalizesDefinitiveUnavailableMxcOff()
363+
{
364+
var source = ReadSandboxPageSource();
365+
var refresh = ExtractMethod(source, "RefreshAvailabilityAsync");
366+
var loadState = ExtractMethod(source, "LoadState");
367+
var definitiveUnavailable = ExtractMethod(source, "IsSandboxDefinitivelyUnavailable");
368+
var normalize = ExtractMethod(source, "NormalizeSandboxToggleForAvailability");
369+
370+
AssertInOrder(
371+
refresh,
372+
"NormalizeSandboxToggleForAvailability();",
373+
"UpdateSandboxStatusCard();",
374+
"UpdateControlsEnabledState();");
375+
AssertInOrder(
376+
loadState,
377+
"NormalizeSandboxToggleForAvailability();",
378+
"UpdatePresetHighlight();",
379+
"UpdateSandboxStatusCard();",
380+
"UpdateControlsEnabledState();");
381+
Assert.Contains("HasAnyBackend: false", definitiveUnavailable);
382+
Assert.Contains("ProbeErrored: false", definitiveUnavailable);
383+
AssertInOrder(
384+
normalize,
385+
"settings.SystemRunSandboxEnabled",
386+
"settings.SystemRunBlockHostFallbackWhenMxcUnavailable",
387+
"settings.SystemRunSandboxEnabled = false");
388+
Assert.Contains("settings.SystemRunSandboxEnabled = false", normalize);
389+
Assert.Contains("SandboxEnabledToggle.IsOn = false", normalize);
390+
Assert.Contains("Save();", normalize);
391+
}
392+
393+
[Fact]
394+
public void SandboxPage_RejectsTurningOnWhenMxcIsDefinitivelyUnavailable()
395+
{
396+
var source = ReadSandboxPageSource();
397+
var toggle = ExtractMethod(source, "OnSandboxEnabledToggledAsync");
398+
var reject = ExtractMethod(source, "RejectSandboxEnableWhenUnavailableAsync");
399+
400+
AssertInOrder(
401+
toggle,
402+
"newValue",
403+
"!oldValue",
404+
"IsSandboxDefinitivelyUnavailable()",
405+
"!s.SystemRunBlockHostFallbackWhenMxcUnavailable",
406+
"await RejectSandboxEnableWhenUnavailableAsync();",
407+
"return;");
408+
Assert.Contains("SandboxEnabledToggle.IsOn = false", reject);
409+
Assert.Contains("Node Sandbox unavailable", reject);
410+
Assert.Contains("usable MXC backend", reject);
411+
}
412+
361413
private static string ReadCoordinatorSource()
362414
{
363415
var root = TestRepositoryPaths.GetRepositoryRoot();
@@ -377,11 +429,18 @@ private static string ReadAppSources()
377429
.Select(File.ReadAllText));
378430
}
379431

432+
private static string ReadSandboxPageSource()
433+
{
434+
var root = TestRepositoryPaths.GetRepositoryRoot();
435+
return File.ReadAllText(Path.Combine(
436+
root, "src", "OpenClaw.Tray.WinUI", "Pages", "SandboxPage.xaml.cs"));
437+
}
438+
380439
private static string ExtractMethod(string source, string methodName)
381440
{
382441
var match = Regex.Match(
383442
source,
384-
$@"(?m)^\s*(?:private|protected|public|internal)\s+(?:async\s+)?(?:Task(?:<[^>]+>)?|void|bool|string\??|IntPtr|OpenClaw\.Connection\.GatewayCredential\?)\s+{Regex.Escape(methodName)}\s*\(");
443+
$@"(?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*\(");
385444
Assert.True(match.Success, $"Could not find method {methodName}.");
386445

387446
var brace = source.IndexOf('{', match.Index);

0 commit comments

Comments
 (0)