diff --git a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs index 1f89e4540..bfef13c64 100644 --- a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs +++ b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs @@ -154,9 +154,10 @@ public async Task ExecuteAsync( if (ct.IsCancellationRequested) ct.ThrowIfCancellationRequested(); - // If our linked token tripped (timeout) and the caller's didn't, surface - // as a TimedOut result rather than throwing. - var timedOut = result.TimedOut || linked.IsCancellationRequested; + // The grace token can trip while stdout is drained after wxc-exec + // has already exited. Only the executor's TimedOut flag means the + // command itself did not finish. + var timedOut = CompletedResultIsTimeout(result, linked.Token); if (timedOut) { return new SandboxExecutionResult( @@ -185,6 +186,12 @@ public async Task ExecuteAsync( } } + internal static bool CompletedResultIsTimeout(MxcResult result, CancellationToken graceToken) + { + _ = graceToken; + return result.TimedOut; + } + private static string CreateScratchDir() { var dir = Path.Combine(Path.GetTempPath(), "openclaw-mxc-" + Guid.NewGuid().ToString("N").Substring(0, 12)); diff --git a/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs index 8808a2d03..f3bd4bad3 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs @@ -27,6 +27,26 @@ public class DirectAppContainerExecutorTests TimeoutMs: 30_000), TimeoutMs: 30_000); + [Fact] + public void CompletedResult_IsNotATimeoutWhenTheGraceTokenIsCancelled() + { + using var grace = new CancellationTokenSource(); + grace.Cancel(); + var finished = new MxcResult + { + Success = true, + ExitCode = 7, + Output = "done", + TimedOut = false, + }; + + Assert.False(DirectAppContainerExecutor.CompletedResultIsTimeout(finished, grace.Token)); + Assert.True(grace.IsCancellationRequested); + + var timedOut = new MxcResult { ExitCode = -1, TimedOut = true }; + Assert.True(DirectAppContainerExecutor.CompletedResultIsTimeout(timedOut, grace.Token)); + } + [Fact] public async Task ExecuteAsync_AppContainerUnavailable_Throws() { diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs index 8cc306f5a..597501ddb 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs @@ -306,6 +306,67 @@ public async Task SystemRun_CustomEnv_DeniesBeforeMxcUnavailableHostFallback() Assert.Equal(0, executor.CallCount); } + [IntegrationFact] + public async Task DirectWxcExec_HostTokenCancelsBeforeSandboxDeadline_ReportsTimeout() + { + var availability = MxcAvailability.Probe(NullLogger.Instance); + if (!availability.CanRunSystemRunSandbox || string.IsNullOrWhiteSpace(availability.WxcExecPath)) + { + Console.WriteLine( + "[mxc-integration] SKIPPING host-token: " + + string.Join("; ", availability.SystemRunSandboxUnsupportedReasons)); + return; + } + + if (!HasSupportedSandboxPath(AppContext.BaseDirectory) + || !HasSupportedSandboxPath(availability.WxcExecPath)) + { + Console.WriteLine("[mxc-integration] SKIPPING host-token: unsupported sandbox path."); + return; + } + + var scratch = Path.Combine(Path.GetTempPath(), "openclaw-mxc-host-token-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(scratch); + try + { + var settings = new SettingsData + { + SystemRunSandboxEnabled = true, + SystemRunAllowOutbound = false, + }; + var policy = MxcPolicyBuilder.ForSystemRun(settings, scratch); + using var argsDoc = JsonDocument.Parse( + """ + {"command":"for /L %i in (1,1,8000000) do @rem","shell":"cmd","args":[],"timeoutMs":60000} + """); + var request = new SandboxExecutionRequest( + "system.run", + argsDoc.RootElement.Clone(), + policy, + TimeoutMs: 60_000, + Cwd: scratch); + var built = MxcConfigBuilder.Build(request, scratch); + var config = built with + { + Process = built.Process with { TimeoutMs = 60_000 }, + }; + + var executor = new MxcExecutor(availability.WxcExecPath!); + using var hostToken = new CancellationTokenSource(TimeSpan.FromMilliseconds(2_000)); + var result = await executor.RunAsync(config, hostToken.Token); + Console.WriteLine( + "[mxc-integration] host-token " + + $"exitCode={result.ExitCode}; timedOut={result.TimedOut}; durationMs={result.DurationMs}; error={result.Error}"); + Assert.True(result.TimedOut, result.Error); + Assert.Contains("cancelled", result.Error ?? "", StringComparison.OrdinalIgnoreCase); + Assert.InRange(result.DurationMs, 1_500, 5_000); + } + finally + { + try { Directory.Delete(scratch, recursive: true); } catch { } + } + } + private static bool HasSupportedSandboxPath(string path) { try