diff --git a/Cargo.lock b/Cargo.lock index 431b79335a..572d0742a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4187,6 +4187,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-tungstenite 0.26.2", + "toml", "tonic", "tracing", "uuid", diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index a85e709d4e..bfead4f202 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -58,6 +58,8 @@ openshell-policy = { path = "../openshell-policy" } base64 = { workspace = true } rustls = { workspace = true } serde_json = { workspace = true } +# Parse the shipped gateway templates in cross-platform drift guards. +toml = { workspace = true } # Used by the drift guard test (handled_fields_inventory) to parse YAML into a # generic serde_json::Value for key enumeration. serde_yml = { workspace = true } diff --git a/crates/openshell-driver-mxc/examples/README.txt b/crates/openshell-driver-mxc/examples/README.txt new file mode 100644 index 0000000000..659124f9e5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/README.txt @@ -0,0 +1,48 @@ +OpenShell MXC Windows inference demos +==================================== + +These two demos exercise the full gateway -> MXC processContainer path: + + Local inference (Hello World) + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ollama-test.ps1 + + Cloud inference (T1) + $env:NV_API_KEY = "nvapi-..." + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-inference-test.ps1 + +Prerequisites +------------- + + - Windows 11 build 26300.8553 or newer with MXC processContainer support. + - openshell-gateway.exe and openshell.exe beside these files, or explicit + -GatewayPath and -CliPath arguments. + - wxc-exec.exe beside these files, on PATH, named by + OPENSHELL_WXC_EXEC_PATH, or passed with -WxcExecPath. + - Local demo: an Ollama-compatible service on 127.0.0.1:11434 by default. + Override -OllamaHost, -OllamaPort, and -Model when needed. + - Cloud demo: NV_API_KEY and outbound HTTPS to integrate.api.nvidia.com. + +The runners use a unique temporary share directory and an available loopback +gateway port for each run. They never require C:\mxc-kit, C:\work, a fixed +gateway port, or edits to the checked-in templates. Missing prerequisites fail +before sandbox creation with a diagnostic naming the parameter or environment +variable that can supply them. + +Security model +-------------- + +Both demos use the fail-closed process_container backend. Their rendered policy +grants only the per-run share and the one requested endpoint, and no broad +AppContainer network capability is enabled. Cloud traffic traverses OpenShell's +enforcing CONNECT proxy. For local Ollama only, curl bypasses proxy variables +for the requested loopback hostname because the proxy's SSRF defense rejects +all loopback destinations; MXC still limits direct traffic to host loopback. This +inherits the driver's documented limitation that governed MXC sandboxes can +reach other host-loopback ports and must not be treated as loopback-service +isolation. The cloud key is passed with `--env-from NV_API_KEY`, is not placed in +argv or written to the results directory, and is never printed. + +Each run leaves a results-* directory containing the rendered TOML and policy, +gateway logs, response artifacts, and summary.txt. The script exits 0 only when +the sandbox creates successfully and the expected inference response is +observed. Use -KeepArtifacts to retain the temporary share for debugging. diff --git a/crates/openshell-driver-mxc/examples/inference.yaml b/crates/openshell-driver-mxc/examples/inference.yaml new file mode 100644 index 0000000000..e20f4020b8 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/inference.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Template rendered by run-inference-test.ps1 into its per-run results directory. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "__OPENSHELL_DEMO_SHARE__" + +network_policies: + nvidia_inference: + name: nvidia-inference + endpoints: + - host: integrate.api.nvidia.com + port: 443 + protocol: rest + # Chat completions use POST. + access: read-write + enforcement: enforce + binaries: + - path: '__CMD_EXE__' diff --git a/crates/openshell-driver-mxc/examples/mxc-inference.toml b/crates/openshell-driver-mxc/examples/mxc-inference.toml new file mode 100644 index 0000000000..1c0d0274fd --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-inference.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gateway template for the cloud-inference (T1) demo. The runner resolves +# wxc-exec and writes a disposable copy. NV_API_KEY is supplied at sandbox +# creation with --env-from and is never stored in this file. + +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "wxc-exec.exe" +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +egress_proxy = true +egress_proxy_addr = "127.0.0.1:0" +debug = false diff --git a/crates/openshell-driver-mxc/examples/mxc-ollama.toml b/crates/openshell-driver-mxc/examples/mxc-ollama.toml new file mode 100644 index 0000000000..8b7a693dd5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ollama.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gateway template for the local-inference demo. run-ollama-test.ps1 resolves +# wxc-exec and writes a disposable copy; workload command, cwd, and environment +# are sandbox-scoped and passed to `openshell sandbox create`. + +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "wxc-exec.exe" +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +egress_proxy = true +# The address is a seed. The driver reserves a distinct ephemeral port for each +# sandbox, so this does not claim a machine-specific port. +egress_proxy_addr = "127.0.0.1:0" +debug = false diff --git a/crates/openshell-driver-mxc/examples/ollama.yaml b/crates/openshell-driver-mxc/examples/ollama.yaml new file mode 100644 index 0000000000..d1668955df --- /dev/null +++ b/crates/openshell-driver-mxc/examples/ollama.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Template rendered by run-ollama-test.ps1 into its per-run results directory. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "__OPENSHELL_DEMO_SHARE__" + +network_policies: + local_ollama: + name: local-ollama + endpoints: + - host: "__OLLAMA_HOST__" + port: __OLLAMA_PORT__ + protocol: rest + # Both /api/tags (GET) and /api/generate (POST) are required. + access: read-write + enforcement: enforce + binaries: + - path: '__CMD_EXE__' diff --git a/crates/openshell-driver-mxc/examples/run-inference-test.ps1 b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 new file mode 100644 index 0000000000..9d87622ac5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 @@ -0,0 +1,294 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Cloud inference (T1) demo for OpenShell on MXC. PowerShell 5.1 compatible. + +[CmdletBinding()] +param( + [string] $ApiKey = $env:NV_API_KEY, + [string] $WxcExecPath, + [string] $GatewayPath, + [string] $CliPath, + [string] $ShareDir, + [string] $Model = "nvidia/nemotron-3.5-lightning-30b-a3b", + [string] $Prompt = "Say hello in exactly five words.", + [ValidateRange(0, 65535)] [int] $Port = 0, + [string] $SandboxName, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-inference-$stamp-$PID" +New-Item -ItemType Directory -Path $resultDir | Out-Null + +function Info([string]$message) { Write-Host " $message" } +function Ok([string]$message) { Write-Host "[OK] $message" -ForegroundColor Green } +function Bad([string]$message) { Write-Host "[FAIL] $message" -ForegroundColor Red } + +function Resolve-Executable([string]$explicit, [string]$leaf, [string]$environmentName) { + $candidates = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace($explicit)) { [void]$candidates.Add($explicit) } + if (-not [string]::IsNullOrWhiteSpace($environmentName)) { + $fromEnvironment = [Environment]::GetEnvironmentVariable($environmentName) + if (-not [string]::IsNullOrWhiteSpace($fromEnvironment)) { [void]$candidates.Add($fromEnvironment) } + } + [void]$candidates.Add((Join-Path $here $leaf)) + [void]$candidates.Add((Join-Path (Join-Path $here "bin") $leaf)) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return [System.IO.Path]::GetFullPath($candidate) + } + } + $command = Get-Command $leaf -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { return $command.Source } + if ($environmentName) { + throw "$leaf was not found. Pass -WxcExecPath, set $environmentName, place $leaf beside this script, or add it to PATH." + } + throw "$leaf was not found. Pass its path explicitly or place it beside this script." +} + +function Get-AvailablePort { + $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-Port([int]$candidate) { + $client = New-Object System.Net.Sockets.TcpClient + try { + $pending = $client.BeginConnect("127.0.0.1", $candidate, $null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(250)) { return $false } + $client.EndConnect($pending) + return $true + } catch { + return $false + } finally { + $client.Dispose() + } +} + +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-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $allArgs = @("--gateway-endpoint", $endpoint) + $CommandArgs + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $cli + $startInfo.Arguments = (($allArgs | 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 OpenShell CLI '$cli'" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $text = (@($stdout.Result, $stderr.Result) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + $text = $text.Trim() + if (-not $AllowFailure -and $process.ExitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $($process.ExitCode)): $text" + } + return @{ ExitCode = $process.ExitCode; Text = $text } +} + +function Write-Utf8([string]$path, [string]$contents) { + [System.IO.File]::WriteAllText($path, $contents, $utf8NoBom) +} + +$gateway = $null +$cli = $null +$wxc = $null +$gatewayProcess = $null +$createdShare = $false +$success = $false +$failure = $null +$oldGatewayConfig = $env:OPENSHELL_GATEWAY_CONFIG +$oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER +$oldApiKey = $env:NV_API_KEY + +try { + if ([string]::IsNullOrWhiteSpace($ApiKey)) { + throw "NV_API_KEY is missing. Set `$env:NV_API_KEY or pass -ApiKey; the key is forwarded with --env-from and is never written to disk." + } + $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" "" + $cli = Resolve-Executable $CliPath "openshell.exe" "" + $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + foreach ($fixture in @("mxc-inference.toml", "inference.yaml")) { + if (-not (Test-Path -LiteralPath (Join-Path $here $fixture) -PathType Leaf)) { + throw "required demo fixture '$fixture' is missing beside the runner" + } + } + if ($Port -eq 0) { $Port = Get-AvailablePort } + $endpoint = "http://127.0.0.1:$Port" + if ([string]::IsNullOrWhiteSpace($SandboxName)) { $SandboxName = "inference-$PID" } + if ([string]::IsNullOrWhiteSpace($ShareDir)) { + $ShareDir = Join-Path ([System.IO.Path]::GetTempPath()) "openshell-mxc-inference-$PID-$([Guid]::NewGuid().ToString('N'))" + $createdShare = $true + } + $ShareDir = [System.IO.Path]::GetFullPath($ShareDir).TrimEnd('\', '/') + if ($ShareDir.Contains('"') -or $ShareDir.Contains("`n") -or $ShareDir.Contains("`r")) { + throw "ShareDir contains a quote or newline and cannot be rendered safely" + } + New-Item -ItemType Directory -Path $ShareDir -Force | Out-Null + + Info "gateway: $gateway" + Info "CLI: $cli" + Info "wxc-exec: $wxc" + Info "share: $ShareDir" + Info "NV_API_KEY: present (value redacted)" + + $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" + $curlExe = Join-Path $env:SystemRoot "System32\curl.exe" + $findStrExe = Join-Path $env:SystemRoot "System32\findstr.exe" + foreach ($systemTool in @($cmdExe, $curlExe, $findStrExe)) { + if (-not (Test-Path -LiteralPath $systemTool -PathType Leaf)) { + throw "required Windows tool was not found at '$systemTool'" + } + } + + $tomlUsed = Join-Path $resultDir "mxc-inference.used.toml" + $tomlText = [System.IO.File]::ReadAllText((Join-Path $here "mxc-inference.toml")) + $escapedWxc = $wxc.Replace('\', '\\').Replace('"', '\"') + $tomlText = $tomlText.Replace('wxc_exec_path = "wxc-exec.exe"', "wxc_exec_path = `"$escapedWxc`"") + Write-Utf8 $tomlUsed $tomlText + + $policyUsed = Join-Path $resultDir "inference.used.yaml" + $sharePolicy = $ShareDir.Replace('\', '/') + $policyText = [System.IO.File]::ReadAllText((Join-Path $here "inference.yaml")) + $policyText = $policyText.Replace("__OPENSHELL_DEMO_SHARE__", $sharePolicy) + $policyText = $policyText.Replace("__CMD_EXE__", $cmdExe) + Write-Utf8 $policyUsed $policyText + + $requestPath = Join-Path $ShareDir "inference-request.json" + $responsePath = Join-Path $ShareDir "inference-response.json" + $headersPath = Join-Path $ShareDir "inference-headers.txt" + $donePath = Join-Path $ShareDir "inference-pass.txt" + $errorPath = Join-Path $ShareDir "inference-error.txt" + $requestJson = @{ model = $Model; messages = @(@{ role = "user"; content = $Prompt }); max_tokens = 128; reasoning_budget = 64; stream = $false } | ConvertTo-Json -Compress -Depth 8 + Write-Utf8 $requestPath $requestJson + + $probePath = Join-Path $ShareDir "inference-probe.cmd" + $probeLines = @( + "@echo off", + # Inbox curl uses Schannel and does not honor CURL_CA_BUNDLE by itself. + # Point --cacert at the public bundle staged by the governed proxy. + "`"$curlExe`" --silent --show-error --fail-with-body --ssl-no-revoke --cacert `"%CURL_CA_BUNDLE%`" --max-time 120 -D `"$headersPath`" -H `"Authorization: Bearer %NV_API_KEY%`" -H `"Content-Type: application/json`" --data-binary `"@$requestPath`" -o `"$responsePath`" `"https://integrate.api.nvidia.com/v1/chat/completions`" 2> `"$errorPath`" || exit /b 31", + "`"$findStrExe`" /C:`"choices`" `"$responsePath`" >nul || exit /b 32", + "echo PASS> `"$donePath`"" + ) + Write-Utf8 $probePath ($probeLines -join "`r`n") + + $driverConfig = @{ mxc = @{ command = @($cmdExe, "/d", "/s", "/c", $probePath); cwd = $ShareDir } } | ConvertTo-Json -Compress -Depth 8 + + $gwLog = Join-Path $resultDir "gateway.log" + $gwErrLog = Join-Path $resultDir "gateway.err.log" + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + # The credential belongs to sandbox creation, not gateway configuration. + Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue + try { + $gatewayProcess = Start-Process -FilePath $gateway -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--port", "$Port", "--log-level", "info") -WorkingDirectory $here -PassThru -WindowStyle Hidden -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + } finally { + $env:NV_API_KEY = $ApiKey + } + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Port $Port)) { + if ($gatewayProcess.HasExited) { + $details = (Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue) -join "`n" + throw "gateway exited before listening: $details" + } + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Port $Port)) { throw "gateway did not listen on $endpoint within 30 seconds" } + Ok "gateway is listening at $endpoint" + + $create = Invoke-Cli @("sandbox", "create", "--name", $SandboxName, "--policy", $policyUsed, "--driver-config-json", $driverConfig, "--env-from", "NV_API_KEY", "--no-tty", "--output", "json") + Ok "sandbox '$SandboxName' created" + $deadline = (Get-Date).AddSeconds(150) + while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath $donePath)) { + $status = Invoke-Cli @("sandbox", "get", $SandboxName, "--output", "json") -AllowFailure + if ($status.ExitCode -eq 0 -and $status.Text -match '"phase"\s*:\s*"Error"') { + $httpStatus = "unknown" + if (Test-Path -LiteralPath $headersPath) { + $statusLines = @([System.IO.File]::ReadAllLines($headersPath) | Where-Object { $_ -match '^HTTP/\S+\s+\d{3}' }) + if ($statusLines.Count -gt 0 -and $statusLines[-1] -match '^HTTP/\S+\s+(\d{3})') { $httpStatus = $Matches[1] } + } + $apiDetail = "" + if (Test-Path -LiteralPath $responsePath) { + try { + $errorResponse = [System.IO.File]::ReadAllText($responsePath) | ConvertFrom-Json + if ($errorResponse.detail) { $apiDetail = ([string]$errorResponse.detail).Trim() } + } catch {} + } + $curlDetail = if (Test-Path -LiteralPath $errorPath) { ([System.IO.File]::ReadAllText($errorPath)).Trim() } else { "no curl diagnostic was produced" } + $diagnostic = "sandbox was created, but cloud inference failed (HTTP $httpStatus): $apiDetail $curlDetail".Trim() + throw $diagnostic.Replace($ApiKey, "***REDACTED***") + } + Start-Sleep -Milliseconds 500 + } + if (-not (Test-Path -LiteralPath $donePath)) { throw "sandbox did not finish cloud inference within 150 seconds" } + $response = [System.IO.File]::ReadAllText($responsePath) + $parsed = $response | ConvertFrom-Json + if (-not $parsed.choices -or $parsed.choices.Count -eq 0) { throw "cloud response did not contain a completion in 'choices'" } + Copy-Item -LiteralPath $requestPath, $responsePath, $headersPath, $probePath -Destination $resultDir -Force + $success = $true + Ok "cloud inference returned a completion from inside the sandbox" +} catch { + $failure = $_.Exception.Message + Bad $failure + foreach ($artifact in @("inference-response.json", "inference-headers.txt", "inference-error.txt")) { + $source = if ($ShareDir) { Join-Path $ShareDir $artifact } else { $null } + if ($source -and (Test-Path -LiteralPath $source)) { Copy-Item -LiteralPath $source -Destination $resultDir -Force -ErrorAction SilentlyContinue } + } +} finally { + if ($cli -and $endpoint -and $SandboxName) { + try { [void](Invoke-Cli @("sandbox", "delete", $SandboxName) -AllowFailure) } catch {} + } + if ($gatewayProcess -and -not $gatewayProcess.HasExited) { + Stop-Process -Id $gatewayProcess.Id -Force -ErrorAction SilentlyContinue + try { [void]$gatewayProcess.WaitForExit(5000) } catch {} + } + $env:OPENSHELL_GATEWAY_CONFIG = $oldGatewayConfig + $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if ([string]::IsNullOrWhiteSpace($oldApiKey)) { Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue } else { $env:NV_API_KEY = $oldApiKey } + if (-not $KeepArtifacts -and $createdShare -and $ShareDir -and (Test-Path -LiteralPath $ShareDir)) { + Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$verdict = if ($success) { "PASS" } else { "FAIL" } +$summary = "verdict=$verdict`r`nbase=cloud-inference`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" +Write-Utf8 (Join-Path $resultDir "summary.txt") $summary +Write-Host "`n$summary" +Write-Host "Results: $resultDir" +if ($success) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 b/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 new file mode 100644 index 0000000000..59c5bbcbb6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 @@ -0,0 +1,282 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Hello World local-inference demo for OpenShell on MXC. PowerShell 5.1 compatible. + +[CmdletBinding()] +param( + [string] $WxcExecPath, + [string] $GatewayPath, + [string] $CliPath, + [string] $ShareDir, + [string] $OllamaHost = "127.0.0.1", + [ValidateRange(1, 65535)] [int] $OllamaPort = 11434, + [string] $Model = "qwen3.5:0.8b", + [string] $Prompt = "Say hello in exactly five words.", + [ValidateRange(0, 65535)] [int] $Port = 0, + [string] $SandboxName, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-ollama-$stamp-$PID" +New-Item -ItemType Directory -Path $resultDir | Out-Null + +function Info([string]$message) { Write-Host " $message" } +function Ok([string]$message) { Write-Host "[OK] $message" -ForegroundColor Green } +function Bad([string]$message) { Write-Host "[FAIL] $message" -ForegroundColor Red } + +function Resolve-Executable([string]$explicit, [string]$leaf, [string]$environmentName) { + $candidates = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace($explicit)) { [void]$candidates.Add($explicit) } + if (-not [string]::IsNullOrWhiteSpace($environmentName)) { + $fromEnvironment = [Environment]::GetEnvironmentVariable($environmentName) + if (-not [string]::IsNullOrWhiteSpace($fromEnvironment)) { [void]$candidates.Add($fromEnvironment) } + } + [void]$candidates.Add((Join-Path $here $leaf)) + [void]$candidates.Add((Join-Path (Join-Path $here "bin") $leaf)) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return [System.IO.Path]::GetFullPath($candidate) + } + } + $command = Get-Command $leaf -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { return $command.Source } + if ($environmentName) { + throw "$leaf was not found. Pass -WxcExecPath, set $environmentName, place $leaf beside this script, or add it to PATH." + } + throw "$leaf was not found. Pass its path explicitly or place it beside this script." +} + +function Get-AvailablePort { + $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-Port([int]$candidate) { + $client = New-Object System.Net.Sockets.TcpClient + try { + $pending = $client.BeginConnect("127.0.0.1", $candidate, $null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(250)) { return $false } + $client.EndConnect($pending) + return $true + } catch { + return $false + } finally { + $client.Dispose() + } +} + +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-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $allArgs = @("--gateway-endpoint", $endpoint) + $CommandArgs + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $cli + $startInfo.Arguments = (($allArgs | 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 OpenShell CLI '$cli'" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $text = (@($stdout.Result, $stderr.Result) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + $text = $text.Trim() + if (-not $AllowFailure -and $process.ExitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $($process.ExitCode)): $text" + } + return @{ ExitCode = $process.ExitCode; Text = $text } +} + +function Write-Utf8([string]$path, [string]$contents) { + [System.IO.File]::WriteAllText($path, $contents, $utf8NoBom) +} + +$gateway = $null +$cli = $null +$wxc = $null +$gatewayProcess = $null +$createdShare = $false +$success = $false +$failure = $null +$oldGatewayConfig = $env:OPENSHELL_GATEWAY_CONFIG +$oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER + +try { + $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" "" + $cli = Resolve-Executable $CliPath "openshell.exe" "" + $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + foreach ($fixture in @("mxc-ollama.toml", "ollama.yaml")) { + if (-not (Test-Path -LiteralPath (Join-Path $here $fixture) -PathType Leaf)) { + throw "required demo fixture '$fixture' is missing beside the runner" + } + } + if ($OllamaHost -notmatch '^[A-Za-z0-9.-]+$') { + throw "OllamaHost '$OllamaHost' is invalid; use an IPv4 address or DNS name" + } + if ($Port -eq 0) { $Port = Get-AvailablePort } + $endpoint = "http://127.0.0.1:$Port" + if ([string]::IsNullOrWhiteSpace($SandboxName)) { $SandboxName = "ollama-$PID" } + if ([string]::IsNullOrWhiteSpace($ShareDir)) { + $ShareDir = Join-Path ([System.IO.Path]::GetTempPath()) "openshell-mxc-ollama-$PID-$([Guid]::NewGuid().ToString('N'))" + $createdShare = $true + } + $ShareDir = [System.IO.Path]::GetFullPath($ShareDir).TrimEnd('\', '/') + if ($ShareDir.Contains('"') -or $ShareDir.Contains("`n") -or $ShareDir.Contains("`r")) { + throw "ShareDir contains a quote or newline and cannot be rendered safely" + } + New-Item -ItemType Directory -Path $ShareDir -Force | Out-Null + + Info "gateway: $gateway" + Info "CLI: $cli" + Info "wxc-exec: $wxc" + Info "share: $ShareDir" + Info "Ollama: http://${OllamaHost}:$OllamaPort" + + try { + $probe = Invoke-WebRequest -UseBasicParsing -Uri "http://${OllamaHost}:$OllamaPort/api/tags" -TimeoutSec 8 + if ([int]$probe.StatusCode -ne 200) { throw "HTTP $($probe.StatusCode)" } + } catch { + throw "Ollama prerequisite is unavailable at http://${OllamaHost}:$OllamaPort/api/tags. Start Ollama or pass -OllamaHost/-OllamaPort. $($_.Exception.Message)" + } + Ok "host-side Ollama prerequisite returned HTTP 200" + + $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" + $curlExe = Join-Path $env:SystemRoot "System32\curl.exe" + $findStrExe = Join-Path $env:SystemRoot "System32\findstr.exe" + foreach ($systemTool in @($cmdExe, $curlExe, $findStrExe)) { + if (-not (Test-Path -LiteralPath $systemTool -PathType Leaf)) { + throw "required Windows tool was not found at '$systemTool'" + } + } + + $tomlUsed = Join-Path $resultDir "mxc-ollama.used.toml" + $tomlText = [System.IO.File]::ReadAllText((Join-Path $here "mxc-ollama.toml")) + $escapedWxc = $wxc.Replace('\', '\\').Replace('"', '\"') + $tomlText = $tomlText.Replace('wxc_exec_path = "wxc-exec.exe"', "wxc_exec_path = `"$escapedWxc`"") + Write-Utf8 $tomlUsed $tomlText + + $policyUsed = Join-Path $resultDir "ollama.used.yaml" + $sharePolicy = $ShareDir.Replace('\', '/') + $policyText = [System.IO.File]::ReadAllText((Join-Path $here "ollama.yaml")) + $policyText = $policyText.Replace("__OPENSHELL_DEMO_SHARE__", $sharePolicy) + $policyText = $policyText.Replace("__OLLAMA_HOST__", $OllamaHost) + $policyText = $policyText.Replace("__OLLAMA_PORT__", [string]$OllamaPort) + $policyText = $policyText.Replace("__CMD_EXE__", $cmdExe) + Write-Utf8 $policyUsed $policyText + + $requestPath = Join-Path $ShareDir "ollama-request.json" + $responsePath = Join-Path $ShareDir "ollama-response.json" + $tagsPath = Join-Path $ShareDir "ollama-tags.json" + $donePath = Join-Path $ShareDir "ollama-pass.txt" + $errorPath = Join-Path $ShareDir "ollama-error.txt" + $requestJson = @{ model = $Model; prompt = $Prompt; stream = $false } | ConvertTo-Json -Compress + Write-Utf8 $requestPath $requestJson + + $probePath = Join-Path $ShareDir "ollama-probe.cmd" + $probeLines = @( + "@echo off", + # MXC's governed path permits only host loopback at the OS boundary. The + # CONNECT proxy deliberately blocks loopback as SSRF, so this local-only + # demo bypasses proxy variables for precisely the requested Ollama host. + "`"$curlExe`" --noproxy `"$OllamaHost`" --silent --show-error --fail --max-time 15 -o `"$tagsPath`" `"http://${OllamaHost}:$OllamaPort/api/tags`" 2> `"$errorPath`" || exit /b 21", + "`"$curlExe`" --noproxy `"$OllamaHost`" --silent --show-error --fail --max-time 120 -H `"Content-Type: application/json`" --data-binary `"@$requestPath`" -o `"$responsePath`" `"http://${OllamaHost}:$OllamaPort/api/generate`" 2>> `"$errorPath`" || exit /b 22", + "`"$findStrExe`" /C:`"response`" `"$responsePath`" >nul || exit /b 23", + "echo PASS> `"$donePath`"" + ) + Write-Utf8 $probePath ($probeLines -join "`r`n") + + $driverConfig = @{ mxc = @{ command = @($cmdExe, "/d", "/s", "/c", $probePath); cwd = $ShareDir } } | ConvertTo-Json -Compress -Depth 8 + + $gwLog = Join-Path $resultDir "gateway.log" + $gwErrLog = Join-Path $resultDir "gateway.err.log" + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + $gatewayProcess = Start-Process -FilePath $gateway -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--port", "$Port", "--log-level", "info") -WorkingDirectory $here -PassThru -WindowStyle Hidden -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Port $Port)) { + if ($gatewayProcess.HasExited) { + $details = (Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue) -join "`n" + throw "gateway exited before listening: $details" + } + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Port $Port)) { throw "gateway did not listen on $endpoint within 30 seconds" } + Ok "gateway is listening at $endpoint" + + $create = Invoke-Cli @("sandbox", "create", "--name", $SandboxName, "--policy", $policyUsed, "--driver-config-json", $driverConfig, "--no-tty", "--output", "json") + Ok "sandbox '$SandboxName' created" + $deadline = (Get-Date).AddSeconds(150) + while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath $donePath)) { + $status = Invoke-Cli @("sandbox", "get", $SandboxName, "--output", "json") -AllowFailure + if ($status.ExitCode -eq 0 -and $status.Text -match '"phase"\s*:\s*"Error"') { + $probeError = if (Test-Path -LiteralPath $errorPath) { ([System.IO.File]::ReadAllText($errorPath)).Trim() } else { "no curl diagnostic was produced" } + throw "sandbox was created, but local inference failed: $probeError" + } + Start-Sleep -Milliseconds 500 + } + if (-not (Test-Path -LiteralPath $donePath)) { throw "sandbox did not finish local inference within 150 seconds" } + $response = [System.IO.File]::ReadAllText($responsePath) + $parsed = $response | ConvertFrom-Json + if ([string]::IsNullOrWhiteSpace([string]$parsed.response)) { throw "Ollama response did not contain a nonempty 'response' field" } + Copy-Item -LiteralPath $requestPath, $responsePath, $tagsPath, $probePath -Destination $resultDir -Force + $success = $true + Ok "local inference returned a completion from inside the sandbox" +} catch { + $failure = $_.Exception.Message + Bad $failure +} finally { + if ($cli -and $endpoint -and $SandboxName) { + try { [void](Invoke-Cli @("sandbox", "delete", $SandboxName) -AllowFailure) } catch {} + } + if ($gatewayProcess -and -not $gatewayProcess.HasExited) { + Stop-Process -Id $gatewayProcess.Id -Force -ErrorAction SilentlyContinue + try { [void]$gatewayProcess.WaitForExit(5000) } catch {} + } + $env:OPENSHELL_GATEWAY_CONFIG = $oldGatewayConfig + $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if (-not $KeepArtifacts -and $createdShare -and $ShareDir -and (Test-Path -LiteralPath $ShareDir)) { + Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$verdict = if ($success) { "PASS" } else { "FAIL" } +$summary = "verdict=$verdict`r`nbase=local-ollama`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" +Write-Utf8 (Join-Path $resultDir "summary.txt") $summary +Write-Host "`n$summary" +Write-Host "Results: $resultDir" +if ($success) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 072b1debf1..07dc86dcfc 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -2363,6 +2363,8 @@ mod lifecycle_tests { NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy, StaticCredentialBinding, StaticCredentialEndpointBinding, UiClipboardAccess, UiPolicy, }; + use openshell_policy::parse_sandbox_policy; + use std::path::Path; use std::time::Duration; fn driver_sandbox(id: &str) -> DriverSandbox { @@ -2492,6 +2494,80 @@ mod lifecycle_tests { None } + fn shipped_demo_config(name: &str) -> MxcComputeConfig { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join(name); + let source = std::fs::read_to_string(&path).expect("read shipped demo config"); + let document: toml::Value = toml::from_str(&source).expect("parse shipped demo config"); + document["openshell"]["drivers"]["mxc"] + .clone() + .try_into() + .expect("deserialize shipped MXC driver config") + } + + fn shipped_demo_policy(name: &str, share: &str) -> SandboxPolicy { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join(name); + let rendered = std::fs::read_to_string(&path) + .expect("read shipped demo policy") + .replace("__OPENSHELL_DEMO_SHARE__", share) + .replace("__OLLAMA_HOST__", "127.0.0.1") + .replace("__OLLAMA_PORT__", "11434") + .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); + parse_sandbox_policy(&rendered).expect("parse rendered shipped demo policy") + } + + #[tokio::test] + async fn shipped_inference_examples_create_process_container_sandboxes() { + for (index, (config_name, policy_name)) in [ + ("mxc-ollama.toml", "ollama.yaml"), + ("mxc-inference.toml", "inference.yaml"), + ] + .into_iter() + .enumerate() + { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let proof = format!("{share}/demo-created.txt"); + let sandbox_name = format!("shipped-demo-{index}"); + let cmd = vec![ + r"C:\Windows\System32\cmd.exe".into(), + "/d".into(), + "/c".into(), + format!(r#"echo PASS>"{proof}""#), + ]; + let config = shipped_demo_config(config_name); + assert_eq!(config.backend, MxcBackend::ProcessContainer); + assert!(config.egress_proxy); + let backend = MxcComputeBackend::new_mocked(config); + let policy = shipped_demo_policy(policy_name, &share); + let sandbox = with_policy( + driver_sandbox_with_command(&sandbox_name, &share, cmd), + policy, + ); + + backend + .create_sandbox(&sandbox) + .await + .unwrap_or_else(|error| panic!("{config_name} create failed: {error}")); + let completed = wait_for(&backend, &sandbox_name, |sandbox| { + ready_condition(sandbox) + .is_some_and(|condition| condition.reason == "AgentCompleted") + }) + .await; + assert!( + completed.is_some(), + "{config_name} did not reach Ready/AgentCompleted" + ); + assert!( + tmp.path().join("demo-created.txt").is_file(), + "{config_name} did not run its in-policy workload" + ); + } + } + #[test] fn mxc_config_defaults_to_default_deny_process_container() { let config = MxcComputeConfig::default(); diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs new file mode 100644 index 0000000000..dac96ac700 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Drift guards for the two shipped Windows inference demos. + +use std::path::{Path, PathBuf}; + +use openshell_policy::{parse_sandbox_policy, validate_sandbox_policy}; +use toml::Value; + +fn examples_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("examples") +} + +#[test] +fn shipped_inference_demo_assets_are_present() { + let root = examples_root(); + for name in [ + "mxc-ollama.toml", + "ollama.yaml", + "run-ollama-test.ps1", + "mxc-inference.toml", + "inference.yaml", + "run-inference-test.ps1", + ] { + let path = root.join(name); + assert!( + path.is_file(), + "shipped demo asset is missing: {}", + path.display() + ); + } +} + +fn read_example(name: &str) -> String { + let path = examples_root().join(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) +} + +#[test] +fn shipped_inference_configs_follow_current_gateway_contract() { + for name in ["mxc-ollama.toml", "mxc-inference.toml"] { + let source = read_example(name); + let parsed: Value = toml::from_str(&source) + .unwrap_or_else(|error| panic!("failed to parse {name}: {error}")); + let openshell = parsed + .get("openshell") + .and_then(Value::as_table) + .unwrap_or_else(|| panic!("{name} is missing [openshell]")); + assert_eq!( + openshell.get("version").and_then(Value::as_integer), + Some(2), + "{name} must use schema version 2" + ); + let mxc = openshell + .get("drivers") + .and_then(Value::as_table) + .and_then(|drivers| drivers.get("mxc")) + .and_then(Value::as_table) + .unwrap_or_else(|| panic!("{name} is missing [openshell.drivers.mxc]")); + assert_eq!( + mxc.get("backend").and_then(Value::as_str), + Some("process_container"), + "{name} must use the filesystem-policy-capable backend" + ); + assert_eq!(mxc.get("egress_proxy").and_then(Value::as_bool), Some(true)); + assert!( + matches!( + mxc.get("egress_proxy_addr").and_then(Value::as_str), + Some(address) if address.starts_with("127.0.0.1:") + ), + "{name} must route network policy through governed loopback egress" + ); + for removed in ["share_dir", "agent_cwd", "agent_command", "agent_env"] { + assert!( + !mxc.contains_key(removed), + "{name} contains removed gateway field {removed}" + ); + } + } +} + +#[test] +fn shipped_inference_policies_are_narrow_and_valid_after_rendering() { + let fixtures = [ + ( + "ollama.yaml", + "local_ollama", + "127.0.0.1", + "C:/portable/demo", + ), + ( + "inference.yaml", + "nvidia_inference", + "integrate.api.nvidia.com", + "C:/portable/demo", + ), + ]; + for (name, rule_name, endpoint, share) in fixtures { + let rendered = read_example(name) + .replace("__OPENSHELL_DEMO_SHARE__", share) + .replace("__OLLAMA_HOST__", "127.0.0.1") + .replace("__OLLAMA_PORT__", "11434") + .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); + let policy = parse_sandbox_policy(&rendered) + .unwrap_or_else(|error| panic!("failed to parse rendered {name}: {error}")); + validate_sandbox_policy(&policy) + .unwrap_or_else(|error| panic!("rendered {name} is invalid: {error:?}")); + let filesystem = policy + .filesystem + .as_ref() + .unwrap_or_else(|| panic!("{name} must have a filesystem policy")); + assert_eq!(filesystem.read_write, vec![share]); + assert_eq!(policy.network_policies.len(), 1); + let rule = policy + .network_policies + .get(rule_name) + .unwrap_or_else(|| panic!("{name} is missing rule {rule_name}")); + assert_eq!(rule.endpoints.len(), 1); + assert_eq!(rule.endpoints[0].host, endpoint); + assert_eq!(rule.binaries.len(), 1); + assert_eq!( + rule.binaries[0].path.to_ascii_lowercase(), + r"c:\windows\system32\cmd.exe" + ); + } +} + +#[test] +fn shipped_runners_supply_sandbox_scoped_workload_configuration() { + for name in ["run-ollama-test.ps1", "run-inference-test.ps1"] { + let source = read_example(name); + assert!( + source.contains("--driver-config-json"), + "{name} must pass command/cwd at sandbox creation" + ); + assert!( + !source.contains("C:\\mxc-kit"), + "{name} must not pin wxc-exec to a machine path" + ); + assert!( + !source.contains("C:\\work"), + "{name} must not pin its share to a machine path" + ); + assert!( + !source.contains("17670"), + "{name} must not pin the gateway to the historical fixed port" + ); + assert!(!source.contains("isolation_session")); + } + let cloud = read_example("run-inference-test.ps1"); + assert!(cloud.contains("--env-from")); + assert!(cloud.contains("NV_API_KEY")); + assert!(cloud.contains("nvidia/nemotron-3.5-lightning-30b-a3b")); + assert!(!cloud.contains("nvidia/nvidia-nemotron-nano-9b-v2")); +} + +#[cfg(target_os = "windows")] +#[test] +fn shipped_runners_parse_in_windows_powershell() { + for name in ["run-ollama-test.ps1", "run-inference-test.ps1"] { + let path = examples_root().join(name); + let script = r" +$errors = $null +[void][System.Management.Automation.Language.Parser]::ParseFile($env:OPENSHELL_DEMO_SCRIPT_TO_PARSE, [ref]$null, [ref]$errors) +if ($errors.Count -gt 0) { + $errors | ForEach-Object { [Console]::Error.WriteLine($_.Message) } + exit 1 +} +"; + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env("OPENSHELL_DEMO_SCRIPT_TO_PARSE", &path) + .output() + .unwrap_or_else(|error| panic!("failed to launch PowerShell for {name}: {error}")); + assert!( + output.status.success(), + "{name} has PowerShell syntax errors:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } +}