Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 86 additions & 8 deletions src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -184,21 +185,18 @@ private void LoadState()
_suppress = false;
}

NormalizeSandboxToggleForAvailability();
UpdatePresetHighlight();
UpdateSandboxStatusCard();
UpdateControlsEnabledState();
}

/// <summary>
/// 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.
/// </summary>
private void UpdateSandboxStatusCard()
{
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 60 additions & 1 deletion tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down
Loading