test(mxc): add workload-to-host service e2e coverage

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
Drew Newberry
2026-10-13 22:45:17 -07:00
parent 68d61b008d
commit 9c8ffd4951
7 changed files with 279 additions and 13 deletions
@@ -452,7 +452,19 @@ variable by default). Require both a nonempty response and the OpenShell proxy
CA issuer; never replace these assertions with an insecure TLS request. This
fixture permits unavailable CRL information using best-effort Schannel revocation
for the ephemeral private CA; this is not revocation qualification. This
scenario is not part of the default six offline-capable E2E scenarios.
scenario is not part of the default seven offline-capable E2E scenarios.
The default `host-service` scenario runs the HTTP fixture on an active host LAN
IPv4 address and a dynamic port, then checks two nonce-bound replies from curl
inside MXC through the authenticated proxy. Its `/32` allowlist and explicit
GET paths permit only the intended service; a denied path must yield HTTP 403
(curl exit 22) and never reach the host request log. Host-side readiness and
request proofs stay outside the workload's writable directory. The service is
stopped even after failed runs. Use `-Scenario host-service` for focused checks.
No Internet access or firewall modification is required; an absent usable host
IPv4 address or failed host health check is a failure, not a successful denial.
Loopback remains blocked by the shared proxy, so do not broaden SSRF controls
to run this test.
When reporting `windows:ci`, distinguish these categories:
+12 -2
View File
@@ -270,7 +270,7 @@ explicitly loads the boundary-injected CA bundle and records the peer certificat
success requires a nonempty response and the OpenShell proxy CA issuer. It does
not install a host trust root or disable CA/hostname verification. The fixture
uses best-effort Schannel revocation because the ephemeral private CA has no CRL
distribution point; it does not qualify revocation enforcement. The default six E2E
distribution point; it does not qualify revocation enforcement. The default seven E2E
scenarios remain independent of Internet access.
The `forwarding` scenario starts a TCP listener inside MXC, then verifies two
@@ -287,12 +287,22 @@ unique GET responses through gateway host-based routing. It deletes the endpoint
and requires HTTP 404 afterward. Requests connect only to the gateway with the
exposed URL's Host header, so no wildcard DNS setup is required.
The default `host-service` scenario runs an HTTP fixture on the Windows host's
active LAN IPv4 address and a dynamic port. A real MXC workload uses curl through
the authenticated supervisor proxy, with that exact address allowed by a `/32`
policy. Two GETs must return the fixture's nonce; a disallowed path must return
HTTP 403 with curl exit 22 and never appear in the host-side request log. The
host service is checked before launch and stopped after testing. Missing host
IPv4 connectivity fails the scenario. This uses the host's LAN address because
the shared proxy always rejects loopback destinations. Select it with
`-Scenario host-service`; no Internet access or firewall changes are required.
The `ws-agent.yaml` and `openclaw-gateway.yaml` policies preserve the Windows
branch's workload grants. `inference.yaml` is a template: replace its filesystem,
curl executable, inference host, and port placeholders before use. Authorize
curl, which owns the network socket, not its cmd launcher. These policies have
parser/mapper regression coverage; they do not add live WebSocket, OpenClaw,
or provider-backed inference scenarios to the six-scenario E2E runner.
or provider-backed inference scenarios to the seven-scenario E2E runner.
With `-KeepRunning`, the runner restores the caller's environment but retains
the disposable CLI/runtime directory for the live gateway and prints its path.
@@ -0,0 +1,23 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
version: 1
filesystem_policy:
include_workdir: false
read_only: []
read_write:
- "C:/work/openshell-mxc-e2e"
network_policies:
host_service:
name: host-service
endpoints:
- host: '__HOST_SERVICE_IP__'
port: __HOST_SERVICE_PORT__
allowed_ips: ['__HOST_SERVICE_IP__/32']
protocol: rest
enforcement: enforce
rules:
- allow:
method: GET
path: /allowed/*
binaries:
- path: '__CURL_EXE__'
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Test-only TCP fixture for real MXC ingress E2E; not a shipped runtime binary.
//! Test-only TCP/HTTP fixture for real MXC E2E; not a shipped runtime binary.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
@@ -9,14 +9,25 @@ use std::time::Duration;
fn main() -> std::io::Result<()> {
let arguments: Vec<String> = std::env::args().collect();
if !(arguments.len() == 3 || (arguments.len() == 4 && arguments[3] == "http")) {
if !(arguments.len() == 3 || (matches!(arguments.len(), 4 | 6) && arguments[3] == "http")) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"usage: mxc-forwarding-agent READY_FILE NONCE [http]",
"usage: mxc-forwarding-agent READY_FILE NONCE [http [BIND_IPV4 REQUEST_LOG]]",
));
}
let http_mode = arguments.len() == 4;
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))?;
let http_mode = arguments.len() >= 4;
let bind_ip = if arguments.len() == 6 {
arguments[4]
.parse::<std::net::Ipv4Addr>()
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?
} else {
std::net::Ipv4Addr::LOCALHOST
};
let request_log = arguments.get(5);
if let Some(path) = request_log {
std::fs::write(path, "")?;
}
let listener = TcpListener::bind((bind_ip, 0))?;
let ready_file = std::path::Path::new(&arguments[1]);
let staged_ready = ready_file.with_extension("tmp");
std::fs::write(&staged_ready, listener.local_addr()?.port().to_string())?;
@@ -59,6 +70,10 @@ fn main() -> std::io::Result<()> {
let target = fields.next().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "missing HTTP target")
})?;
if let Some(path) = request_log {
let mut log = std::fs::OpenOptions::new().append(true).open(path)?;
writeln!(log, "{target}")?;
}
let body = format!("{}:{target}", arguments[2]);
write!(
socket,
@@ -36,6 +36,7 @@
# network-policy - supervisor-owned network policy permits admission.
# forwarding - two TCP request/reply exchanges through OpenShell ingress.
# service-forwarding - named HTTP service routing and endpoint deletion.
# host-service - workload reaches a host HTTP service; denied path never arrives.
# https-ca - opt-in outbound HTTPS using the injected proxy CA.
[CmdletBinding()]
@@ -384,6 +385,46 @@ function Wait-File([string]$path, [int]$seconds) {
return (Test-Path $path)
}
function Get-HostServiceAddress {
foreach ($adapter in [Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces()) {
if ($adapter.OperationalStatus -ne [Net.NetworkInformation.OperationalStatus]::Up) { continue }
foreach ($unicast in $adapter.GetIPProperties().UnicastAddresses) {
$ip = $unicast.Address
if ($ip.AddressFamily -eq [Net.Sockets.AddressFamily]::InterNetwork -and
-not [Net.IPAddress]::IsLoopback($ip) -and
-not $ip.ToString().StartsWith('169.254.') -and $ip.ToString() -ne '0.0.0.0') {
return $ip.ToString()
}
}
}
throw 'host-service requires an active host IPv4 address outside loopback and link-local ranges'
}
function Test-HostServiceResult([string]$work, [string]$nonce, [string]$requestLog) {
if (-not (Wait-File (Join-Path $work 'host-service.done') 60)) { throw 'host-service workload did not complete' }
if ((Get-Content -LiteralPath (Join-Path $work 'host-service.done') -Raw).Trim() -ne $nonce) {
throw 'host-service completion marker does not match this workload'
}
foreach ($name in @('one', 'two')) {
$body = Get-Content -LiteralPath (Join-Path $work "host-service.$name.body") -Raw
$exitCode = (Get-Content -LiteralPath (Join-Path $work "host-service.$name.exit") -Raw).Trim()
if ($exitCode -ne '0' -or $body -ne "${nonce}:/allowed/$name") {
throw "host-service allowed request $name did not return the exact host response"
}
}
$deniedExit = (Get-Content -LiteralPath (Join-Path $work 'host-service.denied.exit') -Raw).Trim()
$deniedStatus = (Get-Content -LiteralPath (Join-Path $work 'host-service.denied.status') -Raw).Trim()
if ($deniedExit -ne '22' -or $deniedStatus -ne '403') {
throw "host-service denied request must receive policy HTTP 403 (curl exit 22), got $deniedStatus / $deniedExit"
}
$requests = @(Get-Content -LiteralPath $requestLog)
if ($requests.Count -ne 3 -or $requests[0] -ne '/health' -or
$requests[1] -ne '/allowed/one' -or $requests[2] -ne '/allowed/two') {
throw "host-service received unexpected requests: $($requests -join ', ')"
}
return 'two exact host responses; denied request returned HTTP 403 and never reached the service'
}
# Exercise the public gRPC forward path, never dial the workload port directly.
# The nonce identifies this workload, not an unrelated host-loopback listener.
function Test-Forwarding([string]$sandboxName, [string]$readyFile, [string]$nonce) {
@@ -610,6 +651,7 @@ function Probe-Backend([string] $backendName, [string] $wxc) {
$results = @()
$gw = $null
$hostService = $null
$harnessError = $null
$backendProbe = @{ Live = $false; Reason = "not probed" }
# Unique per-run suffix so a stale sandbox record from an earlier run can never
@@ -750,10 +792,15 @@ try {
Backends = "process_container"; Kind = "service-forwarding"
ReadyFile = (Join-Path $DemoDir "service-forwarding-port.txt")
Description = "named HTTP service routes two requests into MXC and stops routing after deletion"
},
@{
Name = 'host-service'; PolicyFile = Join-Path $policyDir 'host-service.yaml'
SandboxId = 'host'; Backends = 'process_container'; Kind = 'host-service'
Description = 'workload HTTP egress reaches a host service only for explicitly allowed paths'
}
)
# External connectivity is deliberately opt-in; the default six scenarios
# External connectivity is deliberately opt-in; the default seven scenarios
# remain runnable without Internet access. Use the same authenticated
# gateway launch path as every other workload, not a bare driver fixture.
if ($Scenario -eq 'https-ca') {
@@ -826,6 +873,30 @@ try {
$DemoDir.Replace('\', '/')
)
$curlExe = [IO.Path]::Combine([IO.Path]::GetDirectoryName($cmdExe), 'curl.exe')
if ($sc.Kind -eq 'host-service') {
$hostIp = Get-HostServiceAddress
$hostNonce = [guid]::NewGuid().ToString('N')
# The workload has write access only to DemoDir, not these host-side proofs.
$hostReady = Join-Path $resultDir 'host-service.port'
$hostRequests = Join-Path $resultDir 'host-service.requests'
$fixture = Join-Path $BinaryDir 'mxc-forwarding-agent.exe'
if (-not (Test-Path -LiteralPath $fixture)) { $fixture = Join-Path $BinaryDir 'examples/mxc-forwarding-agent.exe' }
if (-not (Test-Path -LiteralPath $fixture)) { throw 'Missing host-service fixture; run windows:build:mxc-fixtures' }
$hostArguments = @($hostReady, $hostNonce, 'http', $hostIp, $hostRequests)
$hostService = Start-Process -FilePath $fixture -PassThru -WindowStyle Hidden `
-ArgumentList (($hostArguments | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') `
-RedirectStandardOutput (Join-Path $resultDir 'host-service.stdout.log') `
-RedirectStandardError (Join-Path $resultDir 'host-service.stderr.log')
if (-not (Wait-File $hostReady 10) -or $hostService.HasExited) { throw 'host-service fixture did not start' }
$hostPort = [int](Get-Content -LiteralPath $hostReady -Raw).Trim()
$hostUrl = "http://${hostIp}:$hostPort"
$health = Invoke-NativeCaptured $curlExe @('--disable', '--proxy', '', '--noproxy', '*',
'--fail', '--silent', '--show-error', '--max-time', '10', "$hostUrl/health") 15
if ($health.ExitCode -ne 0 -or ($health.Output -join '').Trim() -ne "${hostNonce}:/health") {
throw 'host-service health check failed before sandbox launch'
}
$policyText = $policyText.Replace('__HOST_SERVICE_IP__', $hostIp).Replace('__HOST_SERVICE_PORT__', "$hostPort")
}
# Preserve the exact Windows executable path used in the launch;
# network binary identities are compared as strings, not FS paths.
$policyText = $policyText.Replace('__CURL_EXE__', $curlExe.Replace("'", "''"))
@@ -852,6 +923,20 @@ try {
} else {
$command = @($cmdExe, "/c", "echo denied 1> $denied")
}
} elseif ($sc.Kind -eq 'host-service') {
Remove-Item -Path (Join-Path $DemoDir 'host-service.*') -Force -ErrorAction SilentlyContinue
$batch = Join-Path $DemoDir 'host-service.cmd'
$lines = @('@echo off')
foreach ($name in @('one', 'two')) {
$lines += '"{0}" --disable --proxy "%HTTP_PROXY%" --noproxy "" --fail --silent --show-error --max-time 15 --output "{1}" "{2}/allowed/{3}"' -f $curlExe, (Join-Path $DemoDir "host-service.$name.body"), $hostUrl, $name
$lines += 'echo %errorlevel%> "{0}"' -f (Join-Path $DemoDir "host-service.$name.exit")
}
$lines += '"{0}" --disable --proxy "%HTTP_PROXY%" --noproxy "" --fail --silent --show-error --max-time 15 --output "{1}" --write-out "%%{{http_code}}" "{2}/denied" > "{3}"' -f $curlExe, (Join-Path $DemoDir 'host-service.denied.body'), $hostUrl, (Join-Path $DemoDir 'host-service.denied.status')
$lines += 'echo %errorlevel%> "{0}"' -f (Join-Path $DemoDir 'host-service.denied.exit')
$lines += 'echo {0}> "{1}"' -f $hostNonce, (Join-Path $DemoDir 'host-service.done')
$lines += 'exit /b 0'
Set-Content -LiteralPath $batch -Value ($lines -join "`r`n") -Encoding ASCII
$command = @($cmdExe, '/d', '/c', $batch)
} elseif ($sc.Kind -eq 'socket-owner') {
Remove-Item -LiteralPath $sc.PosTarget, $sc.ControlTarget, $sc.ErrorTarget -Force -ErrorAction SilentlyContinue
$child = 'echo launched > "{0}" && "{1}" --disable --fail --silent --show-error --max-time 30 --ssl-revoke-best-effort --cacert "%CURL_CA_BUNDLE%" --stderr "{2}" --output "{3}" https://example.com/' -f $sc.ControlTarget, $curlExe, $sc.ErrorTarget, $sc.PosTarget
@@ -916,12 +1001,13 @@ try {
# Run sandbox create; require artifact evidence for live workload tests.
$createOut = $null; $createExitCode = 0
try {
$createResult = Invoke-NativeCaptured $cli @(
$createArguments = @(
"sandbox", "create", "--name", $sandboxName,
"--policy", [string]$policyUsed,
"--driver-config-json", $driverConfig,
"--no-tty"
"--driver-config-json", $driverConfig
)
$createArguments += if ($sc.Kind -eq 'host-service') { '--detach' } else { '--no-tty' }
$createResult = Invoke-NativeCaptured $cli $createArguments
$createOut = $createResult.Output
$createExitCode = $createResult.ExitCode
} catch {
@@ -933,7 +1019,18 @@ try {
$gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n"
# Evaluate.
if ($sc.Kind -eq 'socket-owner') {
if ($sc.Kind -eq 'host-service') {
try {
if ($createExitCode -ne 0) { throw "host-service sandbox creation failed: $createOutStr" }
if ($hostService.HasExited) { throw 'host-service fixture exited before scoring' }
$reason = Test-HostServiceResult $DemoDir $hostNonce $hostRequests
Ok "host-service: $reason"
$results += [pscustomobject]@{ Scenario = $sc.Name; Result = 'PASS'; Reason = $reason }
} catch {
Bad "host-service: $($_.Exception.Message)"
$results += [pscustomobject]@{ Scenario = $sc.Name; Result = 'FAIL'; Reason = $_.Exception.Message }
}
} elseif ($sc.Kind -eq 'socket-owner') {
try {
if (-not (Wait-File $sc.ControlTarget 30)) { throw "parent cmd did not launch: $createOutStr" }
$deadline = (Get-Date).AddSeconds(45)
@@ -1079,6 +1176,10 @@ catch {
Bad "harness error: $harnessError"
}
finally {
if ($hostService -and -not $hostService.HasExited) {
Stop-Process -Id $hostService.Id -Force -ErrorAction SilentlyContinue
$hostService.WaitForExit()
}
# --- Summary + results bundle ---------------------------------------------
Step "Summary"
$results | Format-Table -AutoSize
@@ -1121,6 +1222,8 @@ through OpenShell. Service forwarding verifies two HTTP replies and HTTP 404
after endpoint deletion. Missing coverage exits non-zero; network-policy proves
admission and execution, not network enforcement. Forwarding proves managed
ingress works, not that direct host ingress is blocked.
Host-service verifies two exact workload-to-host HTTP replies and HTTP 403 for
a denied path, with a host-side request log proving that denied request never arrived.
"@
Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8
Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" }))
@@ -66,6 +66,69 @@ fn https_fixture_authorizes_direct_curl_with_read_only_https() {
);
}
#[test]
fn host_service_scoring_rejects_transport_failures_and_unenforced_requests() {
let root = tempfile::tempdir().unwrap();
let script = r#"
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$env:OPENSHELL_MXC_RUNNER, [ref]$tokens, [ref]$errors)
if ($errors.Count) { throw "runner parse errors: $($errors.Message -join '; ')" }
foreach ($name in @('Wait-File', 'Test-HostServiceResult')) {
$helper = $ast.Find({ param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name
}, $true)
if (!$helper) { throw "missing $name" }
Invoke-Expression $helper.Extent.Text
}
$work = Join-Path $env:OPENSHELL_MXC_SCORE_ROOT 'work with spaces'
New-Item -ItemType Directory $work | Out-Null
$requestLog = Join-Path $env:OPENSHELL_MXC_SCORE_ROOT 'host.requests'
$nonce = 'unique-host-service-response'
function Reset-Proofs {
foreach ($name in @('one', 'two')) {
[IO.File]::WriteAllText((Join-Path $work "host-service.$name.body"), "${nonce}:/allowed/$name")
[IO.File]::WriteAllText((Join-Path $work "host-service.$name.exit"), '0')
}
[IO.File]::WriteAllText((Join-Path $work 'host-service.denied.exit'), '22')
[IO.File]::WriteAllText((Join-Path $work 'host-service.denied.status'), '403')
[IO.File]::WriteAllText((Join-Path $work 'host-service.done'), $nonce)
[IO.File]::WriteAllText($requestLog, "/health`n/allowed/one`n/allowed/two`n")
}
Reset-Proofs
if ((Test-HostServiceResult $work $nonce $requestLog) -notmatch 'never reached') { throw 'valid host-service run must pass' }
foreach ($case in @('transport', 'denial-reached-host', 'wrong-response', 'wrong-workload')) {
Reset-Proofs
switch ($case) {
'transport' {
[IO.File]::WriteAllText((Join-Path $work 'host-service.denied.exit'), '7')
[IO.File]::WriteAllText((Join-Path $work 'host-service.denied.status'), '000')
}
'denial-reached-host' { [IO.File]::AppendAllText($requestLog, "/denied`n") }
'wrong-response' { [IO.File]::WriteAllText((Join-Path $work 'host-service.one.body'), 'unrelated service') }
'wrong-workload' { [IO.File]::WriteAllText((Join-Path $work 'host-service.done'), 'stale-run') }
}
$rejected = $false
try { Test-HostServiceResult $work $nonce $requestLog | Out-Null } catch { $rejected = $true }
if (!$rejected) { throw "invalid run $case must fail" }
}
"#;
let output = Command::new("powershell.exe")
.args(["-NoProfile", "-Command", script])
.env("OPENSHELL_MXC_RUNNER", examples().join("run-mxc-e2e.ps1"))
.env("OPENSHELL_MXC_SCORE_ROOT", root.path())
.output()
.expect("launch PowerShell host-service regression");
assert!(
output.status.success(),
"{} {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn openclaw_fixture_preserves_explicit_ui_and_node_network_grants() {
let yaml =
@@ -5291,6 +5291,46 @@ process:
OpaEngine::from_strings(TEST_POLICY, L7_TEST_DATA).expect("Failed to load L7 test data")
}
#[test]
fn mxc_host_service_fixture_enforces_paths_and_socket_owner() {
let binary = crate::test_support::executable_path("/usr/bin/curl");
let yaml =
include_str!("../../openshell-driver-mxc/examples/e2e-policies/host-service.yaml")
.replace("__HOST_SERVICE_IP__", "192.168.1.20")
.replace("__HOST_SERVICE_PORT__", "49152")
.replace(
"C:/work/openshell-mxc-e2e",
&std::env::temp_dir().to_string_lossy().replace('\\', "/"),
)
.replace("__CURL_EXE__", binary.to_str().unwrap());
let engine = OpaEngine::from_strings(TEST_POLICY, &yaml).expect("host-service policy");
let network = NetworkInput {
host: "192.168.1.20".into(),
port: 49152,
binary_path: binary.clone(),
binary_sha256: "unused".into(),
ancestors: Vec::new(),
cmdline_paths: Vec::new(),
};
assert!(engine.evaluate_network(&network).unwrap().allowed);
assert_eq!(
engine.query_allowed_ips(&network).unwrap(),
["192.168.1.20/32"]
);
for (path, allowed) in [
("/allowed/one", true),
("/allowed/two", true),
("/denied", false),
] {
let mut input = l7_input("192.168.1.20", 49152, "GET", path);
input["exec"]["path"] = binary.to_str().unwrap().into();
assert_eq!(eval_l7(&engine, &input), allowed, "{path}");
}
let mut wrong_binary = network;
wrong_binary.binary_path = crate::test_support::executable_path("/usr/bin/other");
assert!(!engine.evaluate_network(&wrong_binary).unwrap().allowed);
}
fn l7_input(host: &str, port: u16, method: &str, path: &str) -> serde_json::Value {
l7_input_with_query(host, port, method, path, serde_json::json!({}))
}