Skip to content
Merged
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,19 @@ wxc-exec.exe --config-base64 <base64-encoded-json>

# Debug output
wxc-exec.exe --debug config.json

# Supply or replace process.commandLine from trailing arguments
wxc-exec.exe config.json -- python --version
```

For `wxc-exec.exe`, arguments after the required `--` separator are rendered
for the selected backend and spliced into `process.commandLine` before the
request is parsed. They may supply a missing command or replace the policy's
command. This form is supported for one-shot requests and state-aware `exec`;
other state-aware phases reject it. A policy that relies on trailing arguments
is a CLI template rather than a complete request that can be executed
independently.

On Linux: `./lxc-exec config.json`
On macOS: `./mxc-exec-mac --experimental config.json`

Expand Down
7 changes: 7 additions & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ schema 0.6 and 0.7. During the additive schema 0.8 transition, requests may
continue to use those legacy fields or use the directional fields above, but
cannot mix both formats in one request.

Every complete request that carries a process requires a non-empty
`process.commandLine`. The Windows native CLI may accept a template without
that field when the command is supplied after `--`; `wxc-exec.exe` inserts or
replaces `process.commandLine` before schema and typed request validation. That
entry-point transform does not make the unmodified template a complete request
that can be executed independently.

### Full Schema

```json
Expand Down
55 changes: 39 additions & 16 deletions docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1006,11 +1006,14 @@ fields (`filesystem` / `network` / `ui`) on a per-(backend, phase) Config map di
to top-level wire fields — they are already wire-format-aligned in the Config, so the
SDK passes them through unchanged. Cross-backend exec fields (`commandLine`, `cwd`,
`env`, `timeout`) flow through the top-level `process` block, not through
`experimental`. For non-exec phases the executor emits a single JSON envelope on
stdout; for exec the script's output streams raw and the SDK constructs the result
from PTY events. Responses unwrap any `result` envelope at the SDK boundary so the
caller sees a plain `ProvisionResult` / `StartResult` / `ExecResult` / `StopResult` /
`DeprovisionResult`.
`experimental`. The typed SDK requires `commandLine`. The native `wxc-exec.exe`
entry point may instead complete an `exec` template from arguments after `--`;
it inserts or replaces `process.commandLine` before parsing. Trailing commands
are rejected for every non-exec phase. For non-exec phases the executor emits a
single JSON envelope on stdout; for exec the script's output streams raw and the
SDK constructs the result from PTY events. Responses unwrap any `result`
envelope at the SDK boundary so the caller sees a plain `ProvisionResult` /
`StartResult` / `ExecResult` / `StopResult` / `DeprovisionResult`.

## 8. Error model

Expand Down Expand Up @@ -1102,24 +1105,39 @@ domain types for trivial enum/struct conversions). The state-aware path reuses
this wire model while retaining its per-backend `experimental` subtree for
dispatch-time typing.

When the native CLI supplies trailing command arguments, the loader first
splices the rendered command into `process.commandLine` and then parses that
effective document. Diagnostics and retained state-aware source text are
therefore relative to the effective document; replacing or inserting the
command may shift a later same-line column from its position in the caller's
original bytes.

```rust
// In config_parser.rs — discrimination is by presence of the `phase` key in
// the source JSON without building a full untyped request tree.
// In load_mxc_request_with_options — complete a CLI template first.
let (json_str, override_log) = if opts.cli_command.is_empty() {
(json_str, None)
} else {
apply_cli_command(&json_str, opts.cli_command)?
};
let request = parse_mxc_request_json(&json_str, logger)?;

// In parse_mxc_request_json — discriminate and run the normal typed parser.
let discriminator: RequestDiscriminator<'_> =
config_deserialize::from_str(&json_str)?;
config_deserialize::from_str(json_str)?;
if discriminator.phase.is_some() {
convert_wire_state_aware(
&json_str,
discriminator.experimental,
logger,
allow_missing_command,
)
convert_wire_state_aware(json_str, discriminator.experimental, logger)
} else {
let cfg: wire::MxcConfig = config_deserialize::from_str(&json_str)?;
convert_wire_config(cfg, logger, true, allow_missing_command)
let cfg: wire::MxcConfig = config_deserialize::from_str(json_str)?;
convert_wire_config(cfg, logger, true, false)
}
```

The trailing-command path keeps the exact phase probe separate because it owns
error routing. Backend selection and command editing share one
duplicate-preserving raw root parse, and the resulting effective document then
uses the authoritative parser above. The ordinary path with no trailing command
enters `parse_mxc_request_json` directly.

`wire::MxcConfig` is closed (`deny_unknown_fields`) on its stable surface, so
unknown fields are rejected at the trust boundary. `phase` maps to the
`wire::Phase` enum. The `experimental` block stays permissive and is captured as
Expand Down Expand Up @@ -1598,6 +1616,11 @@ shapes.
| MXC dispatch common (Rust) | Cross-backend per-phase invariants (e.g., `validate_exec_common` checks `process.commandLine` non-empty) | `error.code: malformed_request`, `policy_validation` |
| Backend `validate_<phase>` hooks (Rust) | Per-backend per-phase invariants: config field values, cross-cutting policy honor (per the matrix in §10.3), id format checks beyond prefix matching | `error.code: policy_validation`, `malformed_id`, `stale_id`, `backend_error`, `backend_unavailable` |

The native CLI template form is resolved before these layers: a trailing
command on state-aware `exec` supplies or replaces `process.commandLine`, while
other phases reject trailing commands. The effective request presented to the
Rust parser still contains the required non-empty command.

Each layer validates only what it cheaply can. The SDK's typed config catches structural
errors at compile time. The dispatch layer catches structural errors that escaped the
SDK (e.g., from non-TypeScript callers). The backend catches semantic errors that depend
Expand Down
3 changes: 1 addition & 2 deletions docs/wsl/wsl-container-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,8 @@ let wslc = WslcSection {
..Default::default()
};

let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?;
let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), "python3 -c \"print('Hello from WSLC')\"", None)?;
request
.set_script("python3 -c \"print('Hello from WSLC')\"")
.set_experimental(true);

// Run to completion, capturing output…
Expand Down
25 changes: 15 additions & 10 deletions src/core/mxc-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ let policy = SandboxPolicy {
ui: None,
timeout_ms: Some(10_000),
};
let mut request = build_request(&policy, None)?;
request.set_script("echo hello").set_telemetry_opt_in(true);
let mut request = build_request(&policy, "echo hello", None)?;
request.set_telemetry_opt_in(true);

let output = run(request)?;
assert_eq!(output.outcome, WaitOutcome::Exited(0));
Expand All @@ -40,9 +40,9 @@ Ok(())

[`build_request`] resolves the host's default containment backend (see
[Supported backends](#supported-backends)), builds the wire config, and runs it
through the shared parser. The returned [`SandboxRequest`] has an empty command
line — set the command with [`SandboxRequest::set_script`] (and any working
directory / env) before spawning.
through the shared parser. The command is supplied to [`build_request`], so the
returned [`SandboxRequest`] is complete; optionally adjust its working directory
or environment before spawning.

Telemetry remains off unless `SandboxRequest::set_telemetry_opt_in(true)` is
called. Enabling that per-invocation switch still requires persisted user
Expand Down Expand Up @@ -84,6 +84,7 @@ process_container.capture_denials = Some(CaptureDenials::default());
let request = build_request_with_containment(
&policy,
&Containment::ProcessContainer(process_container),
"echo hello",
None,
)?;
# Ok::<(), mxc_sdk::Error>(())
Expand Down Expand Up @@ -242,9 +243,8 @@ let policy = SandboxPolicy {
ui: None,
timeout_ms: None,
};
let mut request = build_request(&policy, None)?;
request.set_script("cat"); // echoes stdin until EOF

// echoes stdin until EOF
let request = build_request(&policy, "cat", None)?;
let mut proc = spawn_sandbox(request)?;
let mut stdin = proc.take_stdin().unwrap();
let mut stdout = proc.take_stdout().unwrap();
Expand Down Expand Up @@ -437,8 +437,13 @@ let policy = SandboxPolicy {
filesystem: None, network: None, ui: None, timeout_ms: None,
};
let wslc = WslcSection { image: "python:3.12".to_string(), ..Default::default() };
let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?;
request.set_script("python3 -c 'print(42)'").set_experimental(true);
let mut request = build_request_with_containment(
&policy,
&Containment::Wslc(wslc),
"python3 -c 'print(42)'",
None,
)?;
request.set_experimental(true);
let output = run(request)?;
let _ = output;
Ok(())
Expand Down
7 changes: 3 additions & 4 deletions src/core/mxc-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@
//! ui: None,
//! timeout_ms: None,
//! };
//! let mut request = build_request(&policy, None)?;
//! request.set_script("echo hi");
//! let request = build_request(&policy, "echo hi", None)?;
//! let output = run(request)?;
//! match output.outcome {
//! WaitOutcome::Exited(code) => println!("exit={code}"),
Expand Down Expand Up @@ -93,8 +92,8 @@
//! # };
//! // Run a command inside a WSL container (Windows, --features wslc).
//! let wslc = WslcSection { image: "python:3.12".to_string(), ..Default::default() };
//! let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?;
//! request.set_script("python3 -c 'print(42)'").set_experimental(true);
//! let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), "python3 -c 'print(42)'", None)?;
//! request.set_experimental(true);
//! let output = run(request)?;
//! # Ok::<(), mxc_sdk::Error>(())
//! ```
Expand Down
28 changes: 16 additions & 12 deletions src/core/mxc-sdk/tests/isolation_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,14 @@ fn a_single_threaded_apartment_is_refused_before_the_service_is_reached() {

#[test]
fn one_shot_run_refuses_the_backend() {
let mut request =
build_request_with_containment(&iso_policy(), &Containment::IsolationSession, None)
.expect("building the request must succeed — the refusal is at dispatch, not build");
request
.set_script("cmd.exe /c echo hi")
.set_experimental(true);
let mut request = build_request_with_containment(
&iso_policy(),
&Containment::IsolationSession,
"cmd.exe /c echo hi",
None,
)
.expect("building the request must succeed — the refusal is at dispatch, not build");
request.set_experimental(true);

let err = mxc_sdk::run(request).expect_err("one-shot run must refuse IsolationSession");
assert_eq!(
Expand All @@ -126,12 +128,14 @@ fn one_shot_run_refuses_the_backend() {

#[test]
fn one_shot_spawn_refuses_the_backend() {
let mut request =
build_request_with_containment(&iso_policy(), &Containment::IsolationSession, None)
.expect("building the request must succeed — the refusal is at dispatch, not build");
request
.set_script("cmd.exe /c echo hi")
.set_experimental(true);
let mut request = build_request_with_containment(
&iso_policy(),
&Containment::IsolationSession,
"cmd.exe /c echo hi",
None,
)
.expect("building the request must succeed — the refusal is at dispatch, not build");
request.set_experimental(true);

// `Sandbox` is not `Debug`, so match rather than `expect_err`.
match mxc_sdk::spawn_sandbox(request) {
Expand Down
12 changes: 4 additions & 8 deletions src/core/mxc-sdk/tests/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ fn seatbelt_request(command: &str, timeout_ms: u32) -> SandboxRequest {
Some(timeout_ms)
},
};
let mut request = build_request(&policy, None).expect("build_request should succeed");
request.set_script(command);
request
build_request(&policy, command, None).expect("build_request should succeed")
}

/// A Windows ProcessContainer request exposing `C:\Windows\Temp` read-write.
Expand All @@ -58,9 +56,7 @@ fn process_container_request(version: &str, command: &str, timeout_ms: u32) -> S
Some(timeout_ms)
},
};
let mut request = build_request(&policy, None).expect("build_request should succeed");
request.set_script(command);
request
build_request(&policy, command, None).expect("build_request should succeed")
}

/// Outcome of running a sandbox to completion via the streaming API.
Expand Down Expand Up @@ -125,8 +121,8 @@ fn version_older_than_supported_is_rejected() {
timeout_ms: None,
};

let err =
build_request(&policy, None).expect_err("an out-of-range schema version must be rejected");
let err = build_request(&policy, "echo hello", None)
.expect_err("an out-of-range schema version must be rejected");
assert_eq!(err.code, ErrorCode::MalformedRequest);
}

Expand Down
16 changes: 10 additions & 6 deletions src/core/mxc-sdk/tests/sdk_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ fn build_request_rejects_empty_version() {
timeout_ms: None,
};

let err = build_request(&policy, None).expect_err("an empty policy version must be rejected");
let err = build_request(&policy, "echo hello", None)
.expect_err("an empty policy version must be rejected");
assert_eq!(err.code, mxc_sdk::ErrorCode::MalformedRequest);
}

Expand All @@ -148,7 +149,7 @@ fn build_request_host_rules_require_outbound() {

// Unix backends accept host rules without `allowOutbound`; only Windows
// ProcessContainer requires it. Either way this must not panic.
let result = build_request(&policy, None);
let result = build_request(&policy, "echo hello", None);
if cfg!(any(target_os = "linux", target_os = "macos")) {
assert!(
result.is_ok(),
Expand Down Expand Up @@ -180,7 +181,8 @@ fn rust_sdk_builds_legacy_networking() {
timeout_ms: None,
};

build_request(&policy, None).expect("the Rust SDK should build legacy networking");
build_request(&policy, "echo hello", None)
.expect("the Rust SDK should build legacy networking");
}

#[test]
Expand All @@ -206,7 +208,8 @@ fn rust_sdk_builds_directional_networking() {
timeout_ms: None,
};

build_request(&policy, None).expect("the Rust SDK should build directional networking");
build_request(&policy, "echo hello", None)
.expect("the Rust SDK should build directional networking");
}

#[test]
Expand Down Expand Up @@ -246,6 +249,7 @@ fn rust_sdk_builds_directional_process_container_networking_and_capture() {
build_request_with_containment(
&policy,
&Containment::ProcessContainer(process_container),
"echo hello",
None,
)
.expect("public re-exports should build a schema 0.8 ProcessContainer request");
Expand All @@ -267,8 +271,8 @@ fn build_request_then_run_seatbelt() {
timeout_ms: Some(10000),
};

let mut request = build_request(&policy, None).expect("build_request should succeed");
request.set_script("echo built-from-policy");
let request = build_request(&policy, "echo built-from-policy", None)
.expect("build_request should succeed");

let mut proc = spawn_sandbox(request).expect("spawn should succeed");
let mut out = String::new();
Expand Down
3 changes: 1 addition & 2 deletions src/core/mxc-sdk/tests/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ fn seatbelt_request(command: &str, timeout_ms: u32) -> SandboxRequest {
Some(timeout_ms)
},
};
let mut request = build_request(&policy, None).expect("build_request should succeed");
request.set_script(command);
let request = build_request(&policy, command, None).expect("build_request should succeed");
request
}

Expand Down
4 changes: 1 addition & 3 deletions src/core/mxc-sdk/tests/streaming_bubblewrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ fn bwrap_request(command: &str, timeout_ms: u32) -> SandboxRequest {
Some(timeout_ms)
},
};
let mut request = build_request(&policy, None).expect("build_request should succeed");
request.set_script(command);
request
build_request(&policy, command, None).expect("build_request should succeed")
}

/// Whether `pid` still has a `/proc` entry. An exited child stays a zombie --
Expand Down
3 changes: 1 addition & 2 deletions src/core/mxc-sdk/tests/streaming_processcontainer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@ fn streaming_processcontainer_bidirectional_stdio() {
ui: None,
timeout_ms: None,
};
let mut request = build_request(&policy, None).expect("build_request");
// `cmd /c more` echoes stdin to stdout until EOF, then exits.
request.set_script("cmd /c more");
let request = build_request(&policy, "cmd /c more", None).expect("build_request");
let mut proc = spawn_sandbox(request).expect("spawn");

let mut stdin = proc.take_stdin().expect("stdin available");
Expand Down
7 changes: 4 additions & 3 deletions src/core/mxc_config_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
//! not validate the remainder of the selected configuration contract.
//!
//! This crate must not depend on MXC runtime, execution-engine, or containment
//! backend crates. It is not yet consumed by the production configuration
//! parser; version-specific request types and parser dispatch will be added in
//! later phases.
//! backend crates. The production configuration parser reuses the development
//! contract's narrow phase probe when preparing a trailing CLI command.
//! Version-specific request types and exact parser dispatch remain
//! non-authoritative until the later cutover phase.

mod registry;
mod version;
Expand Down
Loading
Loading