feat(spawn): support inherited stdio

This commit is contained in:
2026-05-10 14:32:07 +00:00
parent a0010e7f0f
commit 0ff3596bd8
4 changed files with 50 additions and 8 deletions
+20 -8
View File
@@ -93,6 +93,11 @@ export interface ISpawnOptions extends IExecRuntimeOptions {
passthrough?: boolean;
interactiveControl?: boolean;
usePty?: boolean;
/**
* When set to `inherit`, the child process uses the parent terminal directly.
* This is useful for trusted interactive CLIs while still avoiding shell parsing.
*/
stdio?: 'pipe' | 'inherit';
}
export type TExecCommandOptions = IExecRuntimeOptions;
@@ -155,17 +160,23 @@ export class Smartshell {
let stderrBuffer = '';
const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB
let bufferExceeded = false;
const inheritStdio = options.stdio === 'inherit';
// Handle PTY mode if requested
if (options.usePty) {
throw new Error('PTY mode is not yet supported with execSpawn. Use exec methods with shell:true for PTY.');
}
if (inheritStdio && (options.streaming || options.interactiveControl)) {
throw new Error('execSpawn stdio: inherit cannot be combined with streaming or interactiveControl.');
}
const execChildProcess = cp.spawn(options.command, options.args || [], {
shell: false, // SECURITY: Never use shell with untrusted input
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
detached: true, // Own process group — immune to terminal SIGINT, managed by smartexit
stdio: inheritStdio ? 'inherit' : 'pipe',
detached: inheritStdio ? false : true, // Inherited TTY needs normal terminal signal handling.
signal: options.signal,
});
@@ -183,7 +194,7 @@ export class Smartshell {
}
// Connect stdin if passthrough is enabled (but not for interactive control)
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
if (!inheritStdio && options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
process.stdin.pipe(execChildProcess.stdin);
}
@@ -192,11 +203,12 @@ export class Smartshell {
if (!execChildProcess.stdin) {
throw new Error('stdin is not available for this process');
}
if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) {
const childStdin = execChildProcess.stdin;
if (childStdin.destroyed || !childStdin.writable) {
throw new Error('stdin has been destroyed or is not writable');
}
return new Promise((resolve, reject) => {
execChildProcess.stdin.write(input, 'utf8', (error) => {
childStdin.write(input, 'utf8', (error) => {
if (error) {
reject(error);
} else {
@@ -217,7 +229,7 @@ export class Smartshell {
};
// Capture stdout and stderr output
execChildProcess.stdout.on('data', (data) => {
execChildProcess.stdout?.on('data', (data) => {
if (!options.silent) {
shellLogInstance.writeToConsole(data);
}
@@ -235,7 +247,7 @@ export class Smartshell {
}
});
execChildProcess.stderr.on('data', (data) => {
execChildProcess.stderr?.on('data', (data) => {
if (!options.silent) {
shellLogInstance.writeToConsole(data);
}
@@ -265,7 +277,7 @@ export class Smartshell {
this.smartexit.removeProcess(execChildProcess);
// Safely unpipe stdin when process ends if passthrough was enabled
if (options.passthrough && !options.interactiveControl) {
if (!inheritStdio && options.passthrough && !options.interactiveControl) {
try {
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
process.stdin.unpipe(execChildProcess.stdin);
@@ -302,7 +314,7 @@ export class Smartshell {
this.smartexit.removeProcess(execChildProcess);
// Safely unpipe stdin when process errors if passthrough was enabled
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
if (!inheritStdio && options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
try {
if (!execChildProcess.stdin.destroyed) {
process.stdin.unpipe(execChildProcess.stdin);