From b6db3fcc1898fb8add613ccc26b104df09fc95df Mon Sep 17 00:00:00 2001 From: Gudge Date: Thu, 24 Sep 2026 12:52:00 -0700 Subject: [PATCH] Move lifecycle routing to executor arguments This PR changes direct `wxc-exec` state-aware transport so lifecycle operation and existing sandbox identity are passed as command-line arguments while raw exact SDK and FFI APIs retain their phase-bearing JSON contracts. Details * Add `--operation provision|start|exec|stop|deprovision` and require `--sandbox-id` for operations on an existing sandbox. * Preserve exact phase-specific parsing by overlaying CLI routing onto a source-preserving temporary document before normalization. * Reject caller-supplied `phase` and `sandboxId` fields and emit actionable migration guidance for valid or malformed phase-bearing executor JSON. * Route lifecycle failures and debug diagnostics away from stdout so it remains exclusively a JSON envelope or exec workload stream. * Distinguish malformed encoded or file content from unavailable input sources and retain the selected lifecycle phase on `ConfigRejected` records. * Keep Node on `mxc_ffi`, distinguish SDK/FFI and executor payloads in the documentation, and preserve one-shot routing and debug behavior. Tests * `cargo fmt --all -- --check` * `cargo test -p wxc_common` (912 passed). * `cargo test -p wxc` (68 passed). * `cargo clippy --workspace --all-targets --all-features -- -D warnings` * `git diff --check` Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d91874d-feea-4a33-b183-67d506ae61b5 Generated-with: gpt-5.6-sol --- .../mxc-state-aware-sandbox-api.md | 100 ++-- src/core/wxc/src/main.rs | 501 +++++++++++++++--- src/core/wxc_common/src/audit.rs | 4 + src/core/wxc_common/src/config_parser.rs | 321 ++++++++++- src/core/wxc_common/src/splice.rs | 50 ++ src/testing/wxc_e2e_tests/src/lib.rs | 23 +- .../wxc_e2e_tests/tests/e2e_state_aware.rs | 50 +- .../wxc_e2e_tests/tests/e2e_windows.rs | 6 +- 8 files changed, 936 insertions(+), 119 deletions(-) diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md index 9320da1cd..7a88b01de 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -603,12 +603,12 @@ omits it. ## 7. Wire contract -The wire contract is a typed, JSON-serialised envelope shared by the TypeScript SDK, -`mxc_ffi`, and the executor CLI. The SDK passes the envelope to `mxc_ffi`; direct CLI -callers can provide the same envelope through `--config-base64`. Rust parses both paths -into the same request types (§9.1). The only open content is at the leaves of -`ErrorEnvelope.details`; every other field, including the error envelope's named -structured fields, is statically typed. +The raw exact wire contract is a typed, JSON-serialised envelope shared by the +TypeScript SDK and `mxc_ffi`. Direct executor calls carry lifecycle routing in CLI +arguments while retaining the same phase-specific exact contracts internally. Rust +normalizes both paths into the same request types (§9.1). The only open content is at +the leaves of `ErrorEnvelope.details`; every other field, including the error +envelope's named structured fields, is statically typed. ### 7.1 Request envelope @@ -688,6 +688,25 @@ State-aware-only fields: | `phase` | `Phase` member | Yes | Discriminator. Absence means a one-shot request. | | `process` | `ProcessConfig` | Required for `exec`; absent otherwise. | Cross-backend execution fields. | +#### `wxc-exec` lifecycle transport + +Raw exact SDK and FFI calls retain `phase` and `sandboxId` in JSON. Direct +`wxc-exec` lifecycle calls instead remove those routing fields from the supplied JSON +and pass them as command-line arguments: + +```text +wxc-exec.exe policy.json --operation provision +wxc-exec.exe policy.json --operation start --sandbox-id iso:abc +wxc-exec.exe policy.json --operation exec --sandbox-id iso:abc -- command arg +wxc-exec.exe policy.json --operation stop --sandbox-id iso:abc +wxc-exec.exe policy.json --operation deprovision --sandbox-id iso:abc +``` + +Provision JSON still contains `containment`; later operations route from the +`--sandbox-id` prefix. Supplying `phase` or `sandboxId` in CLI JSON is rejected rather +than overriding the command-line routing. Without `--operation`, `wxc-exec` accepts +only one-shot JSON. + Cross-cutting fields available to state-aware (state-aware-only at top level — backends declare which phases honor them, see §10.3): @@ -757,23 +776,20 @@ state-aware mode so `stdout` remains parseable without sentinels. (One-shot disp keeps its existing `stdout` logger behaviour — the stricter routing applies to state-aware only.) -Configuration parse-phase failures that occur **after** the request is -discriminated as state-aware (i.e. the `phase` field was recognized) follow the -state-aware contract: the typed `{error}` envelope is the only primary output, -while the human-readable actionable parse diagnostic is written only to -configured auxiliary sinks (`--log-file` and the Windows diagnostic console). It -is not duplicated to the logger's primary console/buffer output, so such a parse -failure does not add stderr noise even with `--debug`. Dispatch-time failures, -including typed per-backend configuration errors, use the same auxiliary-only -diagnostic routing before the executor emits their typed `{error}` envelope. - -CLI failures that occur **before** discrimination is possible — malformed base64, -non-UTF-8 bytes, or JSON so malformed that the `phase` field cannot be read — -cannot be attributed to the state-aware path. The diagnostic is written to the -primary output (stderr) and **no** -`{error}` envelope is emitted. Callers that require an envelope even for -unparseable input should validate that the payload is well-formed JSON before -invoking `wxc-exec`. +When `--operation` is present, the executor selects the lifecycle contract before +reading or decoding the configuration source. Every failure from that point onward — +an unreadable file, malformed base64, non-UTF-8 bytes, malformed JSON, an invalid +version, exact-contract rejection, or dispatch failure — emits a typed `{error}` +envelope as the only primary output. The human-readable diagnostic is written only to +configured auxiliary sinks (`--log-file` and the Windows diagnostic console), and the +rejection is recorded through the same `ConfigRejected` audit path. It is not +duplicated to stdout or stderr even with `--debug`. + +Without `--operation`, `wxc-exec` accepts only one-shot requests. Input-source and +pre-parse failures on that path retain the legacy primary diagnostic on stderr and do +not emit a lifecycle envelope. Raw SDK and FFI lifecycle calls do not use this CLI +stream protocol; they return their status and error data through their binding result +surfaces. For exec specifically, MXC diagnostic output mixes with the script's own stderr when `--debug` is passed. This is a small amount of pre- and post-dispatch noise; consumers @@ -856,9 +872,11 @@ codes. ### 7.4 Worked example: IsolationSession end-to-end -A complete state-aware lifecycle, threading TS call → JSON the SDK serialises and passes -to the executor via `--config-base64` → Rust trait method that dispatches → response -shape, across all five phases. +A complete state-aware lifecycle, threading TS call → exact JSON the SDK passes through +`mxc_ffi` → Rust trait method that dispatches → response shape, across all five phases. +Each phase also identifies the equivalent direct `wxc-exec` routing; that transport +removes `phase` and `sandboxId` from the shown exact JSON and supplies them as CLI +arguments. #### Phase 1 — provision @@ -879,8 +897,8 @@ const { sandboxId } = await provisionSandbox( ```json { "version": "0.9.0-alpha", - "containment": "isolation_session", "phase": "provision", + "containment": "isolation_session", "network": { "egress": { "default": "allow" }, "ingress": { "default": "allow", "hostLoopback": "allow" } @@ -888,6 +906,8 @@ const { sandboxId } = await provisionSandbox( } ``` +Direct executor routing: remove `phase` and pass `--operation provision`. + ```rust // Exact adaptation carries the all-allow network policy on the request. After // checked binding and backend validation, the dispatcher calls: @@ -923,6 +943,9 @@ await startSandbox( } ``` +Direct executor routing: remove `phase` and `sandboxId`, then pass +`--operation start --sandbox-id `. + ```rust // `start` carries no per-phase config for this backend — its StartConfig is // `()`, so the envelope above has no backend-specific section and the @@ -958,6 +981,9 @@ const r = await execInSandboxAsync( } ``` +Direct executor routing: remove `phase` and `sandboxId`, then pass +`--operation exec --sandbox-id `. + ```rust // Parser populates request.script_code = "echo hello", request.script_timeout = // 5000 from the wire-format `process` block (same path as one-shot). The @@ -988,6 +1014,9 @@ await stopSandbox(sandboxId, {}); } ``` +Direct executor routing: remove `phase` and `sandboxId`, then pass +`--operation stop --sandbox-id `. + ```rust backend.stop("iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0", &request, /* config */ None) // returns Ok(StopResult { metadata: None }) @@ -1011,6 +1040,9 @@ await deprovisionSandbox(sandboxId, {}); } ``` +Direct executor routing: remove `phase` and `sandboxId`, then pass +`--operation deprovision --sandbox-id `. + ```rust backend.deprovision("iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0", &request, /* config */ None) // returns Ok(DeprovisionResult { metadata: None }) @@ -1028,12 +1060,14 @@ section when serialising state-aware calls — consumers write `appId` directly fields (`filesystem` / `network` / `runtimeConfig` / `ui`) on a per-(backend, phase) Config map directly 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. The typed SDK requires `commandLine`. The executor CLI can complete an `exec` -template from arguments after `--`; it sets `process.commandLine` before parsing. -Trailing commands are rejected for every non-exec phase. The Node SDK receives -owned response data and native process streams through `mxc_ffi`. Responses unwrap -any `result` envelope at the SDK boundary so the caller sees a plain `ProvisionResult` / -`StartResult` / `ExecResult` / `StopResult` / `DeprovisionResult`. +`env`, `timeout`) flow through the top-level `process` block. The typed SDK requires +`commandLine`. The executor CLI supplies operation and existing sandbox identity through +`--operation` and `--sandbox-id`; it can complete an `exec` template from arguments +after `--` by setting `process.commandLine` before parsing. Trailing commands are +rejected for every non-exec operation. The Node SDK receives owned response data and +native process streams through `mxc_ffi`. Responses unwrap any `result` envelope at the +SDK boundary so the caller sees a plain `ProvisionResult` / `StartResult` / +`ExecResult` / `StopResult` / `DeprovisionResult`. ## 8. Error model diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 4b4314e27..55364dc58 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -9,18 +9,19 @@ use std::process; use std::sync::{Mutex, OnceLock}; use std::time::Instant; -use clap::Parser; +use clap::{Parser, ValueEnum}; use process_container_common::appcontainer_runner::delete_app_container_profile; use wxc_common::audit::{AuditEvent, AuditEventName, RejectionReason}; -use wxc_common::config_parser::{LoadOptions, ParseError}; +use wxc_common::config_parser::{LoadOptions, ParseError, RequestInputError}; #[cfg(target_os = "windows")] use wxc_common::diagnostic::DiagnosticConfig; +use wxc_common::error::WxcError; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; use wxc_common::mxc_error::{MxcError, MxcErrorCode, ResponseEnvelope}; use wxc_common::script_runner::{handle_dry_run_exit, ScriptRunner}; use wxc_common::state_aware_dispatch::{resolve_backend, DispatchOutcome}; -use wxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest}; +use wxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; use wxc_common::telemetry; #[derive(Parser)] @@ -64,6 +65,32 @@ struct Cli { #[arg(long = "dry-run")] dry_run: bool, + /// Sandbox lifecycle operation. Lifecycle operations are selected only by + /// this argument, never by the config document. + #[arg( + long, + value_enum, + conflicts_with_all = [ + "delete", + "setup_hyperlight", + "force", + "setup_wslc", + "image", + "storage_path", + "probe", + "force_reclaim" + ] + )] + #[cfg_attr( + target_os = "windows", + arg(conflicts_with_all = ["audit", "audit_verbose"]) + )] + operation: Option, + + /// Existing sandbox targeted by start, exec, stop, or deprovision. + #[arg(long = "sandbox-id", requires = "operation")] + sandbox_id: Option, + /// Path to diagnostic log file (appends, creates if missing) #[arg(long = "log-file")] log_file: Option, @@ -136,7 +163,9 @@ struct Cli { "image", "storage_path", "probe", - "force_reclaim" + "force_reclaim", + "operation", + "sandbox_id" ] )] #[cfg_attr( @@ -189,6 +218,27 @@ impl Cli { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum CliOperation { + Provision, + Start, + Exec, + Stop, + Deprovision, +} + +impl From for wxc_common::state_aware_request::Phase { + fn from(operation: CliOperation) -> Self { + match operation { + CliOperation::Provision => Self::Provision, + CliOperation::Start => Self::Start, + CliOperation::Exec => Self::Exec, + CliOperation::Stop => Self::Stop, + CliOperation::Deprovision => Self::Deprovision, + } + } +} + fn parse_cli() -> Cli { match Cli::try_parse() { Ok(cli) => cli, @@ -269,13 +319,50 @@ fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { /// Read the request source (file path / base64 blob) once, returning the /// decoded JSON. Reused by `--probe` and the normal request loader so a single /// source is only read once per invocation. -fn decode_config_input_once(cli: &Cli) -> Option> { +fn decode_config_input_once(cli: &Cli) -> Option> { let (input, is_base64) = config_input(cli)?; - Some(wxc_common::config_parser::decode_request_input( + Some(wxc_common::config_parser::decode_request_input_classified( &input, is_base64, )) } +fn lifecycle_input_error( + operation: CliOperation, + logger: &mut Logger, + error: RequestInputError, +) -> MxcError { + let (error, reason) = match error { + RequestInputError::Decode(error) => (error, RejectionReason::MalformedJson), + RequestInputError::Source(error) => (error, RejectionReason::InputSourceUnavailable), + }; + let message = error.to_string(); + let phase: Phase = operation.into(); + log_config_rejected(logger, reason, UNKNOWN_BACKEND, "", phase.as_str()); + MxcError::malformed_request(message) +} + +fn logger_for_cli(cli: &Cli) -> Logger { + let mode = if cli.operation.is_some() { + // Lifecycle stdout belongs exclusively to the result envelope or exec + // workload. Buffer every primary diagnostic, including future + // downstream log_line calls, so --debug cannot write prose to stdout. + Mode::Buffer + } else if cli.debug { + Mode::Console + } else { + Mode::Buffer + }; + let mut logger = Logger::new(mode); + + if let Some(ref log_path) = cli.log_file { + if let Err(error) = logger.enable_file_sink(std::path::Path::new(log_path)) { + eprintln!("Warning: could not open log file '{log_path}': {error}"); + } + } + + logger +} + /// On a state-aware dispatch failure, record the error only on the auxiliary /// diagnostic sinks (`--log-file` and the diagnostic pipe) via /// [`Logger::log_diagnostic_line`]. It is deliberately kept out of the primary @@ -653,7 +740,19 @@ fn request_error_route(error: &ParseError) -> RequestErrorRoute<'_> { } } -fn log_request_parse_rejection(logger: &mut Logger, error: &ParseError) { +fn reject_state_aware_without_operation(logger: &mut Logger) -> ! { + let message = "state-aware lifecycle requests require --operation; \ + remove 'phase' and 'sandboxId' from the config JSON"; + let error = ParseError::OneShot(WxcError::ConfigParse(message.to_string())); + log_request_parse_rejection(logger, &error, None); + eprintln!("Request error"); + eprintln!("{message}"); + eprint!("{}", logger.get_buffer()); + process::exit(1); +} + +fn log_request_parse_rejection(logger: &mut Logger, error: &ParseError, phase: Option) { + let phase = phase.map(Phase::as_str).unwrap_or(""); match error { ParseError::Decode(_) => { log_config_rejected( @@ -661,7 +760,7 @@ fn log_request_parse_rejection(logger: &mut Logger, error: &ParseError) { RejectionReason::MalformedJson, UNKNOWN_BACKEND, "", - "", + phase, ); } ParseError::OneShotMalformed(error) => { @@ -671,7 +770,7 @@ fn log_request_parse_rejection(logger: &mut Logger, error: &ParseError) { RejectionReason::MalformedJson, UNKNOWN_BACKEND, offending_field_from_message(&message), - "", + phase, ); } ParseError::Version(error) | ParseError::OneShot(error) => { @@ -681,7 +780,7 @@ fn log_request_parse_rejection(logger: &mut Logger, error: &ParseError) { RejectionReason::SchemaViolation, UNKNOWN_BACKEND, offending_field_from_message(&message), - "", + phase, ); } ParseError::StateAware(error) => { @@ -690,7 +789,7 @@ fn log_request_parse_rejection(logger: &mut Logger, error: &ParseError) { rejection_reason_for(error), UNKNOWN_BACKEND, offending_field_from_message(&error.message), - "", + phase, ); } } @@ -935,8 +1034,7 @@ fn main() { process::exit(outcome.emit()); } // Decode the request source (file path / base64) once, up front. - let decoded_config: Option> = - decode_config_input_once(&cli); + let decoded_config: Option> = decode_config_input_once(&cli); // Propagate --force-reclaim via the environment so it reaches both the // in-process one-shot reconcile and the detached daemon. Set before any @@ -1175,6 +1273,16 @@ fn main() { // --probe is handled at the top of `main` (before COM init) for // SDK first-call latency. See note there. + // Initialize diagnostics before unpacking the input so lifecycle source + // and decode failures use the same envelope contract as parser failures. + let mut logger = logger_for_cli(&cli); + #[cfg(target_os = "windows")] + let diag_config = DiagnosticConfig::from_environment(); + #[cfg(target_os = "windows")] + if diag_config.console_enabled { + logger.enable_diagnostics(&diag_config); + } + // Determine config input. In delete mode the config is optional; every // other path requires it. `decoded_config` above already read the source // once — if it's populated, unpack the decoded JSON (or surface the @@ -1182,43 +1290,42 @@ fn main() { // delete mode or report the missing-config error. let config_json: Option = match decoded_config { Some(Ok(json)) => Some(json), - Some(Err(error)) => { - eprintln!("Request error"); - eprintln!("{error}"); - process::exit(1); - } + Some(Err(error)) => match cli.operation { + Some(operation) => { + let error = lifecycle_input_error(operation, &mut logger, error); + log_state_aware_dispatch_error(&mut logger, &error); + print_error_envelope(&error); + eprint!("{}", logger.get_buffer()); + process::exit(1); + } + None => { + eprintln!("Request error"); + eprintln!("{}", error.into_error()); + process::exit(1); + } + }, None => { if !cli.delete { - eprintln!( - "Error: No config provided. Use a positional path, --config, or --config-base64" - ); + let message = + "No config provided. Use a positional path, --config, or --config-base64"; + if let Some(operation) = cli.operation { + let error = lifecycle_input_error( + operation, + &mut logger, + RequestInputError::Source(WxcError::ConfigParse(message.to_string())), + ); + log_state_aware_dispatch_error(&mut logger, &error); + print_error_envelope(&error); + eprint!("{}", logger.get_buffer()); + process::exit(1); + } + eprintln!("Error: {message}"); process::exit(1); } None } }; - let mut logger = Logger::new(if cli.debug { - Mode::Console - } else { - Mode::Buffer - }); - - if let Some(ref log_path) = cli.log_file { - if let Err(e) = logger.enable_file_sink(std::path::Path::new(log_path)) { - eprintln!("Warning: could not open log file '{}': {}", log_path, e); - } - } - - // Initialize the diagnostic console before parsing so early rejection - // records have an active sink. - #[cfg(target_os = "windows")] - let diag_config = DiagnosticConfig::from_environment(); - #[cfg(target_os = "windows")] - if diag_config.console_enabled { - logger.enable_diagnostics(&diag_config); - } - // Delete mode if cli.delete { let name = match cli.containername { @@ -1236,11 +1343,47 @@ fn main() { // Non-delete paths always have a config JSON at this point (or exited // above with the missing-config error). let config_json = config_json.expect("config_json is Some on non-delete paths"); - - // Load request — discriminates state-aware (top-level `phase` field) from - // one-shot. State-aware failures emit a JSON envelope on stdout; one-shot - // and pre-discrimination failures keep the existing diagnostic-on-stderr - // convention. + if let Some(operation) = cli.operation { + let phase = operation.into(); + let parsed = + match wxc_common::config_parser::load_state_aware_request_from_json_with_options( + &config_json, + &mut logger, + phase, + cli.sandbox_id.as_deref(), + &cli.command, + ) { + Ok(parsed) => parsed, + Err(error) => { + log_request_parse_rejection(&mut logger, &error, Some(phase)); + let error = match error { + ParseError::StateAware(error) => error, + ParseError::Decode(error) + | ParseError::Version(error) + | ParseError::OneShot(error) + | ParseError::OneShotMalformed(error) => { + MxcError::malformed_request(error.to_string()) + } + }; + print_error_envelope(&error); + eprint!("{}", logger.get_buffer()); + process::exit(1); + } + }; + let mut parsed = parsed; + let telemetry_active = parsed + .request() + .telemetry + .as_ref() + .map(|config| telemetry::init(config, &mut logger)) + .unwrap_or(false); + parsed.set_experimental_enabled(cli.experimental); + parsed.set_dry_run(cli.dry_run); + run_state_aware_main(parsed, cli.dry_run, telemetry_active, &mut logger) + } + + // Without --operation, the executor accepts only one-shot requests. Raw + // exact APIs retain phase-bearing lifecycle JSON for compatibility. let load_opts = LoadOptions { is_base64: false, cli_command: &cli.command, @@ -1252,27 +1395,11 @@ fn main() { ); let request = match parsed_request { Ok(MxcRequest::OneShot(req)) => req, - Ok(MxcRequest::StateAware(mut parsed)) => { - let telemetry_active = parsed - .request() - .telemetry - .as_ref() - .map(|config| telemetry::init(config, &mut logger)) - .unwrap_or(false); - // Mirror what the one-shot path does at the post-dispatch stage - // below: copy the CLI `--experimental` flag into the parsed - // request so backends that gate on it (e.g. Windows Sandbox - // experimental features) see the same value regardless of which - // dispatch branch the request entered through. Without this, the - // state-aware path runs without the gate -- a phase-envelope request - // could provision/start/exec experimental backends with no - // `--experimental` on the CLI. - parsed.set_experimental_enabled(cli.experimental); - parsed.set_dry_run(cli.dry_run); - run_state_aware_main(parsed, cli.dry_run, telemetry_active, &mut logger) + Ok(MxcRequest::StateAware(_)) | Err(ParseError::StateAware(_)) => { + reject_state_aware_without_operation(&mut logger) } Err(error) => { - log_request_parse_rejection(&mut logger, &error); + log_request_parse_rejection(&mut logger, &error, None); match request_error_route(&error) { RequestErrorRoute::Diagnostic => { eprint!("Request error\n{}", logger.get_buffer()); @@ -1670,6 +1797,71 @@ mod tests { base64_encode(json.as_bytes()) } + #[test] + fn cli_accepts_lifecycle_operation_and_sandbox_id() { + for (name, expected) in [ + ("provision", CliOperation::Provision), + ("start", CliOperation::Start), + ("exec", CliOperation::Exec), + ("stop", CliOperation::Stop), + ("deprovision", CliOperation::Deprovision), + ] { + let cli = parse_cli(&["wxc-exec", "policy.json", "--operation", name]); + assert_eq!(cli.operation, Some(expected)); + } + + let cli = parse_cli(&[ + "wxc-exec", + "policy.json", + "--operation", + "start", + "--sandbox-id", + "wsb:abcd1234", + ]); + assert_eq!(cli.sandbox_id.as_deref(), Some("wsb:abcd1234")); + + let error = match Cli::try_parse_from([ + "wxc-exec", + "policy.json", + "--sandbox-id", + "wsb:abcd1234", + ]) { + Err(error) => error, + Ok(_) => panic!("--sandbox-id must require --operation"), + }; + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn cli_rejects_lifecycle_operation_with_utility_modes() { + for utility_mode in [ + "--delete", + "--setup-hyperlight", + "--setup-wslc", + "--probe", + "--force-reclaim", + ] { + let error = match Cli::try_parse_from([ + "wxc-exec", + "policy.json", + "--operation", + "provision", + utility_mode, + ]) { + Err(error) => error, + Ok(_) => panic!("--operation must conflict with {utility_mode}"), + }; + assert_eq!( + error.kind(), + clap::error::ErrorKind::ArgumentConflict, + "{utility_mode}" + ); + } + } + fn test_logger() -> Logger { Logger::new(Mode::Buffer) } @@ -1890,6 +2082,181 @@ mod tests { )); } + #[test] + fn cli_operation_routes_input_failures_to_lifecycle_errors() { + let directory = tempfile::tempdir().unwrap(); + let log_path = directory.path().join("audit.log"); + let mut logger = test_logger(); + logger.enable_file_sink(&log_path).unwrap(); + + for operation in [ + CliOperation::Provision, + CliOperation::Start, + CliOperation::Exec, + CliOperation::Stop, + CliOperation::Deprovision, + ] { + let error = lifecycle_input_error( + operation, + &mut logger, + RequestInputError::Decode(WxcError::ConfigParse("decode failed".to_string())), + ); + assert_eq!( + error.code, + wxc_common::mxc_error::MxcErrorCode::MalformedRequest + ); + assert!(error.message.contains("decode failed"), "{}", error.message); + let envelope: serde_json::Value = + serde_json::from_str(&error_envelope_string(&error)).unwrap(); + assert_eq!(envelope["error"]["code"], "malformed_request"); + let buffered = logger.get_buffer(); + assert!( + !buffered.contains("decode failed"), + "lifecycle diagnostics must remain auxiliary" + ); + } + drop(logger); + + let log = std::fs::read_to_string(log_path).unwrap(); + assert_eq!( + log.matches(r#""reason":"malformed_json""#).count(), + 5, + "each lifecycle input failure must emit one ConfigRejected record: {log}" + ); + for phase in ["provision", "start", "exec", "stop", "deprovision"] { + assert_eq!( + log.matches(&format!(r#""phase":"{phase}""#)).count(), + 1, + "missing lifecycle phase {phase}: {log}" + ); + } + + let mut logger = test_logger(); + let source_path = directory.path().join("source-audit.log"); + logger.enable_file_sink(&source_path).unwrap(); + let error = lifecycle_input_error( + CliOperation::Provision, + &mut logger, + RequestInputError::Source(WxcError::ConfigParse( + "configuration source missing".to_string(), + )), + ); + assert!(error.message.contains("configuration source missing")); + drop(logger); + let source_log = std::fs::read_to_string(source_path).unwrap(); + assert_eq!( + source_log + .matches(r#""reason":"input_source_unavailable""#) + .count(), + 1, + "source failures need their own audit classification: {source_log}" + ); + assert!( + source_log.contains(r#""phase":"provision""#), + "source rejection must retain the known phase: {source_log}" + ); + } + + #[test] + fn invalid_utf8_file_is_a_malformed_lifecycle_input() { + let directory = tempfile::tempdir().unwrap(); + let config_path = directory.path().join("invalid-utf8.json"); + std::fs::write(&config_path, [0xff, 0xfe]).unwrap(); + let input_error = wxc_common::config_parser::decode_request_input_classified( + config_path.to_str().unwrap(), + false, + ) + .unwrap_err(); + assert!(matches!(input_error, RequestInputError::Decode(_))); + + let audit_path = directory.path().join("audit.log"); + let mut logger = test_logger(); + logger.enable_file_sink(&audit_path).unwrap(); + let error = lifecycle_input_error(CliOperation::Provision, &mut logger, input_error); + assert!( + error.message.contains("not valid UTF-8"), + "{}", + error.message + ); + drop(logger); + + let audit = std::fs::read_to_string(audit_path).unwrap(); + assert!( + audit.contains(r#""reason":"malformed_json""#), + "invalid content must be classified as malformed JSON: {audit}" + ); + assert!( + audit.contains(r#""phase":"provision""#), + "invalid content must retain the selected lifecycle phase: {audit}" + ); + assert!( + !audit.contains(r#""reason":"input_source_unavailable""#), + "an existing source with malformed content is still available: {audit}" + ); + } + + #[test] + fn lifecycle_debug_logging_is_buffered_without_changing_one_shot_console_mode() { + let lifecycle = parse_cli(&[ + "wxc-exec", + "policy.json", + "--operation", + "provision", + "--debug", + ]); + let mut lifecycle_logger = logger_for_cli(&lifecycle); + lifecycle_logger.log_line("lifecycle diagnostic"); + assert!( + lifecycle_logger + .get_buffer() + .contains("lifecycle diagnostic"), + "lifecycle diagnostics must be buffered away from stdout" + ); + + let one_shot = parse_cli(&["wxc-exec", "policy.json", "--debug"]); + let mut one_shot_logger = logger_for_cli(&one_shot); + one_shot_logger.log_line("one-shot diagnostic"); + assert!( + one_shot_logger.get_buffer().is_empty(), + "one-shot --debug must retain console logging behavior" + ); + } + + #[test] + fn parse_rejection_logging_preserves_optional_phase_context() { + let directory = tempfile::tempdir().unwrap(); + + let lifecycle_path = directory.path().join("lifecycle.log"); + let mut lifecycle_logger = test_logger(); + lifecycle_logger.enable_file_sink(&lifecycle_path).unwrap(); + log_request_parse_rejection( + &mut lifecycle_logger, + &ParseError::Version(WxcError::ConfigParse("bad version".to_string())), + Some(Phase::Exec), + ); + drop(lifecycle_logger); + let lifecycle_log = std::fs::read_to_string(lifecycle_path).unwrap(); + assert!( + lifecycle_log.contains(r#""phase":"exec""#), + "lifecycle parser rejection must retain phase: {lifecycle_log}" + ); + + let one_shot_path = directory.path().join("one-shot.log"); + let mut one_shot_logger = test_logger(); + one_shot_logger.enable_file_sink(&one_shot_path).unwrap(); + log_request_parse_rejection( + &mut one_shot_logger, + &ParseError::Version(WxcError::ConfigParse("bad version".to_string())), + None, + ); + drop(one_shot_logger); + let one_shot_log = std::fs::read_to_string(one_shot_path).unwrap(); + assert!( + !one_shot_log.contains("\"phase\""), + "one-shot parser rejection must omit phase: {one_shot_log}" + ); + } + #[test] fn request_parse_failures_preserve_audit_classification_and_output_route() { for (case, json, reason) in [ @@ -1942,7 +2309,7 @@ mod tests { let path = directory.path().join("audit.log"); let mut logger = test_logger(); logger.enable_file_sink(&path).unwrap(); - log_request_parse_rejection(&mut logger, &error); + log_request_parse_rejection(&mut logger, &error, None); drop(logger); let contents = std::fs::read_to_string(path).unwrap(); assert!( diff --git a/src/core/wxc_common/src/audit.rs b/src/core/wxc_common/src/audit.rs index 9c4eedbdf..27e9a6168 100644 --- a/src/core/wxc_common/src/audit.rs +++ b/src/core/wxc_common/src/audit.rs @@ -262,6 +262,8 @@ impl EffectiveEnforcementLevel { pub enum RejectionReason { /// The input was not valid JSON (or not valid base64-wrapped JSON). MalformedJson, + /// The configuration source was missing or could not be read. + InputSourceUnavailable, /// The input parsed as JSON but violated the config schema. SchemaViolation, /// Neither the policy nor the CLI supplied a command line. @@ -284,6 +286,7 @@ impl RejectionReason { pub fn as_str(self) -> &'static str { match self { Self::MalformedJson => "malformed_json", + Self::InputSourceUnavailable => "input_source_unavailable", Self::SchemaViolation => "schema_violation", Self::MissingCommand => "missing_command", Self::UnsupportedFieldForBackend => "unsupported_field_for_backend", @@ -638,6 +641,7 @@ mod tests { TeardownSkipReason::PreservePolicy.as_str(), TeardownSkipReason::CleanupNotImplemented.as_str(), RejectionReason::MalformedJson.as_str(), + RejectionReason::InputSourceUnavailable.as_str(), RejectionReason::SchemaViolation.as_str(), RejectionReason::MissingCommand.as_str(), RejectionReason::UnsupportedFieldForBackend.as_str(), diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 0895ab435..b3efaaaf8 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -49,6 +49,24 @@ enum ErrorOutput { DiagnosticOnly, } +/// Failure while reading or decoding the configuration source. +#[doc(hidden)] +#[derive(Debug)] +pub enum RequestInputError { + /// The supplied base64 or decoded UTF-8 payload was malformed. + Decode(WxcError), + /// The path was missing or the source could not be read. + Source(WxcError), +} + +impl RequestInputError { + pub fn into_error(self) -> WxcError { + match self { + Self::Decode(error) | Self::Source(error) => error, + } + } +} + impl ParseError { fn output(&self) -> ErrorOutput { match self { @@ -522,7 +540,7 @@ pub fn load_mxc_request_with_options( ) -> Result { let result: Result = (|| { let json_str = decode_request_input(input, opts.is_base64).map_err(ParseError::Decode)?; - parse_mxc_request_json_with_cli(&json_str, logger, opts.cli_command) + parse_mxc_request_json_with_cli(&json_str, logger, opts.cli_command, ErrorOutput::Primary) })(); if let Err(error) = &result { @@ -572,17 +590,99 @@ pub fn load_mxc_request_from_json_with_options( // `is_base64` is meaningless on an already-decoded JSON string; the field // is kept in `LoadOptions` for signature parity with the from-input path. let _ = opts.is_base64; - let result = parse_mxc_request_json_with_cli(json_str, logger, opts.cli_command); + let result = + parse_mxc_request_json_with_cli(json_str, logger, opts.cli_command, ErrorOutput::Primary); if let Err(error) = &result { log_error(logger, &error.message(), error.output()); } result } +/// Parses a state-aware request whose operation and sandbox identity were +/// supplied by the executor command line. +/// +/// Raw exact JSON entry points continue to accept the registered phase-bearing +/// contracts. This CLI-specific transport overlays the out-of-band routing +/// values before sending the effective document through the same exact parser. +pub fn load_state_aware_request_from_json_with_options( + json_str: &str, + logger: &mut Logger, + phase: Phase, + sandbox_id: Option<&str>, + cli_command: &[String], +) -> Result { + let result = (|| { + let version = probe_version(json_str).map_err(exact_version_error)?; + if !matches!( + version, + ContractVersion::V0_9_0Alpha | ContractVersion::V0_10_0Alpha + ) { + return Err(ParseError::StateAware(MxcError::malformed_request( + "sandbox lifecycle operations require schema version \ + '0.9.0-alpha' or '0.10.0-alpha'", + ))); + } + + match (phase, sandbox_id) { + (Phase::Provision, Some(_)) => { + return Err(ParseError::StateAware(MxcError::malformed_request( + "the provision operation does not accept --sandbox-id", + ))); + } + (Phase::Provision, None) => {} + (_, None) => { + return Err(ParseError::StateAware(MxcError::malformed_request( + format!("the {phase} operation requires --sandbox-id"), + ))); + } + (_, Some("")) => { + return Err(ParseError::StateAware(MxcError::malformed_request( + "--sandbox-id must not be empty", + ))); + } + (_, Some(_)) => {} + } + + let source = crate::splice::CommandSource::parse(json_str).ok_or_else(|| { + ParseError::StateAware(MxcError::malformed_request( + "lifecycle request must be a JSON object", + )) + })?; + let routed_json = source + .splice_lifecycle_routing(phase.as_str(), sandbox_id) + .ok_or_else(|| { + ParseError::StateAware(MxcError::malformed_request( + "failed to apply lifecycle routing arguments", + )) + })?; + + match parse_mxc_request_json_with_cli( + &routed_json, + logger, + cli_command, + ErrorOutput::DiagnosticOnly, + )? { + MxcRequest::StateAware(parsed) => Ok(parsed), + MxcRequest::OneShot(_) => Err(ParseError::StateAware(MxcError::malformed_request( + "expected a state-aware lifecycle request", + ))), + } + })(); + + if let Err(error) = &result { + // `--operation` selected the lifecycle contract before this loader was + // called, so every failure must leave primary output available for the + // JSON error envelope regardless of its internal ParseError variant. + log_error(logger, &error.message(), ErrorOutput::DiagnosticOnly); + } + result +} + fn parse_mxc_request_json_with_cli( json_str: &str, logger: &mut Logger, cli_command: &[String], + override_output: ErrorOutput, ) -> Result { if cli_command.is_empty() { return parse_exact_mxc_request_json(json_str, logger); @@ -591,7 +691,7 @@ fn parse_mxc_request_json_with_cli( let (json_str, override_log) = apply_cli_command(json_str, cli_command)?; let request = parse_exact_mxc_request_json(&json_str, logger)?; if let Some(message) = override_log { - logger.log_line(&message); + log_error(logger, &message, override_output); } Ok(request) } @@ -717,12 +817,26 @@ fn log_error(logger: &mut Logger, message: &str, output: ErrorOutput) { /// This performs no logging so callers can apply the correct output contract /// after discriminating execution requests from maintenance commands. pub fn decode_request_input(input: &str, is_base64: bool) -> Result { + decode_request_input_classified(input, is_base64).map_err(RequestInputError::into_error) +} + +/// Decode an input while preserving whether failure came from the source or +/// from the encoded payload. +#[doc(hidden)] +pub fn decode_request_input_classified( + input: &str, + is_base64: bool, +) -> Result { if is_base64 { let bytes = base64_decode(input).map_err(|_| { - WxcError::ConfigParse("Failed to decode base64 configuration".to_string()) + RequestInputError::Decode(WxcError::ConfigParse( + "Failed to decode base64 configuration".to_string(), + )) })?; String::from_utf8(bytes).map_err(|_| { - WxcError::ConfigParse("Base64 decoded content is not valid UTF-8".to_string()) + RequestInputError::Decode(WxcError::ConfigParse( + "Base64 decoded content is not valid UTF-8".to_string(), + )) }) } else { // The file path is untrusted input; on Linux/macOS it may contain @@ -731,15 +845,21 @@ pub fn decode_request_input(input: &str, is_base64: bool) -> Result Ok(contents), + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => { + Err(RequestInputError::Decode(WxcError::ConfigParse(format!( + "Configuration file content is not valid UTF-8: {safe_input}", + )))) + } + Err(error) => Err(RequestInputError::Source(WxcError::ConfigParse(format!( + "Failed to read configuration file '{safe_input}': {error}", + )))), } - fs::read_to_string(input).map_err(|e| { - WxcError::ConfigParse(format!( - "Failed to read configuration file '{safe_input}': {e}" - )) - }) } } @@ -4865,4 +4985,181 @@ mod tests { ); } } + + #[test] + fn operation_cli_transport_uses_exact_phase_specific_contracts() { + let cases = [ + ( + Phase::Provision, + None, + r#"{"version":"0.9.0-alpha","containment":"wslc"}"#, + ), + ( + Phase::Start, + Some("iso:abc"), + r#"{"version":"0.9.0-alpha"}"#, + ), + ( + Phase::Exec, + Some("iso:abc"), + r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi"}}"#, + ), + (Phase::Stop, Some("iso:abc"), r#"{"version":"0.9.0-alpha"}"#), + ( + Phase::Deprovision, + Some("iso:abc"), + r#"{"version":"0.9.0-alpha"}"#, + ), + ]; + + for (phase, sandbox_id, json) in cases { + let parsed = load_state_aware_request_from_json_with_options( + json, + &mut test_logger(), + phase, + sandbox_id, + &[], + ) + .unwrap_or_else(|error| panic!("{phase}: {error:?}")); + assert_eq!(parsed.phase(), phase); + assert_eq!(parsed.sandbox_id(), sandbox_id); + } + } + + #[test] + fn operation_cli_transport_rejects_json_routing_authorities() { + for json in [ + r#"{"version":"0.9.0-alpha","phase":"start"}"#, + r#"{"version":"0.9.0-alpha","sandboxId":"iso:json"}"#, + ] { + let error = load_state_aware_request_from_json_with_options( + json, + &mut test_logger(), + Phase::Start, + Some("iso:cli"), + &[], + ) + .unwrap_err(); + assert!(matches!(error, ParseError::StateAware(_)), "{error:?}"); + } + } + + #[test] + fn operation_cli_transport_validates_sandbox_id_arguments() { + let provision_error = load_state_aware_request_from_json_with_options( + r#"{"version":"0.9.0-alpha","containment":"wslc"}"#, + &mut test_logger(), + Phase::Provision, + Some("wslc:abc"), + &[], + ) + .unwrap_err(); + assert!(provision_error + .message() + .contains("does not accept --sandbox-id")); + + let start_error = load_state_aware_request_from_json_with_options( + r#"{"version":"0.9.0-alpha"}"#, + &mut test_logger(), + Phase::Start, + None, + &[], + ) + .unwrap_err(); + assert!(start_error.message().contains("requires --sandbox-id")); + } + + #[test] + fn operation_cli_transport_routes_all_errors_to_diagnostic_only() { + for json in [ + "{ not json", + r#"{"version":"99.99.99-secret"}"#, + r#"{"version":"0.9.0-alpha","containment":"wslc","unknown":true}"#, + ] { + let directory = tempfile::tempdir().unwrap(); + let log_path = directory.path().join("mxc.log"); + let mut logger = test_logger(); + logger.enable_file_sink(&log_path).unwrap(); + + let error = load_state_aware_request_from_json_with_options( + json, + &mut logger, + Phase::Provision, + None, + &[], + ) + .unwrap_err(); + assert!( + logger.get_buffer().is_empty(), + "lifecycle errors must not reach primary output: {error:?}" + ); + let message = error.message(); + drop(logger); + + let log = std::fs::read_to_string(log_path).unwrap(); + assert_eq!( + log.matches(&message).count(), + 1, + "expected one auxiliary diagnostic for {error:?}: {log:?}" + ); + } + } + + #[test] + fn operation_cli_exec_command_is_applied_after_routing() { + let directory = tempfile::tempdir().unwrap(); + let log_path = directory.path().join("mxc.log"); + let mut logger = test_logger(); + logger.enable_file_sink(&log_path).unwrap(); + let parsed = load_state_aware_request_from_json_with_options( + r#"{ + "version":"0.9.0-alpha", + "process":{"commandLine":"policy.exe"} + }"#, + &mut logger, + Phase::Exec, + Some("iso:abc"), + &["echo".to_string(), "hello".to_string()], + ) + .unwrap(); + + assert_eq!(parsed.request().script_code, "echo hello"); + assert!( + logger.get_buffer().is_empty(), + "lifecycle override diagnostics must not reach primary output" + ); + drop(logger); + let log = std::fs::read_to_string(log_path).unwrap(); + assert_eq!( + log.matches("Overriding policy process.commandLine").count(), + 1, + "expected one auxiliary override diagnostic: {log}" + ); + } + + #[test] + fn one_shot_cli_command_override_keeps_primary_log_behavior() { + let command = ["echo".to_string(), "hello".to_string()]; + let mut logger = test_logger(); + let request = load_mxc_request_from_json_with_options( + r#"{ + "version":"0.9.0-alpha", + "process":{"commandLine":"policy.exe"} + }"#, + &mut logger, + LoadOptions { + is_base64: false, + cli_command: &command, + }, + ) + .unwrap(); + + assert!(matches!(request, MxcRequest::OneShot(_))); + assert!( + logger + .get_buffer() + .contains("Overriding policy process.commandLine"), + "one-shot overrides retain their primary diagnostic" + ); + } } diff --git a/src/core/wxc_common/src/splice.rs b/src/core/wxc_common/src/splice.rs index a60539466..6dcdf7e85 100644 --- a/src/core/wxc_common/src/splice.rs +++ b/src/core/wxc_common/src/splice.rs @@ -159,6 +159,25 @@ fn insert_member(source: &str, object: &RawObject<'_>, member: &str) -> Option { + pub(crate) fn splice_lifecycle_routing( + &self, + phase: &str, + sandbox_id: Option<&str>, + ) -> Option { + let phase = serde_json::to_string(phase).ok()?; + let phase_member = format!(r#""phase":{phase}"#); + let with_phase = insert_member(self.json, &self.root, &phase_member)?; + + let Some(sandbox_id) = sandbox_id else { + return Some(with_phase); + }; + + let sandbox_id = serde_json::to_string(sandbox_id).ok()?; + let sandbox_id_member = format!(r#""sandboxId":{sandbox_id}"#); + let root: RawObject<'_> = serde_json::from_str(&with_phase).ok()?; + insert_member(&with_phase, &root, &sandbox_id_member) + } + pub(crate) fn splice_command(&self, command: &str) -> Option { let command = serde_json::to_string(command).ok()?; @@ -220,6 +239,37 @@ mod tests { CommandSource::parse(json)?.splice_command(command) } + #[test] + fn lifecycle_routing_is_added_without_removing_existing_members() { + let source = CommandSource::parse(r#"{"version":"0.9.0-alpha"}"#).unwrap(); + let spliced = source + .splice_lifecycle_routing("start", Some("iso:abcd")) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&spliced).unwrap(), + serde_json::json!({ + "version": "0.9.0-alpha", + "phase": "start", + "sandboxId": "iso:abcd" + }) + ); + } + + #[test] + fn lifecycle_routing_preserves_conflicting_members_for_exact_rejection() { + let source = CommandSource::parse( + r#"{"version":"0.9.0-alpha","phase":"exec","sandboxId":"iso:old"}"#, + ) + .unwrap(); + let spliced = source + .splice_lifecycle_routing("start", Some("iso:new")) + .unwrap(); + + assert_eq!(spliced.matches(r#""phase""#).count(), 2); + assert_eq!(spliced.matches(r#""sandboxId""#).count(), 2); + } + #[test] fn one_shot_backend_agrees_with_the_parser_for_every_spelling() { for spelling in [ diff --git a/src/testing/wxc_e2e_tests/src/lib.rs b/src/testing/wxc_e2e_tests/src/lib.rs index b7d647fb1..ca93aa5ad 100644 --- a/src/testing/wxc_e2e_tests/src/lib.rs +++ b/src/testing/wxc_e2e_tests/src/lib.rs @@ -502,19 +502,36 @@ pub fn run_wxc_example(config_file: &str, extra_args: &[&str]) -> CommandResult run_executable(config_file, &exe, args) } -/// Run `wxc-exec.exe` with a state-aware request envelope. The JSON value is -/// serialised, base64-encoded, and passed via `--config-base64`. Used by the -/// state-aware smoke tests. +/// Run `wxc-exec.exe` with a state-aware request. Routing fields are moved from +/// the exact request into the executor's CLI arguments before the remaining +/// JSON is serialised and passed via `--config-base64`. pub fn run_wxc_state_aware( label: &str, request: &serde_json::Value, extra_args: &[&str], ) -> CommandResult { let exe = find_binary("wxc-exec.exe").expect("wxc-exec.exe should be available"); + let mut request = request.clone(); + let object = request + .as_object_mut() + .expect("state-aware test request must be an object"); + let operation = object + .remove("phase") + .and_then(|value| value.as_str().map(str::to_owned)) + .expect("state-aware test request must contain a string phase"); + let sandbox_id = object + .remove("sandboxId") + .and_then(|value| value.as_str().map(str::to_owned)); let json = request.to_string(); let encoded = STANDARD.encode(json.as_bytes()); let mut args: Vec = extra_args.iter().map(|s| (*s).to_string()).collect(); + args.push("--operation".to_string()); + args.push(operation.clone()); + if operation != "provision" { + args.push("--sandbox-id".to_string()); + args.push(sandbox_id.expect("non-provision test request must contain sandboxId")); + } args.push("--config-base64".to_string()); args.push(encoded); diff --git a/src/testing/wxc_e2e_tests/tests/e2e_state_aware.rs b/src/testing/wxc_e2e_tests/tests/e2e_state_aware.rs index 833151dbe..d77108f47 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_state_aware.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_state_aware.rs @@ -12,7 +12,7 @@ use std::sync::OnceLock; use serde_json::{json, Value}; -use wxc_e2e_tests::{has_wxc_exe, run_wxc_state_aware, CommandResult}; +use wxc_e2e_tests::{has_wxc_exe, run_wxc_config_value, run_wxc_state_aware, CommandResult}; static HAS_WXC_EXE: OnceLock = OnceLock::new(); @@ -102,3 +102,51 @@ fn state_aware_provision_rejects_a_non_state_aware_containment_structurally() { ); assert_ne!(result.code, Some(0), "non-zero exit expected on error"); } + +#[test] +fn phase_bearing_json_without_operation_reports_migration_guidance() { + if !cached_has_wxc_exe() { + return; + } + + for (label, request) in [ + ( + "valid state-aware missing operation", + json!({ + "version": "0.9.0-alpha", + "phase": "start", + "sandboxId": "iso:abc" + }), + ), + ( + "invalid state-aware missing operation", + json!({ + "version": "0.9.0-alpha", + "phase": "start" + }), + ), + ] { + let result = run_wxc_config_value(label, &request, &[]); + + assert_ne!(result.code, Some(0), "{label}: non-zero exit expected"); + assert!( + result.stdout.is_empty(), + "{label}: stdout must remain empty: {:?}", + result.stdout + ); + assert!( + result + .stderr + .contains("state-aware lifecycle requests require --operation"), + "{label}: missing operation guidance: stderr={:?}", + result.stderr + ); + assert!( + result + .stderr + .contains("remove 'phase' and 'sandboxId' from the config JSON"), + "{label}: missing JSON migration guidance: stderr={:?}", + result.stderr + ); + } +} diff --git a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs index 11e8cb774..38f600a7e 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs @@ -16,7 +16,7 @@ use wxc_e2e_tests::{ assert_success_or_skip_missing_prerequisite, examples_dir, has_hyperlight_runtime, has_hyperlight_snapshot, has_nanvix_binaries, has_test_driver, has_windows_sandbox_feature, has_wxc_exe, repo_root, run_test_driver, run_wxc_config, run_wxc_config_value, run_wxc_example, - run_wxc_state_aware, test_configs_dir, TempDirs, + test_configs_dir, TempDirs, }; static HAS_WXC_EXE: OnceLock = OnceLock::new(); @@ -1103,7 +1103,7 @@ fn hyperlight_suite() { "filesystem": { "readwritePaths": [mount_dir.to_string_lossy()] } }); - let result = run_wxc_state_aware("hyperlight-fs", &config, &["--debug", "--experimental"]); + let result = run_wxc_config_value("hyperlight-fs", &config, &["--debug", "--experimental"]); if result.code != Some(0) { failures.push(format!( @@ -1171,7 +1171,7 @@ fn hyperlight_suite() { } }); - let result = run_wxc_state_aware( + let result = run_wxc_config_value( "hyperlight-fs-readonly", &config, &["--debug", "--experimental"],