From a38cb595fc0e7593f737fd99ac27d6ec08e528f8 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Jan 2026 13:43:15 +0100 Subject: [PATCH 1/9] fix: CDK app fails to launch if paths contain spaces When executing CDK apps, users specify the command as a string. The only feasible interpretation of that is by executing the command line through a shell, either `bash` or `cmd.exe`. We are using shell execution on purpose: - It's used for tests - It's necessary on Windows to properly execute `.bat` and `.cmd` files - Since we have historically offered it you can bet dollars to doughnuts that customers have built workflows depending on that. This is all a preface to explain why we don't have an `argv` array. Automated code scanning tools will probably complain, but we can't change any of this. And since the source of the string and the machine it's executing on are part of the same security domain (it's all "the customer": the customer writes the command string, then executes it on their own machine), that is fine. However, historically we did do trivial parsing and preprocessing of that `string` in order to help the user achieve success. Specifically: if the string pointed to a `.js` file we would run that `.js` file through a Node interpreter, even if: - The file was not marked as executable on POSIX; or - There was no shell association set up for `.js` files on Windows. That light parsing used to fail in the following cases: - If the pointed-to file had spaces in its path. - If Node was installed in a location that had spaces in its path. In this PR we document the choice of command line string a bit better, and handle the cases where the file or interpreter paths can have spaces in them. We still don't do fully generic command line parsing, because it's extremely complex on Windows and we can probably not do it correctly; we're just concerned with quoting the target and interpreter. Closes #636 --- .../lib/api/cloud-assembly/environment.ts | 133 +++++++++++++----- .../lib/api/cloud-assembly/private/exec.ts | 10 +- .../cloud-assembly/private/prepare-source.ts | 2 +- .../lib/api/cloud-assembly/source-builder.ts | 7 +- .../api/cloud-assembly/environment.test.ts | 79 +++++++++++ packages/aws-cdk/lib/cxapp/exec.ts | 2 +- 6 files changed, 192 insertions(+), 41 deletions(-) create mode 100644 packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index 7d76d6df8..6d7b47729 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -107,58 +107,121 @@ export function spaceAvailableForContext(env: Env, limit: number) { /** * Guess the executable from the command-line argument * - * Only do this if the file is NOT marked as executable. If it is, - * we'll defer to the shebang inside the file itself. + * Input is the "app" string the user gave us. Output is the command line we are going to execute. * - * If we're on Windows, we ALWAYS take the handler, since it's hard to - * verify if registry associations have or have not been set up for this - * file type, so we'll assume the worst and take control. + * - On Windows: it's hard to verify if registry associations have or have not + * been set up for this file type (i.e., ShellExec'ing the file will work or not), + * so we'll assume the worst and take control ourselves. + * + * - On POSIX: if the file is NOT marked as executable, guess the interpreter. If it is executable, + * the correct interpreter should be in the file's shebang and we just execute it directly. + * + * The behavior of only guessing the interpreter if the command line is a single file name + * is a bit limited: we can't put a `.js` file with arguments in the command line and have + * it work properly. Nevertheless, this is the behavior we have had for a long time and nobody + * has really complained about it, so we'll keep it for now. */ -export async function guessExecutable(app: string, debugFn: (msg: string) => Promise) { - const commandLine = appToArray(app); - if (commandLine.length === 1) { - let fstat; - - try { - fstat = await fs.stat(commandLine[0]); - } catch { - await debugFn(`Not a file: '${commandLine[0]}'. Using '${commandLine}' as command-line`); - return commandLine; - } - - // eslint-disable-next-line no-bitwise - const isExecutable = (fstat.mode & fs.constants.X_OK) !== 0; - const isWindows = process.platform === 'win32'; - - const handler = EXTENSION_MAP.get(path.extname(commandLine[0])); - if (handler && (!isExecutable || isWindows)) { - return handler(commandLine[0]); - } +export async function guessExecutable(commandLine: string, debugFn: (msg: string) => Promise): Promise { + // The command line with spaces in it could reference a file on disk. If true, + // we quote it and return that, optionally by prefixing an interpreter. + const fullFile = await checkFile(commandLine); + if (fullFile) { + return guessInterpreter(fullFile); + } + + // Otherwise, the first word on the command line could reference a file on + // disk (quoted or non-quoted). If true, we optionally prefix an interpreter. + const [first, rest] = splitFirstShellWord(commandLine); + const firstFile = await checkFile(first); + if (firstFile) { + return `${guessInterpreter(firstFile)} ${rest}`.trim(); } + + // We couldn't parse it, so just use the given command line. + await debugFn(`Not a file: '${commandLine}'. Using '${commandLine} as command-line`); return commandLine; } +/** + * Guess the right interpreter to use to execute the given file and return a (partial) command line to execute it. + * + * This may entail: + * + * - Prefixing an interpreter if necessary + * - Quoting the file name if necessary + */ +function guessInterpreter(file: FileInfo): string { + const isWindows = process.platform === 'win32'; + + const handler = EXTENSION_MAP[path.extname(file.fileName)]; + if (handler && (!file.isExecutable || isWindows)) { + return handler(file.fileName); + } + + return quoteSpaces(file.fileName); +} + /** * Mapping of extensions to command-line generators */ -const EXTENSION_MAP = new Map([ - ['.js', executeNode], -]); +const EXTENSION_MAP: Record = { + '.js': executeNode, +}; -type CommandGenerator = (file: string) => string[]; +type CommandGenerator = (file: string) => string; /** * Execute the given file with the same 'node' process as is running the current process */ -function executeNode(scriptFile: string): string[] { - return [process.execPath, scriptFile]; +function executeNode(scriptFile: string): string { + return `${quoteSpaces(process.execPath)} ${quoteSpaces(scriptFile)}`; } /** - * Make sure the 'app' is an array + * Parse off the first quoted or unquoted shell word. + */ +function splitFirstShellWord(commandLine: string): [string, string] { + commandLine = commandLine.trim(); + if (commandLine[0] === '"') { + // Split on the next quote, ignore any escaping + const endQuote = commandLine.indexOf('"', 1); + return endQuote > -1 ? [commandLine.slice(1, endQuote), commandLine.slice(endQuote + 1).trim()] : [commandLine, '']; + } else { + // Split on the first space + const space = commandLine.indexOf(' '); + return space > -1 ? [commandLine.slice(0, space), commandLine.slice(space + 1)] : [commandLine, '']; + } +} + +/** + * Look up a file and see if it exists and is executable + */ +async function checkFile(fileName: string): Promise { + try { + const fstat = await fs.stat(fileName); + return { + fileName, + // eslint-disable-next-line no-bitwise + isExecutable: (fstat.mode & fs.constants.X_OK) !== 0, + }; + } catch { + return undefined; + } +} + +interface FileInfo { + readonly fileName: string; + readonly isExecutable: boolean; +} + +/** + * Quote a shell part if it contains spaces * - * If it's a string, split on spaces as a trivial way of tokenizing the command line. + * We're only interested in spaces, nothing else. */ -function appToArray(app: any) { - return typeof app === 'string' ? app.split(' ') : app; +function quoteSpaces(part: string) { + if (part.includes(' ')) { + return `"${part}"`; + } + return part; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index 691689cff..f2888b242 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -12,7 +12,7 @@ interface ExecOptions { } /** - * Execute a command and args in a child process + * Execute a command line in a child process */ export async function execInChildProcess(commandAndArgs: string, options: ExecOptions = {}) { return new Promise((ok, fail) => { @@ -27,9 +27,15 @@ export async function execInChildProcess(commandAndArgs: string, options: ExecOp const proc = child_process.spawn(commandAndArgs, { stdio: ['ignore', 'pipe', 'pipe'], detached: false, - shell: true, cwd: options.cwd, env: options.env, + + // We are using 'shell: true' on purprose. Traditionally we have allowed shell features in + // this string, so we have to continue to do so into the future. On Windows, this is simply + // necessary to run .bat and .cmd files properly. + // Code scanning tools will flag this as a risk. The input comes from a trusted source, + // so it does not represent a security risk. + shell: true, }); const eventPublisher: EventPublisher = options.eventPublisher ?? ((type, line) => { diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts index 1c71f8367..a4dcf1df7 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts @@ -163,7 +163,7 @@ export class ExecutionEnvironment implements AsyncDisposable { * verify if registry associations have or have not been set up for this * file type, so we'll assume the worst and take control. */ - public guessExecutable(app: string) { + public guessExecutable(app: string): Promise { return guessExecutable(app, this.debugFn); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts index 4603d42da..e8e8e670a 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts @@ -432,7 +432,10 @@ export abstract class CloudAssemblySourceBuilder { }; } /** - * Use a directory containing an AWS CDK app as source. + * Use an AWS CDK app exectuable as source. + * + * `app` is a command line that will be executed to produce a Cloud Assembly. + * The command will be executed in a shell, so it must come from a trusted source. * * The subprocess will execute in `workingDirectory`, which defaults to * the current process' working directory if not given. @@ -512,7 +515,7 @@ export abstract class CloudAssemblySourceBuilder { }); const cleanupTemp = writeContextToEnv(env, fullContext, 'env-is-complete'); try { - await execInChildProcess(commandLine.join(' '), { + await execInChildProcess(commandLine, { eventPublisher: async (type, line) => { switch (type) { case 'data_stdout': diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts new file mode 100644 index 000000000..3b14864c7 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts @@ -0,0 +1,79 @@ +import * as fs from 'fs-extra'; +import { guessExecutable } from '../../../lib/api/cloud-assembly/environment'; + +const BOTH = 'both' as const; +const DONTCARE = 'DONT-CARE'; + +test.each([ + // Just a simple command + ...explodeBoth(['asdf', BOTH, 'asdf', BOTH, DONTCARE, 'asdf']), + ...explodeBoth(['asdf', BOTH, 'asdf', undefined, DONTCARE, 'asdf']), + // Simple command with args + ...explodeBoth(['asdf arg', BOTH, 'asdf', BOTH, DONTCARE, 'asdf arg']), + ...explodeBoth(['asdf arg', BOTH, 'asdf', undefined, DONTCARE, 'asdf arg']), + // If the full path contains spaces and it's a file, quote it and execute it + ...explodeBoth(['/path with/spaces', BOTH, '/path with/spaces', BOTH, DONTCARE, '"/path with/spaces"']), + ...explodeBoth(['/path with/spaces', BOTH, '/path with/spaces', BOTH, DONTCARE, '"/path with/spaces"']), + // If the path is a .js file on Windows, prepend the node interpreter (quoted if necessary) + ...explodeBoth(['/path with/spaces.js', true, '/path with/spaces.js', BOTH, '/path/to/node', '/path/to/node "/path with/spaces.js"']), + ...explodeBoth(['/path with/spaces.js', true, '/path with/spaces.js', BOTH, '/path to/node', '"/path to/node" "/path with/spaces.js"']), + // If the path is a non-executable .js file on Linux, prepend the node interpreter (quoted if necessary) + ...explodeBoth(['/path with/spaces.js', false, '/path with/spaces.js', false, '/path/to/node', '/path/to/node "/path with/spaces.js"']), + ...explodeBoth(['/path with/spaces.js', false, '/path with/spaces.js', false, '/path to/node', '"/path to/node" "/path with/spaces.js"']), + // If the path is an executable .js file on Linux, don't do anything (perhaps except quoting) + ...explodeBoth(['/path/file.js', false, '/path/file.js', true, '/path/to/node', '/path/file.js']), + ...explodeBoth(['/path with spaces/file.js', false, '/path with spaces/file.js', true, '/path to/node', '"/path with spaces/file.js"']), + // If the path is quoted with spaces that also works + ...explodeBoth(['"command with spaces" arg1 arg2', BOTH, 'command with spaces', BOTH, DONTCARE, '"command with spaces" arg1 arg2']), + ...explodeBoth(['"command with spaces.js" arg1 arg2', true, 'command with spaces.js', false, '/node', '/node "command with spaces.js" arg1 arg2']), +])('cmd=%p win=%p (stat=%p) exe=%p node=%p => %p', async (commandLine: string, isWindows: boolean, statFile: string, isExecutable: boolean | undefined, nodePath: string, expected: string) => { + // GIVEN + process.execPath = nodePath; + Object.defineProperty(process, 'platform', { value: isWindows ? 'win32' : 'linux' }) ; + jest.spyOn(fs, 'stat').mockImplementation((p) => { + if (p !== statFile) { + throw new Error(`Expected a stat() call on '${statFile}' but got '${p}'`); + } + if (isExecutable === undefined) { + const e = new Error(`No such file: ${p}`); + (e as any).code = 'ENOENT'; + return Promise.reject(e); + } + return Promise.resolve({ + mode: isExecutable ? fs.constants.X_OK : 0, + }); + }); + + // WHEN + const actual = await guessExecutable(commandLine, (_) => Promise.resolve()); + + // THEN + expect(actual).toEqual(expected); +}); + + +/** + * Explode all 'both's in a test array to both false and true + */ +function explodeBoth(input: [F, ...R]): [NotBoth, ...NotBothA][] { + const [first, ...rest] = input; + + const values = first === 'both' ? [false, true] : [first]; + + if (rest.length === 0) { + return [values as any]; + } + const explodedRest = explodeBoth(rest as any); + + const ret: [NotBoth, ...NotBothA][] = []; + for (const value of values) { + for (const remainder of explodedRest) { + ret.push([value as any, ...remainder] as any); + } + } + + return ret; +} + +type NotBoth = Exclude; +type NotBothA = { [I in keyof A]: NotBoth }; \ No newline at end of file diff --git a/packages/aws-cdk/lib/cxapp/exec.ts b/packages/aws-cdk/lib/cxapp/exec.ts index 1b7aad1c1..1052b6fb9 100644 --- a/packages/aws-cdk/lib/cxapp/exec.ts +++ b/packages/aws-cdk/lib/cxapp/exec.ts @@ -86,7 +86,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: const cleanupTemp = writeContextToEnv(env, context, 'add-process-env-later'); try { - await exec(commandLine.join(' ')); + await exec(commandLine); const assembly = createAssembly(outdir); From 38710f79b99b4a9b794c406cedc1b3bec5c7f2a7 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Jan 2026 14:31:27 +0100 Subject: [PATCH 2/9] Leave argv arrays in place --- .../lib/api/cloud-assembly/environment.ts | 59 +++++++++++++++---- .../lib/api/cloud-assembly/private/exec.ts | 56 ++++++++++++------ .../cloud-assembly/private/prepare-source.ts | 5 +- .../lib/api/cloud-assembly/source-builder.ts | 4 +- .../api/cloud-assembly/environment.test.ts | 3 +- packages/aws-cdk/lib/cxapp/exec.ts | 45 +++++++++----- 6 files changed, 120 insertions(+), 52 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index 6d7b47729..1398182ce 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -3,6 +3,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs-extra'; import type { SdkProvider } from '../aws-auth/private'; import type { Settings } from '../settings'; +import { Command } from './private/exec'; export type Env = { [key: string]: string | undefined }; export type Context = { [key: string]: unknown }; @@ -105,7 +106,7 @@ export function spaceAvailableForContext(env: Env, limit: number) { } /** - * Guess the executable from the command-line argument + * Guess the executable from the string command * * Input is the "app" string the user gave us. Output is the command line we are going to execute. * @@ -121,25 +122,57 @@ export function spaceAvailableForContext(env: Env, limit: number) { * it work properly. Nevertheless, this is the behavior we have had for a long time and nobody * has really complained about it, so we'll keep it for now. */ -export async function guessExecutable(commandLine: string, debugFn: (msg: string) => Promise): Promise { +export function guessExecutable(command: Command, debugFn: (msg: string) => Promise): Promise { + switch (command.type) { + case 'argv': + return guessArgvExecutable(command, debugFn); + case 'shell': + return guessShellExecutable(command, debugFn); + } +} + +export async function guessArgvExecutable(command: Extract, debugFn: (msg: string) => Promise): Promise { + // We perform "guessInterpreter" on the first value in the array to execute successfully on Windows and on POSIX without the executable + // bit, and nothing else. + const [first, ...rest] = command.argv; + const firstFile = await checkFile(first); + if (firstFile) { + return { + type: 'argv', + argv: [...guessInterpreter(firstFile), ...rest], + }; + } + + // Not a file, so just use the given command line + await debugFn(`Not a file: '${first}'. Using ${JSON.stringify(command.argv)} as command`); + return command; +} + +export async function guessShellExecutable(command: Extract, debugFn: (msg: string) => Promise): Promise { // The command line with spaces in it could reference a file on disk. If true, // we quote it and return that, optionally by prefixing an interpreter. - const fullFile = await checkFile(commandLine); + const fullFile = await checkFile(command.command); if (fullFile) { - return guessInterpreter(fullFile); + return { + type: 'shell', + command: guessInterpreter(fullFile).map(quoteSpaces).join(' '), + }; } // Otherwise, the first word on the command line could reference a file on // disk (quoted or non-quoted). If true, we optionally prefix an interpreter. - const [first, rest] = splitFirstShellWord(commandLine); + const [first, rest] = splitFirstShellWord(command.command); const firstFile = await checkFile(first); if (firstFile) { - return `${guessInterpreter(firstFile)} ${rest}`.trim(); + return { + type: 'shell', + command: [...guessInterpreter(firstFile).map(quoteSpaces), rest].join(' ').trim(), + }; } // We couldn't parse it, so just use the given command line. - await debugFn(`Not a file: '${commandLine}'. Using '${commandLine} as command-line`); - return commandLine; + await debugFn(`Not a file: '${command.command}'. Using '${command.command} as command-line`); + return command; } /** @@ -150,7 +183,7 @@ export async function guessExecutable(commandLine: string, debugFn: (msg: string * - Prefixing an interpreter if necessary * - Quoting the file name if necessary */ -function guessInterpreter(file: FileInfo): string { +function guessInterpreter(file: FileInfo): string[] { const isWindows = process.platform === 'win32'; const handler = EXTENSION_MAP[path.extname(file.fileName)]; @@ -158,7 +191,7 @@ function guessInterpreter(file: FileInfo): string { return handler(file.fileName); } - return quoteSpaces(file.fileName); + return [file.fileName]; } /** @@ -168,13 +201,13 @@ const EXTENSION_MAP: Record = { '.js': executeNode, }; -type CommandGenerator = (file: string) => string; +type CommandGenerator = (file: string) => string[]; /** * Execute the given file with the same 'node' process as is running the current process */ -function executeNode(scriptFile: string): string { - return `${quoteSpaces(process.execPath)} ${quoteSpaces(scriptFile)}`; +function executeNode(scriptFile: string): string[] { + return [process.execPath, scriptFile]; } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index f2888b242..80319a810 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -2,6 +2,7 @@ import * as child_process from 'node:child_process'; // eslint-disable-next-line @typescript-eslint/no-require-imports import split = require('split2'); import { AssemblyError } from '../../../toolkit/toolkit-error'; +import { Readable } from 'node:stream'; type EventPublisher = (event: 'open' | 'data_stdout' | 'data_stderr' | 'close', line: string) => void; @@ -11,32 +12,51 @@ interface ExecOptions { cwd?: string; } +export type Command = + | { type: 'argv'; argv: string[] } + | { type: 'shell'; command: string } + ; + +/** + * Turn a user input into a `Command` type + */ +export function toCommand(input: string | string[]): Command { + if (Array.isArray(input)) { + return { type: 'argv', argv: input }; + } else { + return { type: 'shell', command: input }; + } +} + /** * Execute a command line in a child process */ -export async function execInChildProcess(commandAndArgs: string, options: ExecOptions = {}) { +export async function execInChildProcess(command: Command, options: ExecOptions = {}) { return new Promise((ok, fail) => { - // We use a slightly lower-level interface to: - // - // - Pass arguments in an array instead of a string, to get around a - // number of quoting issues introduced by the intermediate shell layer - // (which would be different between Linux and Windows). - // - // - We have to capture any output to stdout and stderr sp we can pass it on to the IoHost - // To ensure messages get to the user fast, we will emit every full line we receive. - const proc = child_process.spawn(commandAndArgs, { + // Depending on the type of command we have to execute, spawn slightly differently + let proc : child_process.ChildProcessByStdio; + const spawnOpts: child_process.SpawnOptionsWithStdioTuple = { stdio: ['ignore', 'pipe', 'pipe'], detached: false, cwd: options.cwd, env: options.env, + }; - // We are using 'shell: true' on purprose. Traditionally we have allowed shell features in - // this string, so we have to continue to do so into the future. On Windows, this is simply - // necessary to run .bat and .cmd files properly. - // Code scanning tools will flag this as a risk. The input comes from a trusted source, - // so it does not represent a security risk. - shell: true, - }); + switch (command.type) { + case 'argv': + proc = child_process.spawn(command.argv[0], command.argv.slice(1), spawnOpts); + break; + case 'shell': + proc = child_process.spawn(command.command, { + ...spawnOpts, + // Command lines need a shell; necessary on windows for .bat and .cmd files, necessary on + // Linux to use the shell features we've traditionally supported. + // Code scanning tools will flag this as a risk. The input comes from a trusted source, + // so it does not represent a security risk. + shell: true, + }); + break; + } const eventPublisher: EventPublisher = options.eventPublisher ?? ((type, line) => { switch (type) { @@ -71,7 +91,7 @@ export async function execInChildProcess(commandAndArgs: string, options: ExecOp cause = new Error(stderr.join('\n')); cause.name = 'ExecutionError'; } - return fail(AssemblyError.withCause(`${commandAndArgs}: Subprocess exited with error ${code}`, cause)); + return fail(AssemblyError.withCause(`${command}: Subprocess exited with error ${code}`, cause)); } }); }); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts index a4dcf1df7..0c4997d6f 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts @@ -21,6 +21,7 @@ import { loadTree, some } from '../../tree'; import type { Context, Env } from '../environment'; import { prepareDefaultEnvironment, spaceAvailableForContext, guessExecutable, synthParametersFromSettings } from '../environment'; import type { AppSynthOptions, LoadAssemblyOptions } from '../source-builder'; +import { Command } from './exec'; export interface ExecutionEnvironmentOptions { /** @@ -163,8 +164,8 @@ export class ExecutionEnvironment implements AsyncDisposable { * verify if registry associations have or have not been set up for this * file type, so we'll assume the worst and take control. */ - public guessExecutable(app: string): Promise { - return guessExecutable(app, this.debugFn); + public guessExecutable(command: Command): Promise { + return guessExecutable(command, this.debugFn); } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts index e8e8e670a..5fdf72e2a 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts @@ -8,7 +8,7 @@ import { RWLock } from '../rwlock'; import { CachedCloudAssembly } from './cached-source'; import type { ContextAwareCloudAssemblyProps } from './private/context-aware-source'; import { ContextAwareCloudAssemblySource } from './private/context-aware-source'; -import { execInChildProcess } from './private/exec'; +import { execInChildProcess, toCommand } from './private/exec'; import { ExecutionEnvironment, assemblyFromDirectory, parametersFromSynthOptions, writeContextToEnv } from './private/prepare-source'; import { ReadableCloudAssembly } from './private/readable-assembly'; import type { ICloudAssemblySource } from './types'; @@ -491,7 +491,7 @@ export abstract class CloudAssemblySourceBuilder { resolveDefaultAppEnv: props.resolveDefaultEnvironment ?? true, }); - const commandLine = await execution.guessExecutable(app); + const commandLine = await execution.guessExecutable(toCommand(app)); const synthParams = parametersFromSynthOptions(props.synthOptions); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts index 3b14864c7..9c880e4e5 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts @@ -1,5 +1,6 @@ import * as fs from 'fs-extra'; import { guessExecutable } from '../../../lib/api/cloud-assembly/environment'; +import { toCommand } from '../../../lib/api/cloud-assembly/private/exec'; const BOTH = 'both' as const; const DONTCARE = 'DONT-CARE'; @@ -45,7 +46,7 @@ test.each([ }); // WHEN - const actual = await guessExecutable(commandLine, (_) => Promise.resolve()); + const actual = await guessExecutable(toCommand(commandLine), (_) => Promise.resolve()); // THEN expect(actual).toEqual(expected); diff --git a/packages/aws-cdk/lib/cxapp/exec.ts b/packages/aws-cdk/lib/cxapp/exec.ts index 1052b6fb9..08a9ad501 100644 --- a/packages/aws-cdk/lib/cxapp/exec.ts +++ b/packages/aws-cdk/lib/cxapp/exec.ts @@ -6,8 +6,8 @@ import * as cxapi from '@aws-cdk/cx-api'; import { ToolkitError } from '@aws-cdk/toolkit-lib'; import * as fs from 'fs-extra'; import type { IoHelper } from '../../lib/api-private'; -import type { SdkProvider, IReadLock } from '../api'; -import { RWLock, guessExecutable, prepareDefaultEnvironment, writeContextToEnv, synthParametersFromSettings } from '../api'; +import type { SdkProvider, IReadLock, Command } from '../api'; +import { RWLock, guessExecutable, prepareDefaultEnvironment, writeContextToEnv, synthParametersFromSettings, toCommand } from '../api'; import type { Configuration } from '../cli/user-configuration'; import { PROJECT_CONFIG, USER_DEFAULTS } from '../cli/user-configuration'; import { versionNumber } from '../cli/version'; @@ -41,7 +41,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: await exec(build); } - const app = config.settings.get(['app']); + let app = config.settings.get(['app']); if (!app) { throw new ToolkitError(`--app is required either in command-line, in ${PROJECT_CONFIG} or in ${USER_DEFAULTS}`); } @@ -56,7 +56,8 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: return { assembly: createAssembly(app), lock }; } - const commandLine = await guessExecutable(app, debugFn); + const command = toCommand(app); + const commandLine = command.type === 'shell' ? await guessExecutable(app, debugFn) : command; const outdir = config.settings.get(['output']); if (!outdir) { @@ -98,28 +99,40 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: await cleanupTemp(); } - async function exec(commandAndArgs: string) { + async function exec(command: Command) { try { await new Promise((ok, fail) => { - // We use a slightly lower-level interface to: - // - // - Pass arguments in an array instead of a string, to get around a - // number of quoting issues introduced by the intermediate shell layer - // (which would be different between Linux and Windows). - // + // Depending on the type of command we have to execute, spawn slightly differently + // - Inherit stderr from controlling terminal. We don't use the captured value // anyway, and if the subprocess is printing to it for debugging purposes the // user gets to see it sooner. Plus, capturing doesn't interact nicely with some // processes like Maven. - const proc = childProcess.spawn(commandAndArgs, { + let proc : childProcess.ChildProcessByStdio; + const spawnOpts: childProcess.SpawnOptionsWithStdioTuple = { stdio: ['ignore', 'inherit', 'inherit'], detached: false, - shell: true, env: { ...process.env, ...env, }, - }); + }; + + switch (command.type) { + case 'argv': + proc = childProcess.spawn(command.argv[0], command.argv.slice(1), spawnOpts); + break; + case 'shell': + proc = childProcess.spawn(command.command, { + ...spawnOpts, + // Command lines need a shell; necessary on windows for .bat and .cmd files, necessary on + // Linux to use the shell features we've traditionally supported. + // Code scanning tools will flag this as a risk. The input comes from a trusted source, + // so it does not represent a security risk. + shell: true, + }); + break; + } proc.on('error', fail); @@ -127,12 +140,12 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: if (code === 0) { return ok(); } else { - return fail(new ToolkitError(`${commandAndArgs}: Subprocess exited with error ${code}`)); + return fail(new ToolkitError(`${command}: Subprocess exited with error ${code}`)); } }); }); } catch (e: any) { - await debugFn(`failed command: ${commandAndArgs}`); + await debugFn(`failed command: ${command}`); throw e; } } From d7d6a9b6cc9ffeeef13218097727327b3526d25e Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Jan 2026 14:41:33 +0100 Subject: [PATCH 3/9] So I don't lose it --- .../@aws-cdk/toolkit-lib/COMMITMESSAGE.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 packages/@aws-cdk/toolkit-lib/COMMITMESSAGE.md diff --git a/packages/@aws-cdk/toolkit-lib/COMMITMESSAGE.md b/packages/@aws-cdk/toolkit-lib/COMMITMESSAGE.md new file mode 100644 index 000000000..eb5d83bd5 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/COMMITMESSAGE.md @@ -0,0 +1,43 @@ +When executing CDK apps, users specify the `{ "app" }` command as a `string` (heavily advertised) or a `string[]` (not really advertised but historically possible through specific code paths). + +In case the command line is a `string`, the only feasible interpretation of that is by executing the command line through a shell, either `bash` or `cmd.exe`. If the command line is a `string[]`, we would historically coerce it to a `string` by joining it with spaces and then proceeding as usual. + +## Historical processing of .js files + +Historically we have done trivial parsing and preprocessing the `"app"` command in order to help the user achieve success. Specifically: if the string pointed to a `.js` file we would run that `.js` file through a Node interpreter, even if there would be potential misconfiguration obstacles in the way. + +Specifically: + +- We're on POSIX and the file was not marked as executable (can happen if the file is freshly produced by a `tsc` invocation); or +- We're on Windows and there is no shell association set up for `.js` files on Windows. + +That light parsing used to fail in the following cases. + +- If the pointed-to file had spaces in its path. +- If Node was installed in a location that had spaces in its path. + +In this PR we document the choice of command line string a bit better, and handle the cases where the file or interpreter paths can have spaces in them (this PR closes #636). + +We still don't do fully generic command line parsing, because it's extremely complex on Windows and we can probably not do it correctly; we're just concerned with quoting the target and interpreter. + +## Execution of string[] + +Historically, a `string[]` was coerced to a `string` by doing `argv.join(' ')` and then processed as normal. + +This has as a downside that even though the command line is already partitioned into components that can go directly into [execve()](https://man7.org/linux/man-pages/man2/execve.2.html) and prevent shell injection -- everyone's favorite w + + + +## About shell execution + +We are using shell execution on purpose: + +- It's used for tests +- It's necessary on Windows to properly execute `.bat` and `.cmd` files +- Since we have historically offered it you can bet dollars to doughnuts that customers have built workflows depending on that. + +This is all a preface to explain why we don't have an `argv` array. Automated code scanning tools will probably complain, but we can't change any of this. And since the source of the string and the machine it's executing on are part of the same security domain (it's all "the customer": the customer writes the command string, then executes it on their own machine), that is fine. + +--- +By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license + From 12397d9b32662339a3eeff5149ff43c8493111ed Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 21 Jan 2026 14:48:19 +0100 Subject: [PATCH 4/9] Away with you --- .../@aws-cdk/toolkit-lib/COMMITMESSAGE.md | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 packages/@aws-cdk/toolkit-lib/COMMITMESSAGE.md diff --git a/packages/@aws-cdk/toolkit-lib/COMMITMESSAGE.md b/packages/@aws-cdk/toolkit-lib/COMMITMESSAGE.md deleted file mode 100644 index eb5d83bd5..000000000 --- a/packages/@aws-cdk/toolkit-lib/COMMITMESSAGE.md +++ /dev/null @@ -1,43 +0,0 @@ -When executing CDK apps, users specify the `{ "app" }` command as a `string` (heavily advertised) or a `string[]` (not really advertised but historically possible through specific code paths). - -In case the command line is a `string`, the only feasible interpretation of that is by executing the command line through a shell, either `bash` or `cmd.exe`. If the command line is a `string[]`, we would historically coerce it to a `string` by joining it with spaces and then proceeding as usual. - -## Historical processing of .js files - -Historically we have done trivial parsing and preprocessing the `"app"` command in order to help the user achieve success. Specifically: if the string pointed to a `.js` file we would run that `.js` file through a Node interpreter, even if there would be potential misconfiguration obstacles in the way. - -Specifically: - -- We're on POSIX and the file was not marked as executable (can happen if the file is freshly produced by a `tsc` invocation); or -- We're on Windows and there is no shell association set up for `.js` files on Windows. - -That light parsing used to fail in the following cases. - -- If the pointed-to file had spaces in its path. -- If Node was installed in a location that had spaces in its path. - -In this PR we document the choice of command line string a bit better, and handle the cases where the file or interpreter paths can have spaces in them (this PR closes #636). - -We still don't do fully generic command line parsing, because it's extremely complex on Windows and we can probably not do it correctly; we're just concerned with quoting the target and interpreter. - -## Execution of string[] - -Historically, a `string[]` was coerced to a `string` by doing `argv.join(' ')` and then processed as normal. - -This has as a downside that even though the command line is already partitioned into components that can go directly into [execve()](https://man7.org/linux/man-pages/man2/execve.2.html) and prevent shell injection -- everyone's favorite w - - - -## About shell execution - -We are using shell execution on purpose: - -- It's used for tests -- It's necessary on Windows to properly execute `.bat` and `.cmd` files -- Since we have historically offered it you can bet dollars to doughnuts that customers have built workflows depending on that. - -This is all a preface to explain why we don't have an `argv` array. Automated code scanning tools will probably complain, but we can't change any of this. And since the source of the string and the machine it's executing on are part of the same security domain (it's all "the customer": the customer writes the command string, then executes it on their own machine), that is fine. - ---- -By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license - From 5edf55129287890736cb9d4b081e47e2ed7c0f6b Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 22 Jan 2026 10:24:23 +0100 Subject: [PATCH 5/9] Render command --- .../lib/api/cloud-assembly/private/exec.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index 80319a810..20d987128 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -28,6 +28,15 @@ export function toCommand(input: string | string[]): Command { } } +export function renderCommand(command: Command): string { + switch (command.type) { + case 'shell': + return command.command; + case 'argv': + return JSON.stringify(command.argv); + } +} + /** * Execute a command line in a child process */ @@ -91,7 +100,7 @@ export async function execInChildProcess(command: Command, options: ExecOptions cause = new Error(stderr.join('\n')); cause.name = 'ExecutionError'; } - return fail(AssemblyError.withCause(`${command}: Subprocess exited with error ${code}`, cause)); + return fail(AssemblyError.withCause(`${renderCommand(command)}: Subprocess exited with error ${code}`, cause)); } }); }); From 0dbad47980867d8dcf8b10b8c80575ea1742e3fe Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 22 Jan 2026 10:26:34 +0100 Subject: [PATCH 6/9] Render command --- .../toolkit-lib/lib/api/cloud-assembly/environment.ts | 4 ++-- .../toolkit-lib/test/api/cloud-assembly/environment.test.ts | 2 +- packages/aws-cdk/lib/cxapp/exec.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index 1398182ce..364ea5821 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -106,7 +106,7 @@ export function spaceAvailableForContext(env: Env, limit: number) { } /** - * Guess the executable from the string command + * Guess the executable from the command * * Input is the "app" string the user gave us. Output is the command line we are going to execute. * @@ -232,7 +232,7 @@ function splitFirstShellWord(commandLine: string): [string, string] { async function checkFile(fileName: string): Promise { try { const fstat = await fs.stat(fileName); - return { + return { fileName, // eslint-disable-next-line no-bitwise isExecutable: (fstat.mode & fs.constants.X_OK) !== 0, diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts index 63ea699cc..7719b6343 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts @@ -65,7 +65,7 @@ function explodeBoth(input: [F, ...R]): } const explodedRest = explodeBoth(rest as any); - const ret: [NotBoth, ...NotBothA][] = []; + const ret: [NotBoth, ...NotBothA][] = []; for (const value of values) { for (const remainder of explodedRest) { ret.push([value as any, ...remainder] as any); diff --git a/packages/aws-cdk/lib/cxapp/exec.ts b/packages/aws-cdk/lib/cxapp/exec.ts index 08a9ad501..5d00fb794 100644 --- a/packages/aws-cdk/lib/cxapp/exec.ts +++ b/packages/aws-cdk/lib/cxapp/exec.ts @@ -7,7 +7,7 @@ import { ToolkitError } from '@aws-cdk/toolkit-lib'; import * as fs from 'fs-extra'; import type { IoHelper } from '../../lib/api-private'; import type { SdkProvider, IReadLock, Command } from '../api'; -import { RWLock, guessExecutable, prepareDefaultEnvironment, writeContextToEnv, synthParametersFromSettings, toCommand } from '../api'; +import { RWLock, guessExecutable, prepareDefaultEnvironment, writeContextToEnv, synthParametersFromSettings, toCommand, renderCommand } from '../api'; import type { Configuration } from '../cli/user-configuration'; import { PROJECT_CONFIG, USER_DEFAULTS } from '../cli/user-configuration'; import { versionNumber } from '../cli/version'; @@ -140,12 +140,12 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: if (code === 0) { return ok(); } else { - return fail(new ToolkitError(`${command}: Subprocess exited with error ${code}`)); + return fail(new ToolkitError(`${renderCommand(command)}: Subprocess exited with error ${code}`)); } }); }); } catch (e: any) { - await debugFn(`failed command: ${command}`); + await debugFn(`failed command: ${renderCommand(command)}`); throw e; } } From b78903e917fff1b6f3bc51494b490bad9d61e453 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 22 Jan 2026 10:30:38 +0100 Subject: [PATCH 7/9] Fix tests --- .../toolkit-lib/test/api/cloud-assembly/environment.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts index 7719b6343..074a56ae7 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts @@ -49,7 +49,7 @@ test.each([ const actual = await guessExecutable(toCommand(commandLine), (_) => Promise.resolve()); // THEN - expect(actual).toEqual(expected); + expect(actual.type === 'shell' && actual.command).toEqual(expected); }); /** From 69cd27fbf7c3a4247219269024d7424e3b13ebf1 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 22 Jan 2026 11:22:23 +0100 Subject: [PATCH 8/9] Annotation has moved --- .../@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts | 2 +- .../@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts | 2 +- .../lib/api/cloud-assembly/private/prepare-source.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index 364ea5821..7482657f0 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -3,7 +3,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs-extra'; import type { SdkProvider } from '../aws-auth/private'; import type { Settings } from '../settings'; -import { Command } from './private/exec'; +import type { Command } from './private/exec'; export type Env = { [key: string]: string | undefined }; export type Context = { [key: string]: unknown }; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index 20d987128..57a3f8627 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -1,8 +1,8 @@ import * as child_process from 'node:child_process'; +import type { Readable } from 'node:stream'; // eslint-disable-next-line @typescript-eslint/no-require-imports import split = require('split2'); import { AssemblyError } from '../../../toolkit/toolkit-error'; -import { Readable } from 'node:stream'; type EventPublisher = (event: 'open' | 'data_stdout' | 'data_stderr' | 'close', line: string) => void; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts index 0c4997d6f..e157ce0a9 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts @@ -21,7 +21,7 @@ import { loadTree, some } from '../../tree'; import type { Context, Env } from '../environment'; import { prepareDefaultEnvironment, spaceAvailableForContext, guessExecutable, synthParametersFromSettings } from '../environment'; import type { AppSynthOptions, LoadAssemblyOptions } from '../source-builder'; -import { Command } from './exec'; +import type { Command } from './exec'; export interface ExecutionEnvironmentOptions { /** From bbff4660186c65397bde4b1d017d6954fd22ded6 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 22 Jan 2026 14:25:38 +0100 Subject: [PATCH 9/9] The 'build' command also needs to be converted --- packages/aws-cdk/lib/cli/parse-command-line-arguments.ts | 2 +- packages/aws-cdk/lib/cxapp/exec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts b/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts index 1f8e7377b..b4b39acd2 100644 --- a/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts +++ b/packages/aws-cdk/lib/cli/parse-command-line-arguments.ts @@ -3,8 +3,8 @@ // Do not edit by hand; all changes will be overwritten at build time from the config file. // ------------------------------------------------------------------------------------------- /* eslint-disable @stylistic/max-len, @typescript-eslint/consistent-type-imports */ -import { Argv } from 'yargs'; import * as helpers from './util/yargs-helpers'; +import { Argv } from 'yargs'; // @ts-ignore TS6133 export function parseCommandLineArguments(args: Array): any { diff --git a/packages/aws-cdk/lib/cxapp/exec.ts b/packages/aws-cdk/lib/cxapp/exec.ts index 5d00fb794..290abedcd 100644 --- a/packages/aws-cdk/lib/cxapp/exec.ts +++ b/packages/aws-cdk/lib/cxapp/exec.ts @@ -38,7 +38,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: const build = config.settings.get(['build']); if (build) { - await exec(build); + await exec(toCommand(build)); } let app = config.settings.get(['app']);