Skip to content

Commit bc7fdca

Browse files
shanselmanCopilot
andcommitted
Fix MXC probe failure classification
Treat indeterminate probe failures as retryable unless structured probe JSON explicitly reports an unsupported host. Skip availability probing entirely when the sandbox toggle is off. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 404ead9 commit bc7fdca

4 files changed

Lines changed: 111 additions & 44 deletions

File tree

src/OpenClaw.Shared/Mxc/MxcAvailability.cs

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -224,26 +224,43 @@ internal static MxcProbeResult ParseProbeOutput(WxcProbeStatus status, int exitC
224224
$"Could not determine MXC sandbox support (wxc-exec --probe {what}{(detail is null ? "" : $": {Summarize(detail)}")}).");
225225
}
226226

227-
// The process ran to completion. A non-zero exit is the binary reporting that
228-
// this host cannot sandbox — a definitive, non-retryable verdict. (Genuine
229-
// infrastructure failures — timeout / failed launch — are captured above via
230-
// status, so they never reach this branch and get cached as "unsupported".)
231-
if (exitCode != 0)
232-
{
233-
var detail = FirstNonEmpty(stderr, stdout);
234-
return Unsupported(
235-
$"This Windows host does not support the MXC sandbox (wxc-exec --probe exited {exitCode}{(detail is null ? "" : $": {Summarize(detail)}")}).");
236-
}
237-
238-
// Exit 0 but no/garbled output is a binary anomaly, not a clear answer —
227+
// No/garbled output is a binary anomaly, not a clear answer —
239228
// treat as a (retryable) probe error rather than silently "unsupported".
240229
if (string.IsNullOrWhiteSpace(stdout))
241-
return Error("Could not determine MXC sandbox support (wxc-exec --probe returned no output).");
230+
{
231+
var detail = FirstNonEmpty(stderr);
232+
return Error(
233+
$"Could not determine MXC sandbox support (wxc-exec --probe {(exitCode == 0 ? "returned no output" : $"exited {exitCode}")}{(detail is null ? "" : $": {Summarize(detail)}")}).");
234+
}
242235

243236
try
244237
{
245238
using var doc = JsonDocument.Parse(stdout);
246239
var root = doc.RootElement;
240+
var warnings = ReadWarnings(root);
241+
242+
if (TryGetString(root, "error") is { } error)
243+
{
244+
return Unsupported(
245+
$"This Windows host does not support the MXC sandbox (wxc-exec --probe reported: {Summarize(error)}).",
246+
warnings);
247+
}
248+
249+
if (root.TryGetProperty("supported", out var supportedEl)
250+
&& supportedEl.ValueKind == JsonValueKind.False)
251+
{
252+
return Unsupported(
253+
"This Windows host does not support the MXC sandbox (wxc-exec --probe reported no usable isolation tier).",
254+
warnings);
255+
}
256+
257+
if (exitCode != 0)
258+
{
259+
var detail = FirstNonEmpty(stderr, stdout);
260+
return Error(
261+
$"Could not determine MXC sandbox support (wxc-exec --probe exited {exitCode}{(detail is null ? "" : $": {Summarize(detail)}")}).");
262+
}
263+
247264
if (root.ValueKind != JsonValueKind.Object
248265
|| !root.TryGetProperty("tier", out var tierEl)
249266
|| tierEl.ValueKind != JsonValueKind.String
@@ -255,29 +272,53 @@ internal static MxcProbeResult ParseProbeOutput(WxcProbeStatus status, int exitC
255272
var needsDacl = root.TryGetProperty("needsDaclAugmentation", out var d)
256273
&& d.ValueKind == JsonValueKind.True;
257274

258-
var warnings = new List<string>();
259-
if (root.TryGetProperty("warnings", out var w) && w.ValueKind == JsonValueKind.Array)
260-
{
261-
foreach (var item in w.EnumerateArray())
262-
{
263-
if (item.ValueKind != JsonValueKind.String) continue;
264-
var s = item.GetString();
265-
if (!string.IsNullOrWhiteSpace(s)) warnings.Add(s);
266-
}
267-
}
268-
269275
return new MxcProbeResult(MxcProbeOutcome.Supported, tierEl.GetString(), needsDacl, warnings, null);
270276
}
271277
catch (JsonException ex)
272278
{
273-
return Error($"Could not determine MXC sandbox support (wxc-exec --probe returned unparseable output: {ex.Message}).");
279+
var detail = FirstNonEmpty(stderr, stdout);
280+
return Error(
281+
$"Could not determine MXC sandbox support (wxc-exec --probe {(exitCode == 0 ? "returned unparseable output" : $"exited {exitCode}")}: {Summarize(detail ?? ex.Message)}).");
274282
}
275283

276-
static MxcProbeResult Unsupported(string reason) =>
277-
new(MxcProbeOutcome.UnsupportedHost, null, false, Array.Empty<string>(), reason);
284+
static MxcProbeResult Unsupported(string reason, IReadOnlyList<string>? warnings = null) =>
285+
new(MxcProbeOutcome.UnsupportedHost, null, false, warnings ?? Array.Empty<string>(), reason);
278286

279287
static MxcProbeResult Error(string reason) =>
280288
new(MxcProbeOutcome.ProbeError, null, false, Array.Empty<string>(), reason);
289+
290+
static string? TryGetString(JsonElement root, string propertyName)
291+
{
292+
if (root.ValueKind != JsonValueKind.Object
293+
|| !root.TryGetProperty(propertyName, out var property)
294+
|| property.ValueKind != JsonValueKind.String)
295+
{
296+
return null;
297+
}
298+
299+
var value = property.GetString();
300+
return string.IsNullOrWhiteSpace(value) ? null : value;
301+
}
302+
303+
static List<string> ReadWarnings(JsonElement root)
304+
{
305+
var warnings = new List<string>();
306+
if (root.ValueKind != JsonValueKind.Object
307+
|| !root.TryGetProperty("warnings", out var w)
308+
|| w.ValueKind != JsonValueKind.Array)
309+
{
310+
return warnings;
311+
}
312+
313+
foreach (var item in w.EnumerateArray())
314+
{
315+
if (item.ValueKind != JsonValueKind.String) continue;
316+
var s = item.GetString();
317+
if (!string.IsNullOrWhiteSpace(s)) warnings.Add(s);
318+
}
319+
320+
return warnings;
321+
}
281322
}
282323

283324
private static string? FirstNonEmpty(params string?[] values)

src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ public async Task<CommandResult> RunAsync(CommandRequest request, CancellationTo
5151
{
5252
var settings = _settingsProvider();
5353

54+
if (!settings.SystemRunSandboxEnabled)
55+
{
56+
_logger.Info("[mxc] sandbox=disabled; routing system.run through host runner");
57+
return await _hostFallback.RunAsync(request, ct);
58+
}
59+
5460
// When MXC sandboxing isn't available on this host (e.g. Windows 10, an
5561
// older build whose wxc-exec --probe reports no usable isolation tier, or
5662
// missing wxc-exec.exe), fall back to the host runner so the agent can
@@ -66,12 +72,6 @@ public async Task<CommandResult> RunAsync(CommandRequest request, CancellationTo
6672
return await _hostFallback.RunAsync(request, ct);
6773
}
6874

69-
if (!settings.SystemRunSandboxEnabled)
70-
{
71-
_logger.Info("[mxc] sandbox=disabled; routing system.run through host runner");
72-
return await _hostFallback.RunAsync(request, ct);
73-
}
74-
7575
var settingsDirectoryPath = _settingsDirectoryPathProvider();
7676
var policy = MxcPolicyBuilder.ForSystemRun(settings, settingsDirectoryPath);
7777
var argsJson = SerializeArgs(request);

tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,36 @@ public void ParseProbeOutput_CapturesWarningsAndDaclFlag()
9898
}
9999

100100
[Fact]
101-
public void ParseProbeOutput_CompletedNonZeroExit_ReportsUnsupportedHost()
101+
public void ParseProbeOutput_CompletedNonZeroExit_ReportsProbeError()
102102
{
103103
var result = MxcAvailability.ParseProbeOutput(
104104
WxcProbeStatus.Completed,
105105
exitCode: 1,
106106
stdout: "",
107-
stderr: "unsupported os");
107+
stderr: "unknown option --probe");
108+
109+
Assert.Equal(MxcProbeOutcome.ProbeError, result.Outcome);
110+
Assert.False(result.Supported);
111+
Assert.Null(result.Tier);
112+
Assert.NotNull(result.FailureReason);
113+
Assert.Contains("Could not determine", result.FailureReason!);
114+
}
115+
116+
[Fact]
117+
public void ParseProbeOutput_CompletedJsonError_ReportsUnsupportedHost()
118+
{
119+
var result = MxcAvailability.ParseProbeOutput(
120+
WxcProbeStatus.Completed,
121+
exitCode: 1,
122+
stdout: "{\"error\":\"unsupported Windows build\",\"warnings\":[\"need newer host\"]}",
123+
stderr: "");
108124

109-
// The binary ran to completion and returned a definitive negative — not a probe error.
110125
Assert.Equal(MxcProbeOutcome.UnsupportedHost, result.Outcome);
111126
Assert.False(result.Supported);
112127
Assert.Null(result.Tier);
128+
Assert.Equal(["need newer host"], result.Warnings);
113129
Assert.NotNull(result.FailureReason);
114-
Assert.Contains("does not support", result.FailureReason!);
130+
Assert.Contains("unsupported Windows build", result.FailureReason!);
115131
}
116132

117133
[Fact]
@@ -254,13 +270,11 @@ public void Probe_WhenProbeReportsNoTier_ReportsUnavailableWithReason()
254270
NullLogger.Instance,
255271
_ => new WxcProbeInvocation(WxcProbeStatus.Completed, 1, string.Empty, "unsupported os build"));
256272

257-
// wxc-exec is present, but the host probe said no → not a setup issue,
258-
// and a definitive verdict (exit 1) is NOT a transient probe error.
259273
Assert.True(availability.IsWxcExecResolvable);
260274
Assert.False(availability.IsAppContainerAvailable);
261275
Assert.False(availability.HasAnyBackend);
262276
Assert.NotEmpty(availability.UnsupportedReasons);
263-
Assert.False(availability.ProbeErrored);
277+
Assert.True(availability.ProbeErrored);
264278
}
265279
finally
266280
{

tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,25 @@ public async Task RunAsync_SandboxDisabled_AlwaysRoutesToHost()
6161
{
6262
Result = new CommandResult { ExitCode = 0, Stdout = "host" },
6363
};
64-
var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: false));
64+
var availabilityChecks = 0;
65+
var runner = new MxcCommandRunner(
66+
executor,
67+
fallback,
68+
() => NewSettings(sandboxEnabled: false),
69+
() => "C:\\test\\settings",
70+
() =>
71+
{
72+
availabilityChecks++;
73+
return true;
74+
},
75+
invalidateAvailability: null,
76+
NullLogger.Instance);
6577

6678
var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" });
6779

6880
Assert.Equal("host", result.Stdout);
6981
Assert.NotNull(fallback.LastRequest);
82+
Assert.Equal(0, availabilityChecks);
7083
// Executor must not have been touched.
7184
Assert.Null(executor.LastRequest);
7285
}
@@ -101,9 +114,8 @@ public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOff()
101114
[Fact]
102115
public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOn()
103116
{
104-
// Same as the toggle-off variant — the !_isSandboxAvailable() short-circuit
105-
// fires before either the toggle check or the executor path, and both
106-
// routes lead to the host fallback.
117+
// With sandboxing enabled, unavailable MXC is detected before the
118+
// executor path and routes to the host fallback.
107119
var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "MXC missing" };
108120
var fallback = new FakeCommandRunner
109121
{

0 commit comments

Comments
 (0)