Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
# (the AppContainer here can only read paths granted by policy -- see the
# script's "Stage artifacts into share_dir" step for why).

[openshell]
version = 2

[openshell.drivers.mxc]
wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
# openshell-supervisor-relay.exe / the caller's OpenClaw install into
# the sandbox's policy-authorized working directory before creating it.

[openshell]
version = 2

[openshell.drivers.mxc]
wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
# allowLocalNetwork=true lets the AppContainer reach the host's loopback (the gateway
# relay) without routing through the egress proxy, which breaks node.js DLL init.

[openshell]
version = 2

[openshell.drivers.mxc]
wxc_exec_path = "C:\\FromSenthil\\mxc-fixes-env-vars\\wxc-exec.exe"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,10 @@ param(
[Parameter(Mandatory = $true)]
[string] $OpenClawInstallDir,
# Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw, not
# C:\work\openshell-openclaw). Node's CommonJS module resolver calls
# fs.realpathSync while resolving the entry script, which lstat()s every
# parent directory up the chain -- including ones OUTSIDE share_dir. The
# AppContainer only grants share_dir itself, so an intermediate parent like
# C:\work fails with EPERM (confirmed empirically: this exact test failed
# with "EPERM: operation not permitted, lstat 'C:\work'" until the share
# dir was moved to the drive root). The drive root itself (C:\) apparently
# doesn't need an explicit grant to lstat successfully, so a one-level path
# sidesteps the problem entirely.
# C:\work\openshell-openclaw). The staged Node invocation below uses
# --preserve-symlinks-main so Node does not realpath the main module before
# our capture script starts; keeping a one-level path also avoids exposing
# or depending on unrelated intermediate directories.
[string] $ShareDir = "C:\openshell-openclaw",
[int] $TargetPort = 18889,
[int] $ForwardLocalPort = 28889,
Expand Down Expand Up @@ -217,6 +212,7 @@ $fwdProc = $null
$fwdLog = Join-Path $resultDir "forward.log"
$fwdErrLog = Join-Path $resultDir "forward.err.log"
$passed = $false
$failureMessage = ""
$healthJson = $null
$selfProbeOutcome = "not-recorded"
$selfProbeResponseBytes = 0
Expand Down Expand Up @@ -426,6 +422,7 @@ try {
mxc = @{
command = @(
"$shareDirToml/node.exe",
"--preserve-symlinks-main",
"$shareDirToml/openclaw-capture.mjs",
"gateway", "run", "--dev", "--allow-unconfigured",
"--auth", "token", "--bind", "loopback", "--port", "$TargetPort"
Expand Down Expand Up @@ -456,43 +453,23 @@ 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 }
$createBenign = Show-SandboxCreate $createOut $SandboxName
if ($createCode -ne 0 -and -not $createBenign) {
throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)"
}

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

# 10. openshell forward service: opens a fresh, on-demand relay for this
# one call and bridges TargetPort (inside the sandbox) to
Expand Down Expand Up @@ -610,26 +587,55 @@ try {
}
}
catch {
Bad $_.Exception.Message
$failureMessage = $_.Exception.Message
Bad $failureMessage
}
finally {
# Stop the forward before the sandbox so its relay tears down cleanly.
if ($fwdProc -and -not $fwdProc.HasExited) {
try { Stop-Process -Id $fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {}
}
if ($fwdProc) {
try {
if (-not $fwdProc.WaitForExit(5000)) {
throw "forward process did not exit within 5s"
}
} catch {
Info "forward teardown: $($_.Exception.Message)"
if ($passed) { $passed = $false; $failureMessage = $_.Exception.Message }
}
}

# Tear down the sandbox while the gateway is still up (delete needs it).
if ($cli -and $SandboxName) {
try { & $cli sandbox delete $SandboxName 2>&1 | Out-Null }
catch { Info "sandbox teardown '$SandboxName': $($_.Exception.Message) (continuing)" }
$deleteCode = 1
$deleteOut = @()
$deletePrevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try { $deleteOut = & $cli sandbox delete $SandboxName 2>&1; $deleteCode = $LASTEXITCODE }
catch { $deleteOut = $_.Exception.Message }
finally { $ErrorActionPreference = $deletePrevEAP }
if ($deleteCode -ne 0) {
$cleanupFailure = "sandbox teardown '$SandboxName' failed (exit $deleteCode): $($deleteOut | Out-String)"
Info $cleanupFailure
if ($passed) { $passed = $false; $failureMessage = $cleanupFailure }
}
}

if ($KeepRunning -and $gw -and -not $gw.HasExited) {
Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop with: Stop-Process -Id $($gw.Id) -Force"
} elseif ($gw -and -not $gw.HasExited) {
Step "Cleanup"; Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue
try { $gw.WaitForExit(5000) | Out-Null } catch {}
Info "stopped gateway pid $($gw.Id)"
try {
if (-not $gw.WaitForExit(5000)) {
throw "gateway process did not exit within 5s"
}
Info "stopped gateway pid $($gw.Id)"
} catch {
$cleanupFailure = "gateway teardown failed: $($_.Exception.Message)"
Info $cleanupFailure
if ($passed) { $passed = $false; $failureMessage = $cleanupFailure }
}
}

Step "Gateway log (tail)"
Expand Down Expand Up @@ -663,12 +669,17 @@ finally {

Step "RESULT"
$verdict = if ($passed) { "PASS" } else { "FAIL" }
if (-not $passed -and [string]::IsNullOrWhiteSpace($failureMessage)) {
$failureMessage = "one or more qualification assertions failed"
}
$failureSummary = ($failureMessage -replace '\s+', ' ').Trim()
$summary = @"
OpenShell MXC OpenClaw + dynamic forward test
=====================================================================
timestamp : $stamp
machine : $env:COMPUTERNAME
verdict : $verdict
failure : $failureSummary
sandbox : $SandboxName
backend : $Backend
config : $tomlName
Expand Down
84 changes: 54 additions & 30 deletions crates/openshell-driver-mxc/src/control_channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ pub enum ControlChannelError {

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

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

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

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

async fn try_route_named_event(
Expand Down Expand Up @@ -279,7 +284,7 @@ mod tests {
let (slot, rx) = armed_ready_slot();

let consumed =
ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":2}"#)
ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":3}"#)
.await;

assert!(consumed);
Expand All @@ -298,13 +303,13 @@ mod tests {
consumed,
"a recognized ready event is consumed even when rejected"
);
let err = rx.await.unwrap().expect_err("version 2 must be rejected");
let err = rx.await.unwrap().expect_err("version 1 must be rejected");
assert!(
err.contains('2'),
err.contains('1'),
"error should name the offending version: {err}"
);
assert!(
err.contains('1'),
err.contains('3'),
"error should name the required version: {err}"
);
}
Expand Down Expand Up @@ -334,17 +339,17 @@ mod tests {
assert!(!ControlChannel::try_route_ready(&slot, "garbage").await);
}

// ── try_route_target_ready ───────────────────────────────────────────
// ── try_route_target_status ──────────────────────────────────────────

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

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

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

// "ready" and "target_ready" must not be cross-routed into each
// other's slot.
let consumed = ControlChannel::try_route_target_ready(
let consumed = ControlChannel::try_route_target_status(
&slot,
r#"{"event":"ready","protocol_version":2}"#,
r#"{"event":"ready","protocol_version":3}"#,
)
.await;

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

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

assert!(
consumed_again,
Expand All @@ -388,6 +395,23 @@ mod tests {
);
}

#[tokio::test]
async fn try_route_target_failed_preserves_the_diagnostic() {
let (slot, rx) = armed_ready_slot();

let consumed = ControlChannel::try_route_target_status(
&slot,
r#"{"event":"target_failed","error":"exit 23; stderr: early crash"}"#,
)
.await;

assert!(consumed);
assert_eq!(
rx.await.unwrap(),
Err("exit 23; stderr: early crash".to_string())
);
}

// ── fail_all_pending ──────────────────────────────────────────────────

#[tokio::test]
Expand Down
Loading
Loading