Skip to content

Commit b6078cd

Browse files
committed
fix(mxc): preserve JSON arguments in PowerShell 5.1
Signed-off-by: Prekshi Vyas <prekshiv@nvidia.com>
1 parent 3451e72 commit b6078cd

3 files changed

Lines changed: 220 additions & 16 deletions

File tree

‎crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1‎

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,60 @@ function Copy-ItemRetry([string]$src, [string]$dst, [int]$attempts = 10, [int]$d
137137
function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green }
138138
function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red }
139139

140+
# Build one CreateProcess-compatible command-line argument. Windows PowerShell
141+
# 5.1 removes embedded quotes and can split JSON values at embedded spaces when
142+
# invoking native commands through the call operator.
143+
function Quote-NativeArgument([string]$value) {
144+
if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value }
145+
146+
$quoted = New-Object System.Text.StringBuilder
147+
[void]$quoted.Append('"')
148+
$backslashes = 0
149+
foreach ($ch in $value.ToCharArray()) {
150+
if ($ch -eq '\') {
151+
$backslashes++
152+
continue
153+
}
154+
if ($ch -eq '"') {
155+
[void]$quoted.Append(('\' * (2 * $backslashes + 1)))
156+
[void]$quoted.Append('"')
157+
} else {
158+
if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) }
159+
[void]$quoted.Append($ch)
160+
}
161+
$backslashes = 0
162+
}
163+
if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) }
164+
[void]$quoted.Append('"')
165+
return $quoted.ToString()
166+
}
167+
168+
function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) {
169+
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
170+
$startInfo.FileName = $filePath
171+
$startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ')
172+
$startInfo.UseShellExecute = $false
173+
$startInfo.CreateNoWindow = $true
174+
$startInfo.RedirectStandardOutput = $true
175+
$startInfo.RedirectStandardError = $true
176+
177+
$process = New-Object System.Diagnostics.Process
178+
$process.StartInfo = $startInfo
179+
if (-not $process.Start()) { throw "failed to start $filePath" }
180+
$stdout = $process.StandardOutput.ReadToEndAsync()
181+
$stderr = $process.StandardError.ReadToEndAsync()
182+
$process.WaitForExit()
183+
$output = @($stdout.Result, $stderr.Result) |
184+
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
185+
ForEach-Object { $_ -split "`r?`n" } |
186+
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
187+
188+
return @{
189+
ExitCode = $process.ExitCode
190+
Output = @($output)
191+
}
192+
}
193+
140194
function Grant-AppContainerWritableDirectory([string]$Path) {
141195
# AppContainer access is a dual check: the generated package SID grant from
142196
# MXC is necessary, but OpenClaw's SQLite staging also needs the two built-in
@@ -453,14 +507,14 @@ try {
453507
"--env", "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999",
454508
"--no-tty", "--", "exit"
455509
)
456-
# Windows PowerShell 5.1 wraps native stderr as ErrorRecord objects. Keep
457-
# warnings in the captured diagnostic without letting them terminate the
458-
# command before its real exit code and output are collected.
459-
$createPrevEAP = $ErrorActionPreference
460-
$ErrorActionPreference = "Continue"
461-
try { $createOut = & $cli @createArgs 2>&1; $createCode = $LASTEXITCODE }
462-
catch { $createOut = $_.Exception.Message; $createCode = 1 }
463-
finally { $ErrorActionPreference = $createPrevEAP }
510+
try {
511+
$createResult = Invoke-NativeCaptured $cli $createArgs
512+
$createOut = $createResult.Output
513+
$createCode = $createResult.ExitCode
514+
} catch {
515+
$createOut = $_.Exception.Message
516+
$createCode = 1
517+
}
464518
$createBenign = Show-SandboxCreate $createOut $SandboxName
465519
if ($createCode -ne 0 -and -not $createBenign) {
466520
throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)"

‎crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1‎

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,60 @@ function Esc([string]$p) { return $p.Replace('\', '\\') }
112112
# Convert Windows path to forward-slash form (TOML values).
113113
function Fwd([string]$p) { return $p.Replace('\', '/') }
114114

115+
# Build one CreateProcess-compatible command-line argument. Windows PowerShell
116+
# 5.1 removes embedded quotes and can split JSON values at embedded spaces when
117+
# invoking native commands through the call operator.
118+
function Quote-NativeArgument([string]$value) {
119+
if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value }
120+
121+
$quoted = New-Object System.Text.StringBuilder
122+
[void]$quoted.Append('"')
123+
$backslashes = 0
124+
foreach ($ch in $value.ToCharArray()) {
125+
if ($ch -eq '\') {
126+
$backslashes++
127+
continue
128+
}
129+
if ($ch -eq '"') {
130+
[void]$quoted.Append(('\' * (2 * $backslashes + 1)))
131+
[void]$quoted.Append('"')
132+
} else {
133+
if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) }
134+
[void]$quoted.Append($ch)
135+
}
136+
$backslashes = 0
137+
}
138+
if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) }
139+
[void]$quoted.Append('"')
140+
return $quoted.ToString()
141+
}
142+
143+
function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) {
144+
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
145+
$startInfo.FileName = $filePath
146+
$startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ')
147+
$startInfo.UseShellExecute = $false
148+
$startInfo.CreateNoWindow = $true
149+
$startInfo.RedirectStandardOutput = $true
150+
$startInfo.RedirectStandardError = $true
151+
152+
$process = New-Object System.Diagnostics.Process
153+
$process.StartInfo = $startInfo
154+
if (-not $process.Start()) { throw "failed to start $filePath" }
155+
$stdout = $process.StandardOutput.ReadToEndAsync()
156+
$stderr = $process.StandardError.ReadToEndAsync()
157+
$process.WaitForExit()
158+
$output = @($stdout.Result, $stderr.Result) |
159+
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
160+
ForEach-Object { $_ -split "`r?`n" } |
161+
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
162+
163+
return @{
164+
ExitCode = $process.ExitCode
165+
Output = @($output)
166+
}
167+
}
168+
115169
# -WsPort is NOT actually wired through end to end: the in-sandbox server's
116170
# port is a compile-time const (WS_PORT = 22000 in mxc-ws-agent.rs) -- the
117171
# TOML generation below doesn't patch it. Rather than silently accept an
@@ -485,14 +539,15 @@ try {
485539
# Use the same pattern as run-mxc-e2e.ps1: pass --no-tty with a no-op
486540
# command so the CLI fires the SSH attempt, fails quickly (connection
487541
# refused), and returns. Do NOT gate on exit code here.
488-
$createOut = & $cli sandbox create `
489-
--name $sandboxName `
490-
--policy $policyUsed `
491-
--driver-config-json $driverConfigJson `
492-
--no-tty `
493-
-- cmd.exe /c exit 0 `
494-
2>&1
495-
$createExitCode = $LASTEXITCODE
542+
$createResult = Invoke-NativeCaptured $cli @(
543+
"sandbox", "create",
544+
"--name", $sandboxName,
545+
"--policy", $policyUsed,
546+
"--driver-config-json", $driverConfigJson,
547+
"--no-tty", "--", "cmd.exe", "/c", "exit", "0"
548+
)
549+
$createOut = $createResult.Output
550+
$createExitCode = $createResult.ExitCode
496551
} catch {
497552
$createOut = $_.Exception.Message; $createExitCode = 1
498553
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#[cfg(windows)]
5+
#[test]
6+
fn shipped_runners_preserve_driver_config_json_in_windows_powershell() {
7+
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
8+
let directory = tempfile::tempdir().expect("create native-argument test directory");
9+
let receiver = directory.path().join("capture native arguments.ps1");
10+
std::fs::write(
11+
&receiver,
12+
r#"
13+
param(
14+
[Parameter(Mandatory = $true, Position = 0)] [string] $Before,
15+
[Parameter(Mandatory = $true, Position = 1)] [string] $DriverConfigJson,
16+
[Parameter(Mandatory = $true, Position = 2)] [string] $After
17+
)
18+
19+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
20+
Write-Output "BEFORE=$Before"
21+
Write-Output "JSON=$DriverConfigJson"
22+
Write-Output "AFTER=$After"
23+
"#,
24+
)
25+
.expect("write native-argument receiver");
26+
27+
let verifier = r#"
28+
$ErrorActionPreference = "Stop"
29+
$tokens = $null
30+
$errors = $null
31+
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
32+
$env:OPENSHELL_RUNNER_PATH,
33+
[ref] $tokens,
34+
[ref] $errors
35+
)
36+
if ($errors.Count -gt 0) {
37+
throw "runner has PowerShell syntax errors: $($errors.Message -join '; ')"
38+
}
39+
40+
foreach ($name in @("Quote-NativeArgument", "Invoke-NativeCaptured")) {
41+
$functionAst = $ast.Find({
42+
param($node)
43+
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
44+
$node.Name -eq $name
45+
}, $true)
46+
if ($null -eq $functionAst) { throw "$name is missing" }
47+
Invoke-Expression $functionAst.Extent.Text
48+
}
49+
50+
$expected = @{
51+
mxc = @{
52+
command = @("C:/Program Files/OpenShell/agent.exe", "server mode")
53+
cwd = "C:/work/path with spaces"
54+
}
55+
} | ConvertTo-Json -Compress -Depth 4
56+
$powershell = Join-Path $env:SystemRoot "System32/WindowsPowerShell/v1.0/powershell.exe"
57+
$result = Invoke-NativeCaptured $powershell @(
58+
"-NoLogo", "-NoProfile", "-NonInteractive", "-File",
59+
$env:OPENSHELL_ARGUMENT_RECEIVER,
60+
"before value", $expected, "after value"
61+
)
62+
if ($result.ExitCode -ne 0) {
63+
throw "argument receiver exited $($result.ExitCode): $($result.Output -join [Environment]::NewLine)"
64+
}
65+
66+
$lines = @(($result.Output -join "`n") -split "`r?`n")
67+
$before = $lines | Where-Object { $_ -like "BEFORE=*" } | Select-Object -First 1
68+
$json = $lines | Where-Object { $_ -like "JSON=*" } | Select-Object -First 1
69+
$after = $lines | Where-Object { $_ -like "AFTER=*" } | Select-Object -First 1
70+
if ($before -ne "BEFORE=before value") { throw "leading argument changed: $before" }
71+
if ($null -eq $json -or $json.Substring(5) -cne $expected) {
72+
throw "driver config JSON changed: expected '$expected', captured '$json'"
73+
}
74+
if ($after -ne "AFTER=after value") { throw "trailing argument changed: $after" }
75+
"#;
76+
77+
for runner in ["run-ws-agent-test.ps1", "run-openclaw-forward-test.ps1"] {
78+
let runner_path = root.join("examples").join(runner);
79+
let output = std::process::Command::new("powershell.exe")
80+
.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"])
81+
.arg(verifier)
82+
.env("OPENSHELL_RUNNER_PATH", &runner_path)
83+
.env("OPENSHELL_ARGUMENT_RECEIVER", &receiver)
84+
.output()
85+
.unwrap_or_else(|error| {
86+
panic!("failed to launch Windows PowerShell for {runner}: {error}")
87+
});
88+
assert!(
89+
output.status.success(),
90+
"{runner} corrupted a native argument:\nstdout:\n{}\nstderr:\n{}",
91+
String::from_utf8_lossy(&output.stdout),
92+
String::from_utf8_lossy(&output.stderr),
93+
);
94+
}
95+
}

0 commit comments

Comments
 (0)