diff --git a/schemas/dev/mxc-config.schema.0.8.0-dev.json b/schemas/dev/mxc-config.schema.0.8.0-dev.json index 2720d5289..37c8b019d 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -260,19 +260,8 @@ "type": "object" }, "IsolationSession": { - "description": "IsolationSession backend config. Carries the one-shot `user` field and the per-phase state-aware nesting (`provision` / `start` / `stop` / `deprovision`).", + "description": "IsolationSession backend config. Carries the one-shot `user` field and the per-phase state-aware nesting for the phases that take config (`provision` / `start`). `stop`, `deprovision`, and `exec` take no per-phase config payload: `stop` and `deprovision` are invoked with only the top-level `phase` and `sandboxId`, and `exec` additionally carries the top-level `process` block.", "properties": { - "deprovision": { - "anyOf": [ - { - "$ref": "#/definitions/IsolationSessionPhase" - }, - { - "type": "null" - } - ], - "description": "State-aware deprovision-phase configuration." - }, "provision": { "anyOf": [ { @@ -295,17 +284,6 @@ ], "description": "State-aware start-phase configuration." }, - "stop": { - "anyOf": [ - { - "$ref": "#/definitions/IsolationSessionPhase" - }, - { - "type": "null" - } - ], - "description": "State-aware stop-phase configuration." - }, "user": { "anyOf": [ { diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 08883ee4c..c7814303f 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -107,13 +107,9 @@ export interface Filesystem { } /** - * IsolationSession backend config. Carries the one-shot `user` field and the per-phase state-aware nesting (`provision` / `start` / `stop` / `deprovision`). + * IsolationSession backend config. Carries the one-shot `user` field and the per-phase state-aware nesting for the phases that take config (`provision` / `start`). `stop`, `deprovision`, and `exec` take no per-phase config payload: `stop` and `deprovision` are invoked with only the top-level `phase` and `sandboxId`, and `exec` additionally carries the top-level `process` block. */ export interface IsolationSession { - /** - * State-aware deprovision-phase configuration. - */ - deprovision?: IsolationSessionPhase | null; /** * State-aware provision-phase configuration. */ @@ -122,10 +118,6 @@ export interface IsolationSession { * State-aware start-phase configuration. */ start?: IsolationSessionPhase | null; - /** - * State-aware stop-phase configuration. - */ - stop?: IsolationSessionPhase | null; /** * Optional Entra cloud-agent user bundle (one-shot). */ diff --git a/src/backends/isolation_session/common/src/state_aware.rs b/src/backends/isolation_session/common/src/state_aware.rs index 8472e82b4..fbd2306f1 100644 --- a/src/backends/isolation_session/common/src/state_aware.rs +++ b/src/backends/isolation_session/common/src/state_aware.rs @@ -314,6 +314,100 @@ mod tests { ); } + // ====== Wire-model / backend config parity ====== + + // The generated JSON schema (`schemas/dev/`) and the SDK wire types + // (`sdk/node/src/generated/wire.ts`) are both emitted from + // `wxc_common::wire::IsolationSession`, while the phases that actually + // accept a config are the associated types on the impl above. On the + // state-aware path the wire model is never constructed — the dispatcher + // deserializes raw JSON straight into those associated types — so nothing + // couples the two at compile time. The tests below pin that contract from + // both directions: the key set the wire model advertises, that the `()` + // phases reject a payload, and that the phases which do take one still + // accept the payload the wire model describes. + + #[test] + fn wire_model_nests_config_only_for_phases_that_take_one() { + // Field-by-field construction is deliberate: adding a per-phase field + // to the wire struct breaks this test's compilation, forcing a + // decision about whether the backend honors it. + let wire = wxc_common::wire::IsolationSession { + user: None, + provision: None, + start: None, + }; + let value = serde_json::to_value(&wire).unwrap(); + let mut keys: Vec<&str> = value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + ["provision", "start", "user"], + "wire model nests a per-phase config for a phase the backend takes none for" + ); + } + + #[test] + fn phases_without_a_config_reject_a_payload() { + type StopConfig = ::StopConfig; + type DeprovisionConfig = + ::DeprovisionConfig; + type ExecConfig = ::ExecConfig; + + // These are `()`, which deserializes only from null, so any object in + // the slot is a hard error at dispatch. + let payload = serde_json::json!({ "user": { "upn": "a@b.com", "wamToken": "t" } }); + assert!( + serde_json::from_value::(payload.clone()).is_err(), + "stop accepted a config payload" + ); + assert!( + serde_json::from_value::(payload.clone()).is_err(), + "deprovision accepted a config payload" + ); + assert!( + serde_json::from_value::(payload).is_err(), + "exec accepted a config payload" + ); + } + + #[test] + fn phases_with_a_config_accept_the_wire_payload() { + type ProvisionConfig = ::ProvisionConfig; + type StartConfig = ::StartConfig; + + // Derive the payload from the wire type instead of a JSON literal: the + // wire model is only the schema source on this path, so a serde rename + // on either side would go unnoticed. Both config types are + // `#[serde(default)]` with no `deny_unknown_fields`, so a renamed key + // does not error — it drops the bundle and provisions a local sandbox + // for a caller who asked for an Entra one. + let phase = wxc_common::wire::IsolationSessionPhase { + user: Some(wxc_common::wire::IsolationUser { + upn: "alice@contoso.com".to_string(), + wam_token: "tok".to_string(), + }), + }; + let payload = serde_json::to_value(&phase).unwrap(); + + let provision: ProvisionConfig = serde_json::from_value(payload.clone()).unwrap(); + let u = provision + .user + .expect("provision dropped the wire user bundle"); + assert_eq!(u.upn, "alice@contoso.com"); + assert_eq!(u.wam_token, "tok"); + + let start: StartConfig = serde_json::from_value(payload).unwrap(); + let u = start.user.expect("start dropped the wire user bundle"); + assert_eq!(u.upn, "alice@contoso.com"); + assert_eq!(u.wam_token, "tok"); + } + fn request_with_filesystem_policy() -> ExecutionRequest { ExecutionRequest { policy: ContainerPolicy { diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index dee6b8f56..647fa14c5 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -484,8 +484,11 @@ pub enum TransportProtocol { } /// IsolationSession backend config. Carries the one-shot `user` field and -/// the per-phase state-aware nesting (`provision` / `start` / `stop` / -/// `deprovision`). +/// the per-phase state-aware nesting for the phases that take config +/// (`provision` / `start`). `stop`, `deprovision`, and `exec` take no +/// per-phase config payload: `stop` and `deprovision` are invoked with only +/// the top-level `phase` and `sandboxId`, and `exec` additionally carries +/// the top-level `process` block. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] @@ -496,10 +499,6 @@ pub struct IsolationSession { pub provision: Option, /// State-aware start-phase configuration. pub start: Option, - /// State-aware stop-phase configuration. - pub stop: Option, - /// State-aware deprovision-phase configuration. - pub deprovision: Option, } /// Per-phase IsolationSession configuration (state-aware lifecycle).