Skip to content

Commit b2d6280

Browse files
authored
fix(windows): repair OpenClaw ProcessContainer startup (NVBug 6782898) (#3475)
1 parent d78430c commit b2d6280

11 files changed

Lines changed: 781 additions & 310 deletions

File tree

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ miette = { version = "7", features = ["fancy"] }
5757
thiserror = "2"
5858

5959
# Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only)
60-
windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] }
60+
windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] }
6161
anyhow = "1"
6262

6363
# Logging/Tracing

‎crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
# (the AppContainer here can only read paths granted by policy -- see the
1616
# script's "Stage artifacts into share_dir" step for why).
1717

18+
[openshell]
19+
version = 2
20+
1821
[openshell.drivers.mxc]
1922
wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe"
2023

‎crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
# openshell-supervisor-relay.exe / the caller's OpenClaw install into
2727
# the sandbox's policy-authorized working directory before creating it.
2828

29+
[openshell]
30+
version = 2
31+
2932
[openshell.drivers.mxc]
3033
wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe"
3134

‎crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
# allowLocalNetwork=true lets the AppContainer reach the host's loopback (the gateway
77
# relay) without routing through the egress proxy, which breaks node.js DLL init.
88

9+
[openshell]
10+
version = 2
11+
912
[openshell.drivers.mxc]
1013
wxc_exec_path = "C:\\FromSenthil\\mxc-fixes-env-vars\\wxc-exec.exe"
1114

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

Lines changed: 55 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,10 @@ param(
5656
[Parameter(Mandatory = $true)]
5757
[string] $OpenClawInstallDir,
5858
# Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw, not
59-
# C:\work\openshell-openclaw). Node's CommonJS module resolver calls
60-
# fs.realpathSync while resolving the entry script, which lstat()s every
61-
# parent directory up the chain -- including ones OUTSIDE share_dir. The
62-
# AppContainer only grants share_dir itself, so an intermediate parent like
63-
# C:\work fails with EPERM (confirmed empirically: this exact test failed
64-
# with "EPERM: operation not permitted, lstat 'C:\work'" until the share
65-
# dir was moved to the drive root). The drive root itself (C:\) apparently
66-
# doesn't need an explicit grant to lstat successfully, so a one-level path
67-
# sidesteps the problem entirely.
59+
# C:\work\openshell-openclaw). The staged Node invocation below uses
60+
# --preserve-symlinks-main so Node does not realpath the main module before
61+
# our capture script starts; keeping a one-level path also avoids exposing
62+
# or depending on unrelated intermediate directories.
6863
[string] $ShareDir = "C:\openshell-openclaw",
6964
[int] $TargetPort = 18889,
7065
[int] $ForwardLocalPort = 28889,
@@ -217,6 +212,7 @@ $fwdProc = $null
217212
$fwdLog = Join-Path $resultDir "forward.log"
218213
$fwdErrLog = Join-Path $resultDir "forward.err.log"
219214
$passed = $false
215+
$failureMessage = ""
220216
$healthJson = $null
221217
$selfProbeOutcome = "not-recorded"
222218
$selfProbeResponseBytes = 0
@@ -426,6 +422,7 @@ try {
426422
mxc = @{
427423
command = @(
428424
"$shareDirToml/node.exe",
425+
"--preserve-symlinks-main",
429426
"$shareDirToml/openclaw-capture.mjs",
430427
"gateway", "run", "--dev", "--allow-unconfigured",
431428
"--auth", "token", "--bind", "loopback", "--port", "$TargetPort"
@@ -456,43 +453,23 @@ try {
456453
"--env", "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999",
457454
"--no-tty", "--", "exit"
458455
)
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"
459461
try { $createOut = & $cli @createArgs 2>&1; $createCode = $LASTEXITCODE }
460462
catch { $createOut = $_.Exception.Message; $createCode = 1 }
463+
finally { $ErrorActionPreference = $createPrevEAP }
461464
$createBenign = Show-SandboxCreate $createOut $SandboxName
462465
if ($createCode -ne 0 -and -not $createBenign) {
463466
throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)"
464467
}
465468

466-
# 9. Wait for OpenClaw's gateway to report ready, by tailing the gateway's
467-
# own log for the line it prints on successful startup (forwarded from
468-
# the sandbox's stdout via "wxc-exec stdout:"). Generous timeout: Node
469-
# startup + AppContainer/UAC elevation + plugin warmup can take a while
470-
# on a cold run.
471-
Step "Wait for OpenClaw gateway readiness"
472-
$readyDeadline = (Get-Date).AddSeconds(90)
473-
$openclawReady = $false
474-
while ((Get-Date) -lt $readyDeadline) {
475-
if (Test-Path $gwLog) {
476-
# `.*` (not `\s+`) between "[gateway]" and "ready": OpenClaw wraps its
477-
# log lines in ANSI color codes whenever it inherits enough of the host
478-
# env to detect a color-capable terminal -- which happens with
479-
# mxc-openclaw-localnet.toml (-UseLocalNetwork), since that config
480-
# doesn't set pc_minimal_env and so inherits the full host env, unlike
481-
# mxc-openclaw-gateway.toml's curated minimal set. A strict \s+ match
482-
# missed this entirely and timed out waiting for a line that had
483-
# already printed. Those codes render in this log as LITERAL backslash-
484-
# escaped text (e.g. "...\x1b[36mready..."), not real ESC bytes -- so
485-
# "m" from "36m" directly abuts "ready" with no word boundary, which is
486-
# why a \bready\b tightening (tried once) also failed to match; a bare
487-
# substring check is what actually works here. The resulting collision
488-
# risk with "already" is theoretical -- no such line has been observed
489-
# on this "[gateway]"-tagged forwarded-stdout path in practice.
490-
if (Select-String -Path $gwLog -Pattern '\[gateway\].*ready' -Quiet -ErrorAction SilentlyContinue) { $openclawReady = $true; break }
491-
}
492-
Start-Sleep -Seconds 2
493-
}
494-
if (-not $openclawReady) { throw "OpenClaw did not report ready within 90s (see gateway.log in the results bundle)" }
495-
Ok "OpenClaw gateway ready"
469+
# `sandbox create` does not return success until the MXC driver receives the
470+
# relay's target_ready event. Trust that lifecycle result directly instead
471+
# of racing a second, text-based poll against gateway.log.
472+
Ok "OpenClaw gateway ready (sandbox target_ready received)"
496473

497474
# 10. openshell forward service: opens a fresh, on-demand relay for this
498475
# one call and bridges TargetPort (inside the sandbox) to
@@ -610,26 +587,55 @@ try {
610587
}
611588
}
612589
catch {
613-
Bad $_.Exception.Message
590+
$failureMessage = $_.Exception.Message
591+
Bad $failureMessage
614592
}
615593
finally {
616594
# Stop the forward before the sandbox so its relay tears down cleanly.
617595
if ($fwdProc -and -not $fwdProc.HasExited) {
618596
try { Stop-Process -Id $fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {}
619597
}
598+
if ($fwdProc) {
599+
try {
600+
if (-not $fwdProc.WaitForExit(5000)) {
601+
throw "forward process did not exit within 5s"
602+
}
603+
} catch {
604+
Info "forward teardown: $($_.Exception.Message)"
605+
if ($passed) { $passed = $false; $failureMessage = $_.Exception.Message }
606+
}
607+
}
620608

621609
# Tear down the sandbox while the gateway is still up (delete needs it).
622610
if ($cli -and $SandboxName) {
623-
try { & $cli sandbox delete $SandboxName 2>&1 | Out-Null }
624-
catch { Info "sandbox teardown '$SandboxName': $($_.Exception.Message) (continuing)" }
611+
$deleteCode = 1
612+
$deleteOut = @()
613+
$deletePrevEAP = $ErrorActionPreference
614+
$ErrorActionPreference = "Continue"
615+
try { $deleteOut = & $cli sandbox delete $SandboxName 2>&1; $deleteCode = $LASTEXITCODE }
616+
catch { $deleteOut = $_.Exception.Message }
617+
finally { $ErrorActionPreference = $deletePrevEAP }
618+
if ($deleteCode -ne 0) {
619+
$cleanupFailure = "sandbox teardown '$SandboxName' failed (exit $deleteCode): $($deleteOut | Out-String)"
620+
Info $cleanupFailure
621+
if ($passed) { $passed = $false; $failureMessage = $cleanupFailure }
622+
}
625623
}
626624

627625
if ($KeepRunning -and $gw -and -not $gw.HasExited) {
628626
Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop with: Stop-Process -Id $($gw.Id) -Force"
629627
} elseif ($gw -and -not $gw.HasExited) {
630628
Step "Cleanup"; Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue
631-
try { $gw.WaitForExit(5000) | Out-Null } catch {}
632-
Info "stopped gateway pid $($gw.Id)"
629+
try {
630+
if (-not $gw.WaitForExit(5000)) {
631+
throw "gateway process did not exit within 5s"
632+
}
633+
Info "stopped gateway pid $($gw.Id)"
634+
} catch {
635+
$cleanupFailure = "gateway teardown failed: $($_.Exception.Message)"
636+
Info $cleanupFailure
637+
if ($passed) { $passed = $false; $failureMessage = $cleanupFailure }
638+
}
633639
}
634640

635641
Step "Gateway log (tail)"
@@ -663,12 +669,17 @@ finally {
663669

664670
Step "RESULT"
665671
$verdict = if ($passed) { "PASS" } else { "FAIL" }
672+
if (-not $passed -and [string]::IsNullOrWhiteSpace($failureMessage)) {
673+
$failureMessage = "one or more qualification assertions failed"
674+
}
675+
$failureSummary = ($failureMessage -replace '\s+', ' ').Trim()
666676
$summary = @"
667677
OpenShell MXC OpenClaw + dynamic forward test
668678
=====================================================================
669679
timestamp : $stamp
670680
machine : $env:COMPUTERNAME
671681
verdict : $verdict
682+
failure : $failureSummary
672683
sandbox : $SandboxName
673684
backend : $Backend
674685
config : $tomlName

‎crates/openshell-driver-mxc/src/control_channel.rs‎

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ pub enum ControlChannelError {
3030

3131
type PendingMap = Mutex<HashMap<u64, oneshot::Sender<Value>>>;
3232
/// Slot for one of the spawner's one-time, unsolicited events -- startup-
33-
/// ready (see `try_route_ready`) and target-ready (see
34-
/// `try_route_target_ready`) each get their own instance of this type.
33+
/// ready (see `try_route_ready`) and target status (see
34+
/// `try_route_target_status`) each get their own instance of this type.
3535
/// Not part of `PendingMap`: neither has a correlation id or is a reply to
3636
/// anything the driver sent. The payload is `Ok(())` for a normal fire, or
3737
/// `Err(reason)` when the event fired but something about it was rejected
38-
/// (currently only the "ready" event's protocol version check uses this;
39-
/// `"target_ready"` always sends `Ok(())`).
38+
/// `"target_ready"` sends `Ok(())`; `"target_failed"` sends its diagnostic
39+
/// as `Err(reason)`.
4040
pub type ReadySlot = Mutex<Option<oneshot::Sender<Result<(), String>>>>;
4141

4242
/// Wire protocol version this driver requires from
@@ -50,7 +50,7 @@ pub type ReadySlot = Mutex<Option<oneshot::Sender<Result<(), String>>>>;
5050
/// "forward", or the `"target_ready"` event itself) -- an independently
5151
/// staged, stale relay binary then fails fast with a clear error instead of
5252
/// hanging or misbehaving against fields/events it doesn't understand.
53-
const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 2;
53+
const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 4;
5454

5555
/// One control channel per sandboxed process. `request()` is safe to call
5656
/// concurrently — each call gets its own correlation id and awaits only its
@@ -129,20 +129,25 @@ impl ControlChannel {
129129
.await
130130
}
131131

132-
/// Try to recognize `line` as the spawner's unsolicited target-ready
133-
/// event (`{"event":"target_ready"}`) -- sent once the spawner has
134-
/// actually spawned the target and confirmed its configured port is
135-
/// accepting connections (see `wait_for_port_ready` in
136-
/// `openshell-supervisor-relay`). Distinct from the `"launch"`
137-
/// control-channel *response*, which only confirms the command/env
138-
/// arrived, not that the target is running: driver.rs awaits this event
139-
/// too before publishing the sandbox `Ready=True`, so a caller acting on
140-
/// `Ready` can't race a target that hasn't bound its port yet. Returns
141-
/// `true` if `line` was consumed this way. No version gate here -- the
142-
/// startup "ready" handshake above already rejected an incompatible
143-
/// peer long before this could fire.
144-
pub async fn try_route_target_ready(target_ready: &ReadySlot, line: &str) -> bool {
145-
Self::try_route_named_event(target_ready, line, "target_ready", |_| Ok(())).await
132+
/// Route the spawner's one-time target status event. `target_ready`
133+
/// confirms the configured port is accepting connections;
134+
/// `target_failed` carries the target's actual exit/error diagnostic.
135+
/// Both are distinct from the `launch` response, which only confirms the
136+
/// command and environment arrived. No version gate is needed here: the
137+
/// startup handshake already rejected an incompatible peer.
138+
pub async fn try_route_target_status(target_status: &ReadySlot, line: &str) -> bool {
139+
if Self::try_route_named_event(target_status, line, "target_ready", |_| Ok(())).await {
140+
return true;
141+
}
142+
Self::try_route_named_event(target_status, line, "target_failed", |value| {
143+
let error = value
144+
.get("error")
145+
.and_then(Value::as_str)
146+
.filter(|error| !error.trim().is_empty())
147+
.unwrap_or("target failed without a diagnostic");
148+
Err(error.to_string())
149+
})
150+
.await
146151
}
147152

148153
async fn try_route_named_event(
@@ -279,7 +284,7 @@ mod tests {
279284
let (slot, rx) = armed_ready_slot();
280285

281286
let consumed =
282-
ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":2}"#)
287+
ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":4}"#)
283288
.await;
284289

285290
assert!(consumed);
@@ -298,13 +303,13 @@ mod tests {
298303
consumed,
299304
"a recognized ready event is consumed even when rejected"
300305
);
301-
let err = rx.await.unwrap().expect_err("version 2 must be rejected");
306+
let err = rx.await.unwrap().expect_err("version 1 must be rejected");
302307
assert!(
303-
err.contains('2'),
308+
err.contains('1'),
304309
"error should name the offending version: {err}"
305310
);
306311
assert!(
307-
err.contains('1'),
312+
err.contains('4'),
308313
"error should name the required version: {err}"
309314
);
310315
}
@@ -334,17 +339,17 @@ mod tests {
334339
assert!(!ControlChannel::try_route_ready(&slot, "garbage").await);
335340
}
336341

337-
// ── try_route_target_ready ───────────────────────────────────────────
342+
// ── try_route_target_status ──────────────────────────────────────────
338343

339344
#[tokio::test]
340345
async fn try_route_target_ready_fires_ok_with_no_version_gate() {
341346
let (slot, rx) = armed_ready_slot();
342347

343348
// No protocol_version field at all -- unlike "ready", "target_ready"
344349
// must not be gated on one (see the doc comment on
345-
// try_route_target_ready).
350+
// try_route_target_status).
346351
let consumed =
347-
ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await;
352+
ControlChannel::try_route_target_status(&slot, r#"{"event":"target_ready"}"#).await;
348353

349354
assert!(consumed);
350355
assert_eq!(rx.await.unwrap(), Ok(()));
@@ -356,9 +361,9 @@ mod tests {
356361

357362
// "ready" and "target_ready" must not be cross-routed into each
358363
// other's slot.
359-
let consumed = ControlChannel::try_route_target_ready(
364+
let consumed = ControlChannel::try_route_target_status(
360365
&slot,
361-
r#"{"event":"ready","protocol_version":2}"#,
366+
r#"{"event":"ready","protocol_version":4}"#,
362367
)
363368
.await;
364369

@@ -369,13 +374,15 @@ mod tests {
369374
async fn try_route_named_event_is_a_safe_no_op_once_the_slot_is_already_empty() {
370375
let (slot, rx) = armed_ready_slot();
371376

372-
assert!(ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await);
377+
assert!(
378+
ControlChannel::try_route_target_status(&slot, r#"{"event":"target_ready"}"#).await
379+
);
373380
// The slot's sender was taken (and used) on the first fire. A
374381
// repeat of the same event on the wire is still recognized as a
375382
// "target_ready" line (so the caller doesn't mistake it for plain
376383
// log text) but must not panic just because the slot is now empty.
377384
let consumed_again =
378-
ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await;
385+
ControlChannel::try_route_target_status(&slot, r#"{"event":"target_ready"}"#).await;
379386

380387
assert!(
381388
consumed_again,
@@ -388,6 +395,23 @@ mod tests {
388395
);
389396
}
390397

398+
#[tokio::test]
399+
async fn try_route_target_failed_preserves_the_diagnostic() {
400+
let (slot, rx) = armed_ready_slot();
401+
402+
let consumed = ControlChannel::try_route_target_status(
403+
&slot,
404+
r#"{"event":"target_failed","error":"exit 23; stderr: early crash"}"#,
405+
)
406+
.await;
407+
408+
assert!(consumed);
409+
assert_eq!(
410+
rx.await.unwrap(),
411+
Err("exit 23; stderr: early crash".to_string())
412+
);
413+
}
414+
391415
// ── fail_all_pending ──────────────────────────────────────────────────
392416

393417
#[tokio::test]

0 commit comments

Comments
 (0)