From 58276185e26dedeededaec950f368f31640bc311 Mon Sep 17 00:00:00 2001 From: Gudge Date: Thu, 28 May 2026 10:58:22 -0700 Subject: [PATCH] Align 0.6.0-dev schema with what the parser accepts today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev schema is an editor-validation aid for in-development authors (docs/versioning.md), not a 1:1 mirror of parser behavior. Brought it back in sync with wxc_common::config_parser. Added fields: - network.proxy.url (third oneOf variant; parser handles it, schema dropped it between 0.5.0-alpha and 0.6.0-dev) - network.allowLocalNetwork (#422) - containment enum entries: vm, hyperlight, bubblewrap - experimental.wslc.portMappings (with required ports and tcp/udp protocol enum) - experimental.seatbelt.extraMachLookups (#437) - Containment description: note that hyperlight/bubblewrap share the common policy fields (no per-backend block) Intentionally omitted (parser accepts; schema steers authors to the canonical spelling): - containment aliases 'appcontainer' and 'macos_sandbox' — schema points at 'processcontainer' and 'seatbelt' instead - experimental.windows_sandbox.idleTimeout — legacy alias for idleTimeoutMs, same reasoning - processContainer.learningMode — debug-build-only escape hatch, silently stripped with a SECURITY log line in release; would mislead release- build authors Parser tightening for experimental.wslc.portMappings: - RawPortMapping ports are now required u16 (were Option, defaulted to 0) - wslc handler rejects port == 0 and protocol other than 'tcp'/'udp' with WxcError::ConfigParse - 5 regression tests added in config_parser::tests SDK and docs unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) --- schemas/dev/mxc-config.schema.0.6.0-dev.json | 68 +++++++++++++- src/wxc_common/src/config_parser.rs | 99 +++++++++++++++++--- 2 files changed, 149 insertions(+), 18 deletions(-) diff --git a/schemas/dev/mxc-config.schema.0.6.0-dev.json b/schemas/dev/mxc-config.schema.0.6.0-dev.json index 0dc832c2f..6ed6e8c1d 100644 --- a/schemas/dev/mxc-config.schema.0.6.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.6.0-dev.json @@ -17,9 +17,21 @@ }, "containment": { "type": "string", - "enum": ["process", "processcontainer", "windows_sandbox", "lxc", "microvm", "wslc", "seatbelt", "isolation_session", "bubblewrap"], + "enum": [ + "process", + "processcontainer", + "vm", + "windows_sandbox", + "lxc", + "microvm", + "hyperlight", + "wslc", + "seatbelt", + "isolation_session", + "bubblewrap" + ], "default": "processcontainer", - "description": "Containment value to use for execution. Accepts both abstract intents ('process', 'microvm') and concrete backends ('processcontainer', 'windows_sandbox', 'lxc', 'microvm', 'wslc', 'seatbelt', 'isolation_session', 'bubblewrap'). The native binary resolves abstract intents to a concrete backend at run time based on host capabilities. Note: 'windows_sandbox', 'wslc', 'seatbelt', 'isolation_session', and 'bubblewrap' are experimental and require the --experimental CLI flag." + "description": "Containment value to use for execution. Accepts both abstract intents ('process', 'vm') and concrete backends ('processcontainer', 'windows_sandbox', 'lxc', 'microvm', 'hyperlight', 'wslc', 'seatbelt', 'isolation_session', 'bubblewrap'). The native binary resolves abstract intents to a concrete backend at run time based on host capabilities (e.g., 'process' resolves to ProcessContainer on Windows, LXC on Linux, Seatbelt on macOS; 'vm' resolves to Windows Sandbox on Windows). Note: 'windows_sandbox', 'wslc', 'seatbelt', 'isolation_session', 'hyperlight', and 'bubblewrap' are experimental and require the --experimental CLI flag. 'hyperlight' and 'bubblewrap' have no per-backend configuration block; they share the common filesystem/network policy fields. The legacy aliases 'appcontainer' and 'macos_sandbox' are still accepted by the parser (with a deprecation log line) but are intentionally omitted from this enum so editors steer authors toward the canonical names." }, "phase": { @@ -141,6 +153,11 @@ "items": { "type": "string" }, "description": "Hostnames or IP addresses to block (when defaultPolicy is 'allow'). Enforced by 'lxc' and 'processcontainer' containment backends." }, + "allowLocalNetwork": { + "type": "boolean", + "default": false, + "description": "When true, the sandboxed process may bind() and listen() on local IPs and accept incoming connections. Independent of 'defaultPolicy' (which governs outbound traffic). Honored by the 'seatbelt' backend; other backends ignore this field today." + }, "proxy": { "type": "object", "description": "Proxy configuration. Supported with the 'processcontainer' (Windows) and 'bubblewrap' (Linux) backends.", @@ -167,6 +184,16 @@ }, "required": ["builtinTestServer"], "additionalProperties": false + }, + { + "properties": { + "url": { + "type": "string", + "description": "Full proxy URL including port (e.g., http://proxy.example.com:8080). Host and port are extracted from the URL." + } + }, + "required": ["url"], + "additionalProperties": false } ] } @@ -279,7 +306,7 @@ "type": "integer", "minimum": 0, "default": 300000, - "description": "Idle timeout in milliseconds before the daemon tears down the sandbox VM. 0 = no timeout." + "description": "Idle timeout in milliseconds before the daemon tears down the sandbox VM. 0 = no timeout. The parser also accepts the legacy 'idleTimeout' alias (same units) for back-compat, but the schema intentionally omits it so editors steer authors here." }, "daemonPipeName": { "type": "string", @@ -326,6 +353,35 @@ "storagePath": { "type": "string", "description": "Storage path for the WSLC session image store. Omit to use a temporary directory." + }, + "portMappings": { + "type": "array", + "description": "Host-to-container port forwards. Each entry maps a Windows host port to a container port for a given protocol.", + "items": { + "type": "object", + "properties": { + "windowsPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Port on the Windows host to bind." + }, + "containerPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Port inside the container to forward to." + }, + "protocol": { + "type": "string", + "enum": ["tcp", "udp"], + "default": "tcp", + "description": "Transport protocol for the mapping." + } + }, + "required": ["windowsPort", "containerPort"], + "additionalProperties": false + } } } }, @@ -357,6 +413,12 @@ "type": "boolean", "default": false, "description": "Allow the inner process to use the macOS Keychain (e.g. via keytar or Security.framework) end-to-end. Adds Mach lookup for com.apple.SecurityServer, com.apple.securityd, com.apple.trustd, com.apple.ocspd, com.apple.cfprefsd.daemon, com.apple.xpcd and the com.apple.lsd.* family; read access to /private/var/db/mds and /private/var/protected/trustd; and read+write access to ~/Library/Keychains and /private/var/folders (XPC cache). System keychain stores under /Library and /System/Library are already covered by the baseline read-only allows. Defaults to false; opt in only when the inner workload genuinely needs Keychain access." + }, + "extraMachLookups": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Caller-provided Mach service global-names the inner process may resolve. Each entry is emitted into the generated TinyScheme profile as `(allow mach-lookup (global-name \"...\"))`. Use this as a targeted escape hatch when a workload needs a Mach service the baseline profile does not already grant, without resorting to 'profileOverride' to replace the whole profile." } } }, diff --git a/src/wxc_common/src/config_parser.rs b/src/wxc_common/src/config_parser.rs index 2618f9b44..9194fa509 100644 --- a/src/wxc_common/src/config_parser.rs +++ b/src/wxc_common/src/config_parser.rs @@ -103,13 +103,13 @@ struct RawSandbox { daemon_pipe_name: Option, } -#[derive(Deserialize, Default)] -#[serde(default)] +#[derive(Deserialize)] struct RawPortMapping { #[serde(rename = "windowsPort")] - windows_port: Option, + windows_port: u16, #[serde(rename = "containerPort")] - container_port: Option, + container_port: u16, + #[serde(default)] protocol: Option, } @@ -1003,7 +1003,7 @@ fn convert_raw_config_inner( } config }); - let wslc = raw_exp.wslc.map(|cc| { + let wslc = if let Some(cc) = raw_exp.wslc { let mut config = WslcConfig::default(); if let Some(os) = cc.target_os { config.target_os = os; @@ -1019,17 +1019,33 @@ fn convert_raw_config_inner( } config.storage_path = cc.storage_path; if let Some(mappings) = cc.port_mappings { - config.port_mappings = mappings - .into_iter() - .map(|m| PortMapping { - windows_port: m.windows_port.unwrap_or(0), - container_port: m.container_port.unwrap_or(0), - protocol: m.protocol.unwrap_or_else(|| "tcp".to_string()), - }) - .collect(); + let mut converted = Vec::with_capacity(mappings.len()); + for m in mappings { + if m.windows_port == 0 || m.container_port == 0 { + return Err(WxcError::ConfigParse(format!( + "experimental.wslc.portMappings: port 0 is not a valid forward (windowsPort={}, containerPort={})", + m.windows_port, m.container_port + ))); + } + let protocol = m.protocol.unwrap_or_else(|| "tcp".to_string()); + if protocol != "tcp" && protocol != "udp" { + return Err(WxcError::ConfigParse(format!( + "experimental.wslc.portMappings: protocol must be 'tcp' or 'udp', got '{}'", + protocol + ))); + } + converted.push(PortMapping { + windows_port: m.windows_port, + container_port: m.container_port, + protocol, + }); + } + config.port_mappings = converted; } - config - }); + Some(config) + } else { + None + }; let isolation_session = raw_exp.isolation_session.map(|as_cfg| { let mut config = IsolationSessionConfig::default(); if let Some(id) = as_cfg.configuration_id { @@ -2783,6 +2799,59 @@ mod tests { ); } + #[test] + fn wslc_port_mappings_parsed() { + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "tcp"}, {"windowsPort": 5353, "containerPort": 53, "protocol": "udp"}]}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + let wslc = req.experimental.wslc.unwrap(); + assert_eq!(wslc.port_mappings.len(), 2); + assert_eq!(wslc.port_mappings[0].windows_port, 8080); + assert_eq!(wslc.port_mappings[0].container_port, 80); + assert_eq!(wslc.port_mappings[0].protocol, "tcp"); + assert_eq!(wslc.port_mappings[1].protocol, "udp"); + } + + #[test] + fn wslc_port_mappings_default_protocol_is_tcp() { + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80}]}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + let wslc = req.experimental.wslc.unwrap(); + assert_eq!(wslc.port_mappings[0].protocol, "tcp"); + } + + #[test] + fn wslc_port_mappings_missing_windows_port_rejected() { + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"containerPort": 80}]}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + assert!(load_request(&encoded, &mut logger, true).is_err()); + } + + #[test] + fn wslc_port_mappings_zero_port_rejected() { + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 0, "containerPort": 80}]}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + assert!(load_request(&encoded, &mut logger, true).is_err()); + } + + #[test] + fn wslc_port_mappings_invalid_protocol_rejected() { + let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "wslc", "experimental": {"wslc": {"image": "python:3.12", "portMappings": [{"windowsPort": 8080, "containerPort": 80, "protocol": "sctp"}]}}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + assert!(load_request(&encoded, &mut logger, true).is_err()); + } + // ---------- Experimental feature tests ---------- #[test]