feat(spawn): support inherited stdio
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user