diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md index f2629975..a7589934 100644 --- a/sdk/CHANGELOG.md +++ b/sdk/CHANGELOG.md @@ -12,6 +12,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `getPlatformSupport()` now reports `uiCapabilities` on Windows when the native probe can determine which UI restrictions the host can enforce. +## [0.7.0] + +### ⚠️ Breaking changes + +- **`usePty` now defaults to `false`.** `spawnSandboxFromConfig` and + `spawnSandboxAsync` spawn via `child_process` (pipe mode) unless called with + `usePty: true`, so the default path no longer requires the optional `node-pty` + peer dependency. `spawnSandboxFromConfig` therefore returns a `ChildProcess` by + default (and `IPty` only when `usePty: true`); `spawnSandboxAsync` now returns + real, separated `stdout`/`stderr` on the default path. `spawnSandbox` and + `execInSandbox` are unchanged — they always use PTY and require `node-pty`. +- **`node-pty` moved from `dependencies` to an optional `peerDependency`.** + Pipe-only consumers no longer pull it in transitively; consumers that use PTY + mode must install `node-pty` themselves. `loadPty()` surfaces an actionable + error when PTY mode is requested but the peer dependency is missing. + +### Changed + +- `node-pty` is loaded lazily, only when a PTY is actually spawned. Importing the + SDK and spawning in pipe mode never evaluates `node-pty` or loads its native + addon. +- The SDK's public PTY types (`IPty`, `IPtyForkOptions`, etc.) are now vendored + and exported from the package itself. Consumers no longer need `node-pty` + installed to type-check against the SDK (previously this failed with + `TS2307: Cannot find module 'node-pty'`). + ## [0.3.0] ### ⚠️ Breaking changes diff --git a/sdk/README.md b/sdk/README.md index 8c855333..3fae95bc 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -8,6 +8,18 @@ npm install @microsoft/mxc-sdk ``` +`node-pty` is an **optional peer dependency**, used only for PTY mode. PTY mode is +opt-in: `spawnSandbox` (and `execInSandbox`) always use it, and +`spawnSandboxFromConfig` / `spawnSandboxAsync` use it when called with +`usePty: true`. The default path (`usePty` unset/`false`) spawns via +`child_process` and needs no native addon. The SDK's public types are +self-contained, so pipe-only consumers can install and type-check the SDK without +`node-pty`. If you use PTY mode, install it alongside the SDK: + +```bash +npm install node-pty +``` + ```typescript import { spawnSandboxFromConfig, createConfigFromPolicy, diff --git a/sdk/package-lock.json b/sdk/package-lock.json index c5aff1cf..b28c78de 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -1,26 +1,34 @@ { "name": "@microsoft/mxc-sdk", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsoft/mxc-sdk", - "version": "0.6.1", + "version": "0.7.0", "license": "MIT", "dependencies": { - "node-pty": "^1.2.0-beta.12", "semver": "^7.7.4" }, "devDependencies": { "@types/node": "^20.10.0", "@types/semver": "^7.7.1", "node-gyp": "^12.2.0", + "node-pty": "^1.2.0-beta.12", "rimraf": "^6.1.3", "typescript": "^5.3.3" }, "engines": { "node": ">=18.0.0" + }, + "peerDependencies": { + "node-pty": "^1.2.0-beta.12" + }, + "peerDependenciesMeta": { + "node-pty": { + "optional": true + } } }, "node_modules/@gar/promise-retry": { @@ -546,6 +554,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "license": "MIT" }, "node_modules/node-gyp": { @@ -577,6 +586,7 @@ "version": "1.2.0-beta.12", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.12.tgz", "integrity": "sha512-uExTCG/4VmSJa4+TjxFwPXv8BfacmfFEBL6JpxCMDghcwqzvD0yTcGmZ1fKOK6HY33tp0CelLblqTECJizc+Yw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/sdk/package.json b/sdk/package.json index 090c11d4..7b893688 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/mxc-sdk", - "version": "0.6.1", + "version": "0.7.0", "description": "TypeScript SDK for MXC (Microsoft eXecution Containers)", "type": "module", "types": "dist/index.d.ts", @@ -41,13 +41,21 @@ "@types/node": "^20.10.0", "@types/semver": "^7.7.1", "node-gyp": "^12.2.0", + "node-pty": "^1.2.0-beta.12", "rimraf": "^6.1.3", "typescript": "^5.3.3" }, "dependencies": { - "node-pty": "^1.2.0-beta.12", "semver": "^7.7.4" }, + "peerDependencies": { + "node-pty": "^1.2.0-beta.12" + }, + "peerDependenciesMeta": { + "node-pty": { + "optional": true + } + }, "engines": { "node": ">=18.0.0" } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index be02c49a..c5e09e56 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -8,7 +8,7 @@ * * @example * ```typescript - * import { spawnSandbox, spawnSandboxWithPty, SandboxPolicy, getPlatformSupport } from '@microsoft/mxc-sdk'; + * import { spawnSandbox, SandboxPolicy, getPlatformSupport } from '@microsoft/mxc-sdk'; * * if (getPlatformSupport().isSupported) { * const policy: SandboxPolicy = { @@ -16,7 +16,7 @@ * network: { allowOutbound: true }, * }; * - * const ptyProcess = spawnSandboxWithPty('python -c "print(\'Hello from sandbox\')"', policy); + * const ptyProcess = spawnSandbox('python -c "print(\'Hello from sandbox\')"', policy); * ptyProcess.onData((data) => console.log(data)); * ptyProcess.onExit((event) => console.log('Exit code:', event.exitCode)); * } @@ -54,6 +54,17 @@ export { SandboxSpawnOptions, } from './sandbox.js'; +// Export vendored node-pty type surface (so PTY-mode consumers can name these +// types without depending on the optional `node-pty` peer dependency). +export type { + IPty, + IPtyForkOptions, + IWindowsPtyForkOptions, + IBasePtyForkOptions, + IDisposable, + IEvent, +} from './pty-types.js'; + // Export policy discovery functions export { getAvailableToolsPolicy, diff --git a/sdk/src/lazyPty.ts b/sdk/src/lazyPty.ts new file mode 100644 index 00000000..5397d0cc --- /dev/null +++ b/sdk/src/lazyPty.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createRequire } from 'node:module'; +import type { NodePty } from './pty-types.js'; + +const require = createRequire(import.meta.url); + +let cached: NodePty | undefined; + +/** + * Lazily loads the `node-pty` native addon. + * + * `node-pty` loads its native binary during module evaluation, so a top-level + * `import` would force every consumer of the SDK to ship and load the addon — + * even those that only ever spawn in pipe mode (`usePty: false`). Deferring the + * require keeps the `usePty: false` path from ever touching `node-pty`. + * + * The return type is the vendored {@link NodePty} shape rather than node-pty's + * own module type so the generated `.d.ts` stays self-contained and consumers + * without the optional peer dependency can still type-check. + * + * Uses `createRequire` because `node-pty` is CommonJS and the call sites are + * synchronous. + */ +export function loadPty(): NodePty { + if (cached) { + return cached; + } + try { + cached = require('node-pty') as NodePty; + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e?.code === 'MODULE_NOT_FOUND' && /'node-pty'/.test(e.message)) { + throw new Error( + "PTY mode requires the optional peer dependency 'node-pty', which is not " + + 'installed. Install it (e.g. `npm install node-pty`) or spawn with `usePty: false`.', + ); + } + throw err; + } + return cached; +} diff --git a/sdk/src/pty-types.ts b/sdk/src/pty-types.ts new file mode 100644 index 00000000..98fca05e --- /dev/null +++ b/sdk/src/pty-types.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Minimal, vendored subset of the `node-pty` public type surface. + * + * `node-pty` is an *optional* peer dependency (see {@link ./lazyPty}). If the + * SDK referenced `node-pty`'s own types in its public API, every consumer would + * need `node-pty` installed just to type-check — even pipe-only consumers that + * never spawn a PTY (they would otherwise hit `TS2307: Cannot find module + * 'node-pty'`). Re-declaring the slice we expose keeps the generated `.d.ts` + * self-contained. + * + * These declarations are structurally compatible with the real `node-pty` + * types, so values produced by the actual module satisfy them at runtime. + */ + +/** An object that can be disposed via a dispose function. */ +export interface IDisposable { + dispose(): void; +} + +/** + * An event that can be listened to. + * @returns an `IDisposable` to stop listening. + */ +export interface IEvent { + (listener: (e: T) => unknown): IDisposable; +} + +/** Options shared by all platforms when forking a pseudoterminal. */ +export interface IBasePtyForkOptions { + /** Name of the terminal to be set in environment ($TERM variable). */ + name?: string; + /** Number of initial cols of the pty. */ + cols?: number; + /** Number of initial rows of the pty. */ + rows?: number; + /** Working directory to be set for the child program. */ + cwd?: string; + /** Environment to be set for the child program. */ + env?: { [key: string]: string | undefined }; + /** String encoding of the underlying pty. */ + encoding?: string | null; + /** (EXPERIMENTAL) Whether to enable flow control handling. */ + handleFlowControl?: boolean; + /** (EXPERIMENTAL) String that pauses the pty when `handleFlowControl` is true. */ + flowControlPause?: string; + /** (EXPERIMENTAL) String that resumes the pty when `handleFlowControl` is true. */ + flowControlResume?: string; +} + +/** POSIX-specific fork options. */ +export interface IPtyForkOptions extends IBasePtyForkOptions { + uid?: number; + gid?: number; +} + +/** Windows-specific fork options. */ +export interface IWindowsPtyForkOptions extends IBasePtyForkOptions { + /** @deprecated Ignored by node-pty; retained for compatibility. */ + useConpty?: boolean; + /** (EXPERIMENTAL) Use the conpty.dll shipped with node-pty. */ + useConptyDll?: boolean; + /** Whether to use PSEUDOCONSOLE_INHERIT_CURSOR in conpty. */ + conptyInheritCursor?: boolean; +} + +/** An interface representing a pseudoterminal. */ +export interface IPty { + /** The process ID of the outer process. */ + readonly pid: number; + /** The column size in characters. */ + readonly cols: number; + /** The row size in characters. */ + readonly rows: number; + /** The title of the active process. */ + readonly process: string; + /** (EXPERIMENTAL) Whether to handle flow control at runtime. */ + handleFlowControl: boolean; + /** Fires when data is returned from the pty. */ + readonly onData: IEvent; + /** Fires when the pty exits. */ + readonly onExit: IEvent<{ exitCode: number; signal?: number }>; + /** Resizes the dimensions of the pty. */ + resize(columns: number, rows: number, pixelSize?: { width: number; height: number }): void; + /** Clears the pty's internal representation of its buffer (ConPTY only). */ + clear(): void; + /** Writes data to the pty. */ + write(data: string | Buffer): void; + /** Kills the pty. */ + kill(signal?: string): void; + /** Pauses the pty for customizable flow control. */ + pause(): void; + /** Resumes the pty for customizable flow control. */ + resume(): void; +} + +/** + * The slice of the `node-pty` module shape that the SDK calls into. Returned by + * {@link ./lazyPty.loadPty}. + */ +export interface NodePty { + spawn( + file: string, + args: string[] | string, + options: IPtyForkOptions | IWindowsPtyForkOptions, + ): IPty; +} diff --git a/sdk/src/sandbox.ts b/sdk/src/sandbox.ts index a031173b..19df3591 100644 --- a/sdk/src/sandbox.ts +++ b/sdk/src/sandbox.ts @@ -1,12 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import pty from 'node-pty'; +import type { IPty, IPtyForkOptions } from './pty-types.js'; 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 { loadPty } from './lazyPty.js'; import { prepareSpawn, diagLogVersion, applyLinuxNetworkPolicy } from './helper.js'; import { diagLog } from './diagnostic.js'; import { MxcError, mxcErrorFromCode } from './errors.js'; @@ -431,9 +432,11 @@ export interface SandboxSpawnOptions { skipPlatformCheck?: boolean; /** - * PTY options to pass to node-pty (only used by spawnSandbox) + * PTY options to pass to node-pty. Only used by the PTY path + * (`spawnSandbox`, or `spawnSandboxFromConfig`/`spawnSandboxAsync` with + * `usePty: true`). */ - ptyOptions?: pty.IPtyForkOptions; + ptyOptions?: IPtyForkOptions; /** * Dry run mode: parse and validate config without executing. @@ -447,9 +450,13 @@ export interface SandboxSpawnOptions { logDir?: string; /** - * When false, uses child_process.spawn instead of node-pty. - * Provides reliable exit codes and separate stdout/stderr streams. - * Defaults to true (uses PTY). + * Opt into PTY mode for `spawnSandboxFromConfig` / `spawnSandboxAsync`. + * + * When true, the SDK spawns via `node-pty` (which must be installed — it is + * an optional peer dependency) for interactive terminal I/O. When false or + * unset, it uses `child_process.spawn`, which needs no native addon and + * provides reliable exit codes and separate stdout/stderr streams. + * Defaults to false. (`spawnSandbox` always uses PTY regardless.) */ usePty?: boolean; @@ -493,12 +500,12 @@ function injectEnvIntoConfig( /** * Internal helper: resolves the executor binary path and spawns a PTY process. */ -function spawnWithConfig( +function spawnPtyWithConfig( config: ContainerConfig, options: SandboxSpawnOptions, workingDirectory?: string, env?: { [key: string]: string | undefined }, -): pty.IPty { +): IPty { // Inject env vars into config.process.env so they are passed explicitly to // the sandboxed child via the JSON config (not via process inheritance). if (env) { @@ -508,7 +515,7 @@ function spawnWithConfig( const { executablePath, args, logger, startTime } = prepareSpawn(config, options); try { - const ptyOpts: pty.IPtyForkOptions = { + const ptyOpts: IPtyForkOptions = { name: "xterm-color", cols: 120, rows: 80, @@ -516,9 +523,9 @@ function spawnWithConfig( cwd: workingDirectory || process.cwd(), }; - diagLog(`spawnWithConfig: spawning PTY process, cwd=${ptyOpts.cwd}`); + diagLog(`spawnPtyWithConfig: spawning PTY process, cwd=${ptyOpts.cwd}`); - const ptyProcess = pty.spawn(executablePath, args, ptyOpts); + const ptyProcess = loadPty().spawn(executablePath, args, ptyOpts); ptyProcess.onExit((event) => { logger?.log('info', 'mxc.spawn.exit', { @@ -539,6 +546,11 @@ function spawnWithConfig( * Spawn a sandboxed process using wxc-exec with a PTY (node-pty) for * interactive terminal I/O (colors, input forwarding). * + * Always uses PTY mode and therefore requires the optional `node-pty` peer + * dependency to be installed. For non-interactive use that doesn't need a PTY, + * prefer `spawnSandboxAsync` or `spawnSandboxFromConfig` (both default to pipe + * mode and need no native addon). + * * @param script The command line script to execute * @param policy The sandbox policy * @param options - Spawn options @@ -546,7 +558,7 @@ function spawnWithConfig( * @param containerName Optional container name; if not provided, a random name will be generated * @param env Optional environment variables * @returns IPty object for interacting with the sandboxed process - * @throws Error if platform is not supported or wxc-exec is not found + * @throws Error if platform is not supported, wxc-exec is not found, or `node-pty` is not installed * * @example * ```typescript @@ -565,9 +577,49 @@ export function spawnSandbox( workingDirectory?: string, containerName?: string, env?: { [key: string]: string | undefined }, -): pty.IPty { +): IPty { const config = buildSandboxPayload(script, policy, workingDirectory, containerName); - return spawnWithConfig(config, options, workingDirectory, env); + return spawnPtyWithConfig(config, options, workingDirectory, env); +} + +/** + * Internal helper: resolves the executor binary path and spawns a pipe + * (`child_process`) process. Used by the default, non-PTY spawn paths so they + * never touch the optional `node-pty` addon. + */ +function spawnChildWithConfig( + config: ContainerConfig, + options: SandboxSpawnOptions, + workingDirectory?: string, + env?: { [key: string]: string | undefined }, +): ChildProcess { + // Inject env vars into config.process.env so they are passed explicitly to + // the sandboxed child via the JSON config (not via process inheritance). + if (env) { + injectEnvIntoConfig(config, env); + } + + const { executablePath, args, logger, startTime } = prepareSpawn(config, options); + try { + const child = spawn(executablePath, args, { + cwd: workingDirectory || process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.on('close', (code) => { + logger?.log('info', 'mxc.spawn.exit', { + exitCode: code ?? -1, + durationMs: Date.now() - startTime, + }); + logger?.close(); + }); + child.on('error', () => { + logger?.close(); + }); + return child; + } catch (err) { + logger?.close(); + throw err; + } } /** @@ -580,7 +632,7 @@ export function spawnSandbox( * @param config The container configuration (from createConfigFromPolicy) * @param options - Spawn options * @param workingDirectory Optional working directory path - * @returns IPty when usePty is true or unset; ChildProcess when usePty is false + * @returns ChildProcess by default (pipe mode); IPty when `usePty: true` * * @example * ```typescript @@ -588,64 +640,39 @@ export function spawnSandbox( * config.process!.commandLine = 'echo hello'; * config.processContainer!.ui!.isolation = "atoms"; * - * // PTY mode (default) — returns IPty: - * const ptyProcess = spawnSandboxFromConfig(config); - * - * // Non-PTY mode — returns ChildProcess with reliable exit codes: - * const child = spawnSandboxFromConfig(config, { usePty: false }); + * // Pipe mode (default) — returns ChildProcess with reliable exit codes: + * const child = spawnSandboxFromConfig(config); * child.stdout?.on('data', (data) => console.log(data.toString())); + * + * // PTY mode — returns IPty (requires the optional `node-pty` peer dependency): + * const ptyProcess = spawnSandboxFromConfig(config, { usePty: true }); + * ptyProcess.onData((data) => console.log(data)); * ``` */ export function spawnSandboxFromConfig( config: ContainerConfig, - options: SandboxSpawnOptions & { usePty: false }, + options: SandboxSpawnOptions & { usePty: true }, workingDirectory?: string, env?: { [key: string]: string | undefined } -): ChildProcess; +): IPty; export function spawnSandboxFromConfig( config: ContainerConfig, options?: SandboxSpawnOptions, workingDirectory?: string, env?: { [key: string]: string | undefined } -): pty.IPty; +): ChildProcess; export function spawnSandboxFromConfig( config: ContainerConfig, options: SandboxSpawnOptions = {}, workingDirectory?: string, env?: { [key: string]: string | undefined } -): pty.IPty | ChildProcess { - if (options.usePty === false) { - // Inject env vars into config.process.env so they are passed explicitly to - // the sandboxed child via the JSON config (not via process inheritance). - if (env) { - injectEnvIntoConfig(config, env); - } - - const { executablePath, args, logger, startTime } = prepareSpawn(config, options); - try { - const child = spawn(executablePath, args, { - cwd: workingDirectory || process.cwd(), - stdio: ['pipe', 'pipe', 'pipe'], - }); - child.on('close', (code) => { - logger?.log('info', 'mxc.spawn.exit', { - exitCode: code ?? -1, - durationMs: Date.now() - startTime, - }); - logger?.close(); - }); - child.on('error', () => { - logger?.close(); - }); - return child; - } catch (err) { - logger?.close(); - throw err; - } +): IPty | ChildProcess { + if (options.usePty === true) { + diagLogVersion(); + return spawnPtyWithConfig(config, options, workingDirectory, env); } - diagLogVersion(); - return spawnWithConfig(config, options, workingDirectory, env); + return spawnChildWithConfig(config, options, workingDirectory, env); } /** @@ -672,6 +699,35 @@ export function spawnSandboxFromConfig( * console.log('Exit code:', result.exitCode); * ``` */ +/** + * Spawn a sandboxed process and return a promise that resolves with output. + * Convenience wrapper for non-interactive use cases. + * + * Defaults to pipe mode (`child_process`), which needs no native addon and + * yields separate `stdout`/`stderr`. Pass `usePty: true` to run under a PTY + * instead (requires the optional `node-pty` peer dependency); in PTY mode the + * combined terminal output is returned in `stdout` and `stderr` is empty. + * + * @param script The command line script to execute + * @param policy The sandbox policy + * @param options - Spawn options + * @param workingDirectory Optional working directory path + * @param containerName Optional container name; if not provided, a random name will be generated + * + * @returns Promise that resolves with stdout/stderr and exit code + * + * @example + * ```typescript + * const policy: SandboxPolicy = { + * version: '0.4.0-alpha', + * filesystem: { readwritePaths: ['/workspace'] }, + * }; + * + * const result = await spawnSandboxAsync('echo hello', policy); + * console.log('Output:', result.stdout); + * console.log('Exit code:', result.exitCode); + * ``` + */ export function spawnSandboxAsync( script: string, policy: SandboxPolicy, @@ -679,39 +735,83 @@ export function spawnSandboxAsync( workingDirectory?: string, containerName?: string, ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - return new Promise((resolve, reject) => { - try { - const ptyProcess = spawnSandbox(script, policy, options, workingDirectory, containerName); - let output = ''; + const config = buildSandboxPayload(script, policy, workingDirectory, containerName); + + if (options.usePty === true) { + return new Promise((resolve, reject) => { + let ptyProcess: IPty; + try { + ptyProcess = spawnPtyWithConfig(config, options, workingDirectory); + } catch (error) { + reject(error); + return; + } + let output = ''; ptyProcess.onData((data: string) => { output += data; }); - ptyProcess.onExit((event: { exitCode: number; signal?: number }) => { - // Note: wxc-exec doesn't separate stdout/stderr when using PTY - // All output is combined - // - // Check for structured error envelopes from wxc-exec on failure. - if (event.exitCode !== 0) { - const mxcError = tryParseErrorEnvelopeFromLines(output); - if (mxcError) { - reject(mxcError); - return; - } + // wxc-exec runs under a single PTY, so the OS merges stdout/stderr; + // all output arrives on `output` and stderr is reported empty. + try { + resolve(settleSpawnResult(output, '', event.exitCode)); + } catch (err) { + reject(err); } - resolve({ - stdout: output, - stderr: '', - exitCode: event.exitCode - }); }); + }); + } + + return new Promise((resolve, reject) => { + let child: ChildProcess; + try { + child = spawnChildWithConfig(config, options, workingDirectory); } catch (error) { reject(error); + return; } + + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (d: Buffer | string) => { + stdout += typeof d === 'string' ? d : d.toString('utf-8'); + }); + child.stderr?.on('data', (d: Buffer | string) => { + stderr += typeof d === 'string' ? d : d.toString('utf-8'); + }); + + child.on('error', (err) => { + reject(err); + }); + child.on('close', (code) => { + try { + resolve(settleSpawnResult(stdout, stderr, code ?? -1)); + } catch (err) { + reject(err); + } + }); }); } +/** + * Builds the buffered spawn result, or throws the `MxcError` carried by a + * structured error envelope when the executor reported a non-zero exit. + */ +function settleSpawnResult( + stdout: string, + stderr: string, + exitCode: number, +): { stdout: string; stderr: string; exitCode: number } { + if (exitCode !== 0) { + const mxcError = tryParseErrorEnvelopeFromLines(stderr) ?? tryParseErrorEnvelopeFromLines(stdout); + if (mxcError) { + throw mxcError; + } + } + return { stdout, stderr, exitCode }; +} + /** * Scans a multi-line string for a JSON error envelope emitted by wxc-exec * on stderr. Returns the first matching envelope, or null if none found. diff --git a/sdk/src/state-aware.ts b/sdk/src/state-aware.ts index 307b4d61..0623fda9 100644 --- a/sdk/src/state-aware.ts +++ b/sdk/src/state-aware.ts @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import pty from 'node-pty'; +import type { IPty } from './pty-types.js'; import { resolveBinaryAndCommonArgs } from './helper.js'; +import { loadPty } from './lazyPty.js'; import { SandboxSpawnOptions } from './sandbox.js'; import { mxcErrorFromCode } from './errors.js'; import { diagLog } from './diagnostic.js'; @@ -84,7 +85,7 @@ export function execInSandbox( sandboxId: SandboxId, config: ExecConfigFor, options: SandboxSpawnOptions = {}, -): pty.IPty { +): IPty { const backendKey = backendForSandboxId(sandboxId) as C; const envelope = buildStateAwareEnvelope({ phase: 'exec', @@ -94,7 +95,7 @@ export function execInSandbox( }); const { executablePath, args } = resolveBinaryAndCommonArgs(JSON.stringify(envelope), options); diagLog(`state-aware: spawning exec via PTY`); - const ptyProcess = pty.spawn(executablePath, args, { + const ptyProcess = loadPty().spawn(executablePath, args, { name: 'xterm-color', cols: 120, rows: 80, diff --git a/sdk/tests/integration/common.test.ts b/sdk/tests/integration/common.test.ts index d12b0911..32e28fed 100644 --- a/sdk/tests/integration/common.test.ts +++ b/sdk/tests/integration/common.test.ts @@ -67,14 +67,14 @@ for (const schemaVersion of supportedVersions) { assertDryRunResult(result.stdout, result.exitCode, schemaVersion.raw); }); - it('should dry-run via spawnSandboxFromConfig', async () => { + it('should dry-run via spawnSandboxFromConfig (PTY)', async () => { const config = sdk.createConfigFromPolicy(policy); config.process = config.process ?? { commandLine: '' }; config.process.commandLine = 'cmd.exe /c echo test'; config.containerId = `dryrun-fromcfg-${schemaVersion}`; const result = await new Promise<{ exitCode: number; stdout: string }>((resolve) => { - const ptyProcess = sdk.spawnSandboxFromConfig(config, { dryRun: true, ...debugSpawnOptions }); + const ptyProcess = sdk.spawnSandboxFromConfig(config, { dryRun: true, usePty: true, ...debugSpawnOptions }); let stdout = ''; ptyProcess.onData((data: string) => { stdout += data; }); ptyProcess.onExit((event: { exitCode: number }) => { diff --git a/sdk/tests/integration/macos-seatbelt.test.ts b/sdk/tests/integration/macos-seatbelt.test.ts index 90d52032..74dd16c1 100644 --- a/sdk/tests/integration/macos-seatbelt.test.ts +++ b/sdk/tests/integration/macos-seatbelt.test.ts @@ -227,7 +227,7 @@ describe('macOS Seatbelt Container', { config.containerId = 'seatbelt-profile-override'; const result = await new Promise<{ exitCode: number; stdout: string }>((resolve, reject) => { - const ptyProcess = sdk.spawnSandboxFromConfig(config, seatbeltSpawnOptions); + const ptyProcess = sdk.spawnSandboxFromConfig(config, { ...seatbeltSpawnOptions, usePty: true }); let stdout = ''; const timer = setTimeout(() => reject(new Error('Test timed out waiting for onExit')), 25_000); ptyProcess.onData((data: string) => { stdout += data; }); diff --git a/sdk/tests/integration/test-helpers.ts b/sdk/tests/integration/test-helpers.ts index 8dd0d849..fb375680 100644 --- a/sdk/tests/integration/test-helpers.ts +++ b/sdk/tests/integration/test-helpers.ts @@ -242,30 +242,60 @@ export function createTempDir(prefix: string = 'mxc-test'): string { // specific backend build the config directly. // // Notes (kept in lockstep with spawnSandboxAsync): -// - stdout/stderr are merged: wxc-exec runs under node-pty (a single PTY), -// so the OS combines both streams. stderr: '' is structural padding. +// - Defaults to pipe mode (child_process): stdout/stderr are returned +// separately and no node-pty native addon is required. Pass `usePty: true` +// to run under a PTY instead, in which case the OS merges both streams into +// stdout and stderr is '' (structural padding). // - No per-call timeout: node:test enforces test-level timeouts and the // config's process.timeout is enforced by the native runner. -// - IPty has no onError event. Synchronous spawn failures are caught below; -// post-spawn failures surface as a non-zero exitCode via onExit. +// - Synchronous spawn failures are caught below; post-spawn failures surface +// as a non-zero exitCode. export function spawnFromConfigAsync( config: sdkNamespace.ContainerConfig, options: sdkNamespace.SandboxSpawnOptions = {}, workingDirectory?: string, ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + if (options.usePty === true) { + return new Promise((resolve, reject) => { + try { + const ptyProcess = sdkNamespace.spawnSandboxFromConfig( + config, + { ...options, usePty: true as const }, + workingDirectory, + ); + let output = ''; + ptyProcess.onData((data: string) => { + output += data; + }); + ptyProcess.onExit((event: { exitCode: number; signal?: number }) => { + resolve({ stdout: output, stderr: '', exitCode: event.exitCode }); + }); + } catch (err) { + reject(err); + } + }); + } + return new Promise((resolve, reject) => { + let child: import('child_process').ChildProcess; try { - const ptyProcess = sdkNamespace.spawnSandboxFromConfig(config, options, workingDirectory); - let output = ''; - ptyProcess.onData((data: string) => { - output += data; - }); - ptyProcess.onExit((event: { exitCode: number; signal?: number }) => { - resolve({ stdout: output, stderr: '', exitCode: event.exitCode }); - }); + child = sdkNamespace.spawnSandboxFromConfig(config, options, workingDirectory); } catch (err) { reject(err); + return; } + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (d: Buffer) => { + stdout += d.toString(); + }); + child.stderr?.on('data', (d: Buffer) => { + stderr += d.toString(); + }); + child.on('error', reject); + child.on('close', (code: number | null) => { + resolve({ stdout, stderr, exitCode: code ?? -1 }); + }); }); } diff --git a/src/Cargo.lock b/src/Cargo.lock index e37cbc13..a0d33cdf 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -95,7 +95,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "appcontainer_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "flatbuffers", "getrandom 0.2.17", @@ -213,7 +213,7 @@ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bwrap_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "lxc_common", "nix", @@ -899,7 +899,7 @@ dependencies = [ [[package]] name = "hyperlight_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "hyperlight-unikraft-host", "wxc_common", @@ -1067,7 +1067,7 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "isolation_session_bindings" -version = "0.6.1" +version = "0.7.0" dependencies = [ "semver", "windows", @@ -1078,7 +1078,7 @@ dependencies = [ [[package]] name = "isolation_session_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "isolation_session_bindings", "serde", @@ -1242,7 +1242,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lxc" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "bwrap_common", @@ -1258,7 +1258,7 @@ dependencies = [ [[package]] name = "lxc_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "libc", "mxc_pty", @@ -1371,14 +1371,14 @@ dependencies = [ [[package]] name = "mxc_build_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "winresource", ] [[package]] name = "mxc_darwin" -version = "0.6.1" +version = "0.7.0" dependencies = [ "clap", "mxc_build_common", @@ -1388,7 +1388,7 @@ dependencies = [ [[package]] name = "mxc_diagnostic_console" -version = "0.6.1" +version = "0.7.0" dependencies = [ "clap", "mxc_build_common", @@ -1401,7 +1401,7 @@ dependencies = [ [[package]] name = "mxc_pty" -version = "0.6.1" +version = "0.7.0" dependencies = [ "libc", "nix", @@ -1409,14 +1409,14 @@ dependencies = [ [[package]] name = "nanvix_binaries" -version = "0.6.1" +version = "0.7.0" dependencies = [ "nanvix_common", ] [[package]] name = "nanvix_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "serde", "serde_json", @@ -1424,7 +1424,7 @@ dependencies = [ [[package]] name = "nanvix_runner" -version = "0.6.1" +version = "0.7.0" dependencies = [ "libc", "nanvix_common", @@ -1830,7 +1830,7 @@ dependencies = [ [[package]] name = "sandbox_spec" -version = "0.6.1" +version = "0.7.0" dependencies = [ "flatbuffers", ] @@ -1863,7 +1863,7 @@ dependencies = [ [[package]] name = "seatbelt_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "libc", "mxc_pty", @@ -2691,7 +2691,7 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_sandbox_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "serde", "serde_json", @@ -2831,7 +2831,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wslc_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "libloading", "tar", @@ -2843,7 +2843,7 @@ dependencies = [ [[package]] name = "wxc" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "appcontainer_common", @@ -2864,7 +2864,7 @@ dependencies = [ [[package]] name = "wxc_common" -version = "0.6.1" +version = "0.7.0" dependencies = [ "base64", "getrandom 0.2.17", @@ -2885,7 +2885,7 @@ dependencies = [ [[package]] name = "wxc_e2e_tests" -version = "0.6.1" +version = "0.7.0" dependencies = [ "base64", "serde", @@ -2894,7 +2894,7 @@ dependencies = [ [[package]] name = "wxc_host_prep" -version = "0.6.1" +version = "0.7.0" dependencies = [ "clap", "embed-manifest", @@ -2910,7 +2910,7 @@ dependencies = [ [[package]] name = "wxc_test_driver" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "mxc_build_common", @@ -2918,7 +2918,7 @@ dependencies = [ [[package]] name = "wxc_test_proxy" -version = "0.6.1" +version = "0.7.0" dependencies = [ "bytes", "clap", @@ -2936,7 +2936,7 @@ version = "0.1.0" [[package]] name = "wxc_windows_sandbox_daemon" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "mxc_build_common", @@ -2951,7 +2951,7 @@ dependencies = [ [[package]] name = "wxc_windows_sandbox_guest" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "mxc_build_common", @@ -2963,7 +2963,7 @@ dependencies = [ [[package]] name = "wxc_winhttp_proxy_shim" -version = "0.6.1" +version = "0.7.0" dependencies = [ "clap", "mxc_build_common", diff --git a/src/Cargo.toml b/src/Cargo.toml index 4b83d426..73102d5a 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -44,7 +44,7 @@ split-debuginfo = "packed" strip = "debuginfo" [workspace.package] -version = "0.6.1" +version = "0.7.0" edition = "2021" [workspace.dependencies]