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
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,60 @@ function Copy-ItemRetry([string]$src, [string]$dst, [int]$attempts = 10, [int]$d
function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green }
function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red }

# Build one CreateProcess-compatible command-line argument. Windows PowerShell
# 5.1 removes embedded quotes and can split JSON values at embedded spaces when
# invoking native commands through the call operator.
function Quote-NativeArgument([string]$value) {
if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value }

$quoted = New-Object System.Text.StringBuilder
[void]$quoted.Append('"')
$backslashes = 0
foreach ($ch in $value.ToCharArray()) {
if ($ch -eq '\') {
$backslashes++
continue
}
if ($ch -eq '"') {
[void]$quoted.Append(('\' * (2 * $backslashes + 1)))
[void]$quoted.Append('"')
} else {
if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) }
[void]$quoted.Append($ch)
}
$backslashes = 0
}
if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) }
[void]$quoted.Append('"')
return $quoted.ToString()
}

function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) {
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $filePath
$startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ')
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true

$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
if (-not $process.Start()) { throw "failed to start $filePath" }
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
$process.WaitForExit()
$output = @($stdout.Result, $stderr.Result) |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object { $_ -split "`r?`n" } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }

return @{
ExitCode = $process.ExitCode
Output = @($output)
}
}

function Grant-AppContainerWritableDirectory([string]$Path) {
# AppContainer access is a dual check: the generated package SID grant from
# MXC is necessary, but OpenClaw's SQLite staging also needs the two built-in
Expand Down Expand Up @@ -453,14 +507,14 @@ try {
"--env", "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999",
"--no-tty", "--", "exit"
)
# Windows PowerShell 5.1 wraps native stderr as ErrorRecord objects. Keep
# warnings in the captured diagnostic without letting them terminate the
# command before its real exit code and output are collected.
$createPrevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try { $createOut = & $cli @createArgs 2>&1; $createCode = $LASTEXITCODE }
catch { $createOut = $_.Exception.Message; $createCode = 1 }
finally { $ErrorActionPreference = $createPrevEAP }
try {
$createResult = Invoke-NativeCaptured $cli $createArgs
$createOut = $createResult.Output
$createCode = $createResult.ExitCode
} catch {
$createOut = $_.Exception.Message
$createCode = 1
}
$createBenign = Show-SandboxCreate $createOut $SandboxName
if ($createCode -ne 0 -and -not $createBenign) {
throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)"
Expand Down
71 changes: 63 additions & 8 deletions crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,60 @@ function Esc([string]$p) { return $p.Replace('\', '\\') }
# Convert Windows path to forward-slash form (TOML values).
function Fwd([string]$p) { return $p.Replace('\', '/') }

# Build one CreateProcess-compatible command-line argument. Windows PowerShell
# 5.1 removes embedded quotes and can split JSON values at embedded spaces when
# invoking native commands through the call operator.
function Quote-NativeArgument([string]$value) {
if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value }

$quoted = New-Object System.Text.StringBuilder
[void]$quoted.Append('"')
$backslashes = 0
foreach ($ch in $value.ToCharArray()) {
if ($ch -eq '\') {
$backslashes++
continue
}
if ($ch -eq '"') {
[void]$quoted.Append(('\' * (2 * $backslashes + 1)))
[void]$quoted.Append('"')
} else {
if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) }
[void]$quoted.Append($ch)
}
$backslashes = 0
}
if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) }
[void]$quoted.Append('"')
return $quoted.ToString()
}

function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) {
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $filePath
$startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ')
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true

$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
if (-not $process.Start()) { throw "failed to start $filePath" }
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
$process.WaitForExit()
$output = @($stdout.Result, $stderr.Result) |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object { $_ -split "`r?`n" } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }

return @{
ExitCode = $process.ExitCode
Output = @($output)
}
}

# -WsPort is NOT actually wired through end to end: the in-sandbox server's
# port is a compile-time const (WS_PORT = 22000 in mxc-ws-agent.rs) -- the
# TOML generation below doesn't patch it. Rather than silently accept an
Expand Down Expand Up @@ -485,14 +539,15 @@ try {
# Use the same pattern as run-mxc-e2e.ps1: pass --no-tty with a no-op
# command so the CLI fires the SSH attempt, fails quickly (connection
# refused), and returns. Do NOT gate on exit code here.
$createOut = & $cli sandbox create `
--name $sandboxName `
--policy $policyUsed `
--driver-config-json $driverConfigJson `
--no-tty `
-- cmd.exe /c exit 0 `
2>&1
$createExitCode = $LASTEXITCODE
$createResult = Invoke-NativeCaptured $cli @(
"sandbox", "create",
"--name", $sandboxName,
"--policy", $policyUsed,
"--driver-config-json", $driverConfigJson,
"--no-tty", "--", "cmd.exe", "/c", "exit", "0"
)
$createOut = $createResult.Output
$createExitCode = $createResult.ExitCode
} catch {
$createOut = $_.Exception.Message; $createExitCode = 1
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

#[cfg(windows)]
#[test]
fn shipped_runners_preserve_driver_config_json_in_windows_powershell() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let directory = tempfile::tempdir().expect("create native-argument test directory");
let receiver = directory.path().join("capture native arguments.ps1");
std::fs::write(
&receiver,
r#"
param(
[Parameter(Mandatory = $true, Position = 0)] [string] $Before,
[Parameter(Mandatory = $true, Position = 1)] [string] $DriverConfigJson,
[Parameter(Mandatory = $true, Position = 2)] [string] $After
)

[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Write-Output "BEFORE=$Before"
Write-Output "JSON=$DriverConfigJson"
Write-Output "AFTER=$After"
"#,
)
.expect("write native-argument receiver");

let verifier = r#"
$ErrorActionPreference = "Stop"
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$env:OPENSHELL_RUNNER_PATH,
[ref] $tokens,
[ref] $errors
)
if ($errors.Count -gt 0) {
throw "runner has PowerShell syntax errors: $($errors.Message -join '; ')"
}

foreach ($name in @("Quote-NativeArgument", "Invoke-NativeCaptured")) {
$functionAst = $ast.Find({
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq $name
}, $true)
if ($null -eq $functionAst) { throw "$name is missing" }
Invoke-Expression $functionAst.Extent.Text
}

$expected = @{
mxc = @{
command = @("C:/Program Files/OpenShell/agent.exe", "server mode")
cwd = "C:/work/path with spaces"
}
} | ConvertTo-Json -Compress -Depth 4
$powershell = Join-Path $env:SystemRoot "System32/WindowsPowerShell/v1.0/powershell.exe"
$result = Invoke-NativeCaptured $powershell @(
"-NoLogo", "-NoProfile", "-NonInteractive", "-File",
$env:OPENSHELL_ARGUMENT_RECEIVER,
"before value", $expected, "after value"
)
if ($result.ExitCode -ne 0) {
throw "argument receiver exited $($result.ExitCode): $($result.Output -join [Environment]::NewLine)"
}

$lines = @(($result.Output -join "`n") -split "`r?`n")
$before = $lines | Where-Object { $_ -like "BEFORE=*" } | Select-Object -First 1
$json = $lines | Where-Object { $_ -like "JSON=*" } | Select-Object -First 1
$after = $lines | Where-Object { $_ -like "AFTER=*" } | Select-Object -First 1
if ($before -ne "BEFORE=before value") { throw "leading argument changed: $before" }
if ($null -eq $json -or $json.Substring(5) -cne $expected) {
throw "driver config JSON changed: expected '$expected', captured '$json'"
}
if ($after -ne "AFTER=after value") { throw "trailing argument changed: $after" }
"#;

for runner in ["run-ws-agent-test.ps1", "run-openclaw-forward-test.ps1"] {
let runner_path = root.join("examples").join(runner);
let output = std::process::Command::new("powershell.exe")
.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"])
.arg(verifier)
.env("OPENSHELL_RUNNER_PATH", &runner_path)
.env("OPENSHELL_ARGUMENT_RECEIVER", &receiver)
.output()
.unwrap_or_else(|error| {
panic!("failed to launch Windows PowerShell for {runner}: {error}")
});
assert!(
output.status.success(),
"{runner} corrupted a native argument:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
}
Loading