Description of the task
Track and fix the state-aware exec streaming contract gaps surfaced as review comments on #806 and #810. These are three distinct failure modes that share one theme — how exec output and typed errors are framed across the executor/library boundary — and are best fixed together because they touch the same code.
All three still stand on main after #829 ("Relay live exec pipes to executor stdio"), which added the ExecConsumer (Executor vs Library) plumbing and a generic relay_exec_to_stdio, but did not update WSLc's exec (it takes _consumer: ExecConsumer and still returns null pipes) or change the executor's stdout error framing.
1. The require_experimental_optin gate blocks the in-process (FFI/Rust-SDK) path.
require_experimental_optin (src/core/mxc_engine/src/state_aware.rs:43,52,73,109) rejects the experimental backends (WSLc / IsolationSession / WindowsSandbox) unless request.experimental_enabled is set. The CLI sets this from --experimental, but the JSON/FFI entry points parse via convert_wire_state_aware → convert_wire_config, which hardcodes experimental_enabled = false (src/core/wxc_common/src/config_parser.rs:1335). mxc_sdk::exec_sandbox / mxc_state_aware_exec expose no opt-in. Result: every in-process SDK/FFI exec against a wslc: (or IsolationSession/WindowsSandbox) sandbox returns backend_unavailable before dispatch.
Fix (small, ~2 lines): set experimental_enabled = true on the trusted JSON/FFI entry points after parse (linking the native library is itself the opt-in), or thread an explicit opt-in through the SDK/FFI request path.
2. WSLc exec returns null pipe handles and blocks (the ExecConsumer::Library path).
WslcStateAwareRunner::exec (src/backends/wslc/common/src/state_aware.rs:135-206) relays the daemon stream synchronously into process-global stdout/stderr, blocks to exit, then returns an ExecHandle with null_pipe_handle() for stdout/stderr/stdin. Post-#829 it receives an ExecConsumer but ignores it (_consumer, line 140). Correct for the CLI/ExecConsumer::Executor path (self-relay + null pipes is the documented call-through), but the FFI path (state_aware_dispatch.rs:97 passes ExecConsumer::Library) wraps the handle in ExecSandboxProcess, which expects live pipes + non-blocking spawn — so mxc_state_aware_exec blocks until exit and take_stdout/take_stderr return None, contradicting the live MxcSandboxProcess contract.
Fix (moderate, ~150-250 lines): when consumer == ExecConsumer::Library, create real OS pipes, run exec_streaming on a background thread whose callback writes to the pipe write-ends, and return an ExecHandle carrying the read-ends + a waiter that joins the thread. Keep the ExecConsumer::Executor branch on the internal self-relay (or hand live pipes to the generic relay_exec_to_stdio #829 landed). Add stdin forwarding and handle-ownership care. IsolationSession and WindowsSandbox share the identical null-pipe pattern and should adopt the same shared helper.
3. Executor stdout error-framing collision on post-admission failures (ExecConsumer::Executor).
On the CLI/executor path, exec streams user bytes to stdout as they arrive. On a post-admission failure (timeout mid-output, daemon truncation, backend failure after some bytes streamed), exec_streaming returns Err, and run_state_aware_main finalizes via StateAwareExit::Error(json) → println!(envelope); exit(1) (src/core/wxc/src/main.rs:457,484) — appending the JSON error envelope to the same stdout that already carries user output. On the SDK side, execInSandboxAsync calls tryParseErrorEnvelope, which runs JSON.parse(stdout.trim()) over the whole buffer (sdk/node/src/state-aware-helper.ts:172-185); mixed [user output] + {"error":…} is not valid JSON → returns null → no typed MxcError is thrown → the call resolves with { exitCode: 1, stdout: <mixed> }. Pre-admission failures (bad policy, sandbox not found) still stream nothing and produce a clean envelope, so only post-admission failures are affected.
Fix (moderate; coordinated Rust + SDK): give the exec path separable framing for post-admission failures — e.g., route the exec error envelope to stderr (leaving user output as the sole owner of stdout) with the SDK reading the typed error from stderr, or length-prefix/frame the streams. Whatever is chosen must keep pre-admission envelopes working and keep the CLI human-readable.
Additional context
Description of the task
Track and fix the state-aware exec streaming contract gaps surfaced as review comments on #806 and #810. These are three distinct failure modes that share one theme — how exec output and typed errors are framed across the executor/library boundary — and are best fixed together because they touch the same code.
All three still stand on
mainafter #829 ("Relay live exec pipes to executor stdio"), which added theExecConsumer(ExecutorvsLibrary) plumbing and a genericrelay_exec_to_stdio, but did not update WSLc'sexec(it takes_consumer: ExecConsumerand still returns null pipes) or change the executor's stdout error framing.1. The
require_experimental_optingate blocks the in-process (FFI/Rust-SDK) path.require_experimental_optin(src/core/mxc_engine/src/state_aware.rs:43,52,73,109) rejects the experimental backends (WSLc / IsolationSession / WindowsSandbox) unlessrequest.experimental_enabledis set. The CLI sets this from--experimental, but the JSON/FFI entry points parse viaconvert_wire_state_aware→convert_wire_config, which hardcodesexperimental_enabled = false(src/core/wxc_common/src/config_parser.rs:1335).mxc_sdk::exec_sandbox/mxc_state_aware_execexpose no opt-in. Result: every in-process SDK/FFI exec against awslc:(or IsolationSession/WindowsSandbox) sandbox returnsbackend_unavailablebefore dispatch.Fix (small, ~2 lines): set
experimental_enabled = trueon the trusted JSON/FFI entry points after parse (linking the native library is itself the opt-in), or thread an explicit opt-in through the SDK/FFI request path.2. WSLc
execreturns null pipe handles and blocks (theExecConsumer::Librarypath).WslcStateAwareRunner::exec(src/backends/wslc/common/src/state_aware.rs:135-206) relays the daemon stream synchronously into process-global stdout/stderr, blocks to exit, then returns anExecHandlewithnull_pipe_handle()for stdout/stderr/stdin. Post-#829 it receives anExecConsumerbut ignores it (_consumer, line 140). Correct for the CLI/ExecConsumer::Executorpath (self-relay + null pipes is the documented call-through), but the FFI path (state_aware_dispatch.rs:97passesExecConsumer::Library) wraps the handle inExecSandboxProcess, which expects live pipes + non-blocking spawn — somxc_state_aware_execblocks until exit andtake_stdout/take_stderrreturnNone, contradicting the liveMxcSandboxProcesscontract.Fix (moderate, ~150-250 lines): when
consumer == ExecConsumer::Library, create real OS pipes, runexec_streamingon a background thread whose callback writes to the pipe write-ends, and return anExecHandlecarrying the read-ends + a waiter that joins the thread. Keep theExecConsumer::Executorbranch on the internal self-relay (or hand live pipes to the genericrelay_exec_to_stdio#829 landed). Add stdin forwarding and handle-ownership care. IsolationSession and WindowsSandbox share the identical null-pipe pattern and should adopt the same shared helper.3. Executor stdout error-framing collision on post-admission failures (
ExecConsumer::Executor).On the CLI/executor path, exec streams user bytes to stdout as they arrive. On a post-admission failure (timeout mid-output, daemon truncation, backend failure after some bytes streamed),
exec_streamingreturnsErr, andrun_state_aware_mainfinalizes viaStateAwareExit::Error(json)→println!(envelope); exit(1)(src/core/wxc/src/main.rs:457,484) — appending the JSON error envelope to the same stdout that already carries user output. On the SDK side,execInSandboxAsynccallstryParseErrorEnvelope, which runsJSON.parse(stdout.trim())over the whole buffer (sdk/node/src/state-aware-helper.ts:172-185); mixed[user output] + {"error":…}is not valid JSON → returnsnull→ no typedMxcErroris thrown → the call resolves with{ exitCode: 1, stdout: <mixed> }. Pre-admission failures (bad policy, sandbox not found) still stream nothing and produce a clean envelope, so only post-admission failures are affected.Fix (moderate; coordinated Rust + SDK): give the exec path separable framing for post-admission failures — e.g., route the exec error envelope to stderr (leaving user output as the sole owner of stdout) with the SDK reading the typed error from stderr, or length-prefix/frame the streams. Whatever is chosen must keep pre-admission envelopes working and keep the CLI human-readable.
Additional context
exec.main(2b/[WSLC] State-aware sandbox lifecycle over the experimental.wslc surface (PR 2b/3) #801 + Relay live exec pipes to executor stdio #829) — neither PR introduces them, and no in-tree consumer uses the FFI WSLc-streaming path today (C# SDK is run-to-completion only; TS SDK uses the CLI/PTY path).ExecConsumerand a genericrelay_exec_to_stdio(for backends that surface live pipes) but left WSLc on the internal-relay/null-pipe path and did not change the executor's stdout error framing.mxc_engine/state_aware.rs:109,wslc/common/src/state_aware.rs:203) and [WSLC] State-aware lifecycle (PR 3/3): TypeScript SDK per-phase policy configs #810 (wslc/common/src/state_aware.rs:170, comment id 3778810220).