diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9624b8926..34dfa4e22 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -121,8 +121,9 @@ The SDK auto-discovers native binaries by checking `sdk/bin//` (n ### Schema system -- **Stable schemas**: released, immutable schemas live in [`schemas/stable/`](../schemas/stable) (one file per released version, plus a `-strict` view) — never edit them after release. +- **Stable schemas**: released, immutable schemas live in [`schemas/stable/`](../schemas/stable) (one file per released version) — never edit them after release. - **Dev schema**: the in-progress schema lives in [`schemas/dev/`](../schemas/dev). It is **generated** from the Rust wire model (`src/core/wxc_common/src/wire.rs`) by the `mxc_schema_gen` tool — **do not hand-edit it**. To change the dev schema, edit the wire model and regenerate with `cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schemas/dev/mxc-config.schema..json`. `scripts/versioning/check-schema-codegen.js` is a CI gate that regenerates and fails if the committed schema drifts. See [`docs/schema-codegen.md`](../docs/schema-codegen.md). +- **Generated SDK wire types**: `sdk/src/generated/wire.ts` is **generated** from the same wire model by the `mxc_schema_gen --ts` TypeScript emitter (`wxc_common::ts_emit`, no third-party generator) — **do not hand-edit it**. It is a drift oracle (not public API); the SDK unit test `sdk/tests/unit/wire-conformance.test.ts` asserts the hand-written public types in `sdk/src/types.ts` conform to it, and `scripts/versioning/check-sdk-types-codegen.js` is a CI gate that fails if the committed file drifts. Regenerate with `cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --ts sdk/src/generated/wire.ts`. - **Canonical schema-version source**: `schemas/schema-version.json` — the single source of truth for the schema-version constants (min/maxSupported/state-aware/stable/dev). `scripts/versioning/check-schema-versions.js` enforces that the Rust parser, SDK, and schema filenames all agree with it; do not hand-edit a schema-version constant without updating the canonical file. See [`docs/versioning.md`](../docs/versioning.md) for the full design. - Config files can reference schemas via `"$schema"` for editor validation. `scripts/versioning/validate-configs.js` validates the `tests/examples` + `tests/configs` corpus against the dev schema in CI. diff --git a/.github/workflows/Versioning.Checks.Job.yml b/.github/workflows/Versioning.Checks.Job.yml index cfb086571..eafefcd3e 100644 --- a/.github/workflows/Versioning.Checks.Job.yml +++ b/.github/workflows/Versioning.Checks.Job.yml @@ -33,5 +33,8 @@ jobs: - name: Check schema is in sync with the Rust wire model (codegen) run: node scripts/versioning/check-schema-codegen.js + - name: Check SDK wire types are in sync with the Rust wire model (codegen) + run: node scripts/versioning/check-sdk-types-codegen.js + - name: Validate config corpus against dev schema run: node scripts/versioning/validate-configs.js diff --git a/docs/schema-codegen.md b/docs/schema-codegen.md index a5ec9f0b6..0bcad0641 100644 --- a/docs/schema-codegen.md +++ b/docs/schema-codegen.md @@ -102,6 +102,22 @@ rules — gaps consciously owned by the parser. The equivalence is not a one-time review: the codegen gate regenerates the schema from the types on every CI run, and the corpus gate pins the accept-side behavior. +## Generated SDK types (drift oracle, Rust emitter) + +The SDK's wire TypeScript types are generated too — by a **Rust emitter**, with +no third-party generator. `mxc_schema_gen --ts` walks the same generated schema +value and `wxc_common::ts_emit` emits `sdk/src/generated/wire.ts`. That file is +**not public API** — it is a drift oracle. The unit test +`sdk/tests/unit/wire-conformance.test.ts` asserts (at `tsc` time) that the +hand-written public types in `sdk/src/types.ts` still conform to it, and +`check-sdk-types-codegen.js` is a CI gate (running the emitter and diffing the +committed file) that fails on drift. So a wire-model change ripples to all three +surfaces — Rust ⇄ schema ⇄ TS — and a forgotten SDK update fails CI instead of +drifting silently. The emitter handles only the JSON Schema constructs the MXC +schema uses (enums, closed/open objects, `$ref`, `anyOf [T, null]`, arrays, +scalars); extending the wire model with a new construct may require teaching the +emitter about it. + ## Roadmap - The wire model generates the committed dev schema, guarded by the codegen and @@ -109,5 +125,7 @@ CI run, and the corpus gate pins the accept-side behavior. - The parser deserializes directly into the wire model and the `Raw*` structs are gone, so the schema source and the trust boundary share one definition of the wire shape and cannot drift. -- Next: generate the SDK TypeScript types from the same schema and retire the - hand-maintained `*-strict.json` stable view. +- The SDK TypeScript wire types are generated from the same wire model + (`sdk/src/generated/wire.ts`, via the `wxc_common::ts_emit` Rust emitter), + guarded by a conformance test plus the `check-sdk-types-codegen.js` gate, and + the hand-maintained `*-strict.json` stable view has been retired. diff --git a/schemas/stable/mxc-config.schema.0.5.0-alpha-strict.json b/schemas/stable/mxc-config.schema.0.5.0-alpha-strict.json deleted file mode 100644 index 58a4d3002..000000000 --- a/schemas/stable/mxc-config.schema.0.5.0-alpha-strict.json +++ /dev/null @@ -1,250 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/microsoft/mxc/schemas/stable/mxc-config.schema.0.5.0-alpha-strict.json", - "title": "MXC Configuration", - "description": "Strict view of the 0.5.0-alpha contract: only the non-experimental backends and the stable fields. Identical wire-version-compatible alternative to mxc-config.schema.0.5.0-alpha.json (which is preserved unmodified per the immutability rule). Point $schema here when you want editor validation to flag experimental shapes as errors; use the original 0.5.0-alpha.json (or, preferably, the dev schema) when you genuinely use experimental fields.", - "type": "object", - - "required": ["process"], - - "properties": { - "version": { - "type": "string", - "description": "MXC config schema version (semver). Current: '0.5.0-alpha'.", - "examples": ["0.5.0-alpha"] - }, - "containerId": { - "type": "string", - "description": "Externally assigned container identifier." - }, - "containment": { - "type": "string", - "enum": ["appcontainer", "lxc"], - "default": "appcontainer", - "description": "Containment backend to use for execution. This strict view lists only the non-experimental backends. The parser additionally accepts 'windows_sandbox', 'wslc', 'microvm', and 'macos_sandbox' (experimental — require --experimental at runtime); validate against the dev schema if you use them." - }, - - "lifecycle": { - "type": "object", - "description": "Container lifecycle settings shared across all backends.", - "properties": { - "destroyOnExit": { - "type": "boolean", - "default": true, - "description": "Destroy the container after execution completes." - }, - "preservePolicy": { - "type": "boolean", - "default": false, - "description": "If true, retain filesystem and network policies after execution. If false (default), policies are cleaned up on exit." - } - } - }, - - "process": { - "type": "object", - "description": "Process execution settings.", - "required": ["commandLine"], - "properties": { - "commandLine": { - "type": "string", - "minLength": 1, - "description": "Complete command line to execute (e.g., \"python3 app.py\")." - }, - "cwd": { - "type": "string", - "description": "Working directory for the process." - }, - "env": { - "type": "array", - "items": { "type": "string" }, - "description": "Environment variables as KEY=VALUE strings.", - "examples": [["MY_VAR=value", "PATH=/usr/bin"]] - }, - "timeout": { - "type": "integer", - "minimum": 0, - "default": 0, - "description": "Execution timeout in milliseconds. 0 = no timeout (infinite)." - } - } - }, - - "filesystem": { - "type": "object", - "description": "Filesystem access policy. Shared across all backends.", - "properties": { - "readwritePaths": { - "type": "array", - "items": { "type": "string" }, - "default": [], - "description": "Paths the process can read and write." - }, - "readonlyPaths": { - "type": "array", - "items": { "type": "string" }, - "default": [], - "description": "Paths the process can read but not write." - }, - "deniedPaths": { - "type": "array", - "items": { "type": "string" }, - "default": [], - "description": "Paths the process cannot access at all." - } - } - }, - - "network": { - "type": "object", - "description": "Network access policy. Shared across all backends.", - "properties": { - "defaultPolicy": { - "type": "string", - "enum": ["allow", "block"], - "default": "allow", - "description": "Default network posture." - }, - "enforcementMode": { - "type": "string", - "enum": ["capabilities", "firewall", "both"], - "default": "capabilities", - "description": "How network policy is enforced." - }, - "allowedHosts": { - "type": "array", - "items": { "type": "string" }, - "description": "Hostnames or IP/CIDR blocks to allow (when defaultPolicy is 'block'). Enforced by 'lxc' and 'appcontainer' containment backends." - }, - "blockedHosts": { - "type": "array", - "items": { "type": "string" }, - "description": "Hostnames or IP addresses to block (when defaultPolicy is 'allow'). Enforced by 'lxc' and 'appcontainer' containment backends." - }, - "proxy": { - "type": "object", - "description": "Proxy configuration. Only supported with appcontainer backend.", - "oneOf": [ - { - "properties": { - "localhost": { - "type": "integer", - "minimum": 1, - "maximum": 65535, - "description": "Port of an external already running proxy on localhost." - } - }, - "required": ["localhost"], - "additionalProperties": false - }, - { - "properties": { - "builtinTestServer": { - "type": "boolean", - "const": true, - "description": "Use WXC's built-in test proxy server. Mutually exclusive with localhost." - } - }, - "required": ["builtinTestServer"], - "additionalProperties": false - }, - { - "properties": { - "url": { - "type": "string", - "description": "Full proxy URL including port (e.g., http://proxy.example.com:8080)." - } - }, - "required": ["url"], - "additionalProperties": false - } - ] - } - } - }, - - "ui": { - "type": "object", - "description": "Cross-platform UI policy. Mapped from SandboxPolicy.ui by createConfigFromPolicy.", - "properties": { - "disable": { - "type": "boolean", - "default": true, - "description": "Whether UI is disabled (no visible windows)." - }, - "clipboard": { - "type": "string", - "enum": ["none", "read", "write", "all"], - "default": "none", - "description": "Clipboard access level." - }, - "injection": { - "type": "boolean", - "default": false, - "description": "Whether input injection (keyboard/mouse) is allowed." - } - } - }, - - "appContainer": { - "type": "object", - "description": "AppContainer-specific settings. Used when containment is 'appcontainer'.", - "properties": { - "leastPrivilege": { - "type": "boolean", - "default": false, - "description": "Enforce least privilege mode." - }, - "capabilities": { - "type": "array", - "items": { "type": "string" }, - "default": [], - "description": "AppContainer capabilities (e.g., 'internetClient', 'registryRead')." - }, - "ui": { - "type": "object", - "description": "BaseProcessContainer-specific UI settings (Windows only).", - "properties": { - "isolation": { - "type": "string", - "enum": ["desktop", "handles", "atoms", "container"], - "default": "container", - "description": "UI isolation level for the desktop." - }, - "desktopSystemControl": { - "type": "boolean", - "default": false, - "description": "Whether desktop system control is allowed." - }, - "systemSettings": { - "type": "string", - "default": "none", - "description": "System settings access level." - }, - "ime": { - "type": "boolean", - "default": false, - "description": "Whether IME (Input Method Editor) is allowed." - } - } - } - } - }, - - "lxc": { - "type": "object", - "description": "LXC-specific settings. Used when containment is 'lxc'.", - "required": ["distribution", "release"], - "properties": { - "distribution": { - "type": "string", - "description": "Linux distribution for the container rootfs (e.g., 'alpine', 'ubuntu'). Required." - }, - "release": { - "type": "string", - "description": "Distribution release version (e.g., '3.20', '24.04'). Required." - } - } - } - } -} diff --git a/scripts/versioning/check-sdk-types-codegen.js b/scripts/versioning/check-sdk-types-codegen.js new file mode 100644 index 000000000..88307028f --- /dev/null +++ b/scripts/versioning/check-sdk-types-codegen.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// SDK wire-types codegen gate (Phase 2C, option C): the committed +// `sdk/src/generated/wire.ts` must be identical (modulo line endings) to the +// output of the Rust TypeScript emitter (`mxc_schema_gen --ts`), so the SDK's +// drift oracle can never go stale relative to the Rust wire model. Unlike +// options A/B, this uses NO third-party generator — the emitter lives in +// `wxc_common::ts_emit`. +// +// Mirrors `check-schema-codegen.js`. Run from anywhere: +// node scripts/versioning/check-sdk-types-codegen.js + +const { readFileSync, mkdtempSync, rmSync } = require("fs"); +const { join } = require("path"); +const os = require("os"); +const { execFileSync } = require("child_process"); + +const repoRoot = join(__dirname, "..", ".."); +const committedPath = join(repoRoot, "sdk", "src", "generated", "wire.ts"); + +function fail(msg) { + console.error("SDK wire-types codegen check FAILED:"); + console.error(" - " + msg); + process.exit(1); +} + +let committed; +try { + committed = readFileSync(committedPath, "utf8"); +} catch (e) { + fail(`could not read committed ${committedPath}: ${e.message}`); +} + +const tmpDir = mkdtempSync(join(os.tmpdir(), "mxc-ts-emit-")); +const tmpOut = join(tmpDir, "wire.ts"); +try { + // Build + run the emitter. Quiet so only our diagnostics surface. + execFileSync( + "cargo", + ["run", "-q", "-p", "mxc_schema_gen", "--", "--ts", tmpOut], + { cwd: join(repoRoot, "src"), stdio: ["ignore", "ignore", "inherit"] } + ); + const generated = readFileSync(tmpOut, "utf8"); + + // Compare modulo line endings: the file is committed with LF, but a Windows + // checkout with core.autocrlf=true has CRLF in the working tree. The emitter + // always writes LF. + const normalize = (s) => s.replace(/\r\n/g, "\n"); + if (normalize(generated) !== normalize(committed)) { + const g = normalize(generated).split("\n"); + const c = normalize(committed).split("\n"); + let line = 0; + while (line < g.length && line < c.length && g[line] === c[line]) line++; + fail( + `committed SDK wire types are stale at ${committedPath}.\n` + + ` First difference at line ${line + 1}:\n` + + ` committed: ${JSON.stringify(c[line])}\n` + + ` generated: ${JSON.stringify(g[line])}\n` + + ` Regenerate with (from the repo root; the Cargo workspace is in src/):\n` + + ` cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --ts sdk/src/generated/wire.ts` + ); + } +} finally { + rmSync(tmpDir, { recursive: true, force: true }); +} + +console.log( + "SDK wire-types codegen OK: committed sdk/src/generated/wire.ts matches the Rust emitter output." +); diff --git a/sdk/README.md b/sdk/README.md index 329bab9f5..f03bcb6d3 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -56,8 +56,7 @@ child.on('close', (code) => console.log('exit:', code)); | Version | Status | Schema file | | --- | --- | --- | | `0.4.0-alpha` | Stable | [`schemas/stable/mxc-config.schema.0.4.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.4.0-alpha.json) | -| `0.5.0-alpha` | Stable (legacy — see strict sibling below) | [`schemas/stable/mxc-config.schema.0.5.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.5.0-alpha.json) | -| `0.5.0-alpha` (strict) | Stable, non-experimental surface only | [`schemas/stable/mxc-config.schema.0.5.0-alpha-strict.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.5.0-alpha-strict.json) | +| `0.5.0-alpha` | Stable | [`schemas/stable/mxc-config.schema.0.5.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.5.0-alpha.json) | | `0.6.0-alpha` | Stable | [`schemas/stable/mxc-config.schema.0.6.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.6.0-alpha.json) | | `0.7.0-alpha` | Stable (current) | [`schemas/stable/mxc-config.schema.0.7.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.7.0-alpha.json) | | `0.8.0-alpha` | Dev (experimental backends, the `experimental.*` block, state-aware sandbox lifecycle) | [`schemas/dev/mxc-config.schema.0.8.0-dev.json`](https://github.com/microsoft/mxc/blob/main/schemas/dev/mxc-config.schema.0.8.0-dev.json) | diff --git a/sdk/package.json b/sdk/package.json index 2b655df0c..bb3bd9e09 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -23,7 +23,7 @@ "watch": "tsc --watch", "clean": "rimraf dist", "test": "npm run test:unit", - "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/platform.test.js", + "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/wire-conformance.test.js", "test:integration": "cd tests/integration && npm install && npm run build && npm test", "prepublishOnly": "npm run build" }, diff --git a/sdk/src/generated/wire.ts b/sdk/src/generated/wire.ts new file mode 100644 index 000000000..5d1486c77 --- /dev/null +++ b/sdk/src/generated/wire.ts @@ -0,0 +1,530 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* eslint-disable */ +/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Emitted from the generated JSON Schema (itself generated from the Rust wire + * model `wxc_common::wire`) by the `mxc_schema_gen --ts` TypeScript emitter + * (`wxc_common::ts_emit`). This is a drift oracle, not public API: it is never + * exported from the SDK. The conformance test asserts the hand-written public + * types in `../types.ts` still match these. CI gate: + * `scripts/versioning/check-sdk-types-codegen.js`. + * + * Regenerate with: + * cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --ts sdk/src/generated/wire.ts + */ +/** + * BaseProcessContainer UI isolation settings. + */ +export interface BaseProcessUi { + /** + * Whether desktop system control is allowed. + */ + desktopSystemControl?: boolean | null; + /** + * Whether the IME (Input Method Editor) is allowed. + */ + ime?: boolean | null; + /** + * UI isolation level. + */ + isolation?: UiIsolation | null; + /** + * System settings access level. + */ + systemSettings?: string | null; +} + +/** + * Clipboard access level. + */ +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"; + +/** + * 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 { + /** + * IsolationSession backend config (Windows). + */ + isolation_session?: IsolationSession | null; + /** + * Seatbelt backend config (pre-promotion alias). + */ + seatbelt?: Seatbelt | null; + /** + * Placeholder feature for testing experimental infrastructure. + */ + test?: TestFeature | null; + /** + * Windows Sandbox backend config. + */ + windows_sandbox?: WindowsSandbox | null; + /** + * WSL container backend config. + */ + wslc?: Wslc | null; + [k: string]: unknown; +} + +/** + * AppContainer DACL-mutation fallback policy. + */ +export interface Fallback { + /** + * Allow the runner to mutate DACLs as a fallback. + */ + allowDaclMutation?: boolean | null; +} + +/** + * Filesystem access policy. + */ +export interface Filesystem { + /** + * Paths explicitly denied (override broader allow rules). + */ + deniedPaths?: string[] | null; + /** + * Paths the process can read but not write. + */ + readonlyPaths?: string[] | null; + /** + * Paths the process can read and write. + */ + readwritePaths?: string[] | null; +} + +/** + * IsolationSession sizing profile. + */ +export type IsolationConfigurationId = "small" | "medium" | "large" | "composable"; + +/** + * IsolationSession backend config. Carries both the one-shot fields (`configurationId`, `user`) and the per-phase state-aware nesting (`provision` / `start` / `stop` / `deprovision`). + */ +export interface IsolationSession { + /** + * Sizing profile (one-shot). + */ + configurationId?: IsolationConfigurationId | null; + /** + * State-aware deprovision-phase configuration. + */ + deprovision?: IsolationSessionPhase | null; + /** + * State-aware provision-phase configuration. + */ + provision?: IsolationSessionPhase | null; + /** + * State-aware start-phase configuration. + */ + start?: IsolationSessionPhase | null; + /** + * State-aware stop-phase configuration. + */ + stop?: IsolationSessionPhase | null; + /** + * Optional Entra cloud-agent user bundle (one-shot). + */ + user?: IsolationUser | null; + [k: string]: unknown; +} + +/** + * Per-phase IsolationSession configuration (state-aware lifecycle). + */ +export interface IsolationSessionPhase { + /** + * Sizing profile for this phase. + */ + configurationId?: IsolationConfigurationId | null; + /** + * Entra cloud-agent user bundle for this phase. + */ + user?: IsolationUser | null; + [k: string]: unknown; +} + +/** + * Entra cloud-agent user bundle. Reachable only under the permissive `experimental` surface, so unknown fields are tolerated (forward-compat). + */ +export interface IsolationUser { + /** + * User principal name. + */ + upn: string; + /** + * Short-lived WAM bearer token (passed verbatim to the OS service). + */ + wamToken: string; + [k: string]: unknown; +} + +/** + * Seatbelt inner-process launch method. + */ +export type LaunchMethod = "exec" | "open"; + +/** + * Container lifecycle settings. + */ +export interface Lifecycle { + /** + * Destroy the container when the process exits (default true). + */ + destroyOnExit?: boolean | null; + /** + * Preserve the applied policy after exit (default false). + */ + preservePolicy?: boolean | null; +} + +/** + * LXC container settings. + */ +export interface Lxc { + /** + * Distribution image (e.g. `alpine`). + */ + distribution?: string | null; + /** + * Distribution release (e.g. `3.23`). + */ + release?: string | null; +} + +/** + * Network access policy. + */ +export interface Network { + /** + * Allow binding/listening on local IPs and accepting inbound connections. + */ + allowLocalNetwork?: boolean | null; + /** + * Hosts explicitly allowed. + */ + allowedHosts?: string[] | null; + /** + * Hosts explicitly blocked. + */ + blockedHosts?: string[] | null; + /** + * Default outbound policy when no host rule matches. + */ + defaultPolicy?: NetworkPolicy | null; + /** + * How the policy is enforced. + */ + enforcementMode?: NetworkEnforcement | null; + /** + * Proxy configuration (one of localhost / builtinTestServer / url). + */ + proxy?: Proxy | null; +} + +/** + * Network enforcement mechanism. + */ +export type NetworkEnforcement = "capabilities" | "firewall" | "both"; + +/** + * Default network policy. + */ +export type NetworkPolicy = "allow" | "block"; + +/** + * State-aware lifecycle phase. + */ +export type Phase = "provision" | "start" | "exec" | "stop" | "deprovision"; + +/** + * A single host → container port forward. Reachable only under the permissive `experimental` surface, so unknown fields are tolerated (forward-compat). + */ +export interface PortMapping { + /** + * Container port. + */ + containerPort: number; + /** + * Transport protocol for the mapping. Only `tcp` is currently supported. + */ + protocol?: TransportProtocol | null; + /** + * Host (Windows) port. + */ + windowsPort: number; + [k: string]: unknown; +} + +/** + * Process execution settings. + */ +export interface Process { + /** + * Command line (or script) to execute. + */ + commandLine?: string | null; + /** + * Working directory for the process. + */ + cwd?: string | null; + /** + * Environment variables as `"KEY=VALUE"` strings. + */ + env?: string[] | null; + /** + * Wall-clock timeout in milliseconds. + */ + timeout?: number | null; +} + +/** + * ProcessContainer-specific settings. + */ +export interface ProcessContainer { + /** + * AppContainer capabilities (e.g. `internetClient`, `registryRead`). + */ + capabilities?: string[] | null; + /** + * AppContainer permissive learning mode. + */ + learningMode?: boolean | null; + /** + * Enforce least-privilege mode. + */ + leastPrivilege?: boolean | null; + /** + * BaseProcessContainer UI settings (Windows). + */ + ui?: BaseProcessUi | null; +} + +/** + * Proxy configuration. Exactly one variant applies. + */ +export interface Proxy { + /** + * Have wxc launch its own built-in test proxy. + */ + builtinTestServer?: boolean | null; + /** + * External localhost proxy port. + */ + localhost?: number | null; + /** + * Proxy URL (parsed into host:port). + */ + url?: string | null; +} + +/** + * macOS Seatbelt backend configuration. + */ +export interface Seatbelt { + /** + * Additional Mach service global-names the inner process may resolve. + */ + extraMachLookups?: string[] | null; + /** + * Allow GUI (WindowServer) access. + */ + guiAccess?: boolean | null; + /** + * Allow Keychain access. + */ + keychainAccess?: boolean | null; + /** + * Inner process launch method. + */ + launchMethod?: LaunchMethod | null; + /** + * Attach the inner process to a nested pty (default true). + */ + nestedPty?: boolean | null; + /** + * Replace the generated profile entirely (advanced/testing escape hatch). + */ + profileOverride?: string | null; +} + +/** + * Placeholder experimental feature. + */ +export interface TestFeature { + /** + * Message to log when the feature is applied. + */ + message?: string | null; + [k: string]: unknown; +} + +/** + * Port-forward transport protocol. Only `tcp` is currently supported by the vendored WSLC SDK runtime; `udp` is rejected at parse time. + */ +export type TransportProtocol = "tcp"; + +/** + * Cross-platform UI isolation policy. + */ +export interface Ui { + /** + * Clipboard access level. + */ + clipboard?: ClipboardPolicy | null; + /** + * Disable all UI access (default true). + */ + disable?: boolean | null; + /** + * Allow UI injection. + */ + injection?: boolean | null; +} + +/** + * Desktop UI isolation level. + */ +export type UiIsolation = "desktop" | "handles" | "atoms" | "container"; + +/** + * Windows Sandbox backend config. + */ +export interface WindowsSandbox { + /** + * Daemon named-pipe override. + */ + daemonPipeName?: string | null; + /** + * Idle timeout (legacy seconds field). + */ + idleTimeout?: number | null; + /** + * Idle timeout before teardown (ms). + */ + idleTimeoutMs?: number | null; + [k: string]: unknown; +} + +/** + * WSL container backend config. + */ +export interface Wslc { + /** + * vCPU count. + */ + cpuCount?: number | null; + /** + * Enable GPU passthrough. + */ + gpu?: boolean | null; + /** + * Container image reference. + */ + image?: string | null; + /** + * Path to a local image tarball. + */ + imageTarPath?: string | null; + /** + * Memory limit (MB). + */ + memoryMb?: number | null; + /** + * Host → container port forwards. Only TCP is currently supported by the vendored WSLC SDK runtime (Microsoft.WSL.Containers 2.8.1); the parser rejects `udp` because the shipped runtime returns `E_NOTIMPL`. + */ + portMappings?: PortMapping[] | null; + /** + * Storage path override. + */ + storagePath?: string | null; + /** + * OS inside the WSL container. + */ + targetOs?: string | null; + [k: string]: unknown; +} + +/** + * 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. + */ +export interface MXCConfiguration { + /** + * Optional JSON Schema reference for editor validation. Accepted but ignored by the parser. + */ + $schema?: string | null; + /** + * Optional human-readable annotation. Accepted but ignored by the parser. + */ + _comment?: unknown; + /** + * Externally assigned container identifier. + */ + containerId?: string | null; + /** + * Containment backend to use for execution. Accepts abstract intents (`process`, `vm`) and concrete backends; the binary resolves intents to a concrete backend per host at run time. + */ + containment?: Containment | null; + /** + * Experimental features. Only honored when `--experimental` is passed. + */ + experimental?: Experimental | null; + /** + * AppContainer DACL-mutation fallback policy (Windows). + */ + fallback?: Fallback | null; + /** + * Filesystem access policy. Shared across all backends. + */ + filesystem?: Filesystem | null; + /** + * Container lifecycle settings. + */ + lifecycle?: Lifecycle | null; + /** + * LXC container settings (Linux). Used when containment is `lxc`. + */ + lxc?: Lxc | null; + /** + * Network access policy. Shared across all backends. + */ + network?: Network | null; + /** + * State-aware lifecycle phase. When present, the request is a state-aware request (`sandboxId` is required for non-provision phases); when absent, the request is one-shot. + */ + phase?: Phase | null; + /** + * Process to execute and its environment. + */ + process?: Process | null; + /** + * ProcessContainer-specific settings (Windows). Used when containment is `processcontainer`. + */ + processContainer?: ProcessContainer | null; + /** + * Sandbox identifier returned by a prior provision request. Required for non-provision state-aware phases. + */ + sandboxId?: string | null; + /** + * macOS Seatbelt backend configuration. Used when containment is `seatbelt`. + */ + seatbelt?: Seatbelt | null; + /** + * Cross-platform UI isolation policy. + */ + ui?: Ui | null; + /** + * MXC config schema version (semver), e.g. `"0.8.0-alpha"`. + */ + version?: string | null; +} + diff --git a/sdk/tests/unit/wire-conformance.test.ts b/sdk/tests/unit/wire-conformance.test.ts new file mode 100644 index 000000000..18605a521 --- /dev/null +++ b/sdk/tests/unit/wire-conformance.test.ts @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Wire-type conformance oracle (Phase 2C, option C). +// +// The generated module `../../src/generated/wire.ts` is emitted from the Rust +// wire model (`wxc_common::wire`) by the `mxc_schema_gen --ts` Rust TypeScript +// emitter. It is the single source of truth for the wire shape. +// +// This file asserts — at COMPILE TIME — that the hand-written public SDK types +// in `../../src/types.ts` still conform to that generated shape. If the Rust +// wire model changes (a field renamed/removed, an enum value added/dropped, a +// type narrowed) the regenerated `wire.ts` shifts and these assertions stop +// compiling, so `npm run build:test-unit` fails. The runtime body is a no-op; +// the test exists so `tsc` type-checks the assertions below. +// +// Direction & null-handling rationale: +// * Generated fields are uniformly `field?: T | null` (optional AND nullable), +// so they are strictly more permissive than the SDK's `field?: T`. Therefore +// `PublicType extends GeneratedType` ("public is assignable to wire") holds +// cleanly and catches enum/type NARROWING in the wire model. +// * `OnlyInPublic` additionally catches a public field whose wire counterpart +// was renamed or removed (width subtyping alone would not), and is asserted +// to equal a documented, explicit set of SDK-only fields — so a NEW +// divergence (not on the allow-list) fails the build. This is applied at the +// ROOT (`ContainerConfig` ↔ `MXCConfiguration`) as well as the leaves, so a +// top-level rename/removal cannot slip past (review finding F1, codex pass). +// * `OnlyInWire` covers the OPPOSITE direction: because every generated wire +// field is optional, `Public extends Wire` stays true when the SDK forgets a +// newly added wire field, so a wire-only ADDITION needs its own check. Each +// object asserts its wire-only key set equals an explicit allow-list (mostly +// `never`), so a new wire field the SDK does not expose fails the build until +// it is surfaced or documented (review finding F1, gpt-5.5 pass). +// * Assignability is one-way and so does NOT catch a wire ENUM WIDENING (a new +// value added in the wire model). Enum-backed domains the SDK exposes are +// therefore additionally checked with the bidirectional `Equivalent` — both +// the standalone enum types and the enum-typed object fields (review finding +// F2, codex pass). +// +// One emitter artifact is normalized away: `StripIndex` drops the +// `[k: string]: unknown` index signatures the emitter writes on the OPEN +// (experimental) objects; without this, structural assignment to those +// interfaces misbehaves. + +import { test } from 'node:test'; + +import type { + ProcessConfig, + LifecycleConfig, + FilesystemConfig, + NetworkConfig, + UiConfig, + ProcessContainerConfig, + BaseProcessUiConfig, + WslcConfig, + PortMapping as PublicPortMapping, + LxcConfig, + SeatbeltConfig, + ContainerConfig, + ClipboardPolicy as PublicClipboardPolicy, + ContainmentType, + ContainmentBackend, +} from '../../src/types.js'; + +import type { + Process as WireProcess, + Lifecycle as WireLifecycle, + Filesystem as WireFilesystem, + Network as WireNetwork, + Ui as WireUi, + ProcessContainer as WireProcessContainer, + BaseProcessUi as WireBaseProcessUi, + Wslc as WireWslc, + PortMapping as WirePortMapping, + Lxc as WireLxc, + Seatbelt as WireSeatbelt, + MXCConfiguration as WireMxcConfig, + ClipboardPolicy as WireClipboardPolicy, + Containment as WireContainment, + NetworkPolicy as WireNetworkPolicy, + NetworkEnforcement as WireNetworkEnforcement, + UiIsolation as WireUiIsolation, + TransportProtocol as WireTransportProtocol, +} from '../../src/generated/wire.js'; + +// --- type-level assertion helpers ----------------------------------------- + +/** Compiles only when `T` is exactly `true`. */ +type AssertTrue = T; + +/** Drop the `[k: string]: unknown` index signature the emitter writes on open objects. */ +type StripIndex = { [K in keyof T as string extends K ? never : K]: T[K] }; + +/** + * Recursively drop index signatures (open objects nest: e.g. + * `experimental.wslc`), so structural assignment is not tripped by an emitted + * `[k: string]: unknown` at any depth. Modifiers (`?`) are preserved because the + * mapped type is homomorphic over `keyof T`. + */ +type DeepStripIndex = T extends (infer E)[] + ? DeepStripIndex[] + : T extends object + ? { [K in keyof T as string extends K ? never : K]: DeepStripIndex } + : T; + +/** `true` iff every value of `A` is assignable to `B` (index signatures stripped, recursively). */ +type Assignable = [A] extends [DeepStripIndex] ? true : false; + +/** Keys present on the public type but absent from the wire type. */ +type OnlyInPublic = Exclude; + +/** + * Keys present on the wire type but absent from the public type (review finding + * F1, gpt-5.5 pass). Because every generated wire field is optional, + * `Public extends Wire` stays true when the SDK simply forgets a new wire field, + * so the value/`OnlyInPublic` checks alone do NOT catch a wire-only ADDITION. + * This closes that direction. `StripIndex` drops the `[k: string]: unknown` so + * the open experimental objects don't make `keyof` collapse to `string`. + */ +type OnlyInWire = Exclude, keyof Pub>; + +/** `true` iff `A` and `B` are mutually assignable (same value set). */ +type Equivalent = [A] extends [B] + ? [B] extends [A] + ? true + : false + : false; + +// --- enum / union conformance --------------------------------------------- + +// Clipboard policy must be value-for-value identical to the wire enum. +type _Clipboard = AssertTrue>; + +// The SDK splits containment into abstract intents + concrete backends; their +// union must cover exactly the wire `Containment` enum. +type _Containment = AssertTrue< + Equivalent +>; + +// Enum-backed OBJECT FIELDS (review finding F2). Assignability alone is one-way +// and would let a wire ENUM WIDENING (a new value) slip past, so each enum-typed +// field the SDK exposes inline is checked for exact equivalence with its wire +// enum. `NonNullable` strips the generated `| null` so only the value set is +// compared. A new wire enum value now fails the build until the SDK adds it. +type _NetDefaultPolicy = AssertTrue< + Equivalent, WireNetworkPolicy> +>; +type _NetEnforcement = AssertTrue< + Equivalent, WireNetworkEnforcement> +>; +type _BaseProcessUiIsolation = AssertTrue< + Equivalent, WireUiIsolation> +>; +type _PortProtocol = AssertTrue< + Equivalent, WireTransportProtocol> +>; + +// --- object-interface value conformance ----------------------------------- +// Public is assignable to the (more permissive) wire type. Catches enum/type +// narrowing and incompatible field types. + +type _ProcessVals = AssertTrue>; +type _LifecycleVals = AssertTrue>; +type _FilesystemVals = AssertTrue>; +type _NetworkVals = AssertTrue>; +type _UiVals = AssertTrue>; +type _ProcessContainerVals = AssertTrue>; +type _BaseProcessUiVals = AssertTrue>; +type _WslcVals = AssertTrue>; +type _PortMappingVals = AssertTrue>; +type _SeatbeltVals = AssertTrue>; +type _LxcVals = AssertTrue, WireLxc>>; + +// --- key conformance (rename / removal detection) ------------------------- +// Every public field must either exist on the wire type or be on the EXPLICIT +// SDK-only allow-list below. Each list is asserted to equal exactly the +// divergence set, so a NEW field missing from the wire model fails the build +// (the SDK author must either add it to the wire model or extend this list with +// a justification). +// +// These divergences are the oracle doing its job: each listed field is exposed +// by the SDK but is NOT part of the wire contract (the parser's actual target, +// which uses `deny_unknown_fields`). + +type _ProcessKeys = AssertTrue, never>>; +type _LifecycleKeys = AssertTrue, never>>; +type _UiKeys = AssertTrue, never>>; +type _BaseProcessUiKeys = AssertTrue, never>>; +type _WslcKeys = AssertTrue, never>>; +type _PortMappingKeys = AssertTrue, never>>; +type _SeatbeltKeys = AssertTrue, never>>; + +// `FilesystemConfig.clearPolicyOnExit` is an SDK-side convenience flag mapped +// into `lifecycle.preservePolicy`; it is not a wire `filesystem` field. +type _FilesystemKeys = AssertTrue, 'clearPolicyOnExit'>>; + +// `NetworkConfig.removeRulesOnExit` is deprecated (use `lifecycle.preservePolicy`) +// and not a wire `network` field. +type _NetworkKeys = AssertTrue, 'removeRulesOnExit'>>; + +// `ProcessContainerConfig.name` is the deprecated AppContainer profile name +// (superseded by top-level `containerId`); not a wire `processContainer` field. +type _ProcessContainerKeys = AssertTrue, 'name'>>; + +// `LxcConfig` carries SDK-only `containerName` and `destroyOnExit` (the latter +// duplicated by `lifecycle.destroyOnExit`); neither is a wire `lxc` field. +type _LxcKeys = AssertTrue, 'containerName' | 'destroyOnExit'>>; + +// --- ROOT conformance (review finding F1) --------------------------------- +// Without these, a top-level wire field rename/removal regenerates wire.ts but +// no assertion notices, so the leaf-only checks above are not enough. The public +// root `ContainerConfig` is checked the same way as the leaves: +// * value-shape: assignable to the generated `MXCConfiguration`, and +// * key-drift: the only public-but-not-wire root key is `appContainer`, the +// deprecated serde alias the schema folds away (so it is absent from the +// generated root). A NEW root divergence fails the build. +type _RootVals = AssertTrue>; +type _RootKeys = AssertTrue, 'appContainer'>>; + +// --- reverse key conformance: wire-only fields (review finding F1, gpt-5.5) -- +// Catch a NEW optional wire field the SDK forgot to expose. Each list is the +// EXACT set of wire keys the public type intentionally omits; `never` means the +// SDK mirrors the wire object completely. A new wire field not on the relevant +// list fails the build until the SDK either exposes it or documents it here. + +type _ProcessWireKeys = AssertTrue, never>>; +type _LifecycleWireKeys = AssertTrue, never>>; +type _FilesystemWireKeys = AssertTrue, never>>; +type _NetworkWireKeys = AssertTrue, never>>; +type _UiWireKeys = AssertTrue, never>>; +type _BaseProcessUiWireKeys = AssertTrue, never>>; +type _WslcWireKeys = AssertTrue, never>>; +type _PortMappingWireKeys = AssertTrue, never>>; +type _LxcWireKeys = AssertTrue, never>>; + +// `processContainer.learningMode` is a wire field the SDK does not expose (the +// AppContainer permissive learning mode is not surfaced through the policy API). +type _ProcessContainerWireKeys = AssertTrue< + Equivalent, 'learningMode'> +>; + +// `seatbelt.guiAccess` and `seatbelt.launchMethod` are wire fields the one-shot +// `SeatbeltConfig` does not expose today. +type _SeatbeltWireKeys = AssertTrue< + Equivalent, 'guiAccess' | 'launchMethod'> +>; + +// Root: the SDK's `ContainerConfig` intentionally omits the schema-metadata keys +// (`$schema`, `_comment`), the state-aware-only keys (`phase`, `sandboxId` — see +// `state-aware-types.ts`), and `fallback` (AppContainer DACL-mutation policy not +// surfaced through the one-shot policy API). Any OTHER new root wire field fails. +type _RootWireKeys = AssertTrue< + Equivalent< + OnlyInWire, + '$schema' | '_comment' | 'phase' | 'sandboxId' | 'fallback' + > +>; + +// Reference the assertion aliases so they read as intentionally load-bearing. +export type WireConformanceAssertions = [ + _Clipboard, _Containment, + _NetDefaultPolicy, _NetEnforcement, _BaseProcessUiIsolation, _PortProtocol, + _ProcessVals, _LifecycleVals, _FilesystemVals, _NetworkVals, _UiVals, + _ProcessContainerVals, _BaseProcessUiVals, _WslcVals, _PortMappingVals, + _SeatbeltVals, _LxcVals, + _ProcessKeys, _LifecycleKeys, _FilesystemKeys, _NetworkKeys, _UiKeys, + _ProcessContainerKeys, _BaseProcessUiKeys, _WslcKeys, _PortMappingKeys, + _SeatbeltKeys, _LxcKeys, + _RootVals, _RootKeys, + _ProcessWireKeys, _LifecycleWireKeys, _FilesystemWireKeys, _NetworkWireKeys, + _UiWireKeys, _BaseProcessUiWireKeys, _WslcWireKeys, _PortMappingWireKeys, + _LxcWireKeys, _ProcessContainerWireKeys, _SeatbeltWireKeys, _RootWireKeys, +]; + +test('public SDK wire types conform to the generated wire schema (compile-time)', () => { + // Intentionally empty: the guarantee is enforced by the type aliases above at + // `tsc` time. If they fail to compile, `npm run build:test-unit` fails before + // this test ever runs. +}); diff --git a/src/core/wxc_common/src/lib.rs b/src/core/wxc_common/src/lib.rs index 3fefdfc18..a8550eb78 100644 --- a/src/core/wxc_common/src/lib.rs +++ b/src/core/wxc_common/src/lib.rs @@ -26,6 +26,12 @@ pub mod validator; // the JSON Schema is generated from it under the `schema-gen` feature. pub mod wire; +// TypeScript emitter for the SDK wire types (drift oracle). Walks the generated +// schema value and emits `sdk/src/generated/wire.ts`. Compiled with the wire +// model under the `schema-gen` feature. +#[cfg(feature = "schema-gen")] +pub mod ts_emit; + // Thin Windows-only helpers that are not backend-specific. Backend // runners live in dedicated crates under `backends/`; only utilities // shared across host tools (e.g. wxc_host_prep, mxc_diagnostic_console) diff --git a/src/core/wxc_common/src/ts_emit.rs b/src/core/wxc_common/src/ts_emit.rs new file mode 100644 index 000000000..2d0a72944 --- /dev/null +++ b/src/core/wxc_common/src/ts_emit.rs @@ -0,0 +1,356 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TypeScript emitter for the SDK wire types. +//! +//! Walks the generated JSON Schema as a `serde_json::Value` — built from the +//! `MxcConfig` wire model, the same value that +//! [`crate::wire::generate_config_schema_json`] renders to JSON text — and emits +//! the SDK's wire TypeScript types, with no third-party generator. +//! The result is `sdk/src/generated/wire.ts`, a drift oracle that the SDK's +//! hand-written public types are asserted to conform to (and that a CI gate +//! regenerates + diffs). +//! +//! Only the JSON Schema constructs the MXC schema actually uses are handled: +//! enums (`oneOf` of single-value `enum`s, or a direct `enum` array), closed and +//! open objects, `$ref`, `anyOf [T, null]` nullable wrappers, arrays, and scalar +//! types (`string`/`integer`/`number`/`boolean`). The emitter is deterministic: +//! `serde_json`'s default `Map` is a `BTreeMap`, so definitions and properties +//! come out in stable alphabetical order. + +use serde_json::Value; + +const BANNER: &str = "\ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* eslint-disable */ +/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Emitted from the generated JSON Schema (itself generated from the Rust wire + * model `wxc_common::wire`) by the `mxc_schema_gen --ts` TypeScript emitter + * (`wxc_common::ts_emit`). This is a drift oracle, not public API: it is never + * exported from the SDK. The conformance test asserts the hand-written public + * types in `../types.ts` still match these. CI gate: + * `scripts/versioning/check-sdk-types-codegen.js`. + * + * Regenerate with: + * cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --ts sdk/src/generated/wire.ts + */ +"; + +/// Root interface name (mirrors the json-schema-to-typescript convention of +/// deriving it from the schema `title`, "MXC Configuration"). +const ROOT_NAME: &str = "MXCConfiguration"; + +/// Emit the full `wire.ts` content for the given schema root value. +pub fn emit_ts(schema: &Value) -> String { + let root = schema.as_object().expect("schema root is an object"); + let mut out = String::from(BANNER); + + if let Some(Value::Object(defs)) = root.get("definitions") { + for (name, def) in defs { + emit_definition(&mut out, name, def); + } + } + + // The root itself is an object schema; emit it as the top-level interface. + emit_object(&mut out, ROOT_NAME, root); + + out +} + +/// Emit one named definition: an enum (string union) or an object interface. +fn emit_definition(out: &mut String, name: &str, def: &Value) { + let obj = match def.as_object() { + Some(o) => o, + None => { + push_doc(out, def.get("description")); + out.push_str(&format!("export type {name} = unknown;\n\n")); + return; + } + }; + + if let Some(variants) = enum_variants(obj) { + push_doc(out, obj.get("description")); + let union = variants + .iter() + .map(|v| format!("\"{v}\"")) + .collect::>() + .join(" | "); + out.push_str(&format!("export type {name} = {union};\n\n")); + return; + } + + emit_object(out, name, obj_as_map(def)); +} + +/// Collect a string-union's members from either a `oneOf` of single-value +/// `enum`s or a direct `enum` array. Returns `None` if the definition is not an +/// enum. +fn enum_variants(obj: &serde_json::Map) -> Option> { + if let Some(Value::Array(one_of)) = obj.get("oneOf") { + let mut variants = Vec::new(); + for branch in one_of { + let e = branch.get("enum")?.as_array()?; + let s = e.first()?.as_str()?; + variants.push(s.to_string()); + } + return Some(variants); + } + if let Some(Value::Array(e)) = obj.get("enum") { + let variants: Vec = e + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + if !variants.is_empty() { + return Some(variants); + } + } + None +} + +fn obj_as_map(def: &Value) -> &serde_json::Map { + def.as_object().expect("object definition") +} + +/// Emit an `export interface Name { ... }` from an object schema. +fn emit_object(out: &mut String, name: &str, obj: &serde_json::Map) { + push_doc(out, obj.get("description")); + out.push_str(&format!("export interface {name} {{\n")); + + let required: Vec<&str> = obj + .get("required") + .and_then(|r| r.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + if let Some(Value::Object(props)) = obj.get("properties") { + for (pname, pval) in props { + let (ty, nullable) = ts_type(pval); + let optional = !required.contains(&pname.as_str()); + push_field_doc(out, pval.get("description")); + let key = field_key(pname); + let opt = if optional { "?" } else { "" }; + let nul = if nullable { " | null" } else { "" }; + out.push_str(&format!(" {key}{opt}: {ty}{nul};\n")); + } + } + + // Open objects (no `additionalProperties: false`) carry an index signature, + // matching how the permissive experimental block is modeled. + if is_open_object(obj) { + out.push_str(" [k: string]: unknown;\n"); + } + + out.push_str("}\n\n"); +} + +/// An object is "open" unless it explicitly sets `additionalProperties: false`. +fn is_open_object(obj: &serde_json::Map) -> bool { + !matches!(obj.get("additionalProperties"), Some(Value::Bool(false))) +} + +/// Resolve a property schema to a `(typescript-type, nullable)` pair. +fn ts_type(prop: &Value) -> (String, bool) { + let obj = match prop.as_object() { + Some(o) => o, + None => return ("unknown".to_string(), false), + }; + + if let Some(r) = obj.get("$ref").and_then(|v| v.as_str()) { + return (ref_name(r), false); + } + + if let Some(Value::Array(any_of)) = obj.get("anyOf") { + let mut nullable = false; + let mut ty = "unknown".to_string(); + for branch in any_of { + if is_null_schema(branch) { + nullable = true; + } else { + let (t, n) = ts_type(branch); + ty = t; + nullable = nullable || n; + } + } + return (ty, nullable); + } + + match obj.get("type") { + Some(Value::String(s)) => (scalar(s, obj), false), + Some(Value::Array(types)) => { + let mut nullable = false; + let mut base = "unknown".to_string(); + for t in types { + match t.as_str() { + Some("null") => nullable = true, + Some(s) => base = scalar(s, obj), + None => {} + } + } + (base, nullable) + } + _ => ("unknown".to_string(), false), + } +} + +/// Map a JSON Schema scalar/array `type` to its TypeScript spelling. +fn scalar(ty: &str, obj: &serde_json::Map) -> String { + match ty { + "string" => "string".to_string(), + "integer" | "number" => "number".to_string(), + "boolean" => "boolean".to_string(), + "array" => { + let items = obj.get("items").cloned().unwrap_or(Value::Null); + let (item_ty, item_nullable) = ts_type(&items); + if item_nullable { + format!("({item_ty} | null)[]") + } else { + format!("{item_ty}[]") + } + } + "object" => "{ [k: string]: unknown }".to_string(), + "null" => "null".to_string(), + _ => "unknown".to_string(), + } +} + +fn is_null_schema(v: &Value) -> bool { + v.get("type").and_then(|t| t.as_str()) == Some("null") +} + +fn ref_name(reference: &str) -> String { + reference + .rsplit('/') + .next() + .unwrap_or(reference) + .to_string() +} + +/// Quote a property name only when it is not a plain TS identifier. +fn field_key(name: &str) -> String { + let is_ident = !name.is_empty() + && name.chars().enumerate().all(|(i, c)| { + c == '_' || c == '$' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit()) + }); + if is_ident { + name.to_string() + } else { + format!("\"{name}\"") + } +} + +/// Emit a top-level JSDoc block (no indentation) for a definition. +fn push_doc(out: &mut String, description: Option<&Value>) { + if let Some(text) = description.and_then(|v| v.as_str()) { + out.push_str("/**\n"); + for line in jsdoc_lines(text) { + out.push_str(&format!(" * {line}\n")); + } + out.push_str(" */\n"); + } +} + +/// Emit a two-space-indented JSDoc block for an interface field. +fn push_field_doc(out: &mut String, description: Option<&Value>) { + if let Some(text) = description.and_then(|v| v.as_str()) { + out.push_str(" /**\n"); + for line in jsdoc_lines(text) { + out.push_str(&format!(" * {line}\n")); + } + out.push_str(" */\n"); + } +} + +/// Split a description into JSDoc lines, neutralizing any `*/` that would close +/// the comment early. +fn jsdoc_lines(text: &str) -> Vec { + text.replace("*/", "* /") + .split('\n') + .map(|l| l.trim_end().to_string()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn emits_string_union_from_one_of() { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": {}, + "definitions": { + "Color": { + "description": "A color.", + "oneOf": [ + { "enum": ["red"], "type": "string" }, + { "enum": ["green"], "type": "string" } + ] + } + } + }); + let ts = emit_ts(&schema); + assert!( + ts.contains("export type Color = \"red\" | \"green\";"), + "{ts}" + ); + } + + #[test] + fn emits_interface_with_optional_nullable_and_ref_fields() { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": {}, + "definitions": { + "Thing": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "count": { "type": ["integer", "null"] }, + "tags": { "type": ["array", "null"], "items": { "type": "string" } }, + "child": { + "anyOf": [ { "$ref": "#/definitions/Thing" }, { "type": "null" } ] + } + } + } + } + }); + let ts = emit_ts(&schema); + // Required, non-null. + assert!(ts.contains("name: string;"), "{ts}"); + // Optional + nullable scalar. + assert!(ts.contains("count?: number | null;"), "{ts}"); + // Optional + nullable array. + assert!(ts.contains("tags?: string[] | null;"), "{ts}"); + // Optional ref made nullable by the anyOf null branch. + assert!(ts.contains("child?: Thing | null;"), "{ts}"); + } + + #[test] + fn open_object_gets_index_signature_closed_does_not() { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": {}, + "definitions": { + "Open": { "type": "object", "properties": {} }, + "Closed": { "type": "object", "additionalProperties": false, "properties": {} } + } + }); + let ts = emit_ts(&schema); + let open = ts.split("export interface Open").nth(1).unwrap(); + let open_body = open.split('}').next().unwrap(); + assert!(open_body.contains("[k: string]: unknown;"), "{ts}"); + let closed = ts.split("export interface Closed").nth(1).unwrap(); + let closed_body = closed.split('}').next().unwrap(); + assert!(!closed_body.contains("[k: string]: unknown;"), "{ts}"); + } +} diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index f0934c1b4..fb150b7b1 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -524,6 +524,18 @@ mod schema_gen { /// `description` come from the `MxcConfig` schemars attribute / doc comment /// respectively. pub fn generate_config_schema_json() -> String { + let value = schema_value(); + if let serde_json::Value::Object(map) = &value { + return render_root_ordered(map); + } + serde_json::to_string_pretty(&value).expect("schema serialises to JSON") + } + + /// Build the post-processed schema as a `serde_json::Value`: run schemars, + /// normalize integer formats, and inject the canonical `$id`. Shared by the + /// JSON-schema renderer and the TypeScript emitter so both consume exactly the + /// same model. + fn schema_value() -> serde_json::Value { let schema = schemars::schema_for!(MxcConfig); let mut value = serde_json::to_value(&schema).expect("schema serialises to JSON value"); normalize_integer_formats(&mut value); @@ -532,9 +544,20 @@ mod schema_gen { "$id".to_string(), serde_json::Value::String(SCHEMA_ID.to_string()), ); - return render_root_ordered(map); } - serde_json::to_string_pretty(&value).expect("schema serialises to JSON") + value + } + + /// 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/src/generated/wire.ts`): the SDK's hand-written public types are + /// asserted to conform to it by a unit test, and a CI gate regenerates and + /// diffs the committed file. Deterministic: `serde_json`'s default `Map` is a + /// `BTreeMap`, so definitions and object properties are emitted in stable + /// (alphabetical) order. + pub fn generate_sdk_types_ts() -> String { + let value = schema_value(); + crate::ts_emit::emit_ts(&value) } /// Render the root object as pretty JSON with a fixed key order — the schema @@ -619,4 +642,4 @@ mod schema_gen { } #[cfg(feature = "schema-gen")] -pub use schema_gen::generate_config_schema_json; +pub use schema_gen::{generate_config_schema_json, generate_sdk_types_ts}; diff --git a/src/tools/mxc_schema_gen/src/main.rs b/src/tools/mxc_schema_gen/src/main.rs index f09c946b0..e6c098c15 100644 --- a/src/tools/mxc_schema_gen/src/main.rs +++ b/src/tools/mxc_schema_gen/src/main.rs @@ -1,30 +1,52 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Schema codegen tool. Emits the MXC config JSON Schema generated from the -//! dedicated `wxc_common::wire` model. +//! Schema codegen tool. Emits the MXC config JSON Schema — or, with `--ts`, the +//! SDK wire TypeScript types — generated from the dedicated `wxc_common::wire` +//! model. //! //! Usage (run from the repo root; the Cargo workspace lives in `src/`): //! cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- [output-path] +//! cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --ts [output-path] //! -//! With no argument the schema is written to stdout. +//! With no path the artifact is written to stdout. use std::process::ExitCode; fn main() -> ExitCode { - let json = wxc_common::wire::generate_config_schema_json(); + let mut args = std::env::args().skip(1); + let first = args.next(); - match std::env::args().nth(1) { + let (emit_ts, path) = match first.as_deref() { + Some("--ts") => (true, args.next()), + Some(other) => (false, Some(other.to_string())), + None => (false, None), + }; + + let content = if emit_ts { + wxc_common::wire::generate_sdk_types_ts() + } else { + // Preserve the historical schema output: the rendered string + trailing + // newline, byte-for-byte (the schema codegen gate diffs against it). + format!("{}\n", wxc_common::wire::generate_config_schema_json()) + }; + let label = if emit_ts { + "SDK TypeScript types" + } else { + "generated schema" + }; + + match path { Some(path) => { - if let Err(e) = std::fs::write(&path, format!("{json}\n")) { - eprintln!("failed to write schema to {path}: {e}"); + if let Err(e) = std::fs::write(&path, &content) { + eprintln!("failed to write {label} to {path}: {e}"); return ExitCode::FAILURE; } // Status goes to stdout so callers that suppress stdout (the CI - // codegen gate) stay quiet, while write errors above stay on stderr. - println!("wrote generated schema to {path}"); + // codegen gates) stay quiet, while write errors above stay on stderr. + println!("wrote {label} to {path}"); } - None => println!("{json}"), + None => print!("{content}"), } ExitCode::SUCCESS }