diff --git a/docs/schema.md b/docs/schema.md index 9bddcefe6b..b649c053f0 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -200,6 +200,11 @@ cannot mix both formats in one request. { "windowsPort": 8080, "containerPort": 80, "protocol": "tcp" } ] }, + "apple_container": { // Apple Container settings (macOS, 0.9 only) + "image": "docker.io/library/alpine:3.23", // Required OCI image with /bin/sh + "cpuCount": 2, // Optional positive virtual CPU count + "memoryMb": 1024 // Optional positive memory limit in MB + }, "seatbelt": { // macOS sandbox settings (macOS only) "profileOverride": null, // Optional raw TinyScheme profile (escape hatch) "guiAccess": false, // Allow GUI Mach services / IOKit / pty for window-drawing apps @@ -348,6 +353,7 @@ force a particular backend. | `"hyperlight"` | MicroVM isolation via Hyperlight + Unikraft with an embedded CPython snapshot (experimental) | | `"isolation_session"` | Windows isolation session — runs the workload as a freshly-provisioned, per-execution isolated user account in its own OS-managed session (experimental). Dual-mode: one-shot and state-aware. | | `"seatbelt"` | macOS sandbox isolation (Seatbelt) | +| `"apple_container"` | Experimental Apple Container configuration surface for macOS. Requires schema version `0.9.0-alpha`, `--experimental`, and `experimental.apple_container.image`. Runtime execution is currently fail-closed with `unsupported_containment`. | | `"bubblewrap"` | Unprivileged Linux sandboxing via Bubblewrap/user namespaces (experimental) | Only the backend section matching the selected `containment` value is accepted; diff --git a/schemas/dev/mxc-config.schema.0.9.0-alpha.json b/schemas/dev/mxc-config.schema.0.9.0-alpha.json index 84b796cc21..34b93896f2 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-alpha.json +++ b/schemas/dev/mxc-config.schema.0.9.0-alpha.json @@ -136,6 +136,32 @@ } ], "definitions": { + "AppleContainer": { + "additionalProperties": false, + "description": "One-shot Apple Container backend settings.", + "properties": { + "cpuCount": { + "description": "Requested virtual CPU count.", + "maximum": 4294967295, + "minimum": 1.0, + "type": "integer" + }, + "image": { + "$ref": "#/definitions/NonBlankString", + "description": "OCI image reference. The image must provide `/bin/sh`." + }, + "memoryMb": { + "description": "Requested memory limit in megabytes.", + "maximum": 18446744073709551615, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "image" + ], + "type": "object" + }, "CaptureDenials": { "additionalProperties": false, "description": "Windows denial-capture settings.", @@ -773,6 +799,10 @@ }, "type": "object" }, + "NonBlankString": { + "pattern": "\\S", + "type": "string" + }, "NonEmptyString": { "minLength": 1, "type": "string" @@ -869,6 +899,13 @@ "isolation_session" ], "type": "string" + }, + { + "description": "AppleContainer", + "enum": [ + "apple_container" + ], + "type": "string" } ] }, @@ -876,6 +913,10 @@ "additionalProperties": false, "description": "Experimental settings.", "properties": { + "apple_container": { + "$ref": "#/definitions/AppleContainer", + "description": "Optional one-shot Apple Container backend settings." + }, "telemetry": { "$ref": "#/definitions/Telemetry", "description": "Optional telemetry override." @@ -913,6 +954,31 @@ "macos_sandbox" ] } + }, + { + "else": true, + "if": { + "properties": { + "containment": { + "const": "apple_container" + } + }, + "required": [ + "containment" + ] + }, + "then": { + "properties": { + "experimental": { + "required": [ + "apple_container" + ] + } + }, + "required": [ + "experimental" + ] + } } ], "description": "A complete one-shot `0.9.0-alpha` configuration request.", diff --git a/schemas/dev/mxc-config.schema.0.9.0-dev.json b/schemas/dev/mxc-config.schema.0.9.0-dev.json index a3a3fa3a43..6d0ef6f8fc 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.9.0-dev.json @@ -4,7 +4,72 @@ "title": "MXC Configuration", "description": "MXC container execution configuration. Defines the recommended config format for both one-shot and state-aware sandbox lifecycle requests. A few deprecated field spellings not listed here are also accepted via serde aliases.", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "containment": { + "const": "apple_container" + } + }, + "required": [ + "containment" + ] + }, + "then": { + "properties": { + "experimental": { + "allOf": [ + { + "$ref": "#/definitions/Experimental" + }, + { + "required": [ + "apple_container" + ] + } + ] + } + }, + "required": [ + "experimental" + ] + } + } + ], "definitions": { + "AppleContainer": { + "description": "Apple Container backend config.", + "properties": { + "cpuCount": { + "description": "Requested virtual CPU count.", + "maximum": 4294967295, + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "image": { + "description": "OCI image reference. The image must provide `/bin/sh`.", + "pattern": "\\S", + "type": "string" + }, + "memoryMb": { + "description": "Requested memory limit in megabytes.", + "maximum": 18446744073709551615, + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "image" + ], + "type": "object" + }, "BaseProcessUi": { "additionalProperties": false, "description": "BaseProcessContainer UI isolation settings.", @@ -184,12 +249,30 @@ "bubblewrap" ], "type": "string" + }, + { + "description": "Apple Container VM backend (experimental, macOS).", + "enum": [ + "apple_container" + ], + "type": "string" } ] }, "Experimental": { "description": "Experimental features (only honored with `--experimental`). This block is intentionally **permissive** (no `deny_unknown_fields`): experimental backends are in flux, so the schema documents the known shapes for editor help without rejecting in-progress fields. The strict, closed contract is the stable (top-level) surface.", "properties": { + "apple_container": { + "anyOf": [ + { + "$ref": "#/definitions/AppleContainer" + }, + { + "type": "null" + } + ], + "description": "Apple Container backend config (macOS)." + }, "isolation_session": { "anyOf": [ { diff --git a/sdk/node/README.md b/sdk/node/README.md index 223a56a0f2..4372f72b94 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -62,9 +62,12 @@ child.on('close', (code) => console.log('exit:', code)); | `0.8.0-alpha` | Stable (current) | [`schemas/stable/mxc-config.schema.0.8.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.8.0-alpha.json) | | `0.9.0-alpha` | Dev (experimental backends, the `experimental.*` block, state-aware sandbox lifecycle) | [`schemas/dev/mxc-config.schema.0.9.0-dev.json`](https://github.com/microsoft/mxc/blob/main/schemas/dev/mxc-config.schema.0.9.0-dev.json) | -Pick `0.8.0-alpha` for new code on any supported platform. +Pick `0.8.0-alpha` for new code on any supported platform unless selecting +Apple Container, which requires `0.9.0-alpha`. -> **Stable schemas document only the non-experimental surface.** Experimental backends (`windows_sandbox`, `wslc`, `microvm`, `hyperlight`, `isolation_session`), the `experimental.*` block, and state-aware lifecycle live in `0.9.0-dev`. The parser still accepts them when paired with `--experimental` regardless of which schema your config validates against — schema choice affects editor validation, not runtime behavior. +> **Stable schemas document only the non-experimental surface.** Experimental backends (`windows_sandbox`, `wslc`, `microvm`, `hyperlight`, `isolation_session`, `apple_container`), the `experimental.*` block, and state-aware lifecycle live in `0.9.0-dev`. The parser still accepts them when paired with `--experimental` regardless of which schema your config validates against — schema choice affects editor validation, not runtime behavior. +> Apple Container is the exception: its config version must be exactly +> `0.9.0-alpha`. > **Network host allow/block lists are not implemented on Windows.** `network.allowedHosts` / `network.blockedHosts` have no enforcement on this platform — use `network.defaultPolicy` (`allow` / `block`) or `network.proxy` to constrain network access. @@ -140,9 +143,9 @@ for all three connectivity modes and backend-specific support. | --- | --- | --- | --- | | Windows 11 24H2+ (verified on 25H2) | `processcontainer` | `windows_sandbox`, `wslc`, `microvm`, `isolation_session` | `processcontainer`: 26100 (24H2)
`isolation_session`: 26340.9212 ([Insider Preview](https://learn.microsoft.com/en-us/windows-insider/release-notes/experimental/preview-build-26340-9212)) | | Linux x64 / ARM64 | `bubblewrap` | `lxc` | — | -| macOS ARM64 (schema `0.7.0-alpha`+) | `seatbelt` | — | — | +| macOS ARM64 (schema `0.7.0-alpha`+) | `seatbelt` | `apple_container` (config only) | — | -The default `processcontainer`, `bubblewrap`, `lxc`, and `seatbelt` backends work out of the box. **Experimental backends** (`windows_sandbox`, `wslc`, `microvm`, `isolation_session`, `hyperlight`) require `{ experimental: true }` in `SandboxSpawnOptions` when you spawn — see [Choosing a Backend](#choosing-a-backend). +The default `processcontainer`, `bubblewrap`, `lxc`, and `seatbelt` backends work out of the box. **Experimental backends** (`windows_sandbox`, `wslc`, `microvm`, `isolation_session`, `hyperlight`, `apple_container`) require `{ experimental: true }` in `SandboxSpawnOptions` when you spawn — see [Choosing a Backend](#choosing-a-backend). > **Hyperlight** is an opt-in build flavor (Linux x64 and Windows x64) gated by the `--with-hyperlight` cargo feature. Default shipped binaries do not include it; build from source with `build.bat --with-hyperlight` (Windows) or the equivalent cargo invocation on Linux. @@ -268,6 +271,7 @@ console.log(result.stdout); | `microvm` | `microvm` | Windows | Experimental | [`docs/nanvix-microvm/nanvix.md`](https://github.com/microsoft/mxc/blob/main/docs/nanvix-microvm/nanvix.md) — MicroVM via NanVix on Windows Hypervisor Platform | | `wslc` | (concrete only) | Windows | Experimental | [`docs/wsl/wsl-container-getting-started.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wsl-container-getting-started.md) | | `isolation_session` | (concrete only) | Windows | Experimental | [`docs/isolation-session/oneshot.md`](https://github.com/microsoft/mxc/blob/main/docs/isolation-session/oneshot.md) | +| `apple_container` | (concrete only) | macOS ARM64 | Experimental, config only | — | Experimental backends require `{ experimental: true }` in `SandboxSpawnOptions`: @@ -277,6 +281,23 @@ config.process!.commandLine = 'cmd /c whoami'; const pty = spawnSandboxFromConfig(config, { experimental: true }); ``` +Apple Container has a dedicated builder because it requires an OCI image and +schema version `0.9.0-alpha`: + +```typescript +import { createAppleContainerConfig } from '@microsoft/mxc-sdk'; + +const config = createAppleContainerConfig( + { version: '0.9.0-alpha' }, + { image: 'docker.io/library/alpine:3.23', cpuCount: 2, memoryMb: 1024 }, +); +config.process!.commandLine = 'echo hello'; +``` + +This increment exposes and validates the configuration contract only. +`spawnSandboxFromConfig(config, { experimental: true })` currently fails with +`unsupported_containment` before creating Apple Container resources. + Backend-specific tuning lives on the returned `ContainerConfig`. The full set of fields per backend is in the JSON schemas — they're the source of truth: - Stable backends: [`schemas/stable/`](https://github.com/microsoft/mxc/tree/main/schemas/stable/) diff --git a/sdk/node/src/generated/v0_9_0_alpha/wire.ts b/sdk/node/src/generated/v0_9_0_alpha/wire.ts index 842f819214..ebd084b7e5 100644 --- a/sdk/node/src/generated/v0_9_0_alpha/wire.ts +++ b/sdk/node/src/generated/v0_9_0_alpha/wire.ts @@ -12,6 +12,24 @@ * Regenerate with: * cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- types --version 0.9.0-alpha --out sdk/node/src/generated/v0_9_0_alpha/wire.ts */ +/** + * One-shot Apple Container backend settings. + */ +export interface AppleContainer { + /** + * Requested virtual CPU count. + */ + cpuCount?: number; + /** + * OCI image reference. The image must provide `/bin/sh`. + */ + image: NonBlankString; + /** + * Requested memory limit in megabytes. + */ + memoryMb?: number; +} + /** * Windows denial-capture settings. */ @@ -395,14 +413,20 @@ export interface NetworkRule { to?: NetworkPeer[]; } +export type NonBlankString = string; + export type NonEmptyString = string; -export type OneShotContainment = "process" | "processcontainer" | "appcontainer" | "lxc" | "bubblewrap" | "seatbelt" | "macos_sandbox" | "vm" | "windows_sandbox" | "microvm" | "hyperlight" | "wslc" | "isolation_session"; +export type OneShotContainment = "process" | "processcontainer" | "appcontainer" | "lxc" | "bubblewrap" | "seatbelt" | "macos_sandbox" | "vm" | "windows_sandbox" | "microvm" | "hyperlight" | "wslc" | "isolation_session" | "apple_container"; /** * Experimental settings. */ export interface OneShotExperimental { + /** + * Optional one-shot Apple Container backend settings. + */ + apple_container?: AppleContainer; /** * Optional telemetry override. */ diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 811d621cc1..dc2e96e766 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -16,6 +16,25 @@ * Regenerate with: * cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- types --legacy-wire --out sdk/node/src/generated/wire.ts */ +/** + * Apple Container backend config. + */ +export interface AppleContainer { + /** + * Requested virtual CPU count. + */ + cpuCount?: number | null; + /** + * OCI image reference. The image must provide `/bin/sh`. + */ + image: string; + /** + * Requested memory limit in megabytes. + */ + memoryMb?: number | null; + [k: string]: unknown; +} + /** * BaseProcessContainer UI isolation settings. */ @@ -69,12 +88,16 @@ export type ClipboardPolicy = "none" | "read" | "write" | "all"; /** * Containment backend (abstract intent or concrete backend). */ -export type Containment = "process" | "processcontainer" | "vm" | "windows_sandbox" | "lxc" | "microvm" | "hyperlight" | "wslc" | "seatbelt" | "isolation_session" | "bubblewrap"; +export type Containment = "process" | "processcontainer" | "vm" | "windows_sandbox" | "lxc" | "microvm" | "hyperlight" | "wslc" | "seatbelt" | "isolation_session" | "bubblewrap" | "apple_container"; /** * Experimental features (only honored with `--experimental`). This block is intentionally **permissive** (no `deny_unknown_fields`): experimental backends are in flux, so the schema documents the known shapes for editor help without rejecting in-progress fields. The strict, closed contract is the stable (top-level) surface. */ export interface Experimental { + /** + * Apple Container backend config (macOS). + */ + apple_container?: AppleContainer | null; /** * IsolationSession backend config (Windows). */ diff --git a/sdk/node/src/index.ts b/sdk/node/src/index.ts index ab005d047d..3f1fc1ad28 100644 --- a/sdk/node/src/index.ts +++ b/sdk/node/src/index.ts @@ -43,6 +43,7 @@ export { ContainmentTypes, ContainmentBackend, ExperimentalBackends, + AppleContainerConfig, ContainerConfig, ProcessContainerConfig, NetworkAction, @@ -65,6 +66,7 @@ export { // Export sandbox spawning functions export { createConfigFromPolicy, + createAppleContainerConfig, spawnSandbox, spawnSandboxAsync, spawnSandboxFromConfig, diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index 0094845a5c..21a8bb9b75 100644 --- a/sdk/node/src/sandbox.ts +++ b/sdk/node/src/sandbox.ts @@ -6,13 +6,20 @@ import * as os from 'os'; import { spawn, ChildProcess } from 'child_process'; import { randomBytes } from "crypto"; import { parse as semverParse } from 'semver'; -import { SandboxPolicy, ContainerConfig, ContainmentType, ContainmentBackend } from './types.js'; +import { + AppleContainerConfig, + SandboxPolicy, + ContainerConfig, + ContainmentType, + ContainmentBackend, +} from './types.js'; import { prepareSpawn, diagLogVersion, applyLinuxNetworkPolicy } from './helper.js'; import { diagLog } from './diagnostic.js'; import { MxcError, mxcErrorFromEnvelope } from './errors.js'; const SUPPORTED_VERSION = '0.9.0-alpha'; const MIN_VERSION = '0.6.0-alpha'; +const MAX_U32 = 0xFFFF_FFFF; /** * Generates a random 8-character alphanumeric string for the app container name. @@ -432,6 +439,49 @@ export function createConfigFromPolicy( throw new Error(`Containment type '${containment}' is not yet supported.`); } +/** + * Creates an experimental Apple Container config for macOS. + * + * TODO(#972): Remove the config-only warning when Apple Container runtime + * execution lands. + * Runtime execution is not implemented yet; spawning the returned config + * currently fails with `unsupported_containment` before creating resources. + */ +export function createAppleContainerConfig( + policy: SandboxPolicy, + options: AppleContainerConfig, + containerName?: string, +): ContainerConfig { + if (os.platform() !== 'darwin') { + throw new Error('The apple_container backend is only supported on macOS.'); + } + if (policy.version !== SUPPORTED_VERSION) { + throw new Error(`Apple Container requires policy version '${SUPPORTED_VERSION}'.`); + } + if (!options.image.trim()) { + throw new Error('Apple Container image is required.'); + } + if (options.cpuCount !== undefined && + (!Number.isInteger(options.cpuCount) || options.cpuCount <= 0 || + options.cpuCount > MAX_U32)) { + throw new Error('Apple Container cpuCount must be a positive 32-bit integer.'); + } + if (options.memoryMb !== undefined && + (!Number.isSafeInteger(options.memoryMb) || options.memoryMb <= 0)) { + throw new Error('Apple Container memoryMb must be a positive integer.'); + } + + const config = createConfigFromPolicy(policy, 'process', containerName); + delete config.seatbelt; + delete config.processContainer; + config.containment = 'apple_container'; + config.experimental = { + ...config.experimental, + apple_container: { ...options }, + }; + return config; +} + /** * Builds a sandbox payload JSON object from the sandbox policy. * @param script The command line script to execute diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index a76f6c9591..62b9209ff7 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -101,13 +101,14 @@ export type ContainmentBackend = | 'hyperlight' | 'seatbelt' | 'isolation_session' - | 'bubblewrap'; + | 'bubblewrap' + | 'apple_container'; /** * Containment values (abstract intent or concrete backend) that require * the `--experimental` flag. */ -export const ExperimentalBackends: readonly (ContainmentType | ContainmentBackend)[] = ['microvm', 'windows_sandbox', 'hyperlight', 'wslc', 'isolation_session']; +export const ExperimentalBackends: readonly (ContainmentType | ContainmentBackend)[] = ['microvm', 'windows_sandbox', 'hyperlight', 'wslc', 'isolation_session', 'apple_container']; /** * Clipboard access policy levels @@ -328,6 +329,18 @@ export interface WslcConfig { portMappings?: PortMapping[]; } +/** + * Apple Container backend configuration. + */ +export interface AppleContainerConfig { + /** OCI image reference. The image must provide `/bin/sh`. */ + image: string; + /** Requested virtual CPU count. Omit to let Apple Container choose. */ + cpuCount?: number; + /** Requested memory limit in megabytes. Omit to let Apple Container choose. */ + memoryMb?: number; +} + /** * Port mapping for host↔container port forwarding. */ @@ -394,6 +407,8 @@ export interface ContainerConfig { experimental?: { /** WSLC SDK configuration for Linux containers from Windows */ wslc?: WslcConfig; + /** Apple Container configuration for macOS */ + apple_container?: AppleContainerConfig; /** Telemetry configuration for experimental TraceLogging ETW support */ telemetry?: TelemetryConfig; }; diff --git a/sdk/node/tests/unit/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts index e0c8940f00..26715d123e 100644 --- a/sdk/node/tests/unit/sandbox.test.ts +++ b/sdk/node/tests/unit/sandbox.test.ts @@ -3,7 +3,13 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { buildSandboxPayload, createConfigFromPolicy, spawnSandbox, spawnSandboxFromConfig } from '../../src/sandbox.js'; +import { + buildSandboxPayload, + createAppleContainerConfig, + createConfigFromPolicy, + spawnSandbox, + spawnSandboxFromConfig, +} from '../../src/sandbox.js'; import { resolveExecutableAndArgs } from '../../src/helper.js'; import { ContainerConfig, SandboxPolicy, SandboxingMethod } from '../../src/types.js'; import { platformSkip } from './test-helpers.js'; @@ -981,6 +987,70 @@ describe('createConfigFromPolicy', () => { restore(); } }); + + it('should build an Apple Container 0.9 config without a Seatbelt section', () => { + mockDarwin(); + try { + const config = createAppleContainerConfig( + { + version: '0.9.0-alpha', + processContainer: { + network: { + allowedProxyPeer: 'Contoso.Proxy_1234567890abc', + }, + }, + }, + { + image: 'docker.io/library/alpine:3.23', + cpuCount: 2, + memoryMb: 1024, + }, + 'apple-test', + ); + + assert.strictEqual(config.containment, 'apple_container'); + assert.strictEqual(config.containerId, 'apple-test'); + assert.strictEqual(config.seatbelt, undefined); + assert.strictEqual(config.processContainer, undefined); + assert.deepStrictEqual(config.experimental?.apple_container, { + image: 'docker.io/library/alpine:3.23', + cpuCount: 2, + memoryMb: 1024, + }); + } finally { + restore(); + } + }); + + it('should reject invalid Apple Container builder inputs', () => { + mockDarwin(); + try { + assert.throws( + () => createAppleContainerConfig({ version: '0.7.0-alpha' }, { image: 'alpine' }), + /requires policy version/, + ); + assert.throws( + () => createAppleContainerConfig({ version: '0.9.0-alpha' }, { image: ' ' }), + /image is required/, + ); + assert.throws( + () => createAppleContainerConfig( + { version: '0.9.0-alpha' }, + { image: 'alpine', cpuCount: 0 }, + ), + /cpuCount/, + ); + assert.throws( + () => createAppleContainerConfig( + { version: '0.9.0-alpha' }, + { image: 'alpine', cpuCount: 0x1_0000_0000 }, + ), + /cpuCount/, + ); + } finally { + restore(); + } + }); }); describe('network validation', () => { @@ -1288,6 +1358,13 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk ); }); + it('should require experimental mode for apple_container', () => { + assert.throws( + () => resolveExecutableAndArgs(makeConfig('apple_container'), { executablePath: fakeExe }), + { message: /experimental mode/ }, + ); + }); + it('should NOT require experimental mode for explicit lxc containment', function (this: { skip: (reason?: string) => void }) { if (process.platform !== 'linux') { this.skip('lxc is Linux-only'); diff --git a/sdk/node/tests/unit/wire-conformance.test.ts b/sdk/node/tests/unit/wire-conformance.test.ts index 96bc3601f7..22f77a90d6 100644 --- a/sdk/node/tests/unit/wire-conformance.test.ts +++ b/sdk/node/tests/unit/wire-conformance.test.ts @@ -59,6 +59,7 @@ import type { ProcessContainerConfig, BaseProcessUiConfig, WslcConfig, + AppleContainerConfig, PortMapping as PublicPortMapping, LxcConfig, SeatbeltConfig, @@ -83,6 +84,7 @@ import type { ProcessContainer as WireProcessContainer, BaseProcessUi as WireBaseProcessUi, Wslc as WireWslc, + AppleContainer as WireAppleContainer, PortMapping as WirePortMapping, Lxc as WireLxc, Seatbelt as WireSeatbelt, @@ -165,6 +167,7 @@ type _UiVals = AssertTrue>; type _ProcessContainerVals = AssertTrue>; type _BaseProcessUiVals = AssertTrue>; type _WslcVals = AssertTrue>; +type _AppleContainerVals = AssertTrue>; type _PortMappingVals = AssertTrue>; type _SeatbeltVals = AssertTrue>; type _LxcVals = AssertTrue, WireLxc>>; @@ -185,6 +188,7 @@ type _LifecycleKeys = AssertTrue, never>>; type _BaseProcessUiKeys = AssertTrue, never>>; type _WslcKeys = AssertTrue, never>>; +type _AppleContainerKeys = AssertTrue, never>>; type _PortMappingKeys = AssertTrue, never>>; type _SeatbeltKeys = AssertTrue, never>>; @@ -239,6 +243,7 @@ type _BaseProcessUiWireKeys = AssertTrue, 'provision'>>; +type _AppleContainerWireKeys = AssertTrue, never>>; type _PortMappingWireKeys = AssertTrue, never>>; type _LxcWireKeys = AssertTrue, never>>; @@ -273,16 +278,16 @@ export type WireConformanceAssertions = [ _ProcessVals, _LifecycleVals, _FilesystemVals, _NetworkVals, _UiVals, _NetworkEgressVals, _NetworkIngressVals, _NetworkPeerVals, _NetworkPortVals, _NetworkRuleVals, _RuntimeConfigVals, - _ProcessContainerVals, _BaseProcessUiVals, _WslcVals, _PortMappingVals, + _ProcessContainerVals, _BaseProcessUiVals, _WslcVals, _AppleContainerVals, _PortMappingVals, _SeatbeltVals, _LxcVals, _ProcessKeys, _LifecycleKeys, _FilesystemKeys, _NetworkKeys, _UiKeys, - _ProcessContainerKeys, _BaseProcessUiKeys, _WslcKeys, _PortMappingKeys, + _ProcessContainerKeys, _BaseProcessUiKeys, _WslcKeys, _AppleContainerKeys, _PortMappingKeys, _SeatbeltKeys, _LxcKeys, _RootVals, _RootKeys, _ProcessWireKeys, _LifecycleWireKeys, _FilesystemWireKeys, _NetworkWireKeys, _NetworkEgressWireKeys, _NetworkIngressWireKeys, _NetworkPeerWireKeys, _NetworkPortWireKeys, _NetworkRuleWireKeys, _RuntimeConfigWireKeys, - _UiWireKeys, _BaseProcessUiWireKeys, _WslcWireKeys, _PortMappingWireKeys, + _UiWireKeys, _BaseProcessUiWireKeys, _WslcWireKeys, _AppleContainerWireKeys, _PortMappingWireKeys, _LxcWireKeys, _ProcessContainerWireKeys, _SeatbeltWireKeys, _RootWireKeys, ]; diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 4a09928f66..394e11562f 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -381,6 +381,7 @@ default): |---------|-------------------------------------------------|----------------------------------| | Linux | Bubblewrap | `Containment::Process` | | macOS | Seatbelt | `Containment::Process` | +| macOS | Apple Container (config only) | `Containment::AppleContainer` | | Windows | ProcessContainer (AppContainer + BaseContainer) | `Containment::Process` | | Windows | Explicit ProcessContainer configuration | `Containment::ProcessContainer` | | Windows | WSLC (WSL Container) | `Containment::Wslc` | @@ -395,7 +396,41 @@ lifecycle — `run_state_aware_json` plus `exec_attached` or `exec_sandbox`. Backends with no variant at all — Windows Sandbox, MicroVM, Hyperlight, LXC — cannot be named from this crate; use the executor binaries. Windows Sandbox is -still reachable here through the state-aware lifecycle. +still reachable here through the state-aware lifecycle. Apple Container can be +named, but this increment returns [`ErrorCode::UnsupportedContainment`] before +creating resources. + +### Apple Container (experimental config surface) + +Apple Container requests require schema version `0.9.0-alpha`, an OCI image, +and experimental opt-in: + +```rust,no_run +use mxc_sdk::{ + build_request_with_containment, AppleContainerSection, Containment, SandboxPolicy, +}; + +let policy = SandboxPolicy { + version: "0.9.0-alpha".to_string(), + filesystem: None, + network: None, + ui: None, + timeout_ms: None, +}; +let apple = AppleContainerSection { + image: "docker.io/library/alpine:3.23".to_string(), + cpu_count: Some(2), + memory_mb: Some(1024), +}; +let mut request = + build_request_with_containment(&policy, &Containment::AppleContainer(apple), None)?; +request.set_script("echo hello").set_experimental(true); +# Ok::<(), mxc_sdk::Error>(()) +``` + +This increment builds and validates the request only. Both [`run`] and +[`spawn_sandbox`] return [`ErrorCode::UnsupportedContainment`] before creating +resources. ### WSLC (experimental) diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 6a9aeafd3e..65cb81132e 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -49,6 +49,7 @@ //! | ProcessContainer (AppContainer / BaseContainer) | Windows | [`Containment::Process`] | //! | Explicit ProcessContainer configuration | Windows | [`Containment::ProcessContainer`] | //! | WSLC (WSL Container) | Windows | [`Containment::Wslc`] | +//! | Apple Container (config only) | macOS | [`Containment::AppleContainer`] | //! //! WSLC is **experimental**: build with the crate's `wslc` feature, and call //! [`SandboxRequest::set_experimental(true)`](SandboxRequest::set_experimental) @@ -58,7 +59,10 @@ //! Backends with no [`Containment`] variant return an [`Error`] with //! [`ErrorCode::UnsupportedContainment`]; drive the standalone executor //! binaries for those. IsolationSession refuses the one-shot surface the same -//! way, and is reached through the state-aware lifecycle below. +//! way, and is reached through the state-aware lifecycle below. Apple +//! Container currently exposes configuration only: [`run`] and +//! [`spawn_sandbox`] return [`ErrorCode::UnsupportedContainment`] before +//! creating resources. //! //! # Diagnosing a failure //! @@ -145,11 +149,11 @@ pub use mxc_engine::configs; pub use mxc_engine::policy; pub use mxc_engine::{ available_backends, available_tools_policy, build_request, build_request_with_containment, - platform_support, temporary_files_policy, user_profile_policy, AvailableBackend, - BackendCapability, Containment, Error, ErrorCode, FilesystemPolicyResult, NetworkAction, - NetworkEgressSection, NetworkIngressSection, NetworkPeerSection, NetworkPortSection, - NetworkProtocol, NetworkRuleSection, PlatformSupport, RuntimeConfigSection, SandboxPolicy, - SandboxRequest, WslcSection, + platform_support, temporary_files_policy, user_profile_policy, AppleContainerSection, + AvailableBackend, BackendCapability, Containment, Error, ErrorCode, FilesystemPolicyResult, + NetworkAction, NetworkEgressSection, NetworkIngressSection, NetworkPeerSection, + NetworkPortSection, NetworkProtocol, NetworkRuleSection, PlatformSupport, RuntimeConfigSection, + SandboxPolicy, SandboxRequest, WslcSection, }; pub use sandbox::{ diff --git a/src/core/mxc_config_contract/src/dev/experimental.rs b/src/core/mxc_config_contract/src/dev/experimental.rs index 656019f53d..c493881dcb 100644 --- a/src/core/mxc_config_contract/src/dev/experimental.rs +++ b/src/core/mxc_config_contract/src/dev/experimental.rs @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use super::primitives::NonBlankString; use super::primitives::OptionalField; -use std::num::NonZeroU16; +use std::num::{NonZeroU16, NonZeroU32, NonZeroU64}; /// Placeholder feature used to exercise experimental configuration plumbing. #[derive(Debug, serde::Deserialize)] @@ -94,6 +95,22 @@ pub struct OneShotWslc { pub port_mappings: OptionalField>, } +/// One-shot Apple Container backend settings. +#[derive(Debug, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "schema-gen", schemars(rename = "AppleContainer"))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OneShotAppleContainer { + /// OCI image reference. The image must provide `/bin/sh`. + pub image: NonBlankString, + /// Requested virtual CPU count. + #[serde(default)] + pub cpu_count: OptionalField, + /// Requested memory limit in megabytes. + #[serde(default)] + pub memory_mb: OptionalField, +} + /// Experimental settings. #[derive(Debug, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] @@ -108,6 +125,9 @@ pub struct OneShotExperimental { /// Optional one-shot WSLC backend settings. #[serde(default)] pub wslc: OptionalField, + /// Optional one-shot Apple Container backend settings. + #[serde(rename = "apple_container", default)] + pub apple_container: OptionalField, /// Optional telemetry override. #[serde(default)] pub telemetry: OptionalField, diff --git a/src/core/mxc_config_contract/src/dev/mod.rs b/src/core/mxc_config_contract/src/dev/mod.rs index cacf3e9c19..35ffa5779b 100644 --- a/src/core/mxc_config_contract/src/dev/mod.rs +++ b/src/core/mxc_config_contract/src/dev/mod.rs @@ -231,15 +231,15 @@ mod stable; mod state_aware; pub use experimental::{ - OneShotExperimental, OneShotWindowsSandbox, OneShotWslc, PortMapping, Telemetry, TestFeature, - TransportProtocol, + OneShotAppleContainer, OneShotExperimental, OneShotWindowsSandbox, OneShotWslc, PortMapping, + Telemetry, TestFeature, TransportProtocol, }; pub use network::{ DefaultNetworkPolicy, Network, NetworkAction, NetworkEgress, NetworkEnforcementMode, NetworkIngress, NetworkPeer, NetworkPort, NetworkProtocol, NetworkProxy, NetworkRule, }; pub use one_shot::{Containment as OneShotContainment, Request as OneShotRequest}; -pub use primitives::{NonEmptyString, OptionalField, True}; +pub use primitives::{NonBlankString, NonEmptyString, OptionalField, True}; pub use request::{parse_request, Request, RequestParseError}; #[cfg(feature = "schema-gen")] pub use schema::development_schema; diff --git a/src/core/mxc_config_contract/src/dev/one_shot.rs b/src/core/mxc_config_contract/src/dev/one_shot.rs index 0d683bc235..30dcaf291a 100644 --- a/src/core/mxc_config_contract/src/dev/one_shot.rs +++ b/src/core/mxc_config_contract/src/dev/one_shot.rs @@ -38,60 +38,143 @@ string_enum! { Wslc => ["wslc"], /// Select the Windows IsolationSession backend. IsolationSession => ["isolation_session"], + /// Select the Apple Container backend. + AppleContainer => ["apple_container"], } } /// A complete one-shot `0.9.0-alpha` configuration request. -#[derive(Debug, serde::Deserialize)] +#[derive(Debug)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schema-gen", schemars(rename = "OneShotRequest"))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr( + feature = "schema-gen", + serde(rename_all = "camelCase", deny_unknown_fields) +)] pub struct Request { /// Optional JSON Schema reference for editor validation. - #[serde(rename = "$schema", default)] + #[cfg_attr(feature = "schema-gen", serde(rename = "$schema", default))] pub schema: OptionalField, /// Optional human-readable annotation ignored by the runtime. - #[serde(rename = "_comment", default)] + #[cfg_attr(feature = "schema-gen", serde(rename = "_comment", default))] pub comment: OptionalField, /// The exact contract version marker. pub version: Version, /// Optional externally assigned container identifier. - #[serde(default)] + #[cfg_attr(feature = "schema-gen", serde(default))] pub container_id: OptionalField, /// Optional containment selection. - #[serde(default)] + #[cfg_attr(feature = "schema-gen", serde(default))] pub containment: OptionalField, /// Optional lifecycle settings. - #[serde(default)] + #[cfg_attr(feature = "schema-gen", serde(default))] pub lifecycle: OptionalField, /// The process to execute. pub process: Process, /// Optional filesystem policy. - #[serde(default)] + #[cfg_attr(feature = "schema-gen", serde(default))] pub filesystem: OptionalField, /// Optional fallback consent. - #[serde(default)] + #[cfg_attr(feature = "schema-gen", serde(default))] pub fallback: OptionalField, /// Optional network policy. - #[serde(default)] + #[cfg_attr(feature = "schema-gen", serde(default))] pub network: OptionalField, /// Optional cross-platform user-interface policy. - #[serde(default)] + #[cfg_attr(feature = "schema-gen", serde(default))] pub ui: OptionalField, /// Optional ProcessContainer settings. /// The legacy `appContainer` spelling is accepted as an alias. - #[serde(alias = "appContainer", default)] + #[cfg_attr(feature = "schema-gen", serde(alias = "appContainer", default))] pub process_container: OptionalField, /// Optional LXC distribution settings. - #[serde(default)] + #[cfg_attr(feature = "schema-gen", serde(default))] pub lxc: OptionalField, /// Optional macOS Seatbelt configuration. - #[serde(alias = "macos_sandbox", default)] + #[cfg_attr(feature = "schema-gen", serde(alias = "macos_sandbox", default))] pub seatbelt: OptionalField, /// Optional runtime configuration settings. - #[serde(default)] + #[cfg_attr(feature = "schema-gen", serde(default))] pub runtime_config: OptionalField, /// Optional experimental settings. - #[serde(default)] + #[cfg_attr(feature = "schema-gen", serde(default))] pub experimental: OptionalField, } + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct UncheckedRequest { + #[serde(rename = "$schema", default)] + schema: OptionalField, + #[serde(rename = "_comment", default)] + comment: OptionalField, + version: Version, + #[serde(default)] + container_id: OptionalField, + #[serde(default)] + containment: OptionalField, + #[serde(default)] + lifecycle: OptionalField, + process: Process, + #[serde(default)] + filesystem: OptionalField, + #[serde(default)] + fallback: OptionalField, + #[serde(default)] + network: OptionalField, + #[serde(default)] + ui: OptionalField, + #[serde(alias = "appContainer", default)] + process_container: OptionalField, + #[serde(default)] + lxc: OptionalField, + #[serde(alias = "macos_sandbox", default)] + seatbelt: OptionalField, + #[serde(default)] + runtime_config: OptionalField, + #[serde(default)] + experimental: OptionalField, +} + +impl<'de> serde::Deserialize<'de> for Request { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + let request = UncheckedRequest::deserialize(deserializer)?; + if matches!( + request.containment.as_ref(), + Some(Containment::AppleContainer) + ) && request + .experimental + .as_ref() + .and_then(|experimental| experimental.apple_container.as_ref()) + .is_none() + { + return Err(D::Error::custom( + "apple_container containment requires experimental.apple_container", + )); + } + + Ok(Self { + schema: request.schema, + comment: request.comment, + version: request.version, + container_id: request.container_id, + containment: request.containment, + lifecycle: request.lifecycle, + process: request.process, + filesystem: request.filesystem, + fallback: request.fallback, + network: request.network, + ui: request.ui, + process_container: request.process_container, + lxc: request.lxc, + seatbelt: request.seatbelt, + runtime_config: request.runtime_config, + experimental: request.experimental, + }) + } +} diff --git a/src/core/mxc_config_contract/src/dev/primitives.rs b/src/core/mxc_config_contract/src/dev/primitives.rs index acacdb8d86..725424b67d 100644 --- a/src/core/mxc_config_contract/src/dev/primitives.rs +++ b/src/core/mxc_config_contract/src/dev/primitives.rs @@ -150,6 +150,62 @@ impl<'de> Deserialize<'de> for NonEmptyString { } } +/// A string containing at least one non-whitespace character. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NonBlankString(String); + +#[cfg(feature = "schema-gen")] +impl schemars::JsonSchema for NonBlankString { + fn schema_name() -> String { + "NonBlankString".to_string() + } + + fn json_schema(_generator: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, StringValidation}; + + Schema::Object(SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + string: Some(Box::new(StringValidation { + pattern: Some(r"\S".to_string()), + ..Default::default() + })), + ..Default::default() + }) + } +} + +impl NonBlankString { + /// Creates a validated string. + /// + /// Returns an error when `value` contains only whitespace. + pub fn new(value: String) -> Result { + if value.trim().is_empty() { + Err("string must contain a non-whitespace character".to_string()) + } else { + Ok(Self(value)) + } + } + + /// Returns the validated string as a borrowed string slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consumes the wrapper and returns the validated string. + pub fn into_inner(self) -> String { + self.0 + } +} + +impl<'de> Deserialize<'de> for NonBlankString { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + /// A JSON array that must contain at least one element. #[derive(Debug, Clone, PartialEq, Eq)] pub struct NonEmptyVec(Vec); @@ -236,6 +292,18 @@ mod tests { assert_eq!(error, "string must not be empty"); } + #[test] + fn non_blank_string_accepts_surrounding_whitespace() { + let value = NonBlankString::new(" alpine ".to_string()).unwrap(); + assert_eq!(value.as_str(), " alpine "); + } + + #[test] + fn non_blank_string_rejects_whitespace_only() { + let error = NonBlankString::new(" \t\n".to_string()).unwrap_err(); + assert_eq!(error, "string must contain a non-whitespace character"); + } + #[cfg(feature = "schema-gen")] #[test] fn constrained_primitive_schemas_match_deserialization() { @@ -249,6 +317,10 @@ mod tests { let non_empty = serde_json::to_value(NonEmptyString::json_schema(&mut generator)).unwrap(); assert_eq!(non_empty["type"], "string"); assert_eq!(non_empty["minLength"], 1); + + let non_blank = serde_json::to_value(NonBlankString::json_schema(&mut generator)).unwrap(); + assert_eq!(non_blank["type"], "string"); + assert_eq!(non_blank["pattern"], r"\S"); } #[cfg(feature = "schema-gen")] diff --git a/src/core/mxc_config_contract/src/dev/schema.rs b/src/core/mxc_config_contract/src/dev/schema.rs index 0bbe1bae32..4b50ea6499 100644 --- a/src/core/mxc_config_contract/src/dev/schema.rs +++ b/src/core/mxc_config_contract/src/dev/schema.rs @@ -113,6 +113,29 @@ fn exclude_duplicate_alias( })); } +fn require_apple_container_settings(definitions: &mut Value) { + let one_shot = definitions["OneShotRequest"] + .as_object_mut() + .expect("one-shot request definition"); + let constraints = one_shot + .entry("allOf") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("allOf constraints"); + constraints.push(branch( + discriminator("containment", "apple_container"), + json!({ + "properties": { + "experimental": { + "required": ["apple_container"] + } + }, + "required": ["experimental"] + }), + Value::Bool(true), + )); +} + /// Generates the unrendered JSON Schema for the mutable `0.9.0-alpha` /// development contract. /// @@ -160,6 +183,7 @@ pub fn development_schema() -> Value { "seatbelt", "macos_sandbox", ); + require_apple_container_settings(&mut definitions); json!({ "$schema": "http://json-schema.org/draft-07/schema#", @@ -268,7 +292,27 @@ mod tests { .as_array() .expect("alias constraints") .len(), - 2 + 3 + ); + } + + #[test] + fn one_shot_schema_requires_apple_container_settings() { + let schema = development_schema(); + let constraints = schema["definitions"]["OneShotRequest"]["allOf"] + .as_array() + .expect("one-shot constraints"); + let apple_container = constraints + .iter() + .find(|constraint| { + constraint["if"]["properties"]["containment"]["const"] == json!("apple_container") + }) + .expect("Apple Container constraint"); + + assert_eq!(apple_container["then"]["required"], json!(["experimental"])); + assert_eq!( + apple_container["then"]["properties"]["experimental"]["required"], + json!(["apple_container"]) ); } diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs index b4865fa161..e47c77c946 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs @@ -5,7 +5,7 @@ use crate::common::{assert_invalid, assert_valid}; // Enum value tests #[test] -fn accepts_every_containment_value() { +fn accepts_every_unconditional_containment_value() { for containment in [ "process", "processcontainer", @@ -31,6 +31,25 @@ fn accepts_every_containment_value() { } } +#[test] +fn rejects_apple_container_without_experimental_settings() { + assert_invalid( + r#"{ + "version": "0.9.0-alpha", + "containment": "apple_container", + "process": {"commandLine": "echo"} + }"#, + ); + assert_invalid( + r#"{ + "version": "0.9.0-alpha", + "containment": "apple_container", + "experimental": {}, + "process": {"commandLine": "echo"} + }"#, + ); +} + #[test] fn rejects_invalid_containment_value() { assert_invalid( diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental.rs index 1d8719bd31..1c0f1e6bd8 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[path = "experimental/apple_container.rs"] +mod apple_container; #[path = "experimental/root.rs"] mod root; #[path = "experimental/test_and_telemetry.rs"] diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/apple_container.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/apple_container.rs new file mode 100644 index 0000000000..ec9aa8be3e --- /dev/null +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/apple_container.rs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::common::{assert_invalid, assert_valid}; + +fn request(fields: &str) -> String { + format!( + r#"{{ + "version": "0.9.0-alpha", + "containment": "apple_container", + "experimental": {{ + "apple_container": {{{fields}}} + }}, + "process": {{"commandLine": "echo"}} + }}"# + ) +} + +#[test] +fn accepts_required_image_and_optional_resources() { + assert_valid(&request(r#""image": "docker.io/library/alpine:3.23""#)); + assert_valid(&request( + r#""image": "docker.io/library/alpine:3.23", "cpuCount": 1, "memoryMb": 1"#, + )); + assert_valid(&request(&format!( + r#""image": "docker.io/library/alpine:3.23", "cpuCount": {}, "memoryMb": {}"#, + u32::MAX, + u64::MAX + ))); +} + +#[test] +fn rejects_missing_empty_or_non_string_image() { + for fields in [ + "", + r#""image": """#, + r#""image": " ""#, + r#""image": "\t\n""#, + r#""image": null"#, + r#""image": 123"#, + r#""image": true"#, + r#""image": []"#, + r#""image": {}"#, + ] { + assert_invalid(&request(fields)); + } +} + +#[test] +fn rejects_invalid_resource_values() { + for field in ["cpuCount", "memoryMb"] { + for value in ["0", "-1", "1.5", r#""1""#, "true", "[]", "{}"] { + assert_invalid(&request(&format!( + r#""image": "docker.io/library/alpine:3.23", "{field}": {value}"# + ))); + } + } + assert_invalid(&request( + r#""image": "docker.io/library/alpine:3.23", "cpuCount": 4294967296"#, + )); + assert_invalid(&request( + r#""image": "docker.io/library/alpine:3.23", "memoryMb": 18446744073709551616"#, + )); +} + +#[test] +fn rejects_unknown_or_duplicate_fields() { + assert_invalid(&request( + r#""image": "docker.io/library/alpine:3.23", "unknown": true"#, + )); + assert_invalid(&request(r#""image": "first", "image": "second""#)); +} diff --git a/src/core/mxc_engine/src/dispatch.rs b/src/core/mxc_engine/src/dispatch.rs index d99b4ea2d3..9908f3a511 100644 --- a/src/core/mxc_engine/src/dispatch.rs +++ b/src/core/mxc_engine/src/dispatch.rs @@ -70,6 +70,16 @@ pub fn spawn_runner( ContainmentBackend::Bubblewrap => spawn_bubblewrap(request, logger), ContainmentBackend::ProcessContainer => spawn_process_container(request, logger), ContainmentBackend::Wslc => spawn_wslc(request, logger), + ContainmentBackend::AppleContainer => { + if !request.experimental_enabled { + return Err(MxcError::malformed_request( + "Apple Container is an experimental feature. Use --experimental flag.", + )); + } + Err(MxcError::unsupported_containment( + "Apple Container configuration is recognized, but streaming execution is not implemented", + )) + } other => Err(MxcError::unsupported_containment(format!( "the mxc engine does not yet support streaming for the '{}' backend", other.wire_name() @@ -336,6 +346,26 @@ mod tests { assert_eq!(err.code, MxcErrorCode::MalformedRequest); } + #[test] + fn streaming_apple_container_is_gated_then_unimplemented() { + let mut request = build_request(&minimal_policy(), None).expect("build_request"); + request.inner.containment = ContainmentBackend::AppleContainer; + let mut logger = Logger::new(Mode::Buffer); + + let error = match spawn_runner(&request.inner, &mut logger) { + Ok(_) => panic!("Apple Container must require experimental opt-in"), + Err(error) => error, + }; + assert_eq!(error.code, MxcErrorCode::MalformedRequest); + + request.inner.experimental_enabled = true; + let error = match spawn_runner(&request.inner, &mut logger) { + Ok(_) => panic!("Apple Container streaming must remain unimplemented"), + Err(error) => error, + }; + assert_eq!(error.code, MxcErrorCode::UnsupportedContainment); + } + #[test] fn streaming_rejects_unsupported_containment() { // LXC has no streaming path in the library; selecting it must surface a diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 259224b1f1..d06f3b5726 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -47,9 +47,10 @@ pub use platform::isolation_session_available; pub use platform::{platform_support, PlatformSupport}; pub use policy::{ available_tools_policy, build_request, build_request_with_containment, temporary_files_policy, - user_profile_policy, Containment, FilesystemPolicyResult, NetworkAction, NetworkEgressSection, - NetworkIngressSection, NetworkPeerSection, NetworkPortSection, NetworkProtocol, - NetworkRuleSection, RuntimeConfigSection, SandboxPolicy, SandboxRequest, WslcSection, + user_profile_policy, AppleContainerSection, Containment, FilesystemPolicyResult, NetworkAction, + NetworkEgressSection, NetworkIngressSection, NetworkPeerSection, NetworkPortSection, + NetworkProtocol, NetworkRuleSection, RuntimeConfigSection, SandboxPolicy, SandboxRequest, + WslcSection, }; pub use probe::{available_backends, AvailableBackend, BackendCapability}; #[cfg(target_os = "windows")] diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 77f99f60c7..4b5796b538 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -479,6 +479,12 @@ pub enum Containment { /// **Experimental** — the request must have experimental features enabled /// ([`SandboxRequest::set_experimental`]) or the spawn is rejected. Wslc(WslcSection), + /// Apple Container backend, configured by [`AppleContainerSection`]. + /// + /// **Experimental** — the request must have experimental features enabled. + /// The initial config-only implementation rejects execution until the + /// Apple Container management and runtime layers are added. + AppleContainer(AppleContainerSection), /// IsolationSession backend: a Windows isolated user session. /// /// Not served by [`crate::spawn`]; reach it through the state-aware @@ -486,6 +492,31 @@ pub enum Containment { IsolationSession, } +/// Apple Container settings, mirroring `experimental.apple_container`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppleContainerSection { + /// OCI image reference. The image must provide `/bin/sh`. + pub image: String, + /// Requested virtual CPU count. `None` lets Apple Container choose. + pub cpu_count: Option, + /// Requested memory limit in megabytes. `None` lets Apple Container choose. + pub memory_mb: Option, +} + +impl AppleContainerSection { + fn wire(&self) -> serde_json::Value { + use serde_json::json; + let mut config = json!({ "image": self.image }); + if let Some(cpu_count) = self.cpu_count { + config["cpuCount"] = json!(cpu_count); + } + if let Some(memory_mb) = self.memory_mb { + config["memoryMb"] = json!(memory_mb); + } + config + } +} + /// WSL Container settings carried by [`Containment::Wslc`]. /// /// [`Default`] matches the native backend's defaults: the `alpine:latest` @@ -681,6 +712,10 @@ impl SandboxRequest { /// Enable (or disable) experimental features for this request — the /// analogue of the SDK's `SandboxSpawnOptions.experimental` and the /// executor's `--experimental` flag. + /// + /// Experimental backends (today: [`Containment::Wslc`] and + /// [`Containment::AppleContainer`]) refuse to spawn unless this is set, so + /// the gate stays fail-closed. pub fn set_experimental(&mut self, enabled: bool) -> &mut Self { self.inner.experimental_enabled = enabled; self @@ -812,6 +847,7 @@ pub(crate) fn build_wire_config( Containment::Process => cfg!(any(target_os = "linux", target_os = "macos")), Containment::ProcessContainer(_) => false, Containment::Wslc(_) => true, + Containment::AppleContainer(_) => true, Containment::IsolationSession => false, }; @@ -912,6 +948,9 @@ pub(crate) fn build_wire_config( "processcontainer", )?, Containment::Wslc(wslc) => apply_wslc_backend(&mut config, wslc), + Containment::AppleContainer(apple_container) => { + apply_apple_container_backend(&mut config, apple_container) + } Containment::IsolationSession => { config["containment"] = serde_json::json!("isolation_session"); } @@ -980,6 +1019,15 @@ fn apply_wslc_backend(config: &mut serde_json::Value, wslc: &WslcSection) { config["experimental"] = json!({ "wslc": wslc.wire() }); } +fn apply_apple_container_backend( + config: &mut serde_json::Value, + apple_container: &AppleContainerSection, +) { + use serde_json::json; + config["containment"] = json!("apple_container"); + config["experimental"] = json!({ "apple_container": apple_container.wire() }); +} + /// Promote network enforcement to `firewall` when host rules are present and /// no cooperative proxy is configured — the Linux counterpart of the SDK's /// `applyLinuxNetworkPolicy`. @@ -1043,6 +1091,82 @@ mod tests { ); } + #[test] + fn apple_container_builder_maps_required_and_optional_config() { + let policy: super::SandboxPolicy = + serde_json::from_str(r#"{ "version": "0.9.0-alpha" }"#).expect("minimal policy parses"); + let section = super::AppleContainerSection { + image: "docker.io/library/alpine:3.23".to_string(), + cpu_count: Some(2), + memory_mb: Some(1024), + }; + + let request = super::build_request_with_containment( + &policy, + &super::Containment::AppleContainer(section), + Some("apple-test"), + ) + .expect("Apple Container config builds"); + + assert_eq!( + request.inner.containment, + wxc_common::models::ContainmentBackend::AppleContainer + ); + let config = request + .inner + .experimental + .apple_container + .expect("Apple Container config should be present"); + assert_eq!(config.image, "docker.io/library/alpine:3.23"); + assert_eq!(config.cpu_count, Some(2)); + assert_eq!(config.memory_mb, Some(1024)); + } + + #[test] + fn proxy_builtin_test_server_true_is_accepted() { + let spec: ProxySpec = + serde_json::from_str(r#"{ "builtinTestServer": true }"#).expect("true is valid"); + assert!(matches!(spec, ProxySpec::BuiltinTestServer)); + } + + #[test] + fn proxy_builtin_test_server_false_is_rejected() { + // An explicit `false` must not silently select the (experimental, + // deliberately-permissive) built-in proxy — it is rejected as malformed. + let err = serde_json::from_str::(r#"{ "builtinTestServer": false }"#) + .expect_err("false must be rejected"); + assert!( + err.to_string().contains("builtinTestServer must be true"), + "unexpected error: {err}" + ); + } + + #[test] + fn proxy_conflicting_modes_are_rejected() { + // Several modes at once must be rejected (cr-005), not silently reduced + // to the first matching one. + let err = serde_json::from_str::( + r#"{ "builtinTestServer": true, "localhost": 8080 }"#, + ) + .expect_err("conflicting proxy modes must be rejected"); + assert!( + err.to_string().contains("exactly one"), + "unexpected error: {err}" + ); + } + + #[test] + fn proxy_localhost_and_url_still_parse() { + assert!(matches!( + serde_json::from_str::(r#"{ "localhost": 8080 }"#).expect("localhost"), + ProxySpec::Localhost(8080) + )); + assert!(matches!( + serde_json::from_str::(r#"{ "url": "http://proxy" }"#).expect("url"), + ProxySpec::Url(_) + )); + } + #[cfg(target_os = "windows")] #[test] fn powershell_policy_grants_system_drive_root() { diff --git a/src/core/mxc_engine/src/run.rs b/src/core/mxc_engine/src/run.rs index aa958ebe56..2913004b36 100644 --- a/src/core/mxc_engine/src/run.rs +++ b/src/core/mxc_engine/src/run.rs @@ -23,8 +23,9 @@ //! //! Backend selection is per-host: the Windows body drives the ProcessContainer //! fallback tiers plus the Windows experimental backends; the Linux body -//! mirrors `lxc-exec` (Bubblewrap / LXC / experimental); the macOS body always -//! resolves to Seatbelt. +//! mirrors `lxc-exec` (Bubblewrap / LXC / experimental); the macOS body retains +//! its Seatbelt fallback except for explicit Apple Container requests, which +//! must never run under a weaker backend. use wxc_common::logger::Logger; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] @@ -208,6 +209,7 @@ fn resolve_runner_inner_windows( ContainmentBackend::Seatbelt => Err(MxcError::unsupported_containment( "Seatbelt backend is only available on macOS (use mxc-exec-mac)", )), + ContainmentBackend::AppleContainer => reject_unimplemented_apple_container(request), ContainmentBackend::Vm => Err(MxcError::unsupported_containment( "VM backend not yet implemented", )), @@ -296,6 +298,7 @@ fn resolve_runner_inner( "MicroVM is an experimental feature. Use --experimental flag.", )); } + #[cfg(feature = "microvm")] { Ok(ResolvedRunner::without_guard(Box::new( @@ -319,6 +322,7 @@ fn resolve_runner_inner( &request.lifecycle, ), ))), + ContainmentBackend::AppleContainer => reject_unimplemented_apple_container(request), ref other => { logger.log_line(&format!( "Note: containment {other:?} unsupported on lxc-exec; falling back to LXC." @@ -335,8 +339,9 @@ fn resolve_runner_inner( } // --------------------------------------------------------------------------- -// macOS: always Seatbelt (the SDK selects it on darwin; be lenient and log a -// note if the request asked for something else). +// macOS: retain the existing Seatbelt fallback while Apple Container is +// recognized but deliberately unavailable until its management/runtime +// increments land. // --------------------------------------------------------------------------- #[cfg(target_os = "macos")] @@ -346,6 +351,9 @@ fn resolve_runner_inner( ) -> Result { use wxc_common::sandbox_process::Runner; + if request.containment == ContainmentBackend::AppleContainer { + return reject_unimplemented_apple_container(request); + } if request.containment != ContainmentBackend::Seatbelt { logger.log_line("Note: Overriding containment backend to Seatbelt on macOS."); } @@ -354,6 +362,62 @@ fn resolve_runner_inner( )))) } +fn reject_unimplemented_apple_container( + request: &ExecutionRequest, +) -> Result { + if !request.experimental_enabled { + return Err(MxcError::malformed_request( + "Apple Container is an experimental feature. Use --experimental flag.", + )); + } + + Err(MxcError::unsupported_containment( + "Apple Container configuration is recognized, but runtime execution is not implemented", + )) +} + +#[cfg(all(test, target_os = "macos"))] +mod macos_tests { + use super::*; + use wxc_common::logger::Mode; + use wxc_common::mxc_error::MxcErrorCode; + + #[test] + fn apple_container_requires_experimental_opt_in() { + let request = ExecutionRequest { + containment: ContainmentBackend::AppleContainer, + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + + let error = match resolve_runner_inner(&request, &mut logger) { + Ok(_) => panic!("Apple Container must require experimental opt-in"), + Err(error) => error, + }; + + assert_eq!(error.code, MxcErrorCode::MalformedRequest); + assert!(error.message.contains("--experimental")); + } + + #[test] + fn apple_container_does_not_fall_back_to_seatbelt() { + let request = ExecutionRequest { + containment: ContainmentBackend::AppleContainer, + experimental_enabled: true, + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + + let error = match resolve_runner_inner(&request, &mut logger) { + Ok(_) => panic!("Apple Container must not fall back to Seatbelt"), + Err(error) => error, + }; + + assert_eq!(error.code, MxcErrorCode::UnsupportedContainment); + assert!(error.message.contains("not implemented")); + } +} + // --------------------------------------------------------------------------- // Any other host: no backend. // --------------------------------------------------------------------------- diff --git a/src/core/wxc_common/src/cmdline.rs b/src/core/wxc_common/src/cmdline.rs index 4eeb79d76f..97475d0ec6 100644 --- a/src/core/wxc_common/src/cmdline.rs +++ b/src/core/wxc_common/src/cmdline.rs @@ -42,7 +42,8 @@ impl CommandLineContext { ContainmentBackend::Wslc | ContainmentBackend::Lxc | ContainmentBackend::Seatbelt - | ContainmentBackend::Bubblewrap => Self::PosixShell, + | ContainmentBackend::Bubblewrap + | ContainmentBackend::AppleContainer => Self::PosixShell, ContainmentBackend::ProcessContainer | ContainmentBackend::Vm | ContainmentBackend::MicroVm diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs index cfd71c8e21..6b97ba93d4 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs @@ -20,6 +20,7 @@ fn convert_containment(value: contract::OneShotContainment) -> wire::Containment contract::OneShotContainment::Hyperlight => wire::Containment::Hyperlight, contract::OneShotContainment::Wslc => wire::Containment::Wslc, contract::OneShotContainment::IsolationSession => wire::Containment::IsolationSession, + contract::OneShotContainment::AppleContainer => wire::Containment::AppleContainer, } } @@ -258,17 +259,32 @@ fn convert_wslc(value: contract::OneShotWslc) -> wire::Wslc { } } +fn convert_apple_container(value: contract::OneShotAppleContainer) -> wire::AppleContainer { + let contract::OneShotAppleContainer { + image, + cpu_count, + memory_mb, + } = value; + wire::AppleContainer { + image: image.into_inner(), + cpu_count: cpu_count.into_option().map(|value| value.get()), + memory_mb: memory_mb.into_option().map(|value| value.get()), + } +} + fn convert_experimental(value: contract::OneShotExperimental) -> wire::Experimental { let contract::OneShotExperimental { test, windows_sandbox, wslc, + apple_container, telemetry, } = value; wire::Experimental { test: test.into_option().map(convert_test), windows_sandbox: windows_sandbox.into_option().map(convert_windows_sandbox), wslc: wslc.into_option().map(convert_wslc), + apple_container: apple_container.into_option().map(convert_apple_container), isolation_session: None, seatbelt: None, telemetry: telemetry.into_option().map(convert_telemetry), diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs index 873ea82dff..553a0e0d95 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs @@ -64,6 +64,21 @@ const WSLC_REQUEST_JSON: &str = r#"{ } }"#; +const APPLE_CONTAINER_REQUEST_JSON: &str = r#"{ + "version": "0.9.0-alpha", + "containment": "apple_container", + "process": { + "commandLine": "echo hello" + }, + "experimental": { + "apple_container": { + "image": "docker.io/library/alpine:3.23", + "cpuCount": 2, + "memoryMb": 1024 + } + } +}"#; + #[test] fn windows_sandbox_maps_expected_wire_fields() { let wire = adapt(WINDOWS_SANDBOX_REQUEST_JSON); @@ -160,6 +175,25 @@ fn wslc_maps_expected_wire_fields() { assert!(experimental.telemetry.is_none()); } +#[test] +fn apple_container_maps_expected_wire_fields() { + let wire = adapt(APPLE_CONTAINER_REQUEST_JSON); + + assert!(matches!( + wire.containment, + Some(super::wire::Containment::AppleContainer) + )); + + let experimental = wire.experimental.expect("experimental should be populated"); + let apple_container = experimental + .apple_container + .expect("apple_container should be populated"); + + assert_eq!(apple_container.image, "docker.io/library/alpine:3.23"); + assert_eq!(apple_container.cpu_count, Some(2)); + assert_eq!(apple_container.memory_mb, Some(1024)); +} + #[test] fn windows_sandbox_matches_current_wire_deserialization() { assert_matches_current_wire_deserialization(WINDOWS_SANDBOX_REQUEST_JSON); @@ -170,6 +204,11 @@ fn wslc_matches_current_wire_deserialization() { assert_matches_current_wire_deserialization(WSLC_REQUEST_JSON); } +#[test] +fn apple_container_matches_current_wire_deserialization() { + assert_matches_current_wire_deserialization(APPLE_CONTAINER_REQUEST_JSON); +} + #[test] fn test_feature_and_telemetry_match_current_wire_deserialization() { assert_matches_current_wire_deserialization(TEST_FEATURE_AND_TELEMETRY_REQUEST_JSON); diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs index 8ad394b643..cea4cc2250 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs @@ -51,6 +51,7 @@ fn convert_isolation_session_provision_experimental( test: None, windows_sandbox: None, wslc: None, + apple_container: None, isolation_session: isolation_session .into_option() .map(convert_state_aware_isolation_session), @@ -84,6 +85,7 @@ fn convert_windows_sandbox_provision_experimental( test: None, windows_sandbox: None, wslc: None, + apple_container: None, isolation_session: None, seatbelt: None, telemetry: telemetry.into_option().map(convert_telemetry), @@ -124,6 +126,7 @@ fn convert_wslc_provision_experimental( test: None, windows_sandbox: None, wslc: wslc.into_option().map(convert_state_aware_wslc), + apple_container: None, isolation_session: None, seatbelt: None, telemetry: telemetry.into_option().map(convert_telemetry), @@ -136,6 +139,7 @@ fn convert_start_experimental(value: contract::StartExperimental) -> wire::Exper test: None, windows_sandbox: None, wslc: None, + apple_container: None, isolation_session: None, seatbelt: None, telemetry: telemetry.into_option().map(convert_telemetry), @@ -148,6 +152,7 @@ fn convert_exec_experimental(value: contract::ExecExperimental) -> wire::Experim test: None, windows_sandbox: None, wslc: None, + apple_container: None, isolation_session: None, seatbelt: None, telemetry: telemetry.into_option().map(convert_telemetry), @@ -160,6 +165,7 @@ fn convert_stop_experimental(value: contract::StopExperimental) -> wire::Experim test: None, windows_sandbox: None, wslc: None, + apple_container: None, isolation_session: None, seatbelt: None, telemetry: telemetry.into_option().map(convert_telemetry), @@ -174,6 +180,7 @@ fn convert_deprovision_experimental( test: None, windows_sandbox: None, wslc: None, + apple_container: None, isolation_session: None, seatbelt: None, telemetry: telemetry.into_option().map(convert_telemetry), diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 6295a90eff..fcd30d67e0 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -8,10 +8,10 @@ use crate::encoding::base64_decode; use crate::error::WxcError; use crate::logger::Logger; use crate::models::{ - CaptureDenialsConfig, CaptureDenialsMode, ContainerPolicy, ContainmentBackend, - ExecutionRequest, ExperimentalConfig, LifecycleConfig, LxcConfig, NetworkEnforcementMode, - NetworkPolicy, PortMapping, SeatbeltConfig, TelemetryConfig, TestFeatureConfig, UiPolicy, - WindowsSandboxConfig, WslcConfig, + AppleContainerConfig, CaptureDenialsConfig, CaptureDenialsMode, ContainerPolicy, + ContainmentBackend, ExecutionRequest, ExperimentalConfig, LifecycleConfig, LxcConfig, + NetworkEnforcementMode, NetworkPolicy, PortMapping, SeatbeltConfig, TelemetryConfig, + TestFeatureConfig, UiPolicy, WindowsSandboxConfig, WslcConfig, }; use crate::mxc_error::MxcError; use crate::network_parser::{ @@ -332,11 +332,18 @@ const SUPPORTED_VERSION: &str = ">=0.6, <=0.9"; #[cfg(test)] const CURRENT_SCHEMA_VERSION: &str = "0.9.0-alpha"; +const CURRENT_APPLE_CONTAINER_SCHEMA_VERSION: &str = "0.9.0-alpha"; + /// Known `experimental.` keys. Used by validation code to flag /// experimental backend sections that don't match the selected /// `containment`. Add a new entry when promoting a backend to a top-level /// section or graduating one from experimental. -const KNOWN_EXPERIMENTAL_BACKENDS: &[&str] = &["windows_sandbox", "wslc", "isolation_session"]; +const KNOWN_EXPERIMENTAL_BACKENDS: &[&str] = &[ + "windows_sandbox", + "wslc", + "isolation_session", + "apple_container", +]; /// Validate that the schema version (semver) is supported by this binary. /// Compares major.minor only — patch and pre-release labels are ignored. @@ -515,6 +522,9 @@ fn present_backend_sections(cfg: &wire::MxcConfig) -> Vec<&'static str> { if experimental.wslc.is_some() { push(ContainmentBackend::Wslc); } + if experimental.apple_container.is_some() { + push(ContainmentBackend::AppleContainer); + } if experimental.isolation_session.is_some() { push(ContainmentBackend::IsolationSession); } @@ -786,6 +796,18 @@ fn convert_wire_config( let containment = map_wire_containment(cfg.containment.as_ref()); validate_single_backend_section(containment.clone(), &present_backend_sections)?; + if containment == ContainmentBackend::AppleContainer { + if schema_version != CURRENT_APPLE_CONTAINER_SCHEMA_VERSION { + return Err(WxcError::ConfigParse(format!( + "Apple Container requires config version '{CURRENT_APPLE_CONTAINER_SCHEMA_VERSION}'" + ))); + } + if !present_backend_sections.contains(&"experimental.apple_container") { + return Err(WxcError::ConfigParse( + "Apple Container requires the 'experimental.apple_container' section".to_string(), + )); + } + } // LXC configuration let lxc_config = match cfg.lxc { @@ -1343,6 +1365,30 @@ fn convert_wire_config( } else { None }; + let apple_container = if let Some(ac) = raw_exp.apple_container { + if ac.image.trim().is_empty() { + return Err(WxcError::ConfigParse( + "experimental.apple_container.image must not be empty".to_string(), + )); + } + if ac.cpu_count == Some(0) { + return Err(WxcError::ConfigParse( + "experimental.apple_container.cpuCount must be greater than zero".to_string(), + )); + } + if ac.memory_mb == Some(0) { + return Err(WxcError::ConfigParse( + "experimental.apple_container.memoryMb must be greater than zero".to_string(), + )); + } + Some(AppleContainerConfig { + image: ac.image, + cpu_count: ac.cpu_count, + memory_mb: ac.memory_mb, + }) + } else { + None + }; if raw_exp.seatbelt.is_some() { let msg = "'experimental.seatbelt' has moved to the stable section; \ use top-level 'seatbelt' instead." @@ -1356,6 +1402,7 @@ fn convert_wire_config( test, windows_sandbox, wslc, + apple_container, telemetry, } } else { @@ -6156,6 +6203,88 @@ mod tests { ); } + #[test] + fn apple_container_config_maps_to_domain_model() { + let json = r#"{ + "version": "0.9.0-alpha", + "containment": "apple_container", + "process": {"commandLine": "echo hi"}, + "experimental": { + "apple_container": { + "image": "docker.io/library/alpine:3.23", + "cpuCount": 2, + "memoryMb": 1024 + } + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let request = load_request(&encoded, &mut logger, true).unwrap(); + + assert_eq!(request.containment, ContainmentBackend::AppleContainer); + let config = request + .experimental + .apple_container + .expect("Apple Container config should be present"); + assert_eq!(config.image, "docker.io/library/alpine:3.23"); + assert_eq!(config.cpu_count, Some(2)); + assert_eq!(config.memory_mb, Some(1024)); + } + + #[test] + fn apple_container_requires_version_and_backend_section() { + for json in [ + r#"{ + "containment": "apple_container", + "process": {"commandLine": "echo hi"}, + "experimental": {"apple_container": {"image": "alpine:3.23"}} + }"#, + r#"{ + "version": "0.7.0-alpha", + "containment": "apple_container", + "process": {"commandLine": "echo hi"}, + "experimental": {"apple_container": {"image": "alpine:3.23"}} + }"#, + r#"{ + "version": "0.8.0-alpha", + "containment": "apple_container", + "process": {"commandLine": "echo hi"}, + "experimental": {"apple_container": {"image": "alpine:3.23"}} + }"#, + r#"{ + "version": "0.9.0-alpha", + "containment": "apple_container", + "process": {"commandLine": "echo hi"} + }"#, + ] { + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + assert!(load_request(&encoded, &mut logger, true).is_err()); + } + } + + #[test] + fn apple_container_rejects_empty_image_and_zero_resources() { + for fields in [ + r#""image": """#, + r#""image": "alpine:3.23", "cpuCount": 0"#, + r#""image": "alpine:3.23", "memoryMb": 0"#, + ] { + let json = format!( + r#"{{ + "version": "0.9.0-alpha", + "containment": "apple_container", + "process": {{"commandLine": "echo hi"}}, + "experimental": {{"apple_container": {{{fields}}}}} + }}"# + ); + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + assert!(load_request(&encoded, &mut logger, true).is_err()); + } + } + #[test] fn containment_seatbelt_accepted() { let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "seatbelt"}"#; diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 02465f6607..2bed53348b 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -48,6 +48,10 @@ pub enum ContainmentBackend { /// create namespace-isolated processes without root privileges. /// Selected on the wire as `"bubblewrap"`. Bubblewrap, + /// Apple Container — Linux OCI workload in a Virtualization.framework VM. + /// Experimental and available only on Apple silicon macOS hosts. + #[serde(rename = "apple_container")] + AppleContainer, } impl ContainmentBackend { @@ -64,6 +68,7 @@ impl ContainmentBackend { ContainmentBackend::IsolationSession => "isolation_session", ContainmentBackend::Seatbelt => "seatbelt", ContainmentBackend::Bubblewrap => "bubblewrap", + ContainmentBackend::AppleContainer => "apple_container", } } @@ -76,6 +81,7 @@ impl ContainmentBackend { ContainmentBackend::Lxc => Some("lxc"), ContainmentBackend::WindowsSandbox => Some("experimental.windows_sandbox"), ContainmentBackend::Wslc => Some("experimental.wslc"), + ContainmentBackend::AppleContainer => Some("experimental.apple_container"), ContainmentBackend::Seatbelt => Some("seatbelt"), ContainmentBackend::IsolationSession => Some("experimental.isolation_session"), ContainmentBackend::Bubblewrap @@ -130,6 +136,7 @@ impl From for ContainmentBackend { W::Seatbelt => Self::Seatbelt, W::IsolationSession => Self::IsolationSession, W::Bubblewrap => Self::Bubblewrap, + W::AppleContainer => Self::AppleContainer, } } } @@ -867,6 +874,18 @@ pub struct WslcConfig { pub port_mappings: Vec, } +/// Configuration for the experimental Apple Container backend. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppleContainerConfig { + /// OCI image reference. The image must provide `/bin/sh`. + pub image: String, + /// Requested virtual CPU count. `None` lets Apple Container choose. + pub cpu_count: Option, + /// Requested memory limit in megabytes. `None` lets Apple Container choose. + pub memory_mb: Option, +} + impl Default for WslcConfig { fn default() -> Self { Self { @@ -928,6 +947,8 @@ pub struct ExperimentalConfig { pub windows_sandbox: Option, /// WSL Container (WSLC SDK) backend (experimental). pub wslc: Option, + /// Apple Container backend (experimental, macOS). + pub apple_container: Option, /// Telemetry configuration (experimental). pub telemetry: Option, } diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index eb7cc18903..88b40791c8 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -161,6 +161,8 @@ pub enum Containment { IsolationSession, /// Unprivileged Linux bubblewrap sandbox. Bubblewrap, + /// Apple Container VM backend (experimental, macOS). + AppleContainer, } /// Process execution settings. @@ -584,6 +586,8 @@ pub struct Experimental { pub windows_sandbox: Option, /// WSL container backend config. pub wslc: Option, + /// Apple Container backend config (macOS). + pub apple_container: Option, /// IsolationSession backend config (Windows). pub isolation_session: Option, /// Seatbelt backend config (pre-promotion alias). @@ -655,6 +659,41 @@ pub struct Wslc { pub provision: Option, } +/// Apple Container backend config. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct AppleContainer { + /// OCI image reference. The image must provide `/bin/sh`. + #[cfg_attr( + feature = "schema-gen", + schemars(schema_with = "non_blank_string_schema") + )] + pub image: String, + /// Requested virtual CPU count. + #[cfg_attr(feature = "schema-gen", schemars(range(min = 1)))] + pub cpu_count: Option, + /// Requested memory limit in megabytes. + #[cfg_attr(feature = "schema-gen", schemars(range(min = 1)))] + pub memory_mb: Option, +} + +#[cfg(feature = "schema-gen")] +fn non_blank_string_schema( + _generator: &mut schemars::gen::SchemaGenerator, +) -> schemars::schema::Schema { + use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, StringValidation}; + + Schema::Object(SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + string: Some(Box::new(StringValidation { + pattern: Some(r"\S".to_string()), + ..Default::default() + })), + ..Default::default() + }) +} + /// Per-phase WSLc **provision** configuration (state-aware lifecycle), nested /// under `experimental.wslc.provision`. Carries only what the amortized daemon /// session honors: the container image (or a local tarball to import). @@ -772,9 +811,46 @@ mod schema_gen { let schema = schemars::schema_for!(MxcConfig); let mut value = serde_json::to_value(&schema).expect("schema serialises to JSON value"); mxc_schema_support::prepare_schema(&mut value, SCHEMA_ID); + require_apple_container_settings(&mut value); value } + fn require_apple_container_settings(schema: &mut serde_json::Value) { + let root = schema + .as_object_mut() + .expect("generated schema root is an object"); + let constraints = root + .entry("allOf") + .or_insert_with(|| serde_json::Value::Array(Vec::new())) + .as_array_mut() + .expect("root allOf constraints"); + constraints.push(serde_json::json!({ + "if": { + "properties": { + "containment": { + "const": "apple_container" + } + }, + "required": ["containment"] + }, + "then": { + "properties": { + "experimental": { + "allOf": [ + { + "$ref": "#/definitions/Experimental" + }, + { + "required": ["apple_container"] + } + ] + } + }, + "required": ["experimental"] + } + })); + } + /// Emit the SDK's wire TypeScript types directly from the same generated schema /// model — no third-party generator. The output is a drift oracle /// (`sdk/node/src/generated/wire.ts`): the SDK's hand-written public types are @@ -786,6 +862,37 @@ mod schema_gen { let value = schema_value(); mxc_schema_support::emit_ts(&value) } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn apple_container_schema_matches_runtime_requirements() { + let schema = schema_value(); + + assert_eq!( + schema["definitions"]["AppleContainer"]["properties"]["image"]["pattern"], + r"\S" + ); + + let constraints = schema["allOf"].as_array().expect("root allOf constraints"); + let apple_container = constraints + .iter() + .find(|constraint| { + constraint["if"]["properties"]["containment"]["const"] == "apple_container" + }) + .expect("Apple Container constraint"); + assert_eq!( + apple_container["then"]["required"], + serde_json::json!(["experimental"]) + ); + assert_eq!( + apple_container["then"]["properties"]["experimental"]["allOf"][1]["required"], + serde_json::json!(["apple_container"]) + ); + } + } } #[cfg(feature = "schema-gen")]