feat: mxc lib crate#524
Open
caarlos0 wants to merge 85 commits into
Open
Conversation
Add a new `core/mxc` library crate — the Rust analogue of the SDK's `spawnSandboxFromConfig` with `usePty: false`. It parses the same JSON config the executor binaries consume, selects the host containment backend, runs the sandboxed process without ever allocating a pty, and returns captured stdout/stderr in a `ScriptResponse`. Supported backends: Bubblewrap (Linux), Seatbelt (macOS), and ProcessContainer — AppContainer plus the BaseContainer fallback — (Windows). Other backends return `MxcError::unsupported_containment`. Output capture is gated behind the new `ExecutionRequest::capture_output` flag (default `false`, preserving the binaries' streaming behaviour; the library forces it `true`): - Seatbelt gains a non-pty captured exec path (mirrors bubblewrap). - AppContainer/BaseContainer gain a capture path using CreatePipe + reader threads, reusing the existing `process_util` helpers. - Bubblewrap already captures. Backend selection is centralised in `mxc::dispatch::select_runner`, and the `wxc`, `lxc`, and `mxc_darwin` binaries now delegate their shared backend arms to it so the selection logic has a single home. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Add a streaming, handle-based spawn API alongside the run-to-completion entrypoint, so importers can drive a sandboxed process while it runs: persistent bidirectional stdio plus termination. No pty is allocated; the streams are ordinary pipes. - `wxc_common::sandbox_process`: new `SandboxProcess` handle trait (`take_stdin`/`take_stdout`/`take_stderr` -> boxed Write/Read, `try_wait`, `kill`, `wait`) and the `StreamingRunner` spawn trait. Interfaces live in `wxc_common`; impls live in the backend crates. - Seatbelt: `SeatbeltSandboxProcess` + `StreamingRunner` impl — spawns the sandboxed shell with piped stdio, `kill()` does graceful SIGTERM then SIGKILL after a grace period, and `wait()` drains any stream the caller did not take into the `ScriptResponse`. - `mxc::spawn_sandbox` + `dispatch::spawn_runner` return the handle (cfg-split; Seatbelt implemented, other backends report `unsupported_containment` for now). Re-exports `SandboxProcess`. Verified on macOS (bidirectional stdio against `cat`, kill of `sleep`, and untaken-stream capture via `wait`). Linux/Windows streaming impls are follow-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Two Seatbelt backend bugs: - Non-zero exits reported `failurePhase: None`. The runner never set `failure_phase`, so every result defaulted to `None`. Add an `exit_response` helper that tags `ProcessExited` on a non-zero exit (and `None` on success), and route all run-completed paths (exec/pty, captured, open, and the streaming `wait()`) through it. - With no `working_directory` set, the child inherited the host cwd; under the deny-by-default profile that directory (or its parents) can be unreadable, so the child's `getcwd()` walked parent dirs and leaked "getcwd: ... Operation not permitted" to stderr. Default the cwd to a sandbox-allowed path (first readwrite, else readonly, else `/`) and export a matching `PWD` so `getcwd()` uses its fast stat path instead of enumerating unreadable parents. Adds regression tests for both (failure phase on non-zero/zero exit and no getcwd leak when no working directory is set). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Implement the handle-based streaming API (live stdio + kill) for the Windows AppContainer backend, the counterpart to the Seatbelt support. - Add Win32 `PipeReader`/`PipeWriter` adapters in `process_util` so the child's pipe ends can be exposed as `Read`/`Write`. - Refactor `AppContainerScriptRunner` so the monolithic run path is split into a shared `spawn_suspended` (setup + CreateProcess, suspended) and a `SpawnedChild` that either runs to completion (existing blocking path, behaviour preserved) or is wrapped in a streaming handle. Per-run firewall/BFS policy setup/teardown is factored into `prepare`/`teardown` so both paths share it. - Add `AppContainerSandboxProcess` (impl `SandboxProcess`): take_stdin/ stdout/stderr, try_wait, kill (TerminateProcess), and wait (drains untaken streams, runs teardown, adds exit diagnostics). Tears down firewall/BFS policy on wait or drop. - Wire `mxc::dispatch::spawn_runner` for ProcessContainer on Windows (AppContainer fast path). The BaseContainer fallback returns `unsupported_containment` for now (streaming pending). Cross-checked for x86_64-pc-windows-msvc; the in-process Windows path is not runtime-verified here and needs Windows CI. macOS run path and tests unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Complete Windows streaming by adding the handle-based path for the BaseContainer backend (the OS sandbox API used for experimental / newer-schema configs), the common case for the library on modern schemas. - Split BaseContainerRunner::execute() into a shared `spawn_base` (proxy/spec/identity setup + CreateProcessInSandbox, returning a `BaseChild`) and a `run_to_completion` for the blocking path; behaviour of the run path is preserved. - `BaseChild` owns the process handle, parent-side pipes, and the per-run proxy/sandbox-tracking state, and performs `run_sandbox_cleanup` + proxy stop + exit diagnostics after the child exits. - Add `BaseContainerSandboxProcess` (impl `SandboxProcess`): live stdio, kill (TerminateProcess), try_wait, and wait (drains untaken streams, tears down sandbox/proxy, shapes the response). Drop runs teardown. - `mxc::dispatch::spawn_runner` now selects AppContainer vs BaseContainer for ProcessContainer streaming, mirroring the run-to-completion choice. Cross-checked for x86_64-pc-windows-msvc; like the AppContainer path the in-process Windows behaviour (and resource teardown ordering) is not runtime-verified here and needs Windows CI. Linux/Bubblewrap streaming remains a follow-up. macOS tests unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Before backend selection, `select_runner` and `spawn_runner` now check the host OS and return a clear message on platforms with no MXC backend: "the mxc library has no sandbox backend for this host OS (supported: Windows, Linux, macOS)". Previously an unsupported host (e.g. FreeBSD) fell through to the default ProcessContainer arm and surfaced the misleading "...only available on Windows" error. Supported hosts are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Callers cancelling/timing out a sandboxed command need to take its whole process tree down (pipelines, `&`, servers), not just the root. Add the pid and make kill() tree-kill on every streaming backend: - `SandboxProcess::id() -> u32` exposes the child's OS process id for external monitoring or a caller-driven tree kill. - Seatbelt: spawn the streaming child with `setsid()` so it leads its own process group; kill() now signals the whole group (negative-pid SIGTERM then SIGKILL), which is safe even if setsid failed (a negative pid only targets that pid's group, never the host's). - Windows AppContainer: kill() terminates the job object (new `UiJobObject::terminate`) instead of just the root process. - Windows BaseContainer: best-effort-assign the child to a job object after creation so kill() can terminate the tree (falls back to the root process if assignment fails). Adds a macOS test that backgrounds a descendant and asserts kill() reaps it. Windows paths cross-checked only (need Windows CI). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Add the handle-based streaming API (live stdio, kill, id) for the Bubblewrap (Linux) backend, completing streaming across every backend the library supports. - Split BubblewrapScriptRunner::execute() into a shared `spawn_bwrap` (proxy + iptables setup + bwrap spawn, returning a `BwrapChild`) and a `run_to_completion` for the blocking path; run-path behaviour preserved. - Streaming spawns bwrap with piped stdin and `process_group(0)` so the handle can tree-kill it: since bwrap is PID 1 of the sandbox pid namespace (`--unshare-pid`), a `killpg` on its group tears the whole sandbox down. `BubblewrapSandboxProcess` (impl `SandboxProcess`) owns the network proxy/iptables state and tears it down on wait or drop. - Wire `mxc::dispatch::spawn_runner` for the Bubblewrap arm on Linux. Cross-checked + clippy-clean for aarch64-unknown-linux-gnu (via the stable toolchain, since the pinned toolchain's x86_64-linux std is unavailable here); runtime still needs Linux CI with bwrap. macOS run path/tests unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
The PermissionDenied-vs-generic `Command::spawn` failure message was copy-pasted across the GUI, captured, and streaming exec paths. Extract a private `spawn_error(&io::Error) -> String` helper; behaviour unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Port the 5 `@microsoft/mxc-sdk` config-building helpers to Rust so callers no longer need the TypeScript module to build a spawnable config. - `mxc::policy`: `SandboxPolicy` (mirrors the SDK type) and `build_request` — the port of `createConfigFromPolicy`, restricted to the backends the crate runs (`Containment::Process` resolves to Seatbelt / Bubblewrap / ProcessContainer per host; `Containment::Bubblewrap` forces Bubblewrap). It mirrors the SDK field mapping and network proxy / host-filtering validation, building the same wire config internally and running it through the shared parser (with allow_missing_command, since the caller fills script_code), so validation and the wire->model mapping match production. Output is an `ExecutionRequest`. - `mxc::policy` discovery helpers `available_tools_policy`, `user_profile_policy`, `temporary_files_policy` (ports of policy.ts) — PATH + tool/SDK env dirs, user-profile dirs, and the host temp dir. - `mxc::platform::platform_support` — port of `getPlatformSupport`; on Windows it reads the isolation tier / UI capabilities from the in-process fallback probe instead of shelling out to `wxc-exec --probe`. - Add `spawn_streaming_from_request` to round out the request-based API. - Derive `Clone` on the probe's `UiCapabilitySupport` so it can be carried on the cloneable `PlatformSupport`. Verified on macOS (9 new tests incl. policy -> build_request -> real Seatbelt run, plus the existing suite; clippy + fmt clean). Cross-checked for x86_64-pc-windows-msvc and aarch64-unknown-linux-gnu; their runtime behaviour still needs platform CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
…ignment `spawn_base` wrapped `pi.hProcess` in a temporary `OwnedHandle` just to pass it to `job.assign_process(...)`. `OwnedHandle::get()` returns a copy (HANDLE is Copy) and the temporary's `Drop` then closed `pi.hProcess` — so the handle stored on `BaseChild` pointed at a closed (and possibly reused) handle. That broke every BaseContainer run/stream: `WaitForSingleObject`/`GetExitCodeProcess` on a dead handle (garbage/-1 exit codes), wait/terminate potentially acting on an unrelated reused handle, and a double-close on drop. `assign_process` only borrows the handle (it calls `AssignProcessToJobObject` and neither stores nor closes it), so pass the raw `pi.hProcess` and keep sole ownership on the `BaseChild.process` field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Both timeout arms (run-to-completion SpawnedChild::wait_exit and the streaming AppContainerSandboxProcess::wait) terminated only the direct child. The job has no KILL_ON_JOB_CLOSE, so descendants survived holding the inherited stdout/stderr write-ends open — the capture reader threads then blocked in read_to_string forever, join() never returned, and the timeout was silently not enforced (the exact runaway case it exists for), leaving orphaned processes behind. Terminate the job object (as kill() already does) in both WAIT_TIMEOUT arms so the whole tree dies and the pipe write-ends are released. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
spawn_process_container claimed it "mirrors the run-to-completion selection", but the streaming path does not route through dispatch_with_fallback: there is no AppContainer-BFS / AppContainer-DACL fallback for streaming (that dispatcher returns a run-to-completion runner plus a DaclManager guard, neither of which fits a streaming handle). The behaviour is fail-closed and safe — no containment weakening and the BaseContainer tier applies no host DACLs — but a config that runs via spawn_sandbox_from_config on a host lacking the native BaseContainer API would fail via spawn_sandbox. Replace the inaccurate comment with the real contract and document the divergence in the crate README. No behaviour change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
SeatbeltSandboxProcess::wait()'s timeout branch called self.child.kill(), which signals only the direct child and orphans sandboxed descendants — even though kill() already does the correct setsid + killpg(SIGTERM->SIGKILL) group kill. Reuse self.kill() in the timeout branch so the whole process group dies (and the captured pipe write-ends are released), then reap the child. Adds a macOS test that backgrounds a descendant and asserts it is killed when the streaming wait times out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
… on Drop Dropping a streaming handle while the sandboxed process was still running ran the teardown (iptables/proxy on Bubblewrap; firewall/BFS on AppContainer; proxy/sandbox state on BaseContainer) but never terminated the child. An abandoned-but-running sandbox would then lose its host enforcement (firewall mode -> unrestricted egress) and leak as a zombie/orphan. Drop now terminates and reaps the child (reusing each handle's kill()) before removing enforcement, across all three streaming backends. The post-wait Drop path stays a no-op (try_wait early-return + teardown_done guard). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
ClipboardPolicy::wire() emitted "readOnly"/"readWrite", but the config parser only accepts "none"/"read"/"write"/"all" — so every non-None clipboard policy built via build_request was silently dropped to None (fail-closed but wrong). The enum was also missing write-only and read+write(all) levels. Redefine ClipboardPolicy as None/Read/Write/All with wire strings none/read/write/all, matching the SDK type and the parser. Adds a regression test asserting all four levels survive build_request -> ExecutionRequest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
…r works available_tools_policy resolved paths via Path::canonicalize, which on Windows yields a \\?\ verbatim prefix (and resolves symlinks). is_system_critical_path compares against C:\Windows, so the prefixed form never matched and System32 (on PATH) was not filtered out of readonly_paths — weakening the protective filter. canonicalize also hits the filesystem, unlike the SDK's lexical path.resolve. Replace canonicalize with lexical absolutization (join with cwd if relative, then collapse ./.. via components, preserving the prefix/root). This matches path.resolve, keeps the plain C:\... form, and touches no filesystem. Adds a test that a system-critical dir on PATH is filtered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
The deadlock-avoidance rule was unstated, so every backend author re-derived it. Document on the SandboxProcess trait that stdout and stderr are independent bounded pipes and must be consumed concurrently: wait() implementors must drain not-taken streams on separate threads, and callers taking both streams must read them concurrently (taking only one is always safe). No code change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
- dispatch: support LXC on the run-to-completion path (select_runner); reject dry_run on the streaming path (no process to stream) - dispatch: restore wxc-exec diagnostic parity for the BaseContainer fallback (the "Using BaseContainer-fallback dispatcher (reason)" line and the "warning: " prefix) inside select_process_container - lib: correct the env-injection doc (replaces, vs the SDK's append), and the spawn_sandbox doc (dry_run is rejected, not ignored); drop the logger buffer from error strings (may contain config/env); remove the dead capture_output assignment on the streaming entrypoint - policy: read SystemDrive from the process environment (SDK parity); drop the logger buffer from the build_request error string - sandbox_process: clarify id() is only meaningful while the child is alive (Unix PID reuse after reap) - process_util: add SAFETY comments to the ReadFile/WriteFile blocks - README: fix proc.wait() (returns ScriptResponse, not Result) and mark the live spawning examples no_run - lxc: drop the now-unused bwrap_common dependency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
…hrough Add the missing coverage called out in review: - run path: dry_run validates without executing; finite scriptTimeout fires; working_directory override; env override replaces a config value; is_base64 input; stderr-only capture; version-out-of-range and malformed JSON rejection - streaming: take_* second call returns None; try_wait reports the exit code after completion - sdk helpers: build_request maps allowed/blocked hosts and local-network; platform_support assertions gated for Linux and Windows - Windows ProcessContainer integration tests (AppContainer + BaseContainer capture, AppContainer finite-timeout-with-descendant, streaming stdio) as the regression guards for #1 (closed process handle) and microsoft#2 (timeout only reaching the direct child). They run a real sandbox, so they require an elevated, host-prepped Windows host and are #[ignore]d. Also split the macOS-only test imports behind cfg so the test crate compiles cleanly when cross-targeting Windows and Linux. Verified: cargo test -p mxc (macOS, 37 run + 4 ignored Windows), fmt, clippy -D warnings; cargo check -p mxc --tests cross-targeted to Windows and Linux. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a new in-process mxc Rust library plus a cross-backend streaming (“handle-based”) sandbox API, while refactoring executor binaries to share backend-selection logic and introducing an opt-in output-capture switch in ExecutionRequest.
Changes:
- Introduce
wxc_common::sandbox_processtraits (SandboxProcess,StreamingRunner) for streaming sandbox execution. - Add
ExecutionRequest::capture_outputand implement capture/streaming across Seatbelt, Bubblewrap, and Windows ProcessContainer (AppContainer/BaseContainer). - Add new
mxclibrary crate (dispatch, policy builder, platform support) and updatewxc/lxc/mxc_darwinbinaries to reuse shared dispatch.
Show a summary per file
| File | Description |
|---|---|
| src/core/wxc_common/src/sandbox_process.rs | Defines streaming handle traits and deadlock contract for concurrent drain. |
| src/core/wxc_common/src/process_util.rs | Adds Windows pipe wrapper types implementing Read/Write over Win32 handles. |
| src/core/wxc_common/src/models.rs | Adds ExecutionRequest::capture_output flag. |
| src/core/wxc_common/src/lib.rs | Exposes the new sandbox_process module. |
| src/core/wxc_common/src/config_parser.rs | Sets default capture_output: false when building requests from config. |
| src/core/wxc/src/main.rs | Uses mxc::select_runner for ProcessContainer backend selection/guarding. |
| src/core/wxc/Cargo.toml | Adds dependency on the new mxc crate. |
| src/core/mxc_darwin/src/main.rs | Uses mxc::select_runner for shared backend selection. |
| src/core/mxc_darwin/Cargo.toml | Switches to mxc dependency instead of direct Seatbelt dependency. |
| src/core/mxc/tests/streaming.rs | Adds streaming API tests (stdio, kill, wait capture). |
| src/core/mxc/tests/sdk_helpers.rs | Adds tests for policy helpers, platform support, and request builder. |
| src/core/mxc/tests/sandbox.rs | Adds end-to-end mxc library tests (config parsing, capture, timeout, Windows ignores). |
| src/core/mxc/src/policy.rs | Implements SDK-like policy discovery and SandboxPolicy→ExecutionRequest builder. |
| src/core/mxc/src/platform.rs | Implements host platform support detection (SDK-like). |
| src/core/mxc/src/lib.rs | Provides public mxc library API: spawn (capture) + spawn (streaming) + helpers. |
| src/core/mxc/src/dispatch.rs | Centralizes backend runner selection and streaming spawn for library/binaries. |
| src/core/mxc/README.md | Documents mxc usage, policy builder, and streaming API. |
| src/core/mxc/Cargo.toml | Defines the new mxc crate and OS-specific backend deps. |
| src/core/lxc/src/main.rs | Routes Bubblewrap selection through mxc::select_runner. |
| src/core/lxc/Cargo.toml | Adds mxc dependency and removes direct bwrap_common dep. |
| src/backends/seatbelt/common/src/seatbelt_runner.rs | Adds capture-mode execution, fixes cwd behavior, implements StreamingRunner + SandboxProcess. |
| src/backends/bubblewrap/common/src/bwrap_runner.rs | Refactors into spawnable child + adds StreamingRunner + SandboxProcess. |
| src/backends/appcontainer/common/src/probe.rs | Makes UiCapabilitySupport clonable for reuse in mxc::platform. |
| src/backends/appcontainer/common/src/job_object.rs | Adds UiJobObject::terminate helper for process-tree kill. |
| src/backends/appcontainer/common/src/base_container_runner.rs | Refactors to spawnable child + adds capture pipes and StreamingRunner + SandboxProcess. |
| src/backends/appcontainer/common/src/appcontainer_runner.rs | Refactors to suspended spawn + adds capture/streaming handle + shared prepare/teardown. |
| src/Cargo.toml | Adds core/mxc to workspace and workspace dependency entries. |
| .github/copilot-instructions.md | Documents new mxc crate and shared-dispatch architecture. |
Copilot's findings
- Files reviewed: 28/29 changed files
- Comments generated: 6
# Conflicts: # src/backends/appcontainer/common/src/base_container_runner.rs
LXC was never a good fit for the in-process library: the LXC backend has no non-pty capture path (attach_run always runs via run_with_pty and returns empty stdout/stderr while streaming to the host terminal), so the run path violated the crate's "capture output, no pty" contract, and it had no streaming/SandboxProcess impl at all. Rather than half-support it, the crate now rejects LXC with unsupported_containment like the other unsupported backends; Bubblewrap remains the fully-supported Linux backend (captured run + streaming + tree-kill). The lxc-exec binary keeps its native LXC support. - dispatch: remove the ContainmentBackend::Lxc arm and select_lxc - Cargo: drop the now-unused lxc_common dependency - platform_support: report only bubblewrap on Linux (no lxc-ls probe) - tests: assert Linux reports bubblewrap only Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Resolve the six inline review comments on microsoft#524: - base_container streaming wait(): drop an untaken stdin before draining, and on timeout tree-terminate via the job object (reuse kill()) instead of only the root process, so descendants release the captured pipe write-ends and the drain threads can finish. - base_container capture wait_exit(): tree-terminate via the job object on timeout (fall back to the root only when no job exists), same deadlock fix for the non-streaming capture path. - appcontainer streaming wait(): drop an untaken stdin so interactive children observe EOF and exit reliably. - bubblewrap streaming wait(): on timeout use the process-group terminate (reuse kill()) instead of a single-process signal, matching the SandboxProcess contract. - dispatch: return the BaseContainer-fallback diagnostics (fallback notice, dispatcher warnings, selected tier) via Selection.warnings instead of writing them into a logger the library drops; select_runner no longer takes a logger and callers surface the warnings themselves. - policy normalize_lexically(): never pop past a root/prefix on "..", so "/a/../../b" stays "/b" and "C:\\.." stays "C:\\" (keeps the system-critical-path filter and dedup correct). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
The shared config parser treats an empty schema version as "unset" and
accepts it, but the SDK requires a version ("Policy version is required").
build_request now rejects an empty `SandboxPolicy.version` with
MalformedRequest for parity. Added a test.
This was the only still-valid item from the latest review pass; the other
findings (LXC dispatch contract, bwrap streaming timeout group-kill, dead
Selection.warnings field, LXC doc drift) were already resolved by the
earlier LXC removal and review-feedback commits.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
The externally-tagged derive only accepted the bare string
"builtinTestServer" for the unit variant, but the SDK wire shape is
`{ "builtinTestServer": true }`. Replace the derive with a custom
Deserialize over an untagged intermediate so all three proxy forms
(`{builtinTestServer:true}` / `{localhost:n}` / `{url:s}`) round-trip.
Serialize (proxy_to_wire) was already correct. (review O8)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
- pid_alive: use signal-0 `libc::kill(pid, 0)` instead of spawning `ps`, removing the PID-reuse race and no longer treating a probe failure as "dead" (only ESRCH means gone; EPERM etc. means alive). Adds a macOS dev-dependency on libc. - available_tools_policy_filters_nonexistent_and_dedups: assert the full resolved cwd is discovered rather than the near-tautological basename substring. (review O11) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
The capture drains were unsafe for large or binary output: - Unix `read_to_string` was uncapped (OOM on unbounded child output) and failed on the first invalid UTF-8 byte, discarding the whole stream. - Windows `read_from_pipe` stopped draining once it hit the 1 MB char cap, so a child emitting more would block forever on a full pipe (and with the library forcing capture_output + INFINITE default timeout, the call hung). It also decoded per 4 KB chunk, corrupting multibyte UTF-8 at boundaries. Add a shared `wxc_common::capture_io::read_capped_lossy` that reads to EOF (discarding past a 1 MiB byte cap so the child never stalls) and decodes once with from_utf8_lossy. Both Unix runners delegate to it; `read_from_pipe` is rewritten to mirror the behaviour over its HANDLE. (review O1, O6, O7) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
std::process::Command inherits the parent env by default, and the env was only cleared when request.env was non-empty. The mxc library emits an empty process.env by default, so untrusted sandboxed code received the embedding host process's entire environment (cloud creds, API tokens) and could read and exfiltrate it. Always env_clear() and set only a baseline PATH plus the request's vars, matching bubblewrap (--clearenv) and AppContainer. Applied to both the run and streaming paths via a shared helper; adds a regression test. (review O2) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
The Runner adapter is only referenced in the Linux-gated Bubblewrap dispatch arm, so on Windows/macOS the ungated import tripped -D unused-imports and broke the workspace build. Gate the import the same way as the adjacent BubblewrapScriptRunner import. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
rustfmt orders the cfg-gated use into the alphabetical wxc_common slot; keep the source fmt-clean after the previous import-gating fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Unifying the backend on spawn(StdioMode) removed seatbelt's last call to mxc_pty::run_with_pty: the child now inherits the executor binary's own stdio under StdioMode::Inherit (a real TTY when the binary runs under a pty) or exposes pipes under StdioMode::Pipes. The mxc_pty dependency was left dead. Remove it and refresh the stale pty comments that still referenced run_with_pty. mxc_pty stays a workspace member; LXC remains its only consumer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
The library backends were unified on wxc_common::sandbox_process: StreamingRunner is gone, replaced by the SandboxBackend trait (validate + spawn(StdioMode) + diagnose_exit) plus the generic Runner<B> adapter that drives run-to-completion for the executor binaries. Update the architecture notes accordingly and record that mxc_pty is now LXC-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Audited the crate's public surface against its only consumer (copilot-agent-runtime) and the crate's own tests, and removed what nothing reads: - PlatformSupport's isolation_tier / isolation_warnings / ui_capabilities fields, the UiCapabilitySupport struct, and the populate_isolation_from_probe wiring. Consumers read only is_supported / reason (tests also read available_methods); the probe richness was written on Windows but never read. platform_support() no longer runs the AppContainer probe. - The Containment enum and build_request's containment parameter. No caller ever passed Containment::Bubblewrap, and on Linux it resolves identically to Process. build_request is now build_request(policy, container_name); the host-filtering check, the dead Linux proxy branch, and apply_backend collapse accordingly. Net -90 lines. README + tests updated to match. The build_request signature change requires a matching one-line update in the consumer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
spawn_sandbox now accepts a typed mxc::Config (the clean public mirror of the SDK's wire ContainerConfig) and converts it straight to an ExecutionRequest with no JSON round-trip — callers build the config programmatically rather than serialising/parsing JSON. - Config and its sections live in a new wxc_common::config_parser::config child module so they map 1:1 onto the internal Raw* parse types and call the converter without those needing crate-wide visibility; the parser itself is untouched apart from one `pub mod config;` line. Re-exported as wxc_common::config and mxc::Config. - Config carries no JSON: no serde, no from_json/from_base64. The library is typed-only (callers never hand it JSON), so SpawnOptions::is_base64 and the JSON ingestion entry are gone. - Rewrote the mxc end-to-end tests to build typed Config literals. - Inlined the now single-use load_and_prepare helper into spawn_sandbox. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
…ntry The consumer drives the library through build_request + the streaming spawn and never the typed Config / spawn_sandbox(Config) path, so remove that whole surface and unify on one entrypoint. - Delete the typed wire Config mirror (wxc_common's config_parser::config child module) and its conversion; config_parser.rs and wxc_common/lib.rs are back to a zero-line diff vs upstream. Drop the mxc Config* re-exports, SpawnOptions, apply_options, and execution_request_from_config. - Rename the surviving spawn_streaming_from_request to spawn_sandbox(request: ExecutionRequest) — now the sole public spawn entry. Callers build the request via build_request, fill in script_code / working_directory / env, then spawn. - Migrate the end-to-end tests onto build_request + spawn_sandbox, keeping the SandboxProcess-contract and seatbelt regression coverage. - Drop the now-unused serde::Serialize derive on PlatformSupport (nothing serialises it; the consumer reads its fields directly). - Sync the mxc README and .github/copilot-instructions.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
- Extract the identical "strip pre-existing proxy vars, then inject HTTP_PROXY / HTTPS_PROXY" block — used by both the explicit-env and default-env paths — into a private inject_proxy_vars helper. - Make is_api_not_implemented private; it is only referenced within base_container_runner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Author
|
Manually tested end-to-end through Copilot on macOS, Linux, and Windows — exercising the Seatbelt, Bubblewrap, and Windows ProcessContainer backends respectively via the in-process library path. |
The streaming `SandboxProcess` handle only ended a stdout/stderr read at pipe EOF or `kill()`. A backgrounded descendant that inherits a pipe can hold it open past the foreground command's exit, leaving a reader parked indefinitely; killing the child to unblock it defeats any grace window for that descendant. Add a per-stream `StreamCloser` (via `SandboxProcess::stdout_closer` / `stderr_closer`) that makes an in-flight or subsequent read return EOF promptly, without terminating the child: - Unix (Seatbelt, Bubblewrap): non-blocking pipe + poll(2) + self-pipe wakeup (`wxc_common::interruptible_reader`). - Windows (AppContainer, BaseContainer): CancelIoEx + an Arc-shared handle + a cancelled flag (`InterruptiblePipeReader`). The common streaming path (raw pipe reads) is unchanged; closers default to `None` for inherited stdio. Re-exported from `mxc` for consumers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Two latent hangs in the streaming SandboxProcess read path. Both are narrow
(the in-process napi consumer takes both streams, so neither is hit in
practice), but each is an unrecoverable hang when it triggers.
- Windows InterruptiblePipeReader had a lost-cancellation race: CancelIoEx
is edge-triggered, so a close() landing between read()'s cancelled-flag
check and its ReadFile entering the kernel cancelled nothing and never
retried, parking the read until real EOF. Replace the bare AtomicBool with
a Mutex<{cancelled, reading}> handshake: read() announces `reading` under
the lock before blocking; close() sets `cancelled` then loops CancelIoEx
while `reading` is set. The mutex's total order guarantees the cancel can't
be lost. The common read path stays a plain blocking ReadFile; only close()
(at teardown) spins briefly.
- wait()'s internal drain of a *not-taken* stdout/stderr ran the discard
io::copy to real EOF, so a backgrounded descendant holding the pipe open
past the child's exit wedged the drain (and wait()) under a wait-forever
(scriptTimeout==0) timeout. wait() now fires the stored canceller for any
stream it drains before joining (new cancel_drained_stream helper); the
output is discarded anyway. Applied across Seatbelt, Bubblewrap,
AppContainer, and BaseContainer.
Also tightens the stdout_closer/stderr_closer docs: a closer is meant for a
stream the caller has taken — wait() handles not-taken drains itself.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
The policy port's `ProxySpec` deserializer matched any object with a
`builtinTestServer` key and discarded the bool, so `{ builtinTestServer:
false }` silently selected the experimental, deliberately-permissive
built-in test proxy — a fail-open against the policy author's intent.
Reject an explicit `false` with a clear error instead (fail-closed),
matching the wire-config parser (config_parser.rs), which already rejects
it. The SDK union type is `{ builtinTestServer: true }`, so no real producer
emits `false`.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Five confirmed issues in the cross-platform streaming/kill foundation: - group_kill (Unix): a leader that had already exited made it return without signalling the group, so descendants sharing the pgid survived un-reaped; and a transient try_wait error in the grace loop could skip the final SIGKILL. Capture the pgid up front and always SIGTERM→SIGKILL the group (tolerating ESRCH), treating a wait error as "still running". - InterruptibleReader::read (Unix): a zero-length buffer entered poll() and blocked instead of returning Ok(0) as the Read contract requires. - PipeReadCanceller (Windows): held a strong Arc over the pipe handle, so a stored canceller kept a taken stdout/stderr handle open after its reader was dropped (a sandboxed writer could block on a full pipe instead of seeing a broken pipe). Hold a Weak reference and upgrade only for the cancel. - InterruptiblePipeReader::read (Windows): could deliver one completed-but-undelivered chunk after close() fired; now snapshots the cancelled flag after the read and reports EOF, matching the Unix reader. - PipeWriter::write (Windows): didn't map ERROR_BROKEN_PIPE to io::ErrorKind::BrokenPipe, so callers' graceful broken-pipe handling never fired. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
…~ cwd Five confirmed Seatbelt issues: - spawn_exec orphaned the just-spawned sandboxed child if wrap_pipe failed after the spawn (std::process::Child::drop doesn't kill). Kill and reap the child before returning the error. - Inherit-mode timeout (the run-to-completion / executor path) killed only the direct /bin/sh and left descendants running while reporting a timeout. Give inherit-with-timeout runs their own process group (setpgid, same session so the controlling terminal is kept) so the timeout branch tree-kills the group. Limited to timeout-bounded runs since a backgrounded group reading the TTY can SIGTTIN-stop. Verified end-to-end via mxc-exec-mac. - The streaming path rejected timeouts below the 500ms poll interval, refusing small timeouts the API exposes. Drop the rejection and clamp each poll sleep to the time remaining so sub-interval timeouts are honored precisely. - Exit-detection latency: the fixed 500ms poll is now 50ms, cutting up to half a second of latency on the timeout path. - resolve_working_directory could hand Command::current_dir a literal `~` cwd; run the policy-derived default through profile_builder::expand_tilde first. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
- The run-to-completion (Inherit) path called process_group(0) unconditionally, placing bwrap in a background process group while it still shares the controlling terminal — POSIX job control can SIGTTIN-stop it and hang wait(). Restrict process_group(0) to Pipes mode (matching Seatbelt). bwrap is pid 1 of the --unshare-pid namespace, so Inherit-mode kill()/timeout now tears the sandbox down by killing the root process (namespace teardown) instead of a group signal that would hit the executor. - Exit-detection poll lowered from 500ms to 50ms and each sleep clamped to the time remaining, and the "timeout below 500ms" rejection dropped, so small timeouts the API exposes are honored precisely (mirrors the Seatbelt fix). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
- ProxySpec deserialized through an untagged enum, so an object setting several
conflicting modes (e.g. { builtinTestServer: true, localhost: 8080 }) silently
kept the first instead of being rejected like the shared wire-config parser
does. Parse all recognised modes and require exactly one (also keeps the
builtinTestServer:false fail-closed rejection).
- build_request serialised the wire config to JSON, base64-encoded it, then
decoded + re-parsed it — needless work on an in-process path. Add
config_parser::load_request_from_value (Value -> request, same validation and
wire->model mapping) and use it, dropping the base64/string round-trip.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
- Runner<B>(pub B) leaked its inner backend even though Runner::new is the intended constructor (and the only caller path). Make the field private, per the repo's "structs should have private fields" guidance. - The crate-level rustdoc example used proc.wait().expect(...); switch it to proc.wait()? with a Box<dyn Error> tail, matching the README and the repo's preference for ? over expect(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
- Document the Seatbelt backend's unconditional environment clearing (default PATH, process.env overrides) and the omitted-process.cwd fallback (readwritePaths[0] -> readonlyPaths[0] -> /, tilde-expanded, PWD exported) in the Seatbelt guide — both were undocumented. - Correct the LXC guide's now-stale claim that Seatbelt clears env only when process.env is non-empty; Seatbelt now always clears. - The mxc crate README still grouped Seatbelt with the pty path; only LXC uses mxc_pty now, so reword so Seatbelt/Bubblewrap/AppContainer are described as inheriting the executor's stdio directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
The Windows AppContainer and BaseContainer wait() success path ran the stdio drains and run_teardown() but never tree-killed the job first, so a backgrounded descendant could keep running after run_teardown() removed the firewall / BFS / proxy enforcement (Drop's teardown was then a no-op). Both backends now tree-kill (via kill(), matching each backend's own Drop invariant) and reap the root before teardown on every path — killing the tree also closes the descendant's pipe write-ends so the drains finish. WAIT_FAILED and GetExitCodeProcess failures are surfaced as errors instead of a swallowed Ok(-1) / Ok(u32::MAX as -1). Also aligns the host-filtering test with the cr-003 policy fix: only Linux has a real host-filtering backend, so macOS/Windows reject host rules without allowOutbound. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
The BaseContainer child is created running and was then assigned to a job object best-effort: on failure `job` was left `None`, after which `kill()`/timeout/`Drop` could only `TerminateProcess` the root and no descendant was tree-killed at all -- a sandbox that could not be reliably contained. Fail closed instead: if `UiJobObject::new()` or `assign_process` fails, terminate and reap the just-launched child, run sandbox cleanup (and `unregister_ctrl_c_cleanup`, matching `run_teardown`) when `destroy_on_exit`, stop the proxy coordinator, and reject the spawn with a `LaunchFailed` error rather than run an uncontainable sandbox. `job` is therefore always present on a spawned child; the `Option` and the root-only fallback in `kill()` are kept purely as defense-in-depth. The create API (`Experimental_CreateProcessInSandbox`) does not, on the builds validated to date, honor a suspended start the way the AppContainer path does, so the tiny race between the call returning and `assign_process` completing is documented rather than closed (the child is a shell that has not yet run the user command, so the window is empty). A create-suspended path would need verification on an elevated, host-prepped build, which was not available in this environment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
When `pwsh.exe` was found on PATH, the policy granted read-only access to
the entire system drive (`C:\`), widening the sandbox's read surface far
past what PowerShell requires.
Replace the whole-drive grant with a scoped read-only set: `$PSHOME` (the
install dir, which carries pwsh.exe, its self-contained .NET runtime, and
the shipped modules), `%WINDIR%\System32`, and the module search roots
(`$PSModulePath`, `%ProgramFiles*%\{PowerShell,WindowsPowerShell}\Modules`,
`$PSHOME\Modules`, and `%USERPROFILE%\Documents\PowerShell\Modules`),
filtered to existing directories. The PSReadLine history dir stays
read-write and unfiltered so pwsh can create it on first use.
The SDK's `getPowerShellPolicy` -- the source the Rust port mirrors -- is
narrowed identically so the two stay in parity, and the unit tests in both
ports are updated to assert the scoped grant and that no whole-drive root
is ever exposed.
Runtime confirmation that pwsh launches under the narrowed policy needs an
elevated, host-prepped AppContainer host and was not possible in this
environment; the policy-construction logic is covered by the new unit
tests.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
`NetworkManager` cached an STA `INetFwPolicy2` interface and its `CoInitializeEx` state across the handle's lifetime and reused them at teardown. The in-process `mxc` library drives `wait()`/`kill()`/teardown on tokio `spawn_blocking` workers -- potentially a different thread than the one that initialized COM -- so using the STA interface from another apartment, and pairing `CoInitialize`/`CoUninitialize` across threads, was unsound. That is exactly the state the `unsafe impl Send` on the sandbox handle was asserting over. Make each firewall operation self-contained instead: a new `ComApartment` RAII guard does `CoInitializeEx` on creation and `CoUninitialize` on drop, and both `apply_firewall_rules` and `remove_firewall_rules` open their own apartment and create a fresh `INetFwPolicy2` on whichever thread runs them. Windows Firewall rules persist by name, so removal re-acquires a fresh interface (rules are tracked by name in `created_rule_names`). No COM interface or apartment state is cached across calls, so nothing thread-affine is moved across threads and the `unsafe impl Send` SAFETY comment is now honest -- the only remaining manager state is the thread-agnostic Winsock refcount. A small `RuleContext` bundles the per-apply firewall interface and the principal id so the rule helpers stay within the argument-count lint. Verified the cross-thread COM mechanics on a Windows host with a throwaway probe (apply-side enumeration on one thread, fresh-interface re-acquisition on another); driving the full apply/remove rule lifecycle needs an elevated, host-prepped AppContainer host, which was not available here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
This was referenced Jun 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📖 Description
Adds
mxc, an importable Rust library crate for starting sandboxes in-process, without a pty. Callers can stream sandboxed processes without shelling out to the executor binaries or depending on the@microsoft/mxc-sdkTypeScript module.Supported backends: Bubblewrap (Linux), Seatbelt (macOS), and Windows ProcessContainer (AppContainer + BaseContainer fallback).
To share one execution path between the new library (no-pty, caller-owns-stdio) and the existing executor binaries (run-to-completion), the three library backends were unified on a single
SandboxBackendtrait. That part is a refactor of the shared backend layer — not purely additive; see "Unified backend execution model" below.What's included
spawn_sandboxtakes a pre-builtExecutionRequest(typically frombuild_request, see below) and returns aSandboxProcess(inwxc_common::sandbox_process) for persistent bidirectional stdio (take_stdin/take_stdout/take_stderr),try_wait(),id(),kill(), andwait(). No pty is allocated;wait()drains and discards any untaken stdout/stderr (so the child can't block on a full pipe) and returns the exit code asio::Result<i32>(ErrorKind::TimedOuton timeout). Streaming is implemented for Seatbelt, Bubblewrap, and Windows ProcessContainer. The pipe-deadlock contract (drain stdout/stderr concurrently) is documented on the trait.SandboxBackendtrait (validate+spawn(request, logger, StdioMode) -> Box<dyn SandboxProcess>+ adiagnose_exithook that preserves AppContainer launch diagnostics).StdioMode::Pipesis what the library uses (the caller takes stdin/stdout/stderr);StdioMode::Inheritis what the executor binaries use (the child inherits the binary's own stdio, preserving the TTY when launched under a pty). A single genericRunner<B>adapter inwxc_common::sandbox_processprovides the run-to-completionScriptRunnerthe binaries dispatch on, by callingspawn(StdioMode::Inherit)thenwait()— so there is no longer any per-backend run-to-completion logic and the oldStreamingRunnertrait is removed. Thewxc-exec/lxc-exec/mxc-exec-macbinaries wrap their backends inRunnerand otherwise behave identically (LXC keeps its own native pty path).kill()and timeouts take down the whole tree (Unix process-group signal with an unconditionalSIGKILLsweep so a racily-forked descendant can't survive; Windows job-object terminate), so descendants can't keep inherited pipe write-ends open and hangwait(). A timeout surfaces uniformly aswait()returningio::ErrorKind::TimedOutacross all backends.env_clear()s, matching Bubblewrap's--clearenvand the AppContainer clean block), so an embedding host app's secrets aren't exposed to untrusted code.mxc::policy(SandboxPolicy,build_request, plusavailable_tools_policy/user_profile_policy/temporary_files_policydiscovery) andmxc::platform_support, so callers no longer need the TypeScript SDK for policy construction or platform discovery.build_requestmaps aSandboxPolicyto theExecutionRequestthatspawn_sandboxconsumes (setscript_code, and anyworking_directory/env, on the returned request before spawning).Limitations
unsupported_containment. Thelxc-execbinary keeps its native LXC support.dry_runis rejected by the streaming spawn (there is no process to stream).docs/host-prep.md) and are#[ignore]d.🔭 Follow-ups (separate PRs)
The unified
SandboxBackend/Runnermodel can be extended to the remaining backends incrementally; the cost is set by each backend's I/O model, not by trait plumbing:SandboxProcessimpl that would also bring the backend into themxclibrary (Linux-via-LXC; capable Windows hosts).ScriptRunneruntil a concrete need arises.platform_supportUI / isolation reporting — the SDKgetPlatformSupportparity fields (isolation tier, tier-degradation warnings, and the Windows UI-capability probe results) were trimmed fromPlatformSupportsince no consumer reads them yet. Restore them (re-wiring the AppContainer probe) when a caller needs richer host-capability discovery.🔗 References
Follow-up to the TypeScript SDK's one-shot spawn surface; this is its in-process Rust counterpart.
🔍 Validation
cargo test -p mxcon macOS — 30 tests pass (sandbox7,sdk_helpers13,streaming10); 4 Windows-host integration tests#[ignore]d (they spawn a real sandbox and need an elevated, host-prepped Windows host).cargo fmt --all -- --checkandcargo clippy --all-targets -- -D warningsclean (macOS + Linux + Windows cross-check).cargo checkagainstx86_64-pc-windows-msvcandaarch64-unknown-linux-gnu, plus the per-backend crates on each target.guiAccessGUI path,launchMethod: open(Terminal), and timeout tree-kill.0.7.0-alpha) streams stdout and propagates a non-zero exit code (exit /b 7→EXIT=7); a 2000 msscriptTimeoutfires and tree-kills a process tree blocked on a modal dialog (beforestreamed,afternever ran). The direct-AppContainer/BFS path needsbfscfg.exe, absent on the test host, so it was covered by compile checks only.io::ErrorKind::TimedOutfromwait()), theguiAccessstreaming rejection,dry_runrejection, version-out-of-range / empty-version rejection, and thebuild_requestfilesystem/network/clipboard/proxy wire mapping.✅ Checklist
📋 Issue Type