From 64341c5409ea6225315a98fd71b52a7a4b9bbfe6 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Wed, 14 Oct 2026 15:02:46 -0700 Subject: [PATCH 1/3] [WSLC] (3/4) Add the session lifecycle phases and dispatch routing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cde09ffb-ee78-46e1-bea1-830884f0a318 --- .../architecture/container-lifecycle.md | 92 ++++- docs/development/glossary.md | 2 +- .../src/core/mxc_common/config_parser.rs | 102 +++++- src/mxc-sdk/src/core/mxc_common/models.rs | 15 + .../src/core/mxc_common/policy_identity.rs | 96 +++++- src/mxc-sdk/src/core/mxc_common/sdk_input.rs | 15 + src/mxc-sdk/src/core/mxc_common/splice.rs | 31 +- .../core/mxc_common/state_aware_backend.rs | 124 ++++++- .../core/mxc_common/state_aware_binding.rs | 33 +- .../mxc_common/state_aware_binding_tests.rs | 173 +++++++++- .../core/mxc_common/state_aware_dispatch.rs | 314 +++++++++++++++++- .../core/mxc_common/state_aware_operation.rs | 85 ++++- .../core/mxc_common/state_aware_request.rs | 117 ++++++- .../src/core/mxc_engine/state_aware.rs | 132 +++++++- src/mxc-sdk/src/state_aware_sdk.rs | 20 +- 15 files changed, 1277 insertions(+), 74 deletions(-) diff --git a/docs/development/architecture/container-lifecycle.md b/docs/development/architecture/container-lifecycle.md index 76344f44a..fdb1db451 100644 --- a/docs/development/architecture/container-lifecycle.md +++ b/docs/development/architecture/container-lifecycle.md @@ -1060,7 +1060,10 @@ 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()`. The sandbox-addressed variants each carry a required `sandbox_id`, with no backend-specific configuration today. Exec process settings stay in the common request. @@ -1078,7 +1081,10 @@ 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. 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: 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 +1109,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 +1199,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 +1225,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 +1271,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 +1289,10 @@ pub struct ProvisionResult { pub metadata: Option, } +pub struct ProvisionSessionResult { + pub session_id: String, +} + pub struct StartResult { pub metadata: Option, } @@ -1378,11 +1436,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 @@ -1463,10 +1523,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 +1741,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..4ed3bafe7 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,67 @@ 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 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..11f13f3c3 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,70 @@ mod tests { assert_ne!(policy_hash(&changed), policy_hash(&more)); } + #[test] + fn session_sizing_changes_the_hash_and_the_session_id_does_not() { + let base = request(); + let sized = |config: Option| { + state_aware_policy_hash( + &base, + "wslc", + &StateAwareOperation::ProvisionSession(config), + ) + }; + + let absent = sized(None); + let empty = sized(Some(WslcProvisionSessionConfig::default())); + assert_ne!(absent, empty); + + let four_cpus = sized(Some(WslcProvisionSessionConfig { + cpu_count: Some(4), + ..Default::default() + })); + let eight_cpus = sized(Some(WslcProvisionSessionConfig { + cpu_count: Some(8), + ..Default::default() + })); + assert_ne!(four_cpus, eight_cpus); + assert_ne!(empty, four_cpus); + + let small = sized(Some(WslcProvisionSessionConfig { + memory_mb: Some(4096), + ..Default::default() + })); + let large = sized(Some(WslcProvisionSessionConfig { + memory_mb: Some(8192), + ..Default::default() + })); + assert_ne!(small, large); + assert_ne!(empty, small); + + assert_ne!( + four_cpus, small, + "cpuCount and memoryMb must project to distinct keys" + ); + let gpu_off = sized(Some(WslcProvisionSessionConfig { + gpu: Some(false), + ..Default::default() + })); + let gpu_on = sized(Some(WslcProvisionSessionConfig { + gpu: Some(true), + ..Default::default() + })); + assert_ne!(gpu_off, gpu_on); + assert_ne!(empty, gpu_off); + + let release = |session_id: &str| { + state_aware_policy_hash( + &base, + "wslc", + &StateAwareOperation::DeprovisionSession { + session_id: session_id.to_string(), + }, + ) + }; + assert_eq!(release("wslcsess:aaaa"), release("wslcsess:bbbb")); + } + fn parse_state_aware( json: &str, ) -> crate::mxc_common::state_aware_request::ParsedStateAwareRequest { 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..14a89ca92 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,14 @@ fn wslc_port_mappings_requested(operation: &StateAwareOperation) -> bool { ) } +/// Session phases exist only in the `1.1.0-alpha` exact contract. +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..8781ce3c0 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,13 @@ //! 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. use crate::mxc_common::models::{ ContainmentBackend, IsolationSessionProvisionConfig, WslcProvisionConfig, + WslcProvisionSessionConfig, }; use crate::mxc_common::state_aware_request::Phase; @@ -39,30 +41,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 +84,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,6 +98,19 @@ impl StateAwareOperation { } } + /// 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. /// /// Both the JSON and typed-SDK entry points call this, so neither surface @@ -97,11 +126,16 @@ impl StateAwareOperation { None => Ok(()), } } - Self::Provision(_) + + // 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 { .. } - | Self::Deprovision { .. } => Ok(()), + | Self::Deprovision { .. } + | Self::DeprovisionSession { .. } => Ok(()), } } } @@ -162,9 +196,46 @@ 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 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..ea40a71a2 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,98 @@ 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 parsed_session(operation: StateAwareOperation) -> ParsedStateAwareRequest { + let input = SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation).unwrap(); + normalize_sdk_state_aware(input, false, &mut Logger::new(Mode::Buffer)).unwrap() + } + + #[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] { + let error = + run_state_aware(parsed_session(operation.clone()), dry_run).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::BackendUnavailable, "{dry_run}"); + } + } + } + + #[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] { + let error = + run_state_aware(parsed_session(operation.clone()), dry_run).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::UnsupportedPhase, "{dry_run}"); + assert!(error.message.contains("wslc"), "{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", )); From a8ad1c3d92b53a09d7dea2551006ef5e52366ce1 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 15 Oct 2026 13:54:31 -0700 Subject: [PATCH 2/3] Addressed PR comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cde09ffb-ee78-46e1-bea1-830884f0a318 --- .../architecture/container-lifecycle.md | 23 ++- .../src/core/mxc_common/config_parser.rs | 41 +++++ .../src/core/mxc_common/policy_identity.rs | 156 ++++++++++-------- src/mxc-sdk/src/core/mxc_common/sdk_input.rs | 1 - .../core/mxc_common/state_aware_operation.rs | 60 ++++++- .../src/core/mxc_engine/state_aware.rs | 39 +++-- 6 files changed, 235 insertions(+), 85 deletions(-) diff --git a/docs/development/architecture/container-lifecycle.md b/docs/development/architecture/container-lifecycle.md index fdb1db451..a789aa95c 100644 --- a/docs/development/architecture/container-lifecycle.md +++ b/docs/development/architecture/container-lifecycle.md @@ -1063,7 +1063,9 @@ is the sole phase authority. Provision carries or `Wslc(Option)`. `ProvisionSession` carries `Option` and is WSLc-only; `DeprovisionSession` carries a required `session_id`, reachable through `session_id()` rather than -`sandbox_id()`. The sandbox-addressed variants each carry a required +`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. @@ -1078,11 +1080,12 @@ 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. The two session phases are the -exception: their defaults refuse with `unsupported_phase`, so a backend that +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. @@ -1477,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())); } @@ -1511,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())) + } } } ``` 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 4ed3bafe7..c4a349e16 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -4654,6 +4654,47 @@ mod tests { } } + #[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/policy_identity.rs b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs index 11f13f3c3..5f1bf1849 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -910,67 +910,81 @@ mod tests { } #[test] - fn session_sizing_changes_the_hash_and_the_session_id_does_not() { - let base = request(); - let sized = |config: Option| { - state_aware_policy_hash( - &base, - "wslc", - &StateAwareOperation::ProvisionSession(config), - ) + 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:?}" + ); + } + } - let absent = sized(None); - let empty = sized(Some(WslcProvisionSessionConfig::default())); - assert_ne!(absent, empty); - - let four_cpus = sized(Some(WslcProvisionSessionConfig { - cpu_count: Some(4), - ..Default::default() - })); - let eight_cpus = sized(Some(WslcProvisionSessionConfig { - cpu_count: Some(8), - ..Default::default() - })); - assert_ne!(four_cpus, eight_cpus); - assert_ne!(empty, four_cpus); - - let small = sized(Some(WslcProvisionSessionConfig { - memory_mb: Some(4096), - ..Default::default() - })); - let large = sized(Some(WslcProvisionSessionConfig { - memory_mb: Some(8192), - ..Default::default() - })); - assert_ne!(small, large); - assert_ne!(empty, small); - - assert_ne!( - four_cpus, small, - "cpuCount and memoryMb must project to distinct keys" - ); - let gpu_off = sized(Some(WslcProvisionSessionConfig { - gpu: Some(false), - ..Default::default() - })); - let gpu_on = sized(Some(WslcProvisionSessionConfig { - gpu: Some(true), - ..Default::default() - })); - assert_ne!(gpu_off, gpu_on); - assert_ne!(empty, gpu_off); - - let release = |session_id: &str| { - state_aware_policy_hash( - &base, - "wslc", - &StateAwareOperation::DeprovisionSession { - session_id: session_id.to_string(), - }, - ) - }; - assert_eq!(release("wslcsess:aaaa"), release("wslcsess:bbbb")); + #[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: {session_id}" + ); + } } fn parse_state_aware( @@ -1175,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 14a89ca92..38005f780 100644 --- a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs +++ b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs @@ -210,7 +210,6 @@ fn wslc_port_mappings_requested(operation: &StateAwareOperation) -> bool { ) } -/// Session phases exist only in the `1.1.0-alpha` exact contract. fn session_operation(operation: &StateAwareOperation) -> bool { matches!( operation, 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 8781ce3c0..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 @@ -6,7 +6,8 @@ //! The operation determines its phase and routing data. Provision retains its //! backend even when no configuration was supplied. Sandbox-addressed phases //! always carry an ID, whose contents are validated at the existing dispatch -//! boundary. +//! boundary. A session ID is checked by `validate` instead, because routing +//! never reads it. use crate::mxc_common::models::{ ContainmentBackend, IsolationSessionProvisionConfig, WslcProvisionConfig, @@ -111,7 +112,8 @@ impl StateAwareOperation { } } - /// Reject backend configuration the selected backend cannot enforce. + /// 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. @@ -126,6 +128,7 @@ impl StateAwareOperation { None => Ok(()), } } + Self::DeprovisionSession { session_id } => validate_session_id(session_id), // Session sizing carries no cross-backend constraint; the WSLc // backend decides what it can honor. @@ -134,12 +137,33 @@ impl StateAwareOperation { | Self::Start { .. } | Self::Exec { .. } | Self::Stop { .. } - | Self::Deprovision { .. } - | Self::DeprovisionSession { .. } => Ok(()), + | Self::Deprovision { .. } => Ok(()), } } } +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::*; @@ -221,6 +245,34 @@ mod tests { 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!( 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 ea40a71a2..39c284d68 100644 --- a/src/mxc-sdk/src/core/mxc_engine/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_engine/state_aware.rs @@ -1634,9 +1634,20 @@ mod tests { ] } - fn parsed_session(operation: StateAwareOperation) -> ParsedStateAwareRequest { - let input = SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation).unwrap(); - normalize_sdk_state_aware(input, false, &mut Logger::new(Mode::Buffer)).unwrap() + 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")))] @@ -1644,9 +1655,13 @@ mod tests { fn a_session_phase_without_wslc_reports_the_backend_not_the_request() { for operation in session_operations() { for dry_run in [false, true] { - let error = - run_state_aware(parsed_session(operation.clone()), dry_run).unwrap_err(); - assert_eq!(error.code, MxcErrorCode::BackendUnavailable, "{dry_run}"); + 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:?}" + ); + } } } } @@ -1656,10 +1671,14 @@ mod tests { 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] { - let error = - run_state_aware(parsed_session(operation.clone()), dry_run).unwrap_err(); - assert_eq!(error.code, MxcErrorCode::UnsupportedPhase, "{dry_run}"); - assert!(error.message.contains("wslc"), "{error:?}"); + 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:?}"); + } } } } From 0565dd14432a900eb28d8c39fc43618b495da21c Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 15 Oct 2026 20:01:42 -0700 Subject: [PATCH 3/3] [WSLC] Drop the session id from a test message so CodeQL stops flagging it Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cde09ffb-ee78-46e1-bea1-830884f0a318 --- src/mxc-sdk/src/core/mxc_common/policy_identity.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5f1bf1849..80ae4587a 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -982,7 +982,7 @@ mod tests { 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: {session_id}" + "the session id must not enter the policy identity" ); } }