Repository navigation
Description
User Story
I'm integrating OpenShell into Hermes Agent at Nous Research, using the MXC driver on Windows. Each Hermes profile runs in its own sandbox under one gateway, so switching on several profiles starts several sandboxes at once. Every sandbox's supervisor writes its CA to the same C:\etc\openshell-tls, so a sandbox can start with another sandbox's CA or none: with 8 sandboxes started at once, only 3 to 6 completed an HTTPS request.
Problem Statement
The MXC compute driver on the windows branch starts the host openshell-supervisor without OPENSHELL_PROXY_TLS_DIR. The supervisor then writes its proxy CA certificate (openshell-ca.pem) and trust bundle (ca-bundle.pem) to its default /etc/openshell-tls, which Windows resolves to \etc\openshell-tls on the gateway's current drive, C:\etc\openshell-tls on a default install. It reads both files back when it starts the workload, and the workload's SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, GIT_SSL_CAINFO and NODE_EXTRA_CA_CERTS come from them.
Every MXC sandbox on the host, of every gateway, uses the same two files. When several sandboxes start at the same time, a supervisor can read back another sandbox's CA, or fail to replace the files and start with TLS termination disabled. The files stay after openshell sandbox delete. The driver keeps the rest of a sandbox's host state in <state_dir>\<sandbox-id>\<generation> and removes it on delete.
The Linux drivers don't hit this. Docker, Podman and Kubernetes run the supervisor inside the sandbox's own container or pod with OPENSHELL_PROXY_TLS_DIR=/run/openshell/proxy-tls, so each sandbox has its own files and they go away with it. The VM driver sets OPENSHELL_PROXY_TLS_DIR to proxy-tls in the sandbox's state directory. The MXC driver runs the supervisor on the Windows host and sets nothing, so the supervisor uses the default /etc/openshell-tls, one directory for the whole host.
Impact / Why This Matters
With 8 sandboxes created at the same time on one gateway, 3, 4 and 6 of the 8 workloads completed an HTTPS request through the proxy (three runs). Of the 11 failures, 4 workloads had been given another sandbox's CA and curl rejected the proxy's certificate (exit 60), and 7 supervisors logged Failed to write CA files, TLS termination disabled, so their workloads received no CA. With TLS termination disabled, the proxy answers CONNECT to every route that terminates TLS with 503 tls_termination_unavailable (refuse_connect_when_tls_unavailable in openshell-supervisor-network/src/proxy.rs). Sandboxes started one at a time did not fail (3 of 3).
Sandboxes of separate gateways on the same host share the files too, so a gateway's sandboxes can fail because of another gateway's activity. The workaround is to start MXC sandboxes one at a time across the whole host. The directory at the drive root is never removed.
Acceptance Criteria
- The host supervisor of an MXC sandbox writes
openshell-ca.pemandca-bundle.pemunder that sandbox's host state directory. - Running MXC sandboxes does not create or change
\etc\openshell-tlson the gateway's drive. - Each workload receives its own sandbox's proxy CA; 8 sandboxes created at the same time each complete an HTTPS request through the proxy.
-
openshell sandbox deleteremoves the files.
Reproduction Steps
- Build the Windows binaries from
windows(mise run --skip-tools windows:build:x64). - Run the script below. It starts a disposable gateway (mTLS, throwaway certificates) with an explicit
state_dir, creates N ProcessContainer sandboxes at the same time, has each workload save the CA it was given and make one HTTPS request toexample.comthrough the proxy, reports where the CA files are and whether each workload's CA matches them, then deletes the sandboxes:.\proxy-tls-dir.ps1 -BinaryDir <release dir> -WxcExecPath <wxc-exec.exe> -Sandboxes 8 - Observe
C:\etc\openshell-tls\openshell-ca.pemandca-bundle.pemrewritten, no CA files in any sandbox's state directory, and the files still present after the delete. Workloads with another sandbox's CA or none depend on timing: they appeared in four of the last five runs with 8 sandboxes (one is shown below). Repeat the run if all pass.
proxy-tls-dir.ps1
# Where does the MXC host supervisor keep its proxy CA files?
#
# Starts a disposable gateway (mTLS with throwaway certificates) with an explicit MXC
# state_dir and creates one or more ProcessContainer sandboxes at the same time. Each
# workload records the proxy CA it was given and makes one HTTPS request through the
# proxy. The script then reports, per sandbox, where the supervisor's CA files are,
# whether the workload's CA matches its own sandbox's files, how many distinct CAs the
# workloads received, and what is left after `sandbox delete`. Windows PowerShell 5.1.
#
# .\proxy-tls-dir.ps1 -BinaryDir <dir with openshell*.exe and libz3.dll> -WxcExecPath <wxc-exec.exe> [-Sandboxes 4]
param(
[Parameter(Mandatory = $true)] [string] $BinaryDir,
[Parameter(Mandatory = $true)] [string] $WxcExecPath,
[int] $Sandboxes = 1,
[int] $Port = 17760
)
$ErrorActionPreference = 'Stop'
$root = Join-Path ([IO.Path]::GetTempPath()) "openshell-tlsdir-$PID"
$work = Join-Path $root 'work'
$state = Join-Path $root 'mxc'
New-Item -ItemType Directory -Force $work | Out-Null
foreach ($name in 'APPDATA', 'LOCALAPPDATA', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_DATA_HOME') {
Set-Item "Env:$name" (Join-Path $root "cli\$name")
}
$gateway = Join-Path $BinaryDir 'openshell-gateway.exe'
$cli = Join-Path $BinaryDir 'openshell.exe'
$curl = 'C:\Windows\System32\curl.exe'
$shared = Join-Path $env:SystemDrive 'etc\openshell-tls'
function Show-Shared([string] $when) {
if (Test-Path $shared) {
Get-ChildItem $shared | ForEach-Object {
Write-Host " $when $($_.FullName) $($_.LastWriteTime.ToString('HH:mm:ss.fff'))"
}
} else {
Write-Host " $when $shared does not exist"
}
}
function Get-Pem([string] $path) {
if (Test-Path -LiteralPath $path -PathType Leaf) { (Get-Content -LiteralPath $path -Raw).Trim() } else { $null }
}
function Quote-Arguments([string[]] $arguments) {
($arguments | ForEach-Object {
if ($_ -notmatch '[\s"]') { $_ } else { '"' + (($_ -replace '(\\*)"', '$1$1\"') -replace '(\\+)$', '$1$1') + '"' }
}) -join ' '
}
# Windows PowerShell 5.1 does not quote native arguments that contain quotes, so build the command line here.
function Start-Cli([string[]] $arguments) {
$info = New-Object Diagnostics.ProcessStartInfo $cli, (Quote-Arguments $arguments)
$info.UseShellExecute = $false
$info.RedirectStandardOutput = $true
$info.RedirectStandardError = $true
$process = [Diagnostics.Process]::Start($info)
[pscustomobject]@{ Process = $process; Out = $process.StandardOutput.ReadToEndAsync(); Err = $process.StandardError.ReadToEndAsync() }
}
function Invoke-Cli([string[]] $arguments) {
$p = Start-Cli $arguments
$p.Process.WaitForExit()
[pscustomobject]@{ ExitCode = $p.Process.ExitCode; Output = $p.Out.Result + $p.Err.Result }
}
Write-Host '--- before'
Show-Shared 'shared'
# Throwaway CA, server and CLI client certificates, and sandbox JWT signing keys.
$certs = Join-Path $root 'certs'
& $gateway generate-certs --output-dir $certs --server-san 127.0.0.1 --server-san localhost | Out-Null
$jwt = Join-Path $certs 'jwt'
@"
[openshell]
version = 2
[openshell.gateway]
compute_driver = "mxc"
guest_tls_ca = '$certs\ca.crt'
[openshell.gateway.tls]
cert_path = '$certs\server\tls.crt'
key_path = '$certs\server\tls.key'
client_ca_path = '$certs\ca.crt'
[openshell.gateway.mtls_auth]
enabled = true
[openshell.gateway.gateway_jwt]
signing_key_path = '$jwt\signing.pem'
public_key_path = '$jwt\public.pem'
kid_path = '$jwt\kid'
gateway_id = 'tlsdir-repro'
[openshell.drivers.mxc]
allow_driver_config = true
wxc_exec_path = '$WxcExecPath'
state_dir = '$state'
"@ | Set-Content (Join-Path $root 'gateway.toml') -Encoding ASCII
@"
version: 1
filesystem_policy:
include_workdir: false
read_write: ['$($work.Replace('\', '/'))']
network_policies:
example:
name: example
endpoints: [{ host: example.com, port: 443 }]
binaries: [{ path: '$curl' }]
"@ | Set-Content (Join-Path $root 'policy.yaml') -Encoding ASCII
$env:OPENSHELL_GATEWAY_CONFIG = Join-Path $root 'gateway.toml'
$gw = Start-Process $gateway -PassThru -WindowStyle Hidden `
-ArgumentList '--db-url', 'sqlite::memory:', '--port', $Port `
-RedirectStandardOutput (Join-Path $root 'gateway.out') -RedirectStandardError (Join-Path $root 'gateway.err')
try {
$deadline = (Get-Date).AddSeconds(30)
while ($true) {
try { (New-Object Net.Sockets.TcpClient('127.0.0.1', $Port)).Close(); break } catch {}
if ((Get-Date) -gt $deadline -or $gw.HasExited) { throw 'gateway did not start' }
Start-Sleep -Milliseconds 500
}
# The CLI imports the client certificate from OPENSHELL_LOCAL_TLS_DIR.
$env:OPENSHELL_LOCAL_TLS_DIR = $certs
$add = Invoke-Cli @('gateway', 'add', "https://127.0.0.1:$Port", '--local', '--name', 'tlsdir-repro')
Remove-Item Env:OPENSHELL_LOCAL_TLS_DIR
if ($add.ExitCode -ne 0) { throw "gateway add failed: $($add.Output)" }
# OCSF JSON lines carry the supervisor's hostname; the driver forwards them to the gateway log.
$null = Invoke-Cli @('settings', 'set', '--global', '--yes', '--key', 'ocsf_json_enabled', '--value', 'true')
# Each workload saves the proxy CA it was given, then makes one HTTPS request through the
# proxy that trusts only the injected bundle (curl.exe uses Schannel, which ignores
# CURL_CA_BUNDLE without --cacert; the sandbox CA has no revocation endpoint, so revocation
# checks are best-effort), retrying a refused CONNECT.
$names = 1..$Sandboxes | ForEach-Object { "tlsdir-$($PID % 100000)-$_" }
$creates = foreach ($name in $names) {
$batch = Join-Path $work "$name.cmd"
Set-Content $batch -Encoding ASCII -Value @(
'@echo off',
"copy /y `"%NODE_EXTRA_CA_CERTS%`" `"$work\$name-ca.pem`" >NUL",
"echo SSL_CERT_FILE=%SSL_CERT_FILE%>`"$work\$name-env.txt`"",
'set /a TRIES=0',
':retry',
'set /a TRIES+=1',
"`"$curl`" -sS --cacert `"%CURL_CA_BUNDLE%`" --ssl-revoke-best-effort -o NUL -w `"%%{http_code}`" https://example.com/ >`"$work\$name-curl.txt`" 2>&1",
'set RC=%ERRORLEVEL%',
'if "%RC%"=="56" if %TRIES% LSS 5 goto retry',
'if "%RC%"=="7" if %TRIES% LSS 5 goto retry',
"(echo exit=%RC% tries=%TRIES%)>>`"$work\$name-curl.txt`"",
"echo done>`"$work\$name-done.txt`"")
$config = @{ mxc = @{ command = @('C:\Windows\System32\cmd.exe', '/d', '/c', $batch); cwd = $work } } |
ConvertTo-Json -Compress -Depth 4
Start-Cli @('sandbox', 'create', '--name', $name, '--no-tty',
'--policy', (Join-Path $root 'policy.yaml'), '--driver-config-json', $config)
}
foreach ($c in $creates) {
$c.Process.WaitForExit()
if ($c.Process.ExitCode -ne 0) { Write-Host "sandbox create exit $($c.Process.ExitCode): $($c.Out.Result)$($c.Err.Result)" }
}
$deadline = (Get-Date).AddSeconds(90)
while ((@($names | Where-Object { -not (Test-Path (Join-Path $work "$_-done.txt")) }).Count -gt 0) -and (Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 500
}
Write-Host '--- after the workloads ran'
Show-Shared 'shared'
$workloadCas = @()
foreach ($name in $names) {
$id = ((Invoke-Cli @('sandbox', 'get', $name, '-o', 'json')).Output | ConvertFrom-Json).id
$own = Get-ChildItem $state -Recurse -Filter 'openshell-ca.pem' -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like "*\$id\*" } | Select-Object -First 1
$given = Get-Pem (Join-Path $work "$name-ca.pem")
$workloadCas += $given
Write-Host "$name (id $id)"
Write-Host " CA files in its host state: $(if ($own) { $own.DirectoryName } else { 'none' })"
if (-not $given) { Write-Host ' workload received no proxy CA' }
if ($own -and $given) { Write-Host " workload CA matches its own host state file: $($given -eq (Get-Pem $own.FullName))" }
Write-Host " workload CA matches $shared\openshell-ca.pem: $($given -and $given -eq (Get-Pem (Join-Path $shared 'openshell-ca.pem')))"
Write-Host " workload $(Get-Content (Join-Path $work "$name-env.txt") -ErrorAction SilentlyContinue)"
Write-Host " https://example.com through the proxy: $((Get-Content (Join-Path $work "$name-curl.txt") -Raw -ErrorAction SilentlyContinue) -replace '\s+', ' ')"
}
Write-Host "distinct proxy CAs across $Sandboxes workloads: $(@($workloadCas | Where-Object { $_ } | Sort-Object -Unique).Count)"
$failed = @(Get-Content (Join-Path $root 'gateway.out'), (Join-Path $root 'gateway.err') -ErrorAction SilentlyContinue |
Where-Object { $_ -match 'Failed to write CA files' -and $_ -notmatch 'OCSF-JSON' })
Write-Host "supervisors that logged 'Failed to write CA files, TLS termination disabled': $($failed.Count)"
$failed | Select-Object -First 3 | ForEach-Object { Write-Host " $(($_ -replace '\x1b\[[0-9;]*m', '') -replace '^.*?(Failed to write CA files)', '$1')" }
# The driver forwards the host supervisor's stderr, including its OCSF-JSON lines, to the gateway log.
$hostnames = Get-Content (Join-Path $root 'gateway.out'), (Join-Path $root 'gateway.err') -ErrorAction SilentlyContinue |
Where-Object { $_ -match 'OCSF-JSON' -and $_ -match 'supervisor' } |
ForEach-Object { [regex]::Matches($_, 'device\\?":\{\\?"hostname\\?":\\?"([^"\\]+)') } |
ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique
Write-Host "OCSF hostname in the host supervisor's events: $($hostnames -join ', ') (DNS host name $([Net.Dns]::GetHostName()), COMPUTERNAME $env:COMPUTERNAME)"
foreach ($name in $names) { $null = Invoke-Cli @('sandbox', 'delete', $name) }
Start-Sleep -Seconds 2
Write-Host '--- after sandbox delete'
Show-Shared 'shared'
Write-Host " CA files left under ${state}: $(@(Get-ChildItem $state -Recurse -Filter '*.pem' -ErrorAction SilentlyContinue | Where-Object { $_.Name -in 'openshell-ca.pem', 'ca-bundle.pem' }).Count)"
Write-Host "repro state: $root"
} finally {
if (-not $gw.HasExited) { Stop-Process -Id $gw.Id -Force }
}Suggested UX (if applicable)
No new setting. The files move to <state_dir>\<sandbox-id>\<generation>\proxy-tls\.
Environment
- OpenShell:
windowsbranch at5d1bfbf92, release build - OS: Windows 11 26340.9767 x64
- Runtime: MXC 1.0.1 (
wxc-exec --probe: tierbase-container), ProcessContainer backend
Logs
Script output on windows at 5d1bfbf92, 8 sandboxes (state paths shortened; 3 of the 8 workloads shown). This run used an earlier version of the script whose gateway ran without TLS; the workload steps and the output format are the same:
--- before
shared C:\etc\openshell-tls\ca-bundle.pem 06:46:22.965
shared C:\etc\openshell-tls\openshell-ca.pem 06:46:22.962
--- after the workloads ran
shared C:\etc\openshell-tls\ca-bundle.pem 06:46:29.313
shared C:\etc\openshell-tls\openshell-ca.pem 06:46:29.285
tlsdir-64968-1 (id 3c17ced2-808c-4141-adf5-b487b8927664)
CA files in its host state: none
workload CA matches C:\etc\openshell-tls\openshell-ca.pem: False
https://example.com through the proxy: 200 exit=0 tries=1
tlsdir-64968-3 (id d08a2ad9-387c-42c7-9b03-b5c00c322c6e)
CA files in its host state: none
workload received no proxy CA
https://example.com through the proxy: curl: The file '' provided to --cacert does not exist ... exit=2 tries=1
tlsdir-64968-6 (id d3a9e0ec-2003-4d38-9d83-bd655e9163a9)
CA files in its host state: none
workload CA matches C:\etc\openshell-tls\openshell-ca.pem: True
https://example.com through the proxy: curl: (60) schannel: the certificate or certificate chain is based on an untrusted root ... exit=60 tries=1
distinct proxy CAs across 8 workloads: 4
supervisors that logged 'Failed to write CA files, TLS termination disabled': 2
Failed to write CA files, TLS termination disabled: atomically install TLS output /etc/openshell-tls\ca-bundle.pem: Access is denied. (os error 5) sandbox=tlsdir-64968-3 component="supervisor"
Failed to write CA files, TLS termination disabled: atomically install TLS output /etc/openshell-tls\openshell-ca.pem: Access is denied. (os error 5) sandbox=tlsdir-64968-8 component="supervisor"
--- after sandbox delete
shared C:\etc\openshell-tls\ca-bundle.pem 06:46:29.313
shared C:\etc\openshell-tls\openshell-ca.pem 06:46:29.285tlsdir-64968-6 was given the CA that was in C:\etc\openshell-tls at the end of the run, which another sandbox's supervisor had written; its own proxy presented a certificate from a different CA. The os error 5 failures appeared only when sandboxes started together.
Activity
fix(mxc): keep host supervisor proxy CA files in sandbox state (#5059)