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
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## Pending
### Features
- Add inherited terminal stdio support to `execSpawn` for trusted interactive CLIs.
## 2026-05-09 - 3.4.0 - feat(smartshell)
add cwd-aware execution options, structured strict-mode errors, and safer process tree termination
+10
View File
@@ -197,6 +197,16 @@ const result = await shell.execSpawn('git', ['status', '--short'], {
});
```
For trusted interactive CLIs that need the real terminal while still avoiding shell parsing, pass `stdio: 'inherit'`:
```typescript
await shell.execSpawn('opencode', ['run', '--dir', process.cwd(), prompt], {
stdio: 'inherit',
});
```
Inherited stdio returns an `IExecResult` with the exit code, but stdout and stderr are not captured because the child process writes directly to the terminal.
### Why Spawn Matters
```typescript
+14
View File
@@ -80,6 +80,20 @@ tap.test('execSpawn should properly escape arguments', async () => {
expect(result.stdout).toContain('$HOME && ls');
});
tap.test('execSpawn should support inherited stdio for interactive CLIs', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const result = await testSmartshell.execSpawn('node', ['-e', 'console.log("inherited stdio works")'], {
stdio: 'inherit',
});
expect(result.exitCode).toEqual(0);
expect(result.stdout).toEqual('');
expect(result.stderr).toEqual('');
});
tap.test('execSpawn streaming should work', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
+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);