From b6078cd1de0f07c7adce47a8a16c58f37bd95b7d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 29 Sep 2026 12:26:22 -0700 Subject: [PATCH] fix(mxc): preserve JSON arguments in PowerShell 5.1 Signed-off-by: Prekshi Vyas --- .../examples/run-openclaw-forward-test.ps1 | 70 ++++++++++++-- .../examples/run-ws-agent-test.ps1 | 71 ++++++++++++-- .../tests/powershell_native_arguments.rs | 95 +++++++++++++++++++ 3 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 crates/openshell-driver-mxc/tests/powershell_native_arguments.rs diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 index 7c3a7c6e80..60d9fec9b6 100644 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -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 @@ -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)" diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 index 0372864928..7d1be2e5f6 100644 --- a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -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 @@ -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 } diff --git a/crates/openshell-driver-mxc/tests/powershell_native_arguments.rs b/crates/openshell-driver-mxc/tests/powershell_native_arguments.rs new file mode 100644 index 0000000000..27c33b3837 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/powershell_native_arguments.rs @@ -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), + ); + } +}