diff --git a/docs/development/architecture/container-lifecycle.md b/docs/development/architecture/container-lifecycle.md index 76344f44a..a789aa95c 100644 --- a/docs/development/architecture/container-lifecycle.md +++ b/docs/development/architecture/container-lifecycle.md @@ -1060,7 +1060,12 @@ telemetry is populated from the common top-level `telemetry` field. `operation: StateAwareOperation` fields with read-only accessors. The operation is the sole phase authority. Provision carries `IsolationSession(Option)`, `WindowsSandbox`, -or `Wslc(Option)`; later variants each carry a required +or `Wslc(Option)`. `ProvisionSession` carries +`Option` and is WSLc-only; `DeprovisionSession` +carries a required `session_id`, reachable through `session_id()` rather than +`sandbox_id()`. Because routing never reads a session id, operation validation +rejects one that is not `wslcsess:` followed by 32 lowercase hex digits, the form +the WSLc daemon mints. The sandbox-addressed variants each carry a required `sandbox_id`, with no backend-specific configuration today. Exec process settings stay in the common request. @@ -1075,10 +1080,14 @@ the parser and schema generation. ### 9.2 The trait Backends implement the trait with two consts (id prefix and backend key, §5), -associated types for each phase's config and metadata, and method overrides where they -have substantive work. +associated types for each sandbox phase's config and metadata, and method overrides +where they have substantive work. Most methods have default no-op bodies; only `exec` is strictly required. Use `()` for -any associated type the backend does not need. +any associated type the backend does not need. The two session phases are the +exception: they take the concrete `WslcProvisionSessionConfig`, return no backend +metadata, and their defaults refuse with `unsupported_phase`, so a backend that +owns no session needs no code to say so, and a dry run of a session phase +cannot report success on a backend that cannot perform it. ```rust pub trait StatefulSandboxBackend { @@ -1103,6 +1112,19 @@ pub trait StatefulSandboxBackend { type StopMetadata: serde::Serialize; type DeprovisionMetadata: serde::Serialize; + /// Optional. Default refuses: a backend that owns no session cannot mint + /// one. Override when the backend hosts sandboxes inside a session. + fn provision_session( + &mut self, + _request: &ExecutionRequest, + _config: Option, + ) -> Result { + Err(unsupported_session_operation( + Self::BACKEND_KEY, + "provisioning a session", + )) + } + /// Optional. Default mints `:` for a stateless- /// underneath backend; override when the backend has native provision work /// (e.g., allocating a session, registering with the underlying service). @@ -1180,9 +1202,22 @@ pub trait StatefulSandboxBackend { Ok(DeprovisionResult { metadata: None }) } + /// Optional. Default refuses, for the same reason as `provision_session`. + fn deprovision_session( + &mut self, + _session_id: &str, + _request: &ExecutionRequest, + ) -> Result<(), MxcError> { + Err(unsupported_session_operation( + Self::BACKEND_KEY, + "deprovisioning a session", + )) + } + /// Per-phase validation hooks. Called by the dispatch layer before the - /// corresponding phase method. Default: accept all requests. Override to - /// add backend-specific checks (config field semantics, policy honor + /// corresponding phase method. Default: accept all requests, except for the + /// session phases, which refuse exactly as their phase method does. Override + /// to add backend-specific checks (config field semantics, policy honor /// enforcement, id format checks beyond the prefix). Failures surface as /// the chosen `MxcError` code. fn validate_provision( @@ -1193,6 +1228,17 @@ pub trait StatefulSandboxBackend { Ok(()) } + fn validate_provision_session( + &self, + _request: &ExecutionRequest, + _config: Option<&WslcProvisionSessionConfig>, + ) -> Result<(), MxcError> { + Err(unsupported_session_operation( + Self::BACKEND_KEY, + "provisioning a session", + )) + } + fn validate_start( &self, _sandbox_id: &str, @@ -1228,6 +1274,17 @@ pub trait StatefulSandboxBackend { ) -> Result<(), MxcError> { Ok(()) } + + fn validate_deprovision_session( + &self, + _session_id: &str, + _request: &ExecutionRequest, + ) -> Result<(), MxcError> { + Err(unsupported_session_operation( + Self::BACKEND_KEY, + "deprovisioning a session", + )) + } } pub struct ProvisionResult { @@ -1235,6 +1292,10 @@ pub struct ProvisionResult { pub metadata: Option, } +pub struct ProvisionSessionResult { + pub session_id: String, +} + pub struct StartResult { pub metadata: Option, } @@ -1378,11 +1439,13 @@ surface introduces none, so the trait stays minimal and reuses `ExecutionRequest /// Typed backend result before raw response-envelope encoding. enum TypedDispatchOutcome { DryRun, + ProvisionSession(ProvisionSessionResult), Provision(ProvisionResult

), Start(StartResult), ExecCompleted { exit_code: i32 }, Stop(StopResult), Deprovision(DeprovisionResult), + DeprovisionSession, } /// Dispatch outcome. Distinguishes structured-envelope responses (non-exec phases or @@ -1417,6 +1480,12 @@ fn dispatch_state_aware( ) -> Result { let (request, operation) = bound.into_parts(); match operation { + BoundStateAwareOperation::ProvisionSession(config) => { + backend.validate_provision_session(&request, config.as_ref())?; + if dry_run { return Ok(DispatchOutcome::Envelope(empty_envelope())); } + let result = backend.provision_session(&request, config)?; + Ok(DispatchOutcome::Envelope(provision_session_envelope(result))) + } BoundStateAwareOperation::Provision(config) => { backend.validate_provision(&request, config.as_ref())?; if dry_run { return Ok(DispatchOutcome::Envelope(empty_envelope())); } @@ -1451,6 +1520,12 @@ fn dispatch_state_aware( let result = backend.deprovision(&sandbox_id, &request, config)?; Ok(DispatchOutcome::Envelope(deprovision_envelope(result))) } + BoundStateAwareOperation::DeprovisionSession { session_id } => { + backend.validate_deprovision_session(&session_id, &request)?; + if dry_run { return Ok(DispatchOutcome::Envelope(empty_envelope())); } + backend.deprovision_session(&session_id, &request)?; + Ok(DispatchOutcome::Envelope(empty_envelope())) + } } } ``` @@ -1463,10 +1538,14 @@ metadata. The path accepting "MXC request JSON" wraps the same typed dispatch re response construction is therefore confined to raw/executor entry points rather than being an implementation step of typed Rust calls. -`resolve_backend(&parsed)` reads `parsed.containment()` when `phase() == Provision`; for the -other phases it reads the prefix from `parsed.sandbox_id()` and looks it up in the -registered prefix table. Mismatches surface as `unsupported_containment` (unrecognised -prefix) or `malformed_id` (no prefix structure) per §8. +`resolve_backend(&parsed)` reads `parsed.containment()` for the phases where +`phase.routes_by_containment()` is true (`provision-session`, `provision`, and +`deprovision-session`); for the sandbox-addressed phases it reads the prefix from +`parsed.sandbox_id()` and looks it up in the registered prefix table. A session id +carries no backend prefix and is deliberately absent from that table, which is why both +session phases declare a containment even though one of them also carries an id. +Mismatches surface as `unsupported_containment` (unrecognised prefix) or `malformed_id` +(no prefix structure) per §8. `bind_isolation_session`, `bind_windows_sandbox`, and `bind_wslc` live in `mxc_common::state_aware_binding`, without backend crate dependencies. The engine @@ -1677,11 +1756,17 @@ provides default no-op bodies otherwise. The default `provision` mints a synthet (allocating a session, registering with the underlying service) override and produce their own structured id. +`provision_session` and `deprovision_session` are the exception: their defaults refuse +with `unsupported_phase`, and they take a concrete WSLc session configuration rather +than an associated type, because only WSLc owns a session a sandbox can be placed into. +A backend that owns no session needs no code at all to decline them. + Override `validate_` hooks for backend-specific pre-execution checks (config field semantics, policy honor enforcement, id format verification beyond prefix -matching). Defaults are no-ops; only override the phases the backend has checks for. -Validation runs before the phase method; failures short-circuit and surface as typed -`MxcError` codes without invoking the backend. +matching). Defaults are no-ops for the five sandbox phases and refusals for the two +session phases, matching their phase methods; only override the phases the backend has +checks for. Validation runs before the phase method; failures short-circuit and surface +as typed `MxcError` codes without invoking the backend. ### 11.3 Define typed `*Config` interfaces in the SDK diff --git a/docs/development/glossary.md b/docs/development/glossary.md index 8b964cb6e..dbd24cc05 100644 --- a/docs/development/glossary.md +++ b/docs/development/glossary.md @@ -9,7 +9,7 @@ Shared terms: [consumer glossary](../glossary.md). | Existing term | Preferred wording | Specific meaning or example | |---|---|---| -| State-aware / stateful API | Container lifecycle operations | Provision, start, exec, stop, deprovision; implemented by `StatefulSandboxBackend`. Use "manual lifecycle operations" when emphasizing caller-managed calls and cleanup. | +| State-aware / stateful API | Container lifecycle operations | Provision, start, exec, stop, deprovision, bracketed by session provision and deprovision; implemented by `StatefulSandboxBackend`. Use "manual lifecycle operations" when emphasizing caller-managed calls and cleanup. | | One-shot | Create-and-run execution | Make container, run workload, clean up. | | Exact contract / exact registered contract | Contract | Versioned request rules in `mxc_contract`; identify the version, operation, and provision backend. A schema describes the contract. | | Wire format / wire contract | Interface-specific format / contract | Name the format: ["MXC request JSON"](../schema.md#mxc-request-json) or daemon binary frames. | diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index 39c399072..c4a349e16 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -15,7 +15,9 @@ use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::network_parser::{parse_network_policy, NetworkSections}; use crate::mxc_common::state_aware_input::StateAwareInput; use crate::mxc_common::state_aware_operation::{StateAwareOperation, StateAwareProvision}; -use crate::mxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; +use crate::mxc_common::state_aware_request::{ + MxcRequest, ParsedStateAwareRequest, Phase, TargetIdKind, +}; use crate::mxc_common::wire; use crate::mxc_contract::dev::{probe_phase, Phase as ContractPhase}; use crate::mxc_contract::{probe_version, supported_versions, ContractVersion, VersionProbeError}; @@ -662,7 +664,7 @@ pub fn load_mxc_request_from_json_with_options( result } -/// Parses a state-aware request whose operation and sandbox identity were +/// Parses a state-aware request whose operation and target identity were /// supplied by the executor command line. /// /// Raw exact JSON entry points continue to accept the registered phase-bearing @@ -687,24 +689,32 @@ pub fn load_state_aware_request_from_json_with_options( ))); } - match (phase, sandbox_id) { - (Phase::Provision, Some(_)) => { + // The CLI collapses whichever identity flag it was given into one + // argument, so the rejection below can name a flag the caller did not + // pass once more than one exists. + let required_flag = match phase.target_id_kind() { + TargetIdKind::None => None, + TargetIdKind::SandboxId => Some("--container-id"), + TargetIdKind::SessionId => Some("--session-id"), + }; + match (required_flag, sandbox_id) { + (None, Some(_)) => { return Err(ParseError::StateAware(MxcError::malformed_request( - "the provision operation does not accept --container-id", + format!("the {phase} operation does not accept --container-id"), ))); } - (Phase::Provision, None) => {} - (_, None) => { + (None, None) => {} + (Some(flag), None) => { return Err(ParseError::StateAware(MxcError::malformed_request( - format!("the {phase} operation requires --container-id"), + format!("the {phase} operation requires {flag}"), ))); } - (_, Some("")) => { + (Some(flag), Some("")) => { return Err(ParseError::StateAware(MxcError::malformed_request( - "--container-id must not be empty", + format!("{flag} must not be empty"), ))); } - (_, Some(_)) => {} + (Some(_), Some(_)) => {} } let source = @@ -714,7 +724,10 @@ pub fn load_state_aware_request_from_json_with_options( )) })?; let routed_json = source - .splice_lifecycle_routing(phase.as_str(), sandbox_id) + .splice_lifecycle_routing( + phase.as_str(), + phase.target_id_kind().wire_member().zip(sandbox_id), + ) .ok_or_else(|| { ParseError::StateAware(MxcError::malformed_request( "failed to apply lifecycle routing arguments", @@ -1635,6 +1648,8 @@ fn normalize_state_aware( NormalizationContext { phase: operation.phase(), containment: match &operation { + StateAwareOperation::ProvisionSession(_) + | StateAwareOperation::DeprovisionSession { .. } => Some(wire::Containment::Wslc), StateAwareOperation::Provision(provision) => Some(match provision { StateAwareProvision::IsolationSession(_) => wire::Containment::IsolationSession, StateAwareProvision::WindowsSandbox => wire::Containment::WindowsSandbox, @@ -1678,7 +1693,7 @@ fn normalize_state_aware_common( logger: &mut Logger, ) -> Result { let network_supplied = common.network.is_some(); - common.containment = if context.phase == Phase::Provision { + common.containment = if context.phase.routes_by_containment() { context.containment } else { context.sandbox_id.and_then(state_aware_containment_from_id) @@ -4578,6 +4593,108 @@ mod tests { assert!(start_error.message().contains("requires --container-id")); } + #[test] + fn operation_cli_transport_gives_session_phases_their_own_identity_category() { + let provision_session_error = load_state_aware_request_from_json_with_options( + r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, + &mut test_logger(), + Phase::ProvisionSession, + Some("wslcsess:abc"), + &[], + ) + .unwrap_err(); + assert!(provision_session_error + .message() + .contains("the provision-session operation does not accept --container-id")); + + let missing = load_state_aware_request_from_json_with_options( + r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, + &mut test_logger(), + Phase::DeprovisionSession, + None, + &[], + ) + .unwrap_err(); + assert!(missing + .message() + .contains("the deprovision-session operation requires --session-id")); + + let empty = load_state_aware_request_from_json_with_options( + r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, + &mut test_logger(), + Phase::DeprovisionSession, + Some(""), + &[], + ) + .unwrap_err(); + assert!(empty.message().contains("--session-id must not be empty")); + } + + #[test] + fn session_phases_pass_cli_routing_and_are_then_refused_by_every_registered_contract() { + for (phase, session_id) in [ + (Phase::ProvisionSession, None), + (Phase::DeprovisionSession, Some("wslcsess:abcd")), + ] { + let error = load_state_aware_request_from_json_with_options( + r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, + &mut test_logger(), + phase, + session_id, + &[], + ) + .unwrap_err(); + + assert_eq!( + error.message(), + "malformed_request: Unsupported phase", + "{phase} must reach the contract's phase probe, so the refusal comes from the \ + registered contracts rather than from the routing category" + ); + } + } + + #[test] + fn typed_session_operations_normalize_to_wslc_with_no_network_posture() { + let normalize = |operation| { + let input = crate::mxc_common::sdk_input::SdkStateAwareInput::new( + ContractVersion::V1_1_0Alpha, + operation, + ) + .unwrap(); + normalize_sdk_state_aware_request(input, &mut test_logger()).unwrap() + }; + + let provision = normalize(StateAwareOperation::Provision(StateAwareProvision::Wslc( + None, + ))); + assert!( + provision.request().policy.network_ingress.is_some(), + "a provision without a network section must get the default ingress posture" + ); + + for operation in [ + StateAwareOperation::ProvisionSession(None), + StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:0123456789abcdef0123456789abcdef".into(), + }, + ] { + let parsed = normalize(operation); + let phase = parsed.phase(); + let policy = &parsed.request().policy; + assert_eq!( + parsed.request().containment, + ContainmentBackend::Wslc, + "{phase}" + ); + assert!(!policy.network_specified, "{phase}"); + assert!( + policy.network_egress.is_none() && policy.network_ingress.is_none(), + "{phase} declares no network posture, so it must not inherit the provision default" + ); + } + } + #[test] fn operation_cli_transport_routes_all_errors_to_diagnostic_only() { for json in [ diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 4460de7ce..7921fe8ef 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -260,6 +260,21 @@ pub struct WslcProvisionConfig { pub port_mappings: Option>, } +/// Runtime-owned state-aware session provision config for the WSLc backend. +/// +/// Sizing belongs to the session, so these values describe the utility VM the +/// session runs in rather than a per-sandbox quota. Defaulting remains a +/// backend responsibility, so an absent field stays absent here. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct WslcProvisionSessionConfig { + pub cpu_count: Option, + + pub memory_mb: Option, + + /// Whether GPU passthrough is enabled. + pub gpu: Option, +} + /// Configuration specific to the LXC container backend. #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[serde(default)] diff --git a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs index 8d3d1dbd6..80ae4587a 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -79,6 +79,7 @@ use serde_json::{Map, Value}; use crate::mxc_common::hashing::sha256_hex; use crate::mxc_common::models::{ ExecutionRequest, IsolationSessionProvisionConfig, WslcProvisionConfig, + WslcProvisionSessionConfig, }; use crate::mxc_common::state_aware_operation::{StateAwareOperation, StateAwareProvision}; @@ -104,6 +105,11 @@ pub fn policy_hash(request: &ExecutionRequest) -> String { /// Only allow-listed provision fields enter the hash. An absent config remains /// JSON `null`, a present config with no fields remains `{}`, and explicit empty /// strings remain distinct, preserving identities for exact accepted requests. +/// +/// A session's identity covers the sizing it declares. A sandbox placed into an +/// existing session declares none, and this hash is computed from the request +/// before dispatch, so a sandbox's identity does not cover the sizing of the +/// session it joins. pub fn state_aware_policy_hash( request: &ExecutionRequest, backend: &str, @@ -171,7 +177,28 @@ fn state_aware_config_projection(operation: &StateAwareOperation) -> Value { | StateAwareProvision::WindowsSandbox | StateAwareProvision::Wslc(None), ) => Value::Null, - // Sandbox IDs are not policy and can contain account identities. + + StateAwareOperation::ProvisionSession(Some(WslcProvisionSessionConfig { + cpu_count, + memory_mb, + gpu, + })) => { + let mut config = Map::new(); + if let Some(cpu_count) = cpu_count { + config.insert("cpuCount".into(), Value::from(*cpu_count)); + } + if let Some(memory_mb) = memory_mb { + config.insert("memoryMb".into(), Value::from(*memory_mb)); + } + if let Some(gpu) = gpu { + config.insert("gpu".into(), Value::Bool(*gpu)); + } + Value::Object(config) + } + StateAwareOperation::ProvisionSession(None) => Value::Null, + + // Sandbox and session IDs are not policy and can contain account + // identities. StateAwareOperation::Start { sandbox_id: _excluded_sandbox_id, } @@ -184,6 +211,9 @@ fn state_aware_config_projection(operation: &StateAwareOperation) -> Value { | StateAwareOperation::Deprovision { sandbox_id: _excluded_sandbox_id, } => Value::Null, + StateAwareOperation::DeprovisionSession { + session_id: _excluded_session_id, + } => Value::Null, } } @@ -879,6 +909,84 @@ mod tests { assert_ne!(policy_hash(&changed), policy_hash(&more)); } + #[test] + fn state_aware_session_hash_preserves_exact_sizing_shape() { + let request = request(); + let sizing = |cpu_count, memory_mb, gpu| { + Some(WslcProvisionSessionConfig { + cpu_count, + memory_mb, + gpu, + }) + }; + let mut hashes = std::collections::HashSet::new(); + for (config, expected_config) in [ + (None, Value::Null), + (sizing(None, None, None), serde_json::json!({})), + ( + sizing(Some(4), None, None), + serde_json::json!({"cpuCount": 4}), + ), + ( + sizing(Some(8), None, None), + serde_json::json!({"cpuCount": 8}), + ), + ( + sizing(None, Some(4), None), + serde_json::json!({"memoryMb": 4}), + ), + ( + sizing(None, Some(4096), None), + serde_json::json!({"memoryMb": 4096}), + ), + ( + sizing(None, None, Some(false)), + serde_json::json!({"gpu": false}), + ), + ( + sizing(None, None, Some(true)), + serde_json::json!({"gpu": true}), + ), + ( + sizing(Some(4), Some(4), Some(true)), + serde_json::json!({"cpuCount": 4, "memoryMb": 4, "gpu": true}), + ), + ] { + let operation = StateAwareOperation::ProvisionSession(config); + assert_eq!( + state_aware_config_projection(&operation), + expected_config, + "{operation:?}" + ); + let hash = state_aware_policy_hash(&request, "wslc", &operation); + assert_eq!( + hash, + expected_state_aware_hash(&request, "wslc", "provision-session", expected_config), + "{operation:?}" + ); + assert!( + hashes.insert(hash), + "absent, empty and supplied sizing fields must stay distinct, even when two \ + fields carry the same value: {operation:?}" + ); + } + } + + #[test] + fn state_aware_session_hash_excludes_the_session_id() { + let request = request(); + for session_id in ["wslcsess:aaaa", "wslcsess:bbbb"] { + let operation = StateAwareOperation::DeprovisionSession { + session_id: session_id.into(), + }; + assert_eq!( + state_aware_policy_hash(&request, "wslc", &operation), + expected_state_aware_hash(&request, "wslc", "deprovision-session", Value::Null), + "the session id must not enter the policy identity" + ); + } + } + fn parse_state_aware( json: &str, ) -> crate::mxc_common::state_aware_request::ParsedStateAwareRequest { @@ -1081,38 +1189,48 @@ mod tests { let mut hashes = std::collections::HashSet::new(); for (operation, expected_phase) in [ ( - StateAwareOperation::Provision(StateAwareProvision::WindowsSandbox), + StateAwareOperation::ProvisionSession(None), + "provision-session", + ), + ( + StateAwareOperation::Provision(StateAwareProvision::Wslc(None)), "provision", ), ( StateAwareOperation::Start { - sandbox_id: "wsb:deadbeef".into(), + sandbox_id: "wslc:deadbeef".into(), }, "start", ), ( StateAwareOperation::Exec { - sandbox_id: "wsb:deadbeef".into(), + sandbox_id: "wslc:deadbeef".into(), }, "exec", ), ( StateAwareOperation::Stop { - sandbox_id: "wsb:deadbeef".into(), + sandbox_id: "wslc:deadbeef".into(), }, "stop", ), ( StateAwareOperation::Deprovision { - sandbox_id: "wsb:deadbeef".into(), + sandbox_id: "wslc:deadbeef".into(), }, "deprovision", ), + ( + StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:deadbeef".into(), + }, + "deprovision-session", + ), ] { - let hash = state_aware_policy_hash(&request, "windows_sandbox", &operation); + let hash = state_aware_policy_hash(&request, "wslc", &operation); assert_eq!( hash, - expected_state_aware_hash(&request, "windows_sandbox", expected_phase, Value::Null) + expected_state_aware_hash(&request, "wslc", expected_phase, Value::Null) ); assert!(hashes.insert(hash), "phase must affect the policy hash"); } diff --git a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs index bcd15c725..38005f780 100644 --- a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs +++ b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs @@ -138,6 +138,13 @@ impl SdkStateAwareInput { version.as_str() ))); } + if session_operation(&operation) && version != ContractVersion::V1_1_0Alpha { + return Err(WxcError::ConfigParse(format!( + "state-aware session operations require schema version \ + 1.1.0-alpha, got {}", + version.as_str() + ))); + } operation.validate()?; Ok(Self { version, @@ -203,6 +210,13 @@ fn wslc_port_mappings_requested(operation: &StateAwareOperation) -> bool { ) } +fn session_operation(operation: &StateAwareOperation) -> bool { + matches!( + operation, + StateAwareOperation::ProvisionSession(_) | StateAwareOperation::DeprovisionSession { .. } + ) +} + fn map_network(network: SdkNetworkInput) -> wire::Network { wire::Network { egress: network.egress.map(|egress| wire::NetworkEgress { diff --git a/src/mxc-sdk/src/core/mxc_common/splice.rs b/src/mxc-sdk/src/core/mxc_common/splice.rs index 763e0fc24..88c46f91f 100644 --- a/src/mxc-sdk/src/core/mxc_common/splice.rs +++ b/src/mxc-sdk/src/core/mxc_common/splice.rs @@ -164,20 +164,20 @@ impl CommandSource<'_> { pub(crate) fn splice_lifecycle_routing( &self, phase: &str, - sandbox_id: Option<&str>, + target_id: Option<(&str, &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 { + let Some((member, id)) = target_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 id = serde_json::to_string(id).ok()?; + let id_member = format!(r#""{member}":{id}"#); let root: RawObject<'_> = serde_json::from_str(&with_phase).ok()?; - insert_member(&with_phase, &root, &sandbox_id_member) + insert_member(&with_phase, &root, &id_member) } pub(crate) fn splice_command(&self, command: &str) -> Option { @@ -245,7 +245,7 @@ mod tests { 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")) + .splice_lifecycle_routing("start", Some(("sandboxId", "iso:abcd"))) .unwrap(); assert_eq!( @@ -265,13 +265,30 @@ mod tests { ) .unwrap(); let spliced = source - .splice_lifecycle_routing("start", Some("iso:new")) + .splice_lifecycle_routing("start", Some(("sandboxId", "iso:new"))) .unwrap(); assert_eq!(spliced.matches(r#""phase""#).count(), 2); assert_eq!(spliced.matches(r#""sandboxId""#).count(), 2); } + #[test] + fn lifecycle_routing_carries_a_session_id_in_its_own_member() { + let source = CommandSource::parse(r#"{"version":"1.1.0-alpha"}"#).unwrap(); + let spliced = source + .splice_lifecycle_routing("deprovision-session", Some(("sessionId", "wslcsess:abcd"))) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&spliced).unwrap(), + serde_json::json!({ + "version": "1.1.0-alpha", + "phase": "deprovision-session", + "sessionId": "wslcsess:abcd" + }) + ); + } + #[test] fn one_shot_backend_agrees_with_the_parser_for_every_spelling() { for spelling in [ diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_backend.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_backend.rs index fef796aaa..212e238aa 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_backend.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_backend.rs @@ -5,24 +5,28 @@ //! //! Backends opt into state-aware dispatch by implementing //! `StatefulSandboxBackend` alongside (or instead of) `ScriptRunner`. The trait -//! exposes the five lifecycle phases — provision, start, exec, stop, -//! deprovision — plus per-phase validation hooks. +//! exposes the five sandbox lifecycle phases — provision, start, exec, stop, +//! deprovision — plus the two session phases that bracket them, plus per-phase +//! validation hooks. //! //! Methods take `&ExecutionRequest` (the same one-shot domain model used by every -//! existing backend) plus the typed `sandbox_id` for non-provision phases and -//! an optional backend-specific config object. Cross-cutting policy fields +//! existing backend) plus the typed `sandbox_id` for sandbox-addressed phases, +//! the `session_id` for `deprovision_session`, and an optional +//! backend-specific config object where the phase defines one. Cross-cutting +//! policy fields //! flow through `request.policy`; per-exec process info flows through //! `request.script_code` / `working_directory` / `script_timeout` / `env`. //! //! Most phase methods have default no-op bodies — only `exec` is strictly //! required. The default `provision` body mints `:` for //! stateless-underneath backends; backends with native session work override -//! it. +//! it. The two session phases are the exception: their defaults refuse, so a +//! backend that owns no session needs no code to say so. use serde::Serialize; use crate::mxc_common::id::mint_random_token; -use crate::mxc_common::models::ExecutionRequest; +use crate::mxc_common::models::{ExecutionRequest, WslcProvisionSessionConfig}; use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::sandbox_process::{PtySize, SandboxProcess}; @@ -55,6 +59,13 @@ pub struct ProvisionResult { pub metadata: Option, } +/// Session-provision-phase result. Carries the freshly-minted `session_id` +/// that later provisions name to place their sandboxes in this session. +#[derive(Debug)] +pub struct ProvisionSessionResult { + pub session_id: String, +} + /// Start-phase result. Backends with no useful metadata return `None`. #[derive(Debug)] pub struct StartResult { @@ -215,13 +226,27 @@ impl std::fmt::Debug for ExecHandle { } } +/// The error a backend returns when it is asked for a session operation it +/// does not implement. +/// +/// Shared so the refusal reads identically whichever backend raises it, and so +/// a caller can tell "this backend has no sessions" apart from "your request +/// was malformed". +fn unsupported_session_operation(backend: &str, operation: &str) -> MxcError { + MxcError::unsupported_phase(format!( + "the {backend} backend does not support {operation}: it owns no session that sandboxes \ + are placed into" + )) +} + /// State-aware backend trait. Backends declare their `ID_PREFIX` and the /// per-phase typed config / metadata associated types, then override only /// the phase methods where they have substantive work. pub trait StatefulSandboxBackend { /// Backend identifier prefix. Forms the leading `:` segment of every /// `sandbox_id` minted by the default `provision` body, and is the routing - /// key the dispatcher uses to resolve non-provision calls to this backend. + /// key the dispatcher uses to resolve sandbox-addressed calls to this + /// backend. const ID_PREFIX: &'static str; /// Wire-format `containment` value for this backend, matching the SDK's @@ -240,6 +265,19 @@ pub trait StatefulSandboxBackend { type StopMetadata: Serialize; type DeprovisionMetadata: Serialize; + /// Optional. Default refuses: a backend that owns no session cannot mint + /// one. Override when the backend hosts sandboxes inside a session. + fn provision_session( + &mut self, + _request: &ExecutionRequest, + _config: Option, + ) -> Result { + Err(unsupported_session_operation( + Self::BACKEND_KEY, + "provisioning a session", + )) + } + /// Optional. Default mints `:` and returns no /// metadata. Override when the backend has native provision work. fn provision( @@ -338,10 +376,27 @@ pub trait StatefulSandboxBackend { Ok(DeprovisionResult { metadata: None }) } + /// Optional. Default refuses, for the same reason as `provision_session`. + fn deprovision_session( + &mut self, + _session_id: &str, + _request: &ExecutionRequest, + ) -> Result<(), MxcError> { + Err(unsupported_session_operation( + Self::BACKEND_KEY, + "deprovisioning a session", + )) + } + /// Per-phase validation hooks. The dispatcher calls these before the - /// corresponding phase method. Default: accept all requests. Override to - /// add backend-specific checks (config field semantics, policy honor - /// enforcement, id format checks beyond the prefix). + /// corresponding phase method. Default: accept all requests, except for the + /// session phases, which refuse exactly as their phase method does. + /// + /// A permissive default here would let a dry run of an unsupported session + /// operation report success on a backend that cannot perform it. + /// + /// Override to add backend-specific checks (config field semantics, policy + /// honor enforcement, id format checks beyond the prefix). fn validate_provision( &self, _request: &ExecutionRequest, @@ -350,6 +405,17 @@ pub trait StatefulSandboxBackend { Ok(()) } + fn validate_provision_session( + &self, + _request: &ExecutionRequest, + _config: Option<&WslcProvisionSessionConfig>, + ) -> Result<(), MxcError> { + Err(unsupported_session_operation( + Self::BACKEND_KEY, + "provisioning a session", + )) + } + fn validate_start( &self, _sandbox_id: &str, @@ -385,6 +451,17 @@ pub trait StatefulSandboxBackend { ) -> Result<(), MxcError> { Ok(()) } + + fn validate_deprovision_session( + &self, + _session_id: &str, + _request: &ExecutionRequest, + ) -> Result<(), MxcError> { + Err(unsupported_session_operation( + Self::BACKEND_KEY, + "deprovisioning a session", + )) + } } #[cfg(test)] @@ -402,6 +479,7 @@ mod tests { const ID_PREFIX: &'static str = "stub"; const BACKEND_KEY: &'static str = "stub_backend"; type ProvisionConfig = (); + type StartConfig = (); type ExecConfig = (); type StopConfig = (); @@ -491,6 +569,32 @@ mod tests { b.validate_deprovision("stub:abcd1234", &req, None).unwrap(); } + #[test] + fn default_session_phases_refuse_as_unsupported() { + let mut b = StubBackend; + let req = ExecutionRequest::default(); + for error in [ + b.provision_session(&req, None).unwrap_err(), + b.deprovision_session("wslcsess:abcd", &req).unwrap_err(), + ] { + assert_eq!(error.code, MxcErrorCode::UnsupportedPhase); + assert!(error.message.contains("stub_backend"), "{error:?}"); + } + } + + #[test] + fn default_session_validation_refuses_so_a_dry_run_cannot_report_success() { + let b = StubBackend; + let req = ExecutionRequest::default(); + for error in [ + b.validate_provision_session(&req, None).unwrap_err(), + b.validate_deprovision_session("wslcsess:abcd", &req) + .unwrap_err(), + ] { + assert_eq!(error.code, MxcErrorCode::UnsupportedPhase); + } + } + #[test] fn required_exec_returns_error_on_stub() { // Confirms `exec` is wired and reachable; the stub returns a typed diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_binding.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_binding.rs index cade61d9d..4b9c56f1f 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_binding.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_binding.rs @@ -8,6 +8,7 @@ use crate::mxc_common::models::{ ContainmentBackend, ExecutionRequest, IsolationSessionProvisionConfig, WslcProvisionConfig, + WslcProvisionSessionConfig, }; use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::state_aware_backend::StatefulSandboxBackend; @@ -17,6 +18,7 @@ use crate::mxc_common::state_aware_request::{ParsedStateAwareRequest, Phase}; /// Backend-typed operation. Construction is restricted to checked binding. pub(crate) enum BoundStateAwareOperation { + ProvisionSession(Option), Provision(Option), Start { sandbox_id: String, @@ -34,6 +36,9 @@ pub(crate) enum BoundStateAwareOperation { sandbox_id: String, config: Option, }, + DeprovisionSession { + session_id: String, + }, } /// A common runtime request paired with configuration for exactly one backend. @@ -51,6 +56,7 @@ impl std::fmt::Debug for BoundStateAwareRequest { .field("backend", &B::BACKEND_KEY) .field("phase", &self.phase()) .field("sandbox_id", &self.sandbox_id()) + .field("session_id", &self.session_id()) .finish_non_exhaustive() } } @@ -59,11 +65,13 @@ impl BoundStateAwareRequest { /// The operation's phase. pub fn phase(&self) -> Phase { match self.operation { + BoundStateAwareOperation::ProvisionSession(_) => Phase::ProvisionSession, BoundStateAwareOperation::Provision(_) => Phase::Provision, BoundStateAwareOperation::Start { .. } => Phase::Start, BoundStateAwareOperation::Exec { .. } => Phase::Exec, BoundStateAwareOperation::Stop { .. } => Phase::Stop, BoundStateAwareOperation::Deprovision { .. } => Phase::Deprovision, + BoundStateAwareOperation::DeprovisionSession { .. } => Phase::DeprovisionSession, } } @@ -72,10 +80,12 @@ impl BoundStateAwareRequest { &self.request } - /// The ID of a non-provision operation. + /// The ID of a sandbox-addressed operation. pub fn sandbox_id(&self) -> Option<&str> { match &self.operation { - BoundStateAwareOperation::Provision(_) => None, + BoundStateAwareOperation::ProvisionSession(_) + | BoundStateAwareOperation::Provision(_) + | BoundStateAwareOperation::DeprovisionSession { .. } => None, BoundStateAwareOperation::Start { sandbox_id, .. } | BoundStateAwareOperation::Exec { sandbox_id, .. } | BoundStateAwareOperation::Stop { sandbox_id, .. } @@ -83,6 +93,19 @@ impl BoundStateAwareRequest { } } + /// The ID of a session-addressed operation. + pub fn session_id(&self) -> Option<&str> { + match &self.operation { + BoundStateAwareOperation::DeprovisionSession { session_id } => Some(session_id), + BoundStateAwareOperation::ProvisionSession(_) + | BoundStateAwareOperation::Provision(_) + | BoundStateAwareOperation::Start { .. } + | BoundStateAwareOperation::Exec { .. } + | BoundStateAwareOperation::Stop { .. } + | BoundStateAwareOperation::Deprovision { .. } => None, + } + } + pub(crate) fn into_parts(self) -> (ExecutionRequest, BoundStateAwareOperation) { (self.request, self.operation) } @@ -118,6 +141,9 @@ where } let (request, operation) = parsed.into_parts(); let operation = match operation { + StateAwareOperation::ProvisionSession(config) => { + BoundStateAwareOperation::ProvisionSession(config) + } StateAwareOperation::Provision(config) => { BoundStateAwareOperation::Provision(provision(config)?) } @@ -139,6 +165,9 @@ where config: None, } } + StateAwareOperation::DeprovisionSession { session_id } => { + BoundStateAwareOperation::DeprovisionSession { session_id } + } }; Ok(Self { request, operation }) } diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs index f910211e5..026508fb8 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs @@ -24,6 +24,7 @@ enum Config { Unit, Isolation(Option), Wslc(Option, Option, Option>), + WslcSession(Option, Option, Option), } trait Case: Sized { @@ -181,6 +182,34 @@ impl StatefulSandboxBackend for Recording { None, ) } + fn validate_provision_session( + &self, + request: &ExecutionRequest, + config: Option<&WslcProvisionSessionConfig>, + ) -> Result<(), MxcError> { + self.observe( + true, + Phase::ProvisionSession, + None, + request, + observe_session(config), + None, + ) + } + fn validate_deprovision_session( + &self, + session_id: &str, + request: &ExecutionRequest, + ) -> Result<(), MxcError> { + self.observe( + true, + Phase::DeprovisionSession, + Some(session_id), + request, + Config::Absent, + None, + ) + } fn validate_start( &self, id: &str, @@ -238,6 +267,39 @@ impl StatefulSandboxBackend for Recording { metadata: None, }) } + fn provision_session( + &mut self, + request: &ExecutionRequest, + config: Option, + ) -> Result { + self.observe( + false, + Phase::ProvisionSession, + None, + request, + observe_session(config.as_ref()), + None, + )?; + Ok( + crate::mxc_common::state_aware_backend::ProvisionSessionResult { + session_id: "wslcsess:created".to_string(), + }, + ) + } + fn deprovision_session( + &mut self, + session_id: &str, + request: &ExecutionRequest, + ) -> Result<(), MxcError> { + self.observe( + false, + Phase::DeprovisionSession, + Some(session_id), + request, + Config::Absent, + None, + ) + } fn start( &mut self, id: &str, @@ -316,6 +378,12 @@ fn unit(config: Option<&()>) -> Config { config.map_or(Config::Absent, |_| Config::Unit) } +fn observe_session(config: Option<&WslcProvisionSessionConfig>) -> Config { + config.map_or(Config::Absent, |config| { + Config::WslcSession(config.cpu_count, config.memory_mb, config.gpu) + }) +} + fn input(phase: Phase) -> Value { let mut value = json!({ "version": if C::BACKEND == "isolation_session" { @@ -357,14 +425,31 @@ fn parse(input: &Value) -> ParsedStateAwareRequest { } fn assert_dispatch(input: &Value, expected_config: Config) { + assert_dispatch_parsed::(|| parse(input), expected_config); +} + +/// Drive one request through the validation / execution / dry-run matrix. +/// +/// Takes a builder rather than a JSON value because session phases have no +/// JSON form to parse. +fn assert_dispatch_parsed( + build: impl Fn() -> ParsedStateAwareRequest, + expected_config: Config, +) { for (dry_run, reject_validation) in [(false, false), (true, false), (false, true)] { - let parsed = parse(input); + let parsed = build(); let phase = parsed.phase(); - let id = parsed.sandbox_id().map(str::to_owned); + let id = parsed + .sandbox_id() + .or_else(|| parsed.session_id()) + .map(str::to_owned); let common = common_snapshot(parsed.request()); let bound = C::bind(parsed).unwrap(); assert_eq!(bound.phase(), phase); - assert_eq!(bound.sandbox_id(), id.as_deref()); + assert_eq!( + bound.sandbox_id().or_else(|| bound.session_id()), + id.as_deref() + ); assert_eq!(common_snapshot(bound.request()), common); let mut backend = Recording::::new(reject_validation); let outcome = dispatch_state_aware(&mut backend, bound, dry_run); @@ -581,3 +666,85 @@ fn incompatible_backend_operations_never_bind_as_absent_configuration() { mismatch::(); mismatch::(); } + +fn session_parsed(operation: StateAwareOperation) -> ParsedStateAwareRequest { + ParsedStateAwareRequest::new(ExecutionRequest::default(), operation) +} + +#[test] +fn wslc_binding_carries_session_sizing_and_the_session_id_through() { + let sizing = WslcProvisionSessionConfig { + cpu_count: Some(4), + memory_mb: Some(8192), + gpu: Some(true), + }; + let bound = bind_wslc::>(session_parsed( + StateAwareOperation::ProvisionSession(Some(sizing)), + )) + .unwrap(); + assert_eq!(bound.phase(), Phase::ProvisionSession); + assert_eq!(bound.sandbox_id(), None); + let (_, operation) = bound.into_parts(); + let BoundStateAwareOperation::ProvisionSession(config) = operation else { + panic!("expected a bound session provision"); + }; + assert_eq!(config, Some(sizing)); + + let bound = + bind_wslc::>(session_parsed(StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:abcd".into(), + })) + .unwrap(); + assert_eq!(bound.phase(), Phase::DeprovisionSession); + assert_eq!(bound.sandbox_id(), None); + let (_, operation) = bound.into_parts(); + let BoundStateAwareOperation::DeprovisionSession { session_id } = operation else { + panic!("expected a bound session deprovision"); + }; + assert_eq!(session_id, "wslcsess:abcd"); +} + +#[test] +fn session_operations_never_bind_to_a_backend_without_sessions() { + for operation in [ + StateAwareOperation::ProvisionSession(None), + StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:abcd".into(), + }, + ] { + let error = + bind_isolation_session::>(session_parsed(operation.clone())) + .unwrap_err(); + assert_eq!(error.code, MxcErrorCode::MalformedRequest); + let error = bind_windows_sandbox::>(session_parsed(operation)) + .unwrap_err(); + assert_eq!(error.code, MxcErrorCode::MalformedRequest); + } +} + +#[test] +fn session_phases_keep_the_same_validation_execution_and_dry_run_order_as_every_other_phase() { + assert_dispatch_parsed::( + || session_parsed(StateAwareOperation::ProvisionSession(None)), + Config::Absent, + ); + + let sizing = WslcProvisionSessionConfig { + cpu_count: Some(4), + memory_mb: Some(8192), + gpu: Some(true), + }; + assert_dispatch_parsed::( + || session_parsed(StateAwareOperation::ProvisionSession(Some(sizing))), + Config::WslcSession(Some(4), Some(8192), Some(true)), + ); + + assert_dispatch_parsed::( + || { + session_parsed(StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:abcd".into(), + }) + }, + Config::Absent, + ); +} diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs index a1b7b0879..dcd4ea5ce 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs @@ -27,11 +27,11 @@ use crate::mxc_common::models::ContainmentBackend; use crate::mxc_common::mxc_error::{MxcError, ResponseEnvelope}; use crate::mxc_common::sandbox_process::{PtySize, SandboxProcess}; use crate::mxc_common::state_aware_backend::{ - DeprovisionResult, ExecHandle, ExecOutcome, ExecStdio, ProvisionResult, StartResult, - StatefulSandboxBackend, StopResult, + DeprovisionResult, ExecHandle, ExecOutcome, ExecStdio, ProvisionResult, ProvisionSessionResult, + StartResult, StatefulSandboxBackend, StopResult, }; use crate::mxc_common::state_aware_binding::{BoundStateAwareOperation, BoundStateAwareRequest}; -use crate::mxc_common::state_aware_request::{ParsedStateAwareRequest, Phase}; +use crate::mxc_common::state_aware_request::ParsedStateAwareRequest; use crate::mxc_common::validator::validate_exec_common; /// Outcome of dispatching one state-aware request. Distinguishes the two @@ -50,11 +50,13 @@ pub enum DispatchOutcome { #[derive(Debug)] pub enum TypedDispatchOutcome { DryRun, + ProvisionSession(ProvisionSessionResult), Provision(ProvisionResult), Start(StartResult), ExecCompleted { exit_code: i32 }, Stop(StopResult), Deprovision(DeprovisionResult), + DeprovisionSession, } pub type TypedBackendDispatchOutcome = TypedDispatchOutcome< @@ -140,6 +142,9 @@ pub fn dispatch_state_aware( ) -> Result { match dispatch_state_aware_typed(backend, bound, dry_run)? { TypedDispatchOutcome::DryRun => Ok(DispatchOutcome::Envelope(empty_result_envelope())), + TypedDispatchOutcome::ProvisionSession(result) => Ok(DispatchOutcome::Envelope( + provision_session_envelope(result)?, + )), TypedDispatchOutcome::Provision(result) => { Ok(DispatchOutcome::Envelope(provision_envelope(result)?)) } @@ -155,6 +160,9 @@ pub fn dispatch_state_aware( TypedDispatchOutcome::Deprovision(result) => { Ok(DispatchOutcome::Envelope(metadata_envelope(result)?)) } + TypedDispatchOutcome::DeprovisionSession => { + Ok(DispatchOutcome::Envelope(empty_result_envelope())) + } } } @@ -166,6 +174,14 @@ pub fn dispatch_state_aware_typed( ) -> Result, MxcError> { let (request, operation) = bound.into_parts(); match operation { + BoundStateAwareOperation::ProvisionSession(config) => { + backend.validate_provision_session(&request, config.as_ref())?; + if dry_run { + return Ok(TypedDispatchOutcome::DryRun); + } + let result = backend.provision_session(&request, config)?; + Ok(TypedDispatchOutcome::ProvisionSession(result)) + } BoundStateAwareOperation::Provision(config) => { backend.validate_provision(&request, config.as_ref())?; if dry_run { @@ -208,15 +224,24 @@ pub fn dispatch_state_aware_typed( let result = backend.deprovision(&sandbox_id, &request, config)?; Ok(TypedDispatchOutcome::Deprovision(result)) } + BoundStateAwareOperation::DeprovisionSession { session_id } => { + backend.validate_deprovision_session(&session_id, &request)?; + if dry_run { + return Ok(TypedDispatchOutcome::DryRun); + } + backend.deprovision_session(&session_id, &request)?; + Ok(TypedDispatchOutcome::DeprovisionSession) + } } } -/// Resolves the target backend: from `containment` for provision, from the -/// `sandbox_id` prefix for non-provision phases. +/// Resolves the target backend: from `containment` for the phases that declare +/// it, from the `sandbox_id` prefix for the rest. pub fn resolve_backend(parsed: &ParsedStateAwareRequest) -> Result { - if parsed.phase() == Phase::Provision { + let phase = parsed.phase(); + if phase.routes_by_containment() { return parsed.containment().ok_or_else(|| { - MxcError::malformed_request("provision phase requires a containment field") + MxcError::malformed_request(format!("{phase} phase requires a containment field")) }); } let sandbox_id = parsed.sandbox_id_required()?; @@ -226,6 +251,9 @@ pub fn resolve_backend(parsed: &ParsedStateAwareRequest) -> Result Result { match prefix { "iso" => Ok(ContainmentBackend::IsolationSession), @@ -567,6 +595,13 @@ struct MetadataWireBody { metadata: Option, } +/// `{ "result": { "sessionId": "..." } }` +#[derive(Serialize)] +struct ProvisionSessionWireBody { + #[serde(rename = "sessionId")] + session_id: String, +} + fn provision_envelope(r: ProvisionResult) -> Result { let body = ProvisionWireBody { sandbox_id: r.sandbox_id, @@ -588,6 +623,19 @@ fn metadata_envelope(r: R) -> Result { }) } +fn provision_session_envelope(r: ProvisionSessionResult) -> Result { + let body = ProvisionSessionWireBody { + session_id: r.session_id, + }; + let envelope = ResponseEnvelope::Result(body); + serde_json::to_value(&envelope).map_err(|e| { + MxcError::backend_error(format!( + "session provision envelope serialisation failed: {}", + e + )) + }) +} + fn empty_result_envelope() -> Value { serde_json::json!({"result": {}}) } @@ -622,10 +670,13 @@ impl HasMetadata for DeprovisionResult { mod tests { use super::*; use crate::mxc_common::models::ExecutionRequest; + use crate::mxc_common::models::WslcProvisionSessionConfig; use crate::mxc_common::mxc_error::MxcErrorCode; use crate::mxc_common::state_aware_operation::{StateAwareOperation, StateAwareProvision}; + use crate::mxc_common::state_aware_request::Phase; use serde_json::json; use std::cell::Cell; + use std::cell::RefCell; use std::time::Duration; /// Fully-configurable backend stub for dispatcher tests. @@ -676,6 +727,7 @@ mod tests { const ID_PREFIX: &'static str = "stubd"; const BACKEND_KEY: &'static str = "stub_dispatch"; type ProvisionConfig = (); + type StartConfig = (); type ExecConfig = (); type StopConfig = (); @@ -814,6 +866,7 @@ mod tests { const ID_PREFIX: &'static str = "typed"; const BACKEND_KEY: &'static str = "typed_stub"; type ProvisionConfig = (); + type StartConfig = TypedStartConfig; type ExecConfig = (); type StopConfig = (); @@ -844,8 +897,119 @@ mod tests { } } + /// Backend stub that implements the session phases, so dispatch ordering + /// can be observed on a backend that supports them. + struct SessionStubBackend { + provision_session_calls: Cell, + deprovision_session_calls: Cell, + validate_provision_session_calls: Cell, + validate_deprovision_session_calls: Cell, + released: RefCell>, + validated_sizing: Cell>, + provisioned_sizing: Cell>, + } + + impl SessionStubBackend { + fn new() -> Self { + Self { + provision_session_calls: Cell::new(0), + deprovision_session_calls: Cell::new(0), + validate_provision_session_calls: Cell::new(0), + validate_deprovision_session_calls: Cell::new(0), + released: RefCell::new(Vec::new()), + validated_sizing: Cell::new(None), + provisioned_sizing: Cell::new(None), + } + } + } + + impl StatefulSandboxBackend for SessionStubBackend { + const ID_PREFIX: &'static str = "sess"; + const BACKEND_KEY: &'static str = "session_stub"; + type ProvisionConfig = (); + + type StartConfig = (); + type ExecConfig = (); + type StopConfig = (); + type DeprovisionConfig = (); + type ProvisionMetadata = (); + type StartMetadata = (); + type StopMetadata = (); + type DeprovisionMetadata = (); + + fn exec( + &mut self, + _sandbox_id: &str, + _request: &ExecutionRequest, + _config: Option<()>, + _stdio: ExecStdio, + ) -> Result { + Err(MxcError::backend_error("session stub exec not wired")) + } + + fn provision_session( + &mut self, + _request: &ExecutionRequest, + config: Option, + ) -> Result { + self.provision_session_calls + .set(self.provision_session_calls.get() + 1); + self.provisioned_sizing.set(config); + Ok(ProvisionSessionResult { + session_id: "wslcsess:fixed-token".to_string(), + }) + } + + fn deprovision_session( + &mut self, + session_id: &str, + _request: &ExecutionRequest, + ) -> Result<(), MxcError> { + self.deprovision_session_calls + .set(self.deprovision_session_calls.get() + 1); + self.released.borrow_mut().push(session_id.to_string()); + Ok(()) + } + + fn validate_provision_session( + &self, + _request: &ExecutionRequest, + config: Option<&WslcProvisionSessionConfig>, + ) -> Result<(), MxcError> { + self.validate_provision_session_calls + .set(self.validate_provision_session_calls.get() + 1); + self.validated_sizing.set(config.copied()); + Ok(()) + } + + fn validate_deprovision_session( + &self, + _session_id: &str, + _request: &ExecutionRequest, + ) -> Result<(), MxcError> { + self.validate_deprovision_session_calls + .set(self.validate_deprovision_session_calls.get() + 1); + Ok(()) + } + } + + fn session_bound( + phase: Phase, + session_id: Option<&str>, + ) -> BoundStateAwareRequest { + let operation = match phase { + Phase::ProvisionSession => BoundStateAwareOperation::ProvisionSession(None), + Phase::DeprovisionSession => BoundStateAwareOperation::DeprovisionSession { + session_id: session_id.unwrap().into(), + }, + other => panic!("session_bound does not cover {other}"), + }; + BoundStateAwareRequest::for_test(ExecutionRequest::default(), operation) + } + fn bound(phase: Phase, sandbox_id: Option<&str>) -> BoundStateAwareRequest { let operation = match phase { + Phase::ProvisionSession => BoundStateAwareOperation::ProvisionSession(None), Phase::Provision => BoundStateAwareOperation::Provision(None), Phase::Start => BoundStateAwareOperation::Start { sandbox_id: sandbox_id.unwrap().into(), @@ -863,6 +1027,9 @@ mod tests { sandbox_id: sandbox_id.unwrap().into(), config: None, }, + Phase::DeprovisionSession => BoundStateAwareOperation::DeprovisionSession { + session_id: sandbox_id.unwrap().into(), + }, }; BoundStateAwareRequest::for_test(ExecutionRequest::default(), operation) } @@ -895,6 +1062,122 @@ mod tests { assert_eq!(env, json!({"result": {"sandboxId": "stubd:fixed-token"}})); } + #[test] + fn dispatch_provision_session_returns_the_minted_session_id() { + let mut b = SessionStubBackend::new(); + let env = assert_envelope( + dispatch_state_aware(&mut b, session_bound(Phase::ProvisionSession, None), false) + .unwrap(), + ); + assert_eq!(b.validate_provision_session_calls.get(), 1); + assert_eq!(b.provision_session_calls.get(), 1); + assert_eq!( + env, + json!({"result": {"sessionId": "wslcsess:fixed-token"}}) + ); + } + + #[test] + fn dispatch_deprovision_session_releases_the_named_session() { + let mut b = SessionStubBackend::new(); + let env = assert_envelope( + dispatch_state_aware( + &mut b, + session_bound(Phase::DeprovisionSession, Some("wslcsess:abcd")), + false, + ) + .unwrap(), + ); + assert_eq!(b.validate_deprovision_session_calls.get(), 1); + assert_eq!(b.deprovision_session_calls.get(), 1); + assert_eq!(*b.released.borrow(), vec!["wslcsess:abcd".to_string()]); + assert_eq!(env, json!({"result": {}})); + } + + #[test] + fn dispatch_carries_session_sizing_to_both_the_hook_and_the_phase_method() { + let sizing = WslcProvisionSessionConfig { + cpu_count: Some(4), + memory_mb: Some(8192), + gpu: Some(true), + }; + let bound = BoundStateAwareRequest::::for_test( + ExecutionRequest::default(), + BoundStateAwareOperation::ProvisionSession(Some(sizing)), + ); + let mut b = SessionStubBackend::new(); + dispatch_state_aware(&mut b, bound, false).unwrap(); + assert_eq!(b.validated_sizing.get(), Some(sizing)); + assert_eq!(b.provisioned_sizing.get(), Some(sizing)); + } + + #[test] + fn a_session_dry_run_still_shows_the_hook_its_sizing() { + let sizing = WslcProvisionSessionConfig { + cpu_count: Some(2), + ..Default::default() + }; + let bound = BoundStateAwareRequest::::for_test( + ExecutionRequest::default(), + BoundStateAwareOperation::ProvisionSession(Some(sizing)), + ); + let mut b = SessionStubBackend::new(); + dispatch_state_aware(&mut b, bound, true).unwrap(); + assert_eq!(b.validated_sizing.get(), Some(sizing)); + assert_eq!(b.provisioned_sizing.get(), None); + assert_eq!(b.provision_session_calls.get(), 0); + } + + #[test] + fn dispatch_session_dry_run_validates_without_minting_or_releasing() { + let mut b = SessionStubBackend::new(); + for bound in [ + session_bound(Phase::ProvisionSession, None), + session_bound(Phase::DeprovisionSession, Some("wslcsess:abcd")), + ] { + assert_eq!( + assert_envelope(dispatch_state_aware(&mut b, bound, true).unwrap()), + json!({"result": {}}) + ); + } + assert_eq!(b.validate_provision_session_calls.get(), 1); + assert_eq!(b.validate_deprovision_session_calls.get(), 1); + assert_eq!(b.provision_session_calls.get(), 0); + assert_eq!(b.deprovision_session_calls.get(), 0); + assert!(b.released.borrow().is_empty()); + } + + #[test] + fn dispatch_session_phases_refuse_on_a_backend_without_sessions_even_in_dry_run() { + for dry_run in [false, true] { + let mut b = StubBackend::new(); + for bound in [ + bound(Phase::ProvisionSession, None), + bound(Phase::DeprovisionSession, Some("wslcsess:abcd")), + ] { + let error = dispatch_state_aware(&mut b, bound, dry_run).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::UnsupportedPhase, "{dry_run}"); + assert!(error.message.contains("stub_dispatch"), "{error:?}"); + } + } + } + + #[test] + fn a_session_phase_cannot_be_streamed_as_an_exec() { + let mut b = SessionStubBackend::new(); + for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] { + let bound = session_bound(phase, Some("wslcsess:abcd")); + let error = dispatch_state_aware_exec(&mut b, bound).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::MalformedRequest); + assert_eq!( + error.message, + format!("streaming exec requires the exec phase, got {phase}") + ); + } + assert_eq!(b.provision_session_calls.get(), 0); + assert_eq!(b.deprovision_session_calls.get(), 0); + } + #[test] fn dispatch_provision_dry_run_skips_provision_call_but_runs_validate() { let mut b = StubBackend::new(); @@ -1160,6 +1443,23 @@ mod tests { assert_eq!(err.code, MxcErrorCode::MalformedId); } + #[test] + fn resolve_backend_for_session_phases_uses_containment_not_the_id_prefix() { + for operation in [ + StateAwareOperation::ProvisionSession(None), + StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:deadbeef".into(), + }, + ] { + let parsed = ParsedStateAwareRequest::new(ExecutionRequest::default(), operation); + assert_eq!(resolve_backend(&parsed).unwrap(), ContainmentBackend::Wslc); + } + assert_eq!( + backend_from_prefix("wslcsess").unwrap_err().code, + MxcErrorCode::UnsupportedContainment + ); + } + // ===== stdio topology per entry point =================================== fn parsed_start(id: &str) -> ParsedStateAwareRequest { diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs index 863699fc4..5395dc8cc 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs @@ -4,11 +4,14 @@ //! Backend-neutral lifecycle operations produced by exact contract adaptation. //! //! The operation determines its phase and routing data. Provision retains its -//! backend even when no configuration was supplied. Later phases always carry -//! an ID, whose contents are validated at the existing dispatch boundary. +//! backend even when no configuration was supplied. Sandbox-addressed phases +//! always carry an ID, whose contents are validated at the existing dispatch +//! boundary. A session ID is checked by `validate` instead, because routing +//! never reads it. use crate::mxc_common::models::{ ContainmentBackend, IsolationSessionProvisionConfig, WslcProvisionConfig, + WslcProvisionSessionConfig, }; use crate::mxc_common::state_aware_request::Phase; @@ -39,30 +42,41 @@ impl StateAwareProvision { /// Execution process settings, policy, and telemetry belong to the common /// `ExecutionRequest`, not this payload. Empty backend phase sections do not /// manufacture backend configurations for non-provision operations. +/// +/// The two session operations are WSLc-only: no other backend owns a session +/// a sandbox can be placed into, so no other backend is expressible here. #[derive(Debug, Clone, PartialEq, Eq)] pub enum StateAwareOperation { + ProvisionSession(Option), Provision(StateAwareProvision), Start { sandbox_id: String }, Exec { sandbox_id: String }, Stop { sandbox_id: String }, Deprovision { sandbox_id: String }, + DeprovisionSession { session_id: String }, } impl StateAwareOperation { /// Phase derived from the operation, never an independent discriminator. pub fn phase(&self) -> Phase { match self { + Self::ProvisionSession(_) => Phase::ProvisionSession, Self::Provision(_) => Phase::Provision, Self::Start { .. } => Phase::Start, Self::Exec { .. } => Phase::Exec, Self::Stop { .. } => Phase::Stop, Self::Deprovision { .. } => Phase::Deprovision, + Self::DeprovisionSession { .. } => Phase::DeprovisionSession, } } - /// Declared containment for provision; later phases route by sandbox ID. + /// Declared containment for the phases that route by containment; the rest + /// route by sandbox ID. pub fn containment(&self) -> Option { match self { + Self::ProvisionSession(_) | Self::DeprovisionSession { .. } => { + Some(ContainmentBackend::Wslc) + } Self::Provision(provision) => Some(provision.containment()), Self::Start { .. } | Self::Exec { .. } @@ -71,10 +85,13 @@ impl StateAwareOperation { } } - /// Required ID for later phases; provision has not created an ID yet. + /// Required ID for sandbox-addressed phases; the rest create their own + /// identity or address a session. pub fn sandbox_id(&self) -> Option<&str> { match self { - Self::Provision(_) => None, + Self::ProvisionSession(_) | Self::Provision(_) | Self::DeprovisionSession { .. } => { + None + } Self::Start { sandbox_id } | Self::Exec { sandbox_id } | Self::Stop { sandbox_id } @@ -82,7 +99,21 @@ impl StateAwareOperation { } } - /// Reject backend configuration the selected backend cannot enforce. + /// Required ID for session-addressed phases. + pub fn session_id(&self) -> Option<&str> { + match self { + Self::DeprovisionSession { session_id } => Some(session_id), + Self::ProvisionSession(_) + | Self::Provision(_) + | Self::Start { .. } + | Self::Exec { .. } + | Self::Stop { .. } + | Self::Deprovision { .. } => None, + } + } + + /// Reject backend configuration the selected backend cannot enforce, and + /// session ids not in the form the WSLc daemon mints. /// /// Both the JSON and typed-SDK entry points call this, so neither surface /// can accept a payload the other rejects. @@ -97,7 +128,12 @@ impl StateAwareOperation { None => Ok(()), } } - Self::Provision(_) + Self::DeprovisionSession { session_id } => validate_session_id(session_id), + + // Session sizing carries no cross-backend constraint; the WSLc + // backend decides what it can honor. + Self::ProvisionSession(_) + | Self::Provision(_) | Self::Start { .. } | Self::Exec { .. } | Self::Stop { .. } @@ -106,6 +142,28 @@ impl StateAwareOperation { } } +const SESSION_ID_TAG: &str = "wslcsess:"; +const SESSION_ID_HEX_DIGITS: usize = 32; + +fn validate_session_id(session_id: &str) -> Result<(), crate::mxc_common::error::WxcError> { + let well_formed = session_id + .strip_prefix(SESSION_ID_TAG) + .is_some_and(|token| { + token.len() == SESSION_ID_HEX_DIGITS + && token + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }); + if well_formed { + Ok(()) + } else { + Err(crate::mxc_common::error::WxcError::ConfigParse(format!( + "a session id must be `{SESSION_ID_TAG}` followed by {SESSION_ID_HEX_DIGITS} \ + lowercase hex digits" + ))) + } +} + #[cfg(test)] mod tests { use super::*; @@ -162,9 +220,74 @@ mod tests { assert_eq!(operation.phase(), phase); assert_eq!(operation.containment(), None); assert_eq!(operation.sandbox_id(), Some(id)); + assert_eq!(operation.session_id(), None); } } + #[test] + fn session_operations_are_wslc_and_keep_their_id_off_the_sandbox_accessor() { + let provision = StateAwareOperation::ProvisionSession(Some(WslcProvisionSessionConfig { + cpu_count: Some(4), + memory_mb: Some(8192), + gpu: Some(true), + })); + assert_eq!(provision.phase(), Phase::ProvisionSession); + assert_eq!(provision.containment(), Some(ContainmentBackend::Wslc)); + assert_eq!(provision.sandbox_id(), None); + assert_eq!(provision.session_id(), None); + + let deprovision = StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:abcd".into(), + }; + assert_eq!(deprovision.phase(), Phase::DeprovisionSession); + assert_eq!(deprovision.containment(), Some(ContainmentBackend::Wslc)); + assert_eq!(deprovision.sandbox_id(), None); + assert_eq!(deprovision.session_id(), Some("wslcsess:abcd")); + } + + #[test] + fn a_session_id_must_have_the_form_the_daemon_mints() { + let release = |session_id: &str| { + StateAwareOperation::DeprovisionSession { + session_id: session_id.into(), + } + .validate() + }; + assert!(release("wslcsess:0123456789abcdef0123456789abcdef").is_ok()); + + for malformed in [ + "", + "wslcsess:", + "wslcsess:abcd", + "wslcsess:0123456789abcdef0123456789abcdef0", + "wslcsess:0123456789ABCDEF0123456789ABCDEF", + "wslcsess:0123456789abcdef0123456789abcdeg", + "wslc:0123456789abcdef0123456789abcdef", + "0123456789abcdef0123456789abcdef", + ] { + let error = release(malformed).expect_err(malformed).to_string(); + assert!( + error.contains("`wslcsess:` followed by 32 lowercase hex digits"), + "the refusal of {malformed:?} must name the expected form, got: {error}" + ); + } + } + + #[test] + fn session_sizing_presence_and_empty_values_are_distinct() { + assert_ne!( + StateAwareOperation::ProvisionSession(None), + StateAwareOperation::ProvisionSession(Some(WslcProvisionSessionConfig::default())) + ); + assert_ne!( + StateAwareOperation::ProvisionSession(Some(WslcProvisionSessionConfig::default())), + StateAwareOperation::ProvisionSession(Some(WslcProvisionSessionConfig { + gpu: Some(false), + ..WslcProvisionSessionConfig::default() + })) + ); + } + #[test] fn provision_presence_and_empty_values_are_distinct() { let absent = StateAwareProvision::IsolationSession(None); diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_request.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_request.rs index 4296b8159..646b18f58 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_request.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_request.rs @@ -7,25 +7,65 @@ use crate::mxc_common::models::{ContainmentBackend, ExecutionRequest}; use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::state_aware_operation::StateAwareOperation; +/// The out-of-band identity a phase is addressed by. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TargetIdKind { + None, + SandboxId, + SessionId, +} + +impl TargetIdKind { + /// Wire member that carries this identity on a lifecycle request. + pub fn wire_member(self) -> Option<&'static str> { + match self { + Self::None => None, + Self::SandboxId => Some("sandboxId"), + Self::SessionId => Some("sessionId"), + } + } +} + /// Lifecycle phase in a state-aware request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Phase { + ProvisionSession, Provision, Start, Exec, Stop, Deprovision, + DeprovisionSession, } impl Phase { /// Wire-format string matching the SDK's `Phase` union. pub fn as_str(self) -> &'static str { match self { + Self::ProvisionSession => "provision-session", Self::Provision => "provision", Self::Start => "start", Self::Exec => "exec", Self::Stop => "stop", Self::Deprovision => "deprovision", + Self::DeprovisionSession => "deprovision-session", + } + } + + /// Whether the backend comes from the declared `containment` rather than + /// from the prefix of the phase's target ID. + /// + /// Only a sandbox ID carries a backend prefix, so a phase addressed by + /// anything else has to declare its containment. + pub fn routes_by_containment(self) -> bool { + !matches!(self.target_id_kind(), TargetIdKind::SandboxId) + } + + pub fn target_id_kind(self) -> TargetIdKind { + match self { + Self::ProvisionSession | Self::Provision => TargetIdKind::None, + Self::Start | Self::Exec | Self::Stop | Self::Deprovision => TargetIdKind::SandboxId, + Self::DeprovisionSession => TargetIdKind::SessionId, } } } @@ -128,17 +168,23 @@ impl ParsedStateAwareRequest { self.operation.phase() } - /// Declared backend for provision; later phases route by sandbox ID. + /// Declared backend for the phases that route by containment; the rest + /// route by sandbox ID. pub fn containment(&self) -> Option { self.operation.containment() } - /// Sandbox ID carried by a non-provision operation. + /// Sandbox ID carried by a sandbox-addressed operation. pub fn sandbox_id(&self) -> Option<&str> { self.operation.sandbox_id() } - /// Require an ID for a non-provision execution entry point. + /// Session ID carried by a session-addressed operation. + pub fn session_id(&self) -> Option<&str> { + self.operation.session_id() + } + + /// Require an ID for a sandbox-addressed execution entry point. pub fn sandbox_id_required(&self) -> Result<&str, MxcError> { self.sandbox_id().ok_or_else(|| { MxcError::malformed_request(format!("phase {} requires a sandboxId", self.phase())) @@ -215,4 +261,69 @@ mod tests { let (_, observed) = parsed.into_parts(); assert_eq!(observed, operation); } + + #[test] + fn every_phase_has_a_distinct_wire_string() { + let phases = [ + Phase::ProvisionSession, + Phase::Provision, + Phase::Start, + Phase::Exec, + Phase::Stop, + Phase::Deprovision, + Phase::DeprovisionSession, + ]; + let mut seen: Vec<&str> = phases.iter().map(|phase| phase.as_str()).collect(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(seen.len(), phases.len()); + assert_eq!(Phase::ProvisionSession.as_str(), "provision-session"); + assert_eq!(Phase::DeprovisionSession.as_str(), "deprovision-session"); + assert_eq!(Phase::ProvisionSession.to_string(), "provision-session"); + } + + #[test] + fn session_phases_route_by_containment_and_carry_no_sandbox_id() { + for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] { + assert!(phase.routes_by_containment(), "{phase}"); + assert_ne!(phase.target_id_kind(), TargetIdKind::SandboxId, "{phase}"); + } + assert_eq!(Phase::ProvisionSession.target_id_kind(), TargetIdKind::None); + assert_eq!( + Phase::DeprovisionSession.target_id_kind(), + TargetIdKind::SessionId + ); + } + + #[test] + fn sandbox_addressed_phases_route_by_their_id() { + for phase in [Phase::Start, Phase::Exec, Phase::Stop, Phase::Deprovision] { + assert!(!phase.routes_by_containment(), "{phase}"); + assert_eq!(phase.target_id_kind(), TargetIdKind::SandboxId, "{phase}"); + } + assert!(Phase::Provision.routes_by_containment()); + assert_eq!(Phase::Provision.target_id_kind(), TargetIdKind::None); + } + + #[test] + fn each_identity_has_its_own_wire_member() { + assert_eq!(TargetIdKind::None.wire_member(), None); + assert_eq!(TargetIdKind::SandboxId.wire_member(), Some("sandboxId")); + assert_eq!(TargetIdKind::SessionId.wire_member(), Some("sessionId")); + } + + #[test] + fn a_session_operation_exposes_its_id_only_as_a_session_id() { + let parsed = ParsedStateAwareRequest::new( + ExecutionRequest::default(), + StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:abcd".into(), + }, + ); + assert_eq!(parsed.phase(), Phase::DeprovisionSession); + assert_eq!(parsed.containment(), Some(ContainmentBackend::Wslc)); + assert_eq!(parsed.session_id(), Some("wslcsess:abcd")); + assert!(parsed.sandbox_id().is_none()); + assert!(parsed.sandbox_id_required().is_err()); + } } diff --git a/src/mxc-sdk/src/core/mxc_engine/state_aware.rs b/src/mxc-sdk/src/core/mxc_engine/state_aware.rs index 7e4a0c71a..39c284d68 100644 --- a/src/mxc-sdk/src/core/mxc_engine/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_engine/state_aware.rs @@ -52,9 +52,10 @@ fn isolation_session_unavailable() -> MxcError { /// This phase's telemetry correlation vector, purely internal to MXC: no /// caller ever supplies or relays one. `provision` (whose `sandboxId` doesn't -/// exist yet) mints a fresh vector; every later phase recalls the same +/// exist yet) mints a fresh vector; every later sandbox phase recalls the same /// lifecycle root persisted by `provision` and spins a distinct child off it — -/// see [`telemetry::correlation_state`]. +/// see [`telemetry::correlation_state`]. Session phases carry no `sandboxId`, +/// so each seeds its own vector. fn phase_correlation(active: bool, phase: Phase, sandbox_id: Option<&str>) -> String { telemetry::correlation_state::pre_dispatch_vector(active, phase == Phase::Provision, sandbox_id) } @@ -169,6 +170,7 @@ pub enum EngineProvisionMetadata { #[non_exhaustive] pub struct EngineStateAwareResult { pub sandbox_id: Option, + pub session_id: Option, pub metadata: Option, pub warnings: Vec, } @@ -178,6 +180,7 @@ impl EngineStateAwareResult { fn empty() -> Self { Self { sandbox_id: None, + session_id: None, metadata: None, warnings: Vec::new(), } @@ -187,10 +190,21 @@ impl EngineStateAwareResult { fn provision(sandbox_id: String, metadata: Option) -> Self { Self { sandbox_id: Some(sandbox_id), + session_id: None, metadata, warnings: Vec::new(), } } + + #[cfg(target_os = "windows")] + fn provision_session(session_id: String) -> Self { + Self { + sandbox_id: None, + session_id: Some(session_id), + metadata: None, + warnings: Vec::new(), + } + } } #[cfg(target_os = "windows")] @@ -212,6 +226,9 @@ fn typed_dispatch_result< ) -> Result { match outcome { TypedDispatchOutcome::DryRun => Ok(EngineStateAwareResult::empty()), + TypedDispatchOutcome::ProvisionSession(result) => { + Ok(EngineStateAwareResult::provision_session(result.session_id)) + } TypedDispatchOutcome::Provision(result) => Ok(EngineStateAwareResult::provision( result.sandbox_id, result @@ -243,6 +260,7 @@ fn typed_dispatch_result< } Ok(EngineStateAwareResult::empty()) } + TypedDispatchOutcome::DeprovisionSession => Ok(EngineStateAwareResult::empty()), TypedDispatchOutcome::ExecCompleted { .. } => Err(MxcError::backend_error( "typed envelope dispatch returned an exec completion", )), @@ -682,7 +700,14 @@ fn run_state_aware_envelope( ); } } - _ => {} + + // Lifecycle records are keyed by sandbox ID, so a session phase has + // nothing to persist against and nothing to recall. + Phase::ProvisionSession + | Phase::DeprovisionSession + | Phase::Start + | Phase::Exec + | Phase::Stop => {} } telemetry::emit_sdk_state_aware_with_kind( telemetry_active, @@ -839,7 +864,14 @@ pub fn run_typed_state_aware_request( ); } } - _ => {} + + // Lifecycle records are keyed by sandbox ID, so a session phase has + // nothing to persist against and nothing to recall. + Phase::ProvisionSession + | Phase::DeprovisionSession + | Phase::Start + | Phase::Exec + | Phase::Stop => {} } telemetry::emit_sdk_state_aware_typed( telemetry_active, @@ -1593,6 +1625,117 @@ mod tests { .contains("compiled without the `wslc` feature")); } + fn session_operations() -> [StateAwareOperation; 2] { + [ + StateAwareOperation::ProvisionSession(None), + StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:00000000000000000000000000000000".to_string(), + }, + ] + } + + fn session_refusal_on_each_route( + operation: &StateAwareOperation, + dry_run: bool, + ) -> [(&'static str, Error); 2] { + let input = || SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation.clone()); + let parsed = + normalize_sdk_state_aware(input().unwrap(), false, &mut Logger::new(Mode::Buffer)) + .unwrap(); + let envelope = run_state_aware(parsed, dry_run).unwrap_err(); + let typed = run_typed_state_aware_request(input().unwrap(), false, dry_run).unwrap_err(); + [ + ("run_state_aware", envelope.into()), + ("run_typed_state_aware_request", typed), + ] + } + + #[cfg(not(all(target_os = "windows", feature = "wslc")))] + #[test] + fn a_session_phase_without_wslc_reports_the_backend_not_the_request() { + for operation in session_operations() { + for dry_run in [false, true] { + for (route, error) in session_refusal_on_each_route(&operation, dry_run) { + assert_eq!( + error.code, + crate::ErrorCode::BackendUnavailable, + "{route} with dry_run={dry_run}: {error:?}" + ); + } + } + } + } + + #[cfg(all(target_os = "windows", feature = "wslc"))] + #[test] + fn a_session_phase_reaches_wslc_and_is_refused_as_unsupported_until_it_is_implemented() { + for operation in session_operations() { + for dry_run in [false, true] { + for (route, error) in session_refusal_on_each_route(&operation, dry_run) { + assert_eq!( + error.code, + crate::ErrorCode::UnsupportedPhase, + "{route} with dry_run={dry_run}: {error:?}" + ); + assert!(error.message.contains("wslc"), "{route}: {error:?}"); + } + } + } + } + + #[test] + fn session_operations_require_the_development_contract() { + for operation in session_operations() { + let error = SdkStateAwareInput::new(ContractVersion::V1_0_0, operation).unwrap_err(); + assert!(error.to_string().contains("1.1.0-alpha"), "{error}",); + } + } + + #[cfg(target_os = "windows")] + #[test] + fn a_minted_session_id_reaches_the_typed_result_without_being_mistaken_for_a_sandbox() { + let minted = typed_dispatch_result( + TypedDispatchOutcome::<(), (), (), ()>::ProvisionSession( + crate::mxc_common::state_aware_backend::ProvisionSessionResult { + session_id: "wslcsess:abcd".to_string(), + }, + ), + no_provision_metadata, + ) + .unwrap(); + assert_eq!(minted.session_id.as_deref(), Some("wslcsess:abcd")); + assert_eq!(minted.sandbox_id, None); + assert_eq!(minted.metadata, None); + + let released = typed_dispatch_result( + TypedDispatchOutcome::<(), (), (), ()>::DeprovisionSession, + no_provision_metadata, + ) + .unwrap(); + assert_eq!(released.session_id, None); + assert_eq!(released.sandbox_id, None); + } + + #[cfg(target_os = "windows")] + #[test] + fn the_sdk_facade_cannot_silently_discard_a_minted_session_id() { + let result = crate::state_aware_sdk::StateAwareResult::from_engine( + EngineStateAwareResult::provision_session("wslcsess:abcd".to_string()), + ); + assert_eq!(result.session_id.as_deref(), Some("wslcsess:abcd")); + + assert!( + result.clone().into_lifecycle().is_err(), + "a minted session id must not pass as an empty lifecycle result, leaving the \ + caller no way to release the session" + ); + assert!( + result.into_validation().is_err(), + "a minted session id must not pass as a dry-run result, leaving the caller no \ + way to release the session" + ); + } + #[cfg(target_os = "windows")] #[test] fn exec_state_aware_routes_windows_sandbox_exec_to_backend() { diff --git a/src/mxc-sdk/src/state_aware_sdk.rs b/src/mxc-sdk/src/state_aware_sdk.rs index 1e78c3069..4e0f15e95 100644 --- a/src/mxc-sdk/src/state_aware_sdk.rs +++ b/src/mxc-sdk/src/state_aware_sdk.rs @@ -340,13 +340,22 @@ pub struct ValidationResult { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct StateAwareResult { pub sandbox_id: Option, + pub session_id: Option, pub metadata: Option, pub warnings: Vec, } impl StateAwareResult { pub(crate) fn from_engine(result: crate::mxc_engine::EngineStateAwareResult) -> Self { - let metadata = result.metadata.map(|metadata| match metadata { + // A result field added later is a compile error here rather than a + // dropped value. + let crate::mxc_engine::EngineStateAwareResult { + sandbox_id, + session_id, + metadata, + warnings, + } = result; + let metadata = metadata.map(|metadata| match metadata { crate::mxc_engine::EngineProvisionMetadata::IsolationSession { agent_user_name, agent_user_sid, @@ -359,9 +368,10 @@ impl StateAwareResult { }), }); Self { - sandbox_id: result.sandbox_id, + sandbox_id, + session_id, metadata, - warnings: result.warnings, + warnings, } } @@ -377,7 +387,7 @@ impl StateAwareResult { } pub(crate) fn into_lifecycle(self) -> Result { - if self.sandbox_id.is_some() || self.metadata.is_some() { + if self.sandbox_id.is_some() || self.session_id.is_some() || self.metadata.is_some() { return Err(MxcError::backend_error( "typed lifecycle operation returned provision-only output", )); @@ -388,7 +398,7 @@ impl StateAwareResult { } pub(crate) fn into_validation(self) -> Result { - if self.sandbox_id.is_some() || self.metadata.is_some() { + if self.sandbox_id.is_some() || self.session_id.is_some() || self.metadata.is_some() { return Err(MxcError::backend_error( "typed dry run returned execution output", ));