Skip to content

Commit e70868d

Browse files
TheAngryPitshanselmanCopilot
authored
Add Gateway system.run MXC runtime E2E proof (#787)
* test: add Gateway MXC runtime E2E proof * test: probe MXC E2E gate with discovered wxc-exec * test: extend Gateway MXC E2E timeout budgets * test: avoid blocked redirection in Gateway MXC deny proof * test: prove MXC denied copy reaches destination * test: document serialized MXC E2E collection gate * test: raise MXC E2E sandbox timeout cap * test: approve MXC denied-write copy proof step * test: sync MXC approval proof diagnostics * test: narrow MXC exec approval proof policy * ci: extend setup-connect E2E timeout Give only the setup-connect E2E shard extra time for the combined setup and MXC runtime proof fixtures while leaving the other E2E shards on the existing budget. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: omit unsupported MXC denied paths Windows wxc-exec rejects filesystem.deniedPaths before launching the sandboxed command. Keep denied paths as the local allow-list filter, but omit the unsupported backend field so AppContainer default-deny can run the command while still stripping parent/exact grants over sensitive paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Vitor Cepeda Lopes <7040636+TheAngryPit@users.noreply.github.com> Co-authored-by: Scott Hanselman <scott@hanselman.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6c56a83 commit e70868d

12 files changed

Lines changed: 790 additions & 118 deletions

File tree

.github/workflows/ci.yml

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,16 +293,19 @@ jobs:
293293
needs: repo-hygiene
294294
if: ${{ !cancelled() }}
295295
runs-on: windows-latest
296-
timeout-minutes: 25
296+
timeout-minutes: ${{ matrix.timeout_minutes }}
297297
strategy:
298298
fail-fast: false
299299
matrix:
300300
include:
301301
- name: setup-connect
302-
filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests
302+
timeout_minutes: 45
303+
filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests"
303304
- name: revocation-recovery
305+
timeout_minutes: 25
304306
filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.RevocationAndRecoveryTests
305307
- name: network-recovery
308+
timeout_minutes: 25
306309
filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.NetworkRecoveryTests
307310
steps:
308311
- name: Fail if repo hygiene failed
@@ -366,6 +369,36 @@ jobs:
366369
Write-Error "E2E shard '${{ matrix.name }}' executed zero tests. Check OPENCLAW_RUN_E2E gating/filter before merging."
367370
exit 1
368371
}
372+
if ("${{ matrix.name }}" -eq "setup-connect") {
373+
$mxcProofNames = @(
374+
"RealGateway_SystemRun_ExecutesThroughWindowsNodeMxcSandbox",
375+
"RealGateway_SystemRun_BlocksWritesToTrayDataDirectoryInMxcSandbox"
376+
)
377+
378+
foreach ($mxcProofName in $mxcProofNames) {
379+
$mxcProof = @($trx.TestRun.Results.UnitTestResult | Where-Object { $_.testName -like "*$mxcProofName*" }) | Select-Object -First 1
380+
if ($null -eq $mxcProof) {
381+
Write-Error "E2E shard '${{ matrix.name }}' did not report the MXC proof test '$mxcProofName'. Check the setup-connect filter before merging."
382+
exit 1
383+
}
384+
385+
$mxcOutcome = [string]$mxcProof.outcome
386+
if ($mxcOutcome -eq "Passed") {
387+
Write-Host "MXC E2E proof passed: $mxcProofName"
388+
} elseif ($mxcOutcome -eq "NotExecuted" -or $mxcOutcome -eq "Skipped") {
389+
$mxcSkipReason = @($mxcProof.Output.ErrorInfo.Message, $mxcProof.Output.StdOut) |
390+
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
391+
Select-Object -First 1
392+
if ([string]::IsNullOrWhiteSpace($mxcSkipReason)) {
393+
$mxcSkipReason = "skip reason was not present in the trx output"
394+
}
395+
Write-Warning "MXC E2E proof skipped: $mxcProofName; $mxcSkipReason"
396+
} else {
397+
Write-Error "MXC E2E proof '$mxcProofName' had unexpected outcome '$mxcOutcome'."
398+
exit 1
399+
}
400+
}
401+
}
369402
370403
- name: Upload E2E Test Results & Logs
371404
if: always()

docs/WINDOWS_NODE_TESTING.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,29 @@ When the node connects, it advertises these capabilities:
129129
dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --filter "FullyQualifiedName~Mxc"
130130
```
131131

132+
### Full Gateway `system.run` MXC runtime proof
133+
- The focused E2E below provisions a fresh WSL Gateway, starts an isolated tray instance, sets a local exec approval rule through MCP, invokes `system.run` through the real Gateway `node.invoke` path, and verifies tray MXC diagnostics show contained `mxc-direct-appc` execution for both allowed execution and denied writes to the tray data directory.
134+
- Run it when validating the Gateway/Windows node runtime path, not just direct MCP or shared library behavior.
135+
- When reproducing this manually against an existing Gateway, make sure `gateway.nodes.allowCommands` includes `system.run`, `system.run.prepare`, and `system.which`, then approve any `pending-reapproval` request with `openclaw nodes approve <pendingRequestId>`. The node can advertise `system.run` while the Gateway still blocks it until both gates are updated.
136+
137+
```powershell
138+
.\build.ps1
139+
$env:OPENCLAW_REPO_ROOT = (Get-Location).Path
140+
$env:OPENCLAW_RUN_E2E = "1"
141+
dotnet test .\tests\OpenClaw.E2ETests\OpenClaw.E2ETests.csproj `
142+
--no-restore `
143+
--filter "FullyQualifiedName~RealGateway_SystemRun_ExecutesThroughWindowsNodeMxcSandbox" `
144+
--logger "console;verbosity=normal" `
145+
-r win-x64
146+
```
147+
148+
- Expected proof markers:
149+
- Gateway response contains `OPENCLAW_GATEWAY_SYSTEM_RUN_MXC_OK` with `exitCode=0`.
150+
- The denied-write proof targets a fresh file under the isolated tray data directory, returns non-zero, and leaves that file absent.
151+
- `openclaw-tray.log` contains `[mxc] system.run sandbox request` with `executor=mxc-direct-appc`, `contained=True`, and `shell=cmd`.
152+
- `openclaw-tray.log` contains `[mxc] system.run sandbox result` with `containment=mxc` for both the successful execution and the denied write.
153+
- E2E artifacts are written under `TestResults\E2E\<run-id>` and skip known secret-bearing files such as gateway records and settings.
154+
132155
## Remaining Work (Roadmap)
133156

134157
1. ~~**system.run + exec approvals**~~ ✅ Implemented

src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs

Lines changed: 5 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ internal static MxcConfig Build(
6565
if (request is null) throw new ArgumentNullException(nameof(request));
6666
if (string.IsNullOrWhiteSpace(scratchDir)) throw new ArgumentException("scratchDir required", nameof(scratchDir));
6767
if (context is null) throw new ArgumentNullException(nameof(context));
68-
var deniedPathExists = context.DeniedPathExists ?? PathExists;
6968
var readonlyGrantIsBackendSafe = context.ReadonlyGrantIsBackendSafe ?? IsBackendSafeReadonlyGrant;
7069

7170
var policy = request.Policy;
@@ -107,13 +106,11 @@ internal static MxcConfig Build(
107106
rwFromPolicy.Add(scratchDir);
108107

109108
// denied list from policy (settings dir, ~/.ssh, browser profiles, ...).
110-
// Use the full list for local allow-list filtering, but do not emit
111-
// known host profile roots to the MXC DACL fallback: those paths often
112-
// cannot be prepared and make the sandbox fail before command launch.
109+
// Keep the full list for local allow-list filtering, but do not emit
110+
// filesystem.deniedPaths to wxc-exec. Windows MXC 0.7 rejects that field;
111+
// omitted grants remain denied by default inside the AppContainer.
113112
var deniedForFiltering = (policy?.Filesystem?.DeniedPaths ?? Array.Empty<string>()).ToList();
114-
var deniedForBackend = deniedForFiltering
115-
.Where(path => ShouldEmitDeniedPathToBackend(path, deniedPathExists))
116-
.ToList();
113+
string[]? deniedForBackend = null;
117114

118115
// cwd auto-grant — AppContainer does not auto-grant the working
119116
// directory. Give ungranted cwd read access so shells can start, but
@@ -188,7 +185,7 @@ internal static MxcConfig Build(
188185
{
189186
ReadonlyPaths = roFromPolicy.ToArray(),
190187
ReadwritePaths = rwFromPolicy.ToArray(),
191-
DeniedPaths = deniedForBackend.ToArray(),
188+
DeniedPaths = deniedForBackend,
192189
// SDK output didn't include clearPolicyOnExit even when the
193190
// input policy had it set, so we omit it here too.
194191
ClearPolicyOnExit = null,
@@ -385,59 +382,6 @@ private static List<string> FilterOutDenied(List<string> allowed, List<string> d
385382
.ToList();
386383
}
387384

388-
private static bool ShouldEmitDeniedPathToBackend(string path, Func<string, bool> pathExists)
389-
{
390-
var normalized = NormalizePath(path);
391-
if (string.IsNullOrWhiteSpace(normalized))
392-
return false;
393-
394-
foreach (var hostProfileRoot in HostProfileDenyRoots())
395-
{
396-
var root = NormalizePath(hostProfileRoot);
397-
if (!string.IsNullOrWhiteSpace(root) && IsSameOrNested(normalized, root))
398-
return false;
399-
}
400-
401-
try
402-
{
403-
if (!pathExists(normalized))
404-
return false;
405-
}
406-
catch
407-
{
408-
return false;
409-
}
410-
411-
return true;
412-
}
413-
414-
private static bool PathExists(string path) => Directory.Exists(path) || File.Exists(path);
415-
416-
private static IEnumerable<string> HostProfileDenyRoots()
417-
{
418-
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
419-
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
420-
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
421-
422-
if (!string.IsNullOrWhiteSpace(userProfile))
423-
{
424-
yield return Path.Combine(userProfile, ".ssh");
425-
}
426-
427-
if (!string.IsNullOrWhiteSpace(localAppData))
428-
{
429-
yield return Path.Combine(localAppData, "Google", "Chrome", "User Data");
430-
yield return Path.Combine(localAppData, "Microsoft", "Edge", "User Data");
431-
yield return Path.Combine(localAppData, "BraveSoftware", "Brave-Browser", "User Data");
432-
}
433-
434-
if (!string.IsNullOrWhiteSpace(appData))
435-
{
436-
yield return Path.Combine(appData, "Mozilla", "Firefox", "Profiles");
437-
yield return Path.Combine(appData, "Microsoft", "Windows", "PowerShell", "PSReadLine");
438-
}
439-
}
440-
441385
private static bool IsCoveredBy(string candidate, IEnumerable<string> ancestors)
442386
{
443387
var nc = NormalizePath(candidate);
@@ -538,7 +482,6 @@ private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList<
538482
internal sealed record MxcConfigBuildContext(
539483
string? ContainerId = null,
540484
string? PathEnvVar = null,
541-
Func<string, bool>? DeniedPathExists = null,
542485
Func<string, bool>? ReadonlyGrantIsBackendSafe = null)
543486
{
544487
public static MxcConfigBuildContext Default { get; } = new();

tests/OpenClaw.E2ETests/E2EFactAttribute.cs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Xunit;
2+
using OpenClaw.Shared.Mxc;
23

34
namespace OpenClaw.E2ETests;
45

@@ -16,6 +17,164 @@ public E2EFactAttribute()
1617
}
1718
}
1819

20+
/// <summary>
21+
/// Focused E2E tests that require MXC support must not fail the regular E2E
22+
/// shard on Windows runners where the Gateway path works but MXC is unavailable.
23+
/// </summary>
24+
public sealed class MxcE2EFactAttribute : FactAttribute
25+
{
26+
public MxcE2EFactAttribute()
27+
{
28+
Skip = MxcE2ETestGate.SkipReason;
29+
}
30+
}
31+
32+
internal static class MxcE2ETestGate
33+
{
34+
private static readonly Lazy<string?> s_skipReason = new(GetSkipReason);
35+
36+
public static string? SkipReason => s_skipReason.Value;
37+
38+
private static string? GetSkipReason()
39+
{
40+
if (!E2ETestGate.IsEnabled)
41+
return $"E2E tests disabled. Set {E2ETestGate.EnvVar}=1 to enable.";
42+
43+
try
44+
{
45+
var availability = ProbeAvailabilityForE2E();
46+
var hasBackend = availability.IsAppContainerAvailable || availability.IsIsolationSessionAvailable;
47+
if (!hasBackend)
48+
{
49+
var reason = availability.UnsupportedReasons.Count == 0
50+
? "MXC backend is unavailable."
51+
: string.Join("; ", availability.UnsupportedReasons);
52+
return $"MXC E2E test skipped: {reason}";
53+
}
54+
55+
if (!availability.IsWxcExecResolvable && !TryFindE2EWxcExec(out _))
56+
{
57+
var reason = availability.UnsupportedReasons.Count == 0
58+
? "wxc-exec.exe is unavailable."
59+
: string.Join("; ", availability.UnsupportedReasons);
60+
return $"MXC E2E test skipped: {reason}";
61+
}
62+
63+
return null;
64+
}
65+
catch (Exception ex)
66+
{
67+
return $"MXC E2E test skipped: availability probe failed ({ex.GetType().Name}: {ex.Message}).";
68+
}
69+
}
70+
71+
private static MxcAvailability ProbeAvailabilityForE2E()
72+
{
73+
if (TryFindE2EWxcExec(out var wxcExecPath))
74+
{
75+
var previousOverride = Environment.GetEnvironmentVariable(MxcAvailability.WxcExecOverrideEnvVar);
76+
try
77+
{
78+
Environment.SetEnvironmentVariable(MxcAvailability.WxcExecOverrideEnvVar, wxcExecPath);
79+
return MxcAvailability.Probe();
80+
}
81+
finally
82+
{
83+
Environment.SetEnvironmentVariable(MxcAvailability.WxcExecOverrideEnvVar, previousOverride);
84+
}
85+
}
86+
87+
return MxcAvailability.Probe();
88+
}
89+
90+
private static bool TryFindE2EWxcExec(out string? path)
91+
{
92+
var overridePath = Environment.GetEnvironmentVariable(MxcAvailability.WxcExecOverrideEnvVar);
93+
if (FileExists(overridePath))
94+
{
95+
path = overridePath;
96+
return true;
97+
}
98+
99+
foreach (var repoRoot in CandidateRepoRoots())
100+
{
101+
var arch = GetSdkArchString();
102+
var nodeModulesWxcExec = Path.Combine(repoRoot, "node_modules", "@microsoft", "mxc-sdk", "bin", arch, "wxc-exec.exe");
103+
if (FileExists(nodeModulesWxcExec))
104+
{
105+
path = nodeModulesWxcExec;
106+
return true;
107+
}
108+
109+
var trayBin = Path.Combine(repoRoot, "src", "OpenClaw.Tray.WinUI", "bin");
110+
if (Directory.Exists(trayBin))
111+
{
112+
try
113+
{
114+
var trayWxcExec = Directory.EnumerateFiles(trayBin, "wxc-exec.exe", SearchOption.AllDirectories)
115+
.FirstOrDefault(file => file.EndsWith(Path.Combine("mxc", arch, "wxc-exec.exe"), StringComparison.OrdinalIgnoreCase));
116+
if (FileExists(trayWxcExec))
117+
{
118+
path = trayWxcExec;
119+
return true;
120+
}
121+
}
122+
catch
123+
{
124+
// Discovery-only guard; a failed search should become an
125+
// ordinary MXC skip rather than a discovery failure.
126+
}
127+
}
128+
}
129+
130+
path = null;
131+
return false;
132+
}
133+
134+
private static IEnumerable<string> CandidateRepoRoots()
135+
{
136+
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
137+
foreach (var start in new[]
138+
{
139+
Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"),
140+
Directory.GetCurrentDirectory(),
141+
AppContext.BaseDirectory,
142+
})
143+
{
144+
if (string.IsNullOrWhiteSpace(start))
145+
continue;
146+
147+
var dir = Directory.Exists(start)
148+
? new DirectoryInfo(start)
149+
: new FileInfo(start).Directory;
150+
while (dir is not null)
151+
{
152+
if (File.Exists(Path.Combine(dir.FullName, "package.json"))
153+
&& Directory.Exists(Path.Combine(dir.FullName, "src", "OpenClaw.Tray.WinUI")))
154+
{
155+
if (seen.Add(dir.FullName))
156+
yield return dir.FullName;
157+
break;
158+
}
159+
dir = dir.Parent;
160+
}
161+
}
162+
}
163+
164+
private static bool FileExists(string? path)
165+
{
166+
try { return !string.IsNullOrWhiteSpace(path) && File.Exists(path); }
167+
catch { return false; }
168+
}
169+
170+
private static string GetSdkArchString() => System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
171+
{
172+
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
173+
System.Runtime.InteropServices.Architecture.X64 => "x64",
174+
_ => "x64",
175+
};
176+
}
177+
19178
internal static class E2ETestGate
20179
{
21180
public const string EnvVar = "OPENCLAW_RUN_E2E";

0 commit comments

Comments
 (0)