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
37 changes: 35 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -293,16 +293,19 @@ jobs:
needs: repo-hygiene
if: ${{ !cancelled() }}
runs-on: windows-latest
timeout-minutes: 25
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
matrix:
include:
- name: setup-connect
filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests
timeout_minutes: 45
filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests"
- name: revocation-recovery
timeout_minutes: 25
filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.RevocationAndRecoveryTests
- name: network-recovery
timeout_minutes: 25
filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.NetworkRecoveryTests
steps:
- name: Fail if repo hygiene failed
Expand Down Expand Up @@ -366,6 +369,36 @@ jobs:
Write-Error "E2E shard '${{ matrix.name }}' executed zero tests. Check OPENCLAW_RUN_E2E gating/filter before merging."
exit 1
}
if ("${{ matrix.name }}" -eq "setup-connect") {
$mxcProofNames = @(
"RealGateway_SystemRun_ExecutesThroughWindowsNodeMxcSandbox",
"RealGateway_SystemRun_BlocksWritesToTrayDataDirectoryInMxcSandbox"
)

foreach ($mxcProofName in $mxcProofNames) {
$mxcProof = @($trx.TestRun.Results.UnitTestResult | Where-Object { $_.testName -like "*$mxcProofName*" }) | Select-Object -First 1
if ($null -eq $mxcProof) {
Write-Error "E2E shard '${{ matrix.name }}' did not report the MXC proof test '$mxcProofName'. Check the setup-connect filter before merging."
exit 1
}

$mxcOutcome = [string]$mxcProof.outcome
if ($mxcOutcome -eq "Passed") {
Write-Host "MXC E2E proof passed: $mxcProofName"
} elseif ($mxcOutcome -eq "NotExecuted" -or $mxcOutcome -eq "Skipped") {
$mxcSkipReason = @($mxcProof.Output.ErrorInfo.Message, $mxcProof.Output.StdOut) |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($mxcSkipReason)) {
$mxcSkipReason = "skip reason was not present in the trx output"
}
Write-Warning "MXC E2E proof skipped: $mxcProofName; $mxcSkipReason"
} else {
Write-Error "MXC E2E proof '$mxcProofName' had unexpected outcome '$mxcOutcome'."
exit 1
}
}
}

- name: Upload E2E Test Results & Logs
if: always()
Expand Down
23 changes: 23 additions & 0 deletions docs/WINDOWS_NODE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,29 @@ When the node connects, it advertises these capabilities:
dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --filter "FullyQualifiedName~Mxc"
```

### Full Gateway `system.run` MXC runtime proof
- 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.
- Run it when validating the Gateway/Windows node runtime path, not just direct MCP or shared library behavior.
- 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.

```powershell
.\build.ps1
$env:OPENCLAW_REPO_ROOT = (Get-Location).Path
$env:OPENCLAW_RUN_E2E = "1"
dotnet test .\tests\OpenClaw.E2ETests\OpenClaw.E2ETests.csproj `
--no-restore `
--filter "FullyQualifiedName~RealGateway_SystemRun_ExecutesThroughWindowsNodeMxcSandbox" `
--logger "console;verbosity=normal" `
-r win-x64
```

- Expected proof markers:
- Gateway response contains `OPENCLAW_GATEWAY_SYSTEM_RUN_MXC_OK` with `exitCode=0`.
- The denied-write proof targets a fresh file under the isolated tray data directory, returns non-zero, and leaves that file absent.
- `openclaw-tray.log` contains `[mxc] system.run sandbox request` with `executor=mxc-direct-appc`, `contained=True`, and `shell=cmd`.
- `openclaw-tray.log` contains `[mxc] system.run sandbox result` with `containment=mxc` for both the successful execution and the denied write.
- E2E artifacts are written under `TestResults\E2E\<run-id>` and skip known secret-bearing files such as gateway records and settings.

## Remaining Work (Roadmap)

1. ~~**system.run + exec approvals**~~ ✅ Implemented
Expand Down
67 changes: 5 additions & 62 deletions src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ internal static MxcConfig Build(
if (request is null) throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(scratchDir)) throw new ArgumentException("scratchDir required", nameof(scratchDir));
if (context is null) throw new ArgumentNullException(nameof(context));
var deniedPathExists = context.DeniedPathExists ?? PathExists;
var readonlyGrantIsBackendSafe = context.ReadonlyGrantIsBackendSafe ?? IsBackendSafeReadonlyGrant;

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

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

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

private static bool ShouldEmitDeniedPathToBackend(string path, Func<string, bool> pathExists)
{
var normalized = NormalizePath(path);
if (string.IsNullOrWhiteSpace(normalized))
return false;

foreach (var hostProfileRoot in HostProfileDenyRoots())
{
var root = NormalizePath(hostProfileRoot);
if (!string.IsNullOrWhiteSpace(root) && IsSameOrNested(normalized, root))
return false;
}

try
{
if (!pathExists(normalized))
return false;
}
catch
{
return false;
}

return true;
}

private static bool PathExists(string path) => Directory.Exists(path) || File.Exists(path);

private static IEnumerable<string> HostProfileDenyRoots()
{
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);

if (!string.IsNullOrWhiteSpace(userProfile))
{
yield return Path.Combine(userProfile, ".ssh");
}

if (!string.IsNullOrWhiteSpace(localAppData))
{
yield return Path.Combine(localAppData, "Google", "Chrome", "User Data");
yield return Path.Combine(localAppData, "Microsoft", "Edge", "User Data");
yield return Path.Combine(localAppData, "BraveSoftware", "Brave-Browser", "User Data");
}

if (!string.IsNullOrWhiteSpace(appData))
{
yield return Path.Combine(appData, "Mozilla", "Firefox", "Profiles");
yield return Path.Combine(appData, "Microsoft", "Windows", "PowerShell", "PSReadLine");
}
}

private static bool IsCoveredBy(string candidate, IEnumerable<string> ancestors)
{
var nc = NormalizePath(candidate);
Expand Down Expand Up @@ -538,7 +482,6 @@ private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList<
internal sealed record MxcConfigBuildContext(
string? ContainerId = null,
string? PathEnvVar = null,
Func<string, bool>? DeniedPathExists = null,
Func<string, bool>? ReadonlyGrantIsBackendSafe = null)
{
public static MxcConfigBuildContext Default { get; } = new();
Expand Down
159 changes: 159 additions & 0 deletions tests/OpenClaw.E2ETests/E2EFactAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Xunit;
using OpenClaw.Shared.Mxc;

namespace OpenClaw.E2ETests;

Expand All @@ -16,6 +17,164 @@ public E2EFactAttribute()
}
}

/// <summary>
/// Focused E2E tests that require MXC support must not fail the regular E2E
/// shard on Windows runners where the Gateway path works but MXC is unavailable.
/// </summary>
public sealed class MxcE2EFactAttribute : FactAttribute
{
public MxcE2EFactAttribute()
{
Skip = MxcE2ETestGate.SkipReason;
}
}

internal static class MxcE2ETestGate
{
private static readonly Lazy<string?> s_skipReason = new(GetSkipReason);

public static string? SkipReason => s_skipReason.Value;

private static string? GetSkipReason()
{
if (!E2ETestGate.IsEnabled)
return $"E2E tests disabled. Set {E2ETestGate.EnvVar}=1 to enable.";

try
{
var availability = ProbeAvailabilityForE2E();
var hasBackend = availability.IsAppContainerAvailable || availability.IsIsolationSessionAvailable;
if (!hasBackend)
{
var reason = availability.UnsupportedReasons.Count == 0
? "MXC backend is unavailable."
: string.Join("; ", availability.UnsupportedReasons);
return $"MXC E2E test skipped: {reason}";
}

if (!availability.IsWxcExecResolvable && !TryFindE2EWxcExec(out _))
{
var reason = availability.UnsupportedReasons.Count == 0
? "wxc-exec.exe is unavailable."
: string.Join("; ", availability.UnsupportedReasons);
return $"MXC E2E test skipped: {reason}";
}

return null;
}
catch (Exception ex)
{
return $"MXC E2E test skipped: availability probe failed ({ex.GetType().Name}: {ex.Message}).";
}
}

private static MxcAvailability ProbeAvailabilityForE2E()
{
if (TryFindE2EWxcExec(out var wxcExecPath))
{
var previousOverride = Environment.GetEnvironmentVariable(MxcAvailability.WxcExecOverrideEnvVar);
try
{
Environment.SetEnvironmentVariable(MxcAvailability.WxcExecOverrideEnvVar, wxcExecPath);
return MxcAvailability.Probe();
}
finally
{
Environment.SetEnvironmentVariable(MxcAvailability.WxcExecOverrideEnvVar, previousOverride);
}
}

return MxcAvailability.Probe();
}

private static bool TryFindE2EWxcExec(out string? path)
{
var overridePath = Environment.GetEnvironmentVariable(MxcAvailability.WxcExecOverrideEnvVar);
if (FileExists(overridePath))
{
path = overridePath;
return true;
}

foreach (var repoRoot in CandidateRepoRoots())
{
var arch = GetSdkArchString();
var nodeModulesWxcExec = Path.Combine(repoRoot, "node_modules", "@microsoft", "mxc-sdk", "bin", arch, "wxc-exec.exe");
if (FileExists(nodeModulesWxcExec))
{
path = nodeModulesWxcExec;
return true;
}

var trayBin = Path.Combine(repoRoot, "src", "OpenClaw.Tray.WinUI", "bin");
if (Directory.Exists(trayBin))
{
try
{
var trayWxcExec = Directory.EnumerateFiles(trayBin, "wxc-exec.exe", SearchOption.AllDirectories)
.FirstOrDefault(file => file.EndsWith(Path.Combine("mxc", arch, "wxc-exec.exe"), StringComparison.OrdinalIgnoreCase));
if (FileExists(trayWxcExec))
{
path = trayWxcExec;
return true;
}
}
catch
{
// Discovery-only guard; a failed search should become an
// ordinary MXC skip rather than a discovery failure.
}
}
}

path = null;
return false;
}

private static IEnumerable<string> CandidateRepoRoots()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var start in new[]
{
Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"),
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory,
})
{
if (string.IsNullOrWhiteSpace(start))
continue;

var dir = Directory.Exists(start)
? new DirectoryInfo(start)
: new FileInfo(start).Directory;
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "package.json"))
&& Directory.Exists(Path.Combine(dir.FullName, "src", "OpenClaw.Tray.WinUI")))
{
if (seen.Add(dir.FullName))
yield return dir.FullName;
break;
}
dir = dir.Parent;
}
}
}

private static bool FileExists(string? path)
{
try { return !string.IsNullOrWhiteSpace(path) && File.Exists(path); }
catch { return false; }
}

private static string GetSdkArchString() => System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
{
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
System.Runtime.InteropServices.Architecture.X64 => "x64",
_ => "x64",
};
}

internal static class E2ETestGate
{
public const string EnvVar = "OPENCLAW_RUN_E2E";
Expand Down
Loading
Loading