feat(smartshell): Add secure spawn APIs, PTY support, interactive/streaming control, timeouts and buffer limits; update README and tests

This commit is contained in:
2025-08-17 15:20:26 +00:00
parent f8e431f41e
commit 529b33fda1
10 changed files with 1748 additions and 615 deletions

View File

@@ -1,5 +1,17 @@
# Changelog
## 2025-08-17 - 3.3.0 - feat(smartshell)
Add secure spawn APIs, PTY support, interactive/streaming control, timeouts and buffer limits; update README and tests
- Introduce execSpawn family (execSpawn, execSpawnStreaming, execSpawnInteractiveControl) for shell-free, secure execution of untrusted input (shell:false).
- Add PTY support (optional node-pty) with execInteractiveControlPty and execStreamingInteractiveControlPty; PTY is lazy-loaded and documented as an optional dependency.
- Expose interactive control primitives (sendInput, sendLine, endInput, finalPromise) for both spawn and shell-based executions, and streaming interfaces with process control (terminate, kill, keyboardInterrupt, customSignal).
- Implement timeouts, maxBuffer limits and onData callbacks to prevent OOM, stream output incrementally, and support early termination and debugging logs.
- Improve process lifecycle handling: safe unpipe/unpipe-on-error, smartexit integration, and safer signal-based tree-kill behavior.
- Enhance execAndWaitForLine with timeout and terminateOnMatch options to allow pattern-based waits with configurable behavior.
- Update README with a Security Guide recommending execSpawn for untrusted input, PTY usage guidance, and new feature documentation (timeouts, buffer limits, debug mode, environment control).
- Add extensive tests covering error handling, interactive control, passthrough, PTY behavior, spawn behavior, silent/streaming modes and environment propagation.
## 2025-08-16 - 3.2.4 - fix(tests)
Update tests & CI config, bump deps, add docs and project configs

968
readme.md

File diff suppressed because it is too large Load Diff

222
test/test.errorHandling.ts Normal file
View File

@@ -0,0 +1,222 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartshell from '../ts/index.js';
tap.test('should handle EPIPE errors gracefully', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const streaming = await testSmartshell.execStreamingInteractiveControl('head -n 1');
// Send more data after head exits (will cause EPIPE)
await streaming.sendLine('Line 1');
// This should not throw even though head has exited
let errorThrown = false;
try {
await streaming.sendLine('Line 2');
await streaming.sendLine('Line 3');
} catch (error) {
errorThrown = true;
// EPIPE or destroyed stdin is expected
}
const result = await streaming.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Line 1');
});
tap.test('should handle strict mode with non-zero exit codes', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let errorThrown = false;
let errorMessage = '';
try {
await testSmartshell.execStrict('exit 42');
} catch (error) {
errorThrown = true;
errorMessage = error.message;
}
expect(errorThrown).toBeTrue();
expect(errorMessage).toContain('exited with code 42');
});
tap.test('should handle strict mode with signal termination', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let errorThrown = false;
let errorMessage = '';
try {
// Use execSpawn with strict mode and kill it
const result = testSmartshell.execSpawn('sleep', ['10'], {
strict: true,
timeout: 100 // Will cause SIGTERM
});
await result;
} catch (error) {
errorThrown = true;
errorMessage = error.message;
}
expect(errorThrown).toBeTrue();
expect(errorMessage).toContain('terminated by signal');
});
tap.test('execAndWaitForLine with timeout should reject properly', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let errorThrown = false;
let errorMessage = '';
try {
await testSmartshell.execAndWaitForLine(
'sleep 5 && echo "Too late"',
/Too late/,
false,
{ timeout: 100 }
);
} catch (error) {
errorThrown = true;
errorMessage = error.message;
}
expect(errorThrown).toBeTrue();
expect(errorMessage).toContain('Timeout waiting for pattern');
});
tap.test('execAndWaitForLine with terminateOnMatch should stop process', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const start = Date.now();
await testSmartshell.execAndWaitForLine(
'echo "Match this" && sleep 5',
/Match this/,
false,
{ terminateOnMatch: true }
);
const duration = Date.now() - start;
// Should terminate immediately after match, not wait for sleep
expect(duration).toBeLessThan(2000);
});
tap.test('should handle process ending without matching pattern', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let errorThrown = false;
let errorMessage = '';
try {
await testSmartshell.execAndWaitForLine(
'echo "Wrong text"',
/Never appears/,
false
);
} catch (error) {
errorThrown = true;
errorMessage = error.message;
}
expect(errorThrown).toBeTrue();
expect(errorMessage).toContain('Process ended without matching pattern');
});
tap.test('passthrough unpipe should handle destroyed stdin gracefully', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// This should complete without throwing even if stdin operations fail
const result = await testSmartshell.execPassthrough('echo "Test passthrough unpipe"');
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Test passthrough unpipe');
});
tap.test('should handle write after stream destroyed', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const interactive = await testSmartshell.execInteractiveControl('true'); // Exits immediately
// Wait for process to exit
await interactive.finalPromise;
// Try to write after process has exited
let errorThrown = false;
try {
await interactive.sendLine('This should fail');
} catch (error) {
errorThrown = true;
expect(error.message).toContain('destroyed');
}
expect(errorThrown).toBeTrue();
});
tap.test('debug mode should log additional information', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Capture console.log output
const originalLog = console.log;
let debugOutput = '';
console.log = (msg: string) => {
debugOutput += msg + '\n';
};
try {
const streaming = await testSmartshell.execSpawnStreaming('echo', ['Debug test'], {
debug: true
});
await streaming.terminate();
await streaming.finalPromise;
} finally {
console.log = originalLog;
}
// Should have logged debug messages
expect(debugOutput).toContain('[smartshell]');
});
tap.test('custom environment variables should be passed correctly', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const result = await testSmartshell.execSpawn('bash', ['-c', 'echo $CUSTOM_VAR'], {
env: {
...process.env,
CUSTOM_VAR: 'test_value_123'
}
});
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('test_value_123');
});
export default tap.start();

View File

@@ -0,0 +1,84 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartshell from '../ts/index.js';
tap.test('should handle programmatic input control with simple commands', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Use cat which works well with pipe mode
const interactive = await testSmartshell.execInteractiveControl('cat');
// Send input programmatically
await interactive.sendLine('TestUser');
interactive.endInput();
// Wait for completion
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('TestUser');
});
tap.test('should handle streaming interactive control with cat', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Use cat for reliable pipe mode operation
const streaming = await testSmartshell.execStreamingInteractiveControl('cat');
// Send multiple inputs
await streaming.sendLine('One');
await streaming.sendLine('Two');
streaming.endInput();
const result = await streaming.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('One');
expect(result.stdout).toContain('Two');
});
tap.test('should handle sendInput without newline', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Use cat for testing input without newline
const interactive = await testSmartshell.execInteractiveControl('cat');
// Send characters without newline, then newline, then EOF
await interactive.sendInput('ABC');
await interactive.sendInput('DEF');
await interactive.sendInput('\n');
interactive.endInput();
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('ABCDEF');
});
tap.test('should mix passthrough and interactive control modes', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Test that passthrough still works
const passthroughResult = await testSmartshell.execPassthrough('echo "Passthrough works"');
expect(passthroughResult.exitCode).toEqual(0);
expect(passthroughResult.stdout).toContain('Passthrough works');
// Test that interactive control works
const interactiveResult = await testSmartshell.execInteractiveControl('echo "Interactive control works"');
const finalResult = await interactiveResult.finalPromise;
expect(finalResult.exitCode).toEqual(0);
expect(finalResult.stdout).toContain('Interactive control works');
});
// Note: Tests requiring bash read with prompts should use PTY mode
// See test.pty.ts for examples of testing commands that require terminal features
export default tap.start();

146
test/test.pty.ts Normal file
View File

@@ -0,0 +1,146 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartshell from '../ts/index.js';
// Helper to check if node-pty is available
const isPtyAvailable = async (): Promise<boolean> => {
try {
await import('node-pty');
return true;
} catch {
return false;
}
};
tap.test('PTY: should handle bash read with prompts correctly', async (tools) => {
const ptyAvailable = await isPtyAvailable();
if (!ptyAvailable) {
console.log('Skipping PTY test - node-pty not installed');
return;
}
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// This test should work with PTY (bash read with prompt)
const interactive = await testSmartshell.execInteractiveControlPty("bash -c 'read -p \"Enter name: \" name && echo \"Hello, $name\"'");
// Send input programmatically
await interactive.sendLine('TestUser');
// Wait for completion
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Enter name:'); // Prompt should be visible with PTY
expect(result.stdout).toContain('Hello, TestUser');
});
tap.test('PTY: should handle terminal colors and escape sequences', async (tools) => {
const ptyAvailable = await isPtyAvailable();
if (!ptyAvailable) {
console.log('Skipping PTY test - node-pty not installed');
return;
}
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// ls --color=auto should produce colors in PTY mode
const result = await testSmartshell.execInteractiveControlPty('ls --color=always /tmp');
const finalResult = await result.finalPromise;
expect(finalResult.exitCode).toEqual(0);
// Check for ANSI escape sequences (colors) in output
const hasColors = /\x1b\[[0-9;]*m/.test(finalResult.stdout);
expect(hasColors).toEqual(true);
});
tap.test('PTY: should handle interactive password prompt simulation', async (tools) => {
const ptyAvailable = await isPtyAvailable();
if (!ptyAvailable) {
console.log('Skipping PTY test - node-pty not installed');
return;
}
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Simulate a password prompt scenario
const interactive = await testSmartshell.execStreamingInteractiveControlPty(
"bash -c 'read -s -p \"Password: \" pass && echo && echo \"Got password of length ${#pass}\"'"
);
await tools.delayFor(100);
await interactive.sendLine('secretpass');
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Password:');
expect(result.stdout).toContain('Got password of length 10');
});
tap.test('PTY: should handle terminal size options', async (tools) => {
const ptyAvailable = await isPtyAvailable();
if (!ptyAvailable) {
console.log('Skipping PTY test - node-pty not installed');
return;
}
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Check terminal size using stty
const result = await testSmartshell.execInteractiveControlPty('stty size');
const finalResult = await result.finalPromise;
expect(finalResult.exitCode).toEqual(0);
// Default size should be 30 rows x 120 cols as set in _execCommandPty
expect(finalResult.stdout).toContain('30 120');
});
tap.test('PTY: should handle Ctrl+C (SIGINT) properly', async (tools) => {
const ptyAvailable = await isPtyAvailable();
if (!ptyAvailable) {
console.log('Skipping PTY test - node-pty not installed');
return;
}
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Start a long-running process
const streaming = await testSmartshell.execStreamingInteractiveControlPty('sleep 10');
// Send interrupt after a short delay
await tools.delayFor(100);
await streaming.keyboardInterrupt();
const result = await streaming.finalPromise;
// Process should exit with non-zero code due to interrupt
expect(result.exitCode).not.toEqual(0);
});
tap.test('Regular pipe mode should still work alongside PTY', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Regular mode should work without PTY
const interactive = await testSmartshell.execInteractiveControl('echo "Pipe mode works"');
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Pipe mode works');
});
export default tap.start();

View File

@@ -0,0 +1,54 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartshell from '../ts/index.js';
tap.test('should send input to cat command', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Use cat which simply echoes what it receives
const interactive = await testSmartshell.execInteractiveControl('cat');
// Send some text and close stdin
await interactive.sendLine('Hello World');
interactive.endInput(); // Close stdin properly
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Hello World');
});
tap.test('should work with simple echo', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// This should work without any input
const interactive = await testSmartshell.execInteractiveControl('echo "Direct test"');
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Direct test');
});
tap.test('should handle streaming with input control', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Test with streaming and cat
const streaming = await testSmartshell.execStreamingInteractiveControl('cat');
await streaming.sendLine('Line 1');
await streaming.sendLine('Line 2');
streaming.endInput(); // Close stdin
const result = await streaming.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Line 1');
expect(result.stdout).toContain('Line 2');
});
export default tap.start();

150
test/test.spawn.ts Normal file
View File

@@ -0,0 +1,150 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartshell from '../ts/index.js';
tap.test('execSpawn should execute commands with args array (shell:false)', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Test basic command with args
const result = await testSmartshell.execSpawn('echo', ['Hello', 'World']);
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Hello World');
});
tap.test('execSpawn should handle command not found errors', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let errorThrown = false;
try {
await testSmartshell.execSpawn('nonexistentcommand123', ['arg1']);
} catch (error) {
errorThrown = true;
expect(error.code).toEqual('ENOENT');
}
expect(errorThrown).toBeTrue();
});
tap.test('execSpawn should properly escape arguments', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Test that shell metacharacters are treated as literals
const result = await testSmartshell.execSpawn('echo', ['$HOME', '&&', 'ls']);
expect(result.exitCode).toEqual(0);
// Should output literal strings, not expanded/executed
expect(result.stdout).toContain('$HOME && ls');
});
tap.test('execSpawn streaming should work', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const streaming = await testSmartshell.execSpawnStreaming('echo', ['Streaming test']);
const result = await streaming.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Streaming test');
});
tap.test('execSpawn interactive control should work', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const interactive = await testSmartshell.execSpawnInteractiveControl('cat', []);
await interactive.sendLine('Input line');
interactive.endInput();
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Input line');
});
tap.test('execSpawn should capture stderr', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// ls on non-existent directory should produce stderr
const result = await testSmartshell.execSpawn('ls', ['/nonexistent/directory/path']);
expect(result.exitCode).not.toEqual(0);
expect(result.stderr).toBeTruthy();
expect(result.stderr).toContain('No such file or directory');
});
tap.test('execSpawn with timeout should terminate process', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const start = Date.now();
const result = await testSmartshell.execSpawn('sleep', ['10'], { timeout: 100 });
const duration = Date.now() - start;
// Process should be terminated by timeout
expect(duration).toBeLessThan(500);
expect(result.exitCode).not.toEqual(0);
expect(result.signal).toBeTruthy(); // Should have been killed by signal
});
tap.test('execSpawn with maxBuffer should truncate output', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Generate large output
const result = await testSmartshell.execSpawn('bash', ['-c', 'for i in {1..1000}; do echo "Line $i with some padding text to make it longer"; done'], {
maxBuffer: 1024, // Very small buffer
});
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('[Output truncated - exceeded maxBuffer]');
});
tap.test('execSpawn with onData callback should stream data', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let dataReceived = '';
const result = await testSmartshell.execSpawn('echo', ['Test data'], {
onData: (chunk) => {
dataReceived += chunk.toString();
}
});
expect(result.exitCode).toEqual(0);
expect(dataReceived).toContain('Test data');
});
tap.test('execSpawn with signal should report signal in result', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const streaming = await testSmartshell.execSpawnStreaming('sleep', ['10']);
// Send SIGTERM after a short delay
setTimeout(() => streaming.terminate(), 100);
const result = await streaming.finalPromise;
expect(result.exitCode).not.toEqual(0);
expect(result.signal).toEqual('SIGTERM');
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartshell',
version: '3.2.4',
version: '3.3.0',
description: 'A library for executing shell commands using promises.'
}

View File

@@ -8,6 +8,15 @@ import * as cp from 'child_process';
export interface IExecResult {
exitCode: number;
stdout: string;
signal?: NodeJS.Signals;
stderr?: string;
}
export interface IExecResultInteractive extends IExecResult {
sendInput: (input: string) => Promise<void>;
sendLine: (line: string) => Promise<void>;
endInput: () => void;
finalPromise: Promise<IExecResult>;
}
export interface IExecResultStreaming {
@@ -17,6 +26,9 @@ export interface IExecResultStreaming {
terminate: () => Promise<void>;
keyboardInterrupt: () => Promise<void>;
customSignal: (signal: plugins.smartexit.TProcessSignal) => Promise<void>;
sendInput: (input: string) => Promise<void>;
sendLine: (line: string) => Promise<void>;
endInput: () => void;
}
interface IExecOptions {
@@ -26,6 +38,23 @@ interface IExecOptions {
streaming?: boolean;
interactive?: boolean;
passthrough?: boolean;
interactiveControl?: boolean;
usePty?: boolean;
ptyCols?: number;
ptyRows?: number;
ptyTerm?: string;
ptyShell?: string;
maxBuffer?: number;
onData?: (chunk: Buffer | string) => void;
timeout?: number;
debug?: boolean;
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
}
export interface ISpawnOptions extends Omit<IExecOptions, 'commandString'> {
command: string;
args?: string[];
}
export class Smartshell {
@@ -73,92 +102,441 @@ export class Smartshell {
}
/**
* Executes a command and returns either a non-streaming result or a streaming interface.
* Executes a command with args array (shell:false) for security
*/
private async _execCommand(options: IExecOptions): Promise<IExecResult | IExecResultStreaming> {
const commandToExecute = this.shellEnv.createEnvExecString(options.commandString);
private async _execSpawn(options: ISpawnOptions): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
const shellLogInstance = new ShellLog();
let stderrBuffer = '';
const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB
let bufferExceeded = false;
const execChildProcess = cp.spawn(commandToExecute, [], {
shell: true,
// 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.');
}
const execChildProcess = cp.spawn(options.command, options.args || [], {
shell: false, // SECURITY: Never use shell with untrusted input
cwd: process.cwd(),
env: process.env,
env: options.env || process.env,
detached: false,
signal: options.signal,
});
this.smartexit.addProcess(execChildProcess);
// Connect stdin if passthrough is enabled
if (options.passthrough && execChildProcess.stdin) {
// Handle timeout
let timeoutHandle: NodeJS.Timeout | null = null;
if (options.timeout) {
timeoutHandle = setTimeout(() => {
if (options.debug) {
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
}
execChildProcess.kill('SIGTERM');
}, options.timeout);
}
// Connect stdin if passthrough is enabled (but not for interactive control)
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
process.stdin.pipe(execChildProcess.stdin);
}
// Capture stdout and stderr output.
// Create input methods for interactive control
const sendInput = async (input: string): Promise<void> => {
if (!execChildProcess.stdin) {
throw new Error('stdin is not available for this process');
}
if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) {
throw new Error('stdin has been destroyed or is not writable');
}
return new Promise((resolve, reject) => {
execChildProcess.stdin.write(input, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
};
const sendLine = async (line: string): Promise<void> => {
return sendInput(line + '\n');
};
const endInput = (): void => {
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
execChildProcess.stdin.end();
}
};
// Capture stdout and stderr output
execChildProcess.stdout.on('data', (data) => {
if (!options.silent) {
shellLogInstance.writeToConsole(data);
}
shellLogInstance.addToBuffer(data);
if (options.onData) {
options.onData(data);
}
if (!bufferExceeded) {
shellLogInstance.addToBuffer(data);
if (shellLogInstance.logStore.length > maxBuffer) {
bufferExceeded = true;
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
}
}
});
execChildProcess.stderr.on('data', (data) => {
if (!options.silent) {
shellLogInstance.writeToConsole(data);
}
shellLogInstance.addToBuffer(data);
const dataStr = data.toString();
stderrBuffer += dataStr;
if (options.onData) {
options.onData(data);
}
if (!bufferExceeded) {
shellLogInstance.addToBuffer(data);
if (shellLogInstance.logStore.length > maxBuffer) {
bufferExceeded = true;
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
}
}
});
// Wrap child process termination into a Promise.
// Wrap child process termination into a Promise
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
execChildProcess.on('exit', (code, signal) => {
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
this.smartexit.removeProcess(execChildProcess);
// Unpipe stdin when process ends if passthrough was enabled
if (options.passthrough) {
process.stdin.unpipe(execChildProcess.stdin);
// Safely unpipe stdin when process ends if passthrough was enabled
if (options.passthrough && !options.interactiveControl) {
try {
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
process.stdin.unpipe(execChildProcess.stdin);
}
} catch (err) {
if (options.debug) {
console.log(`[smartshell] Error unpiping stdin: ${err}`);
}
}
}
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
const execResult: IExecResult = {
exitCode: typeof code === 'number' ? code : (signal ? 1 : 0),
exitCode,
stdout: shellLogInstance.logStore.toString(),
signal: signal || undefined,
stderr: stderrBuffer,
};
if (options.strict && code !== 0) {
reject(new Error(`Command "${options.commandString}" exited with code ${code}`));
if (options.strict && exitCode !== 0) {
const errorMsg = signal
? `Command "${options.command}" terminated by signal ${signal}`
: `Command "${options.command}" exited with code ${exitCode}`;
reject(new Error(errorMsg));
} else {
resolve(execResult);
}
});
};
execChildProcess.on('error', (error) => {
execChildProcess.once('exit', handleExit);
execChildProcess.once('error', (error) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
this.smartexit.removeProcess(execChildProcess);
// Unpipe stdin when process errors if passthrough was enabled
if (options.passthrough && execChildProcess.stdin) {
process.stdin.unpipe(execChildProcess.stdin);
// Safely unpipe stdin when process errors if passthrough was enabled
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
try {
if (!execChildProcess.stdin.destroyed) {
process.stdin.unpipe(execChildProcess.stdin);
}
} catch (err) {
if (options.debug) {
console.log(`[smartshell] Error unpiping stdin on error: ${err}`);
}
}
}
reject(error);
});
});
// If streaming mode is enabled, return a streaming interface immediately.
// If interactive control is enabled but not streaming, return interactive interface
if (options.interactiveControl && !options.streaming) {
return {
exitCode: 0, // Will be updated when process ends
stdout: '', // Will be updated when process ends
sendInput,
sendLine,
endInput,
finalPromise: childProcessEnded,
} as IExecResultInteractive;
}
// If streaming mode is enabled, return a streaming interface
if (options.streaming) {
return {
childProcess: execChildProcess,
finalPromise: childProcessEnded,
sendInput,
sendLine,
endInput,
kill: async () => {
console.log(`Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
if (options.debug) {
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
}
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
},
terminate: async () => {
console.log(`Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
if (options.debug) {
console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
}
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
},
keyboardInterrupt: async () => {
console.log(`Running tree kill with SIGINT on process ${execChildProcess.pid}`);
if (options.debug) {
console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
}
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
},
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
console.log(`Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
if (options.debug) {
console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
}
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
},
} as IExecResultStreaming;
}
// For non-streaming mode, wait for the process to complete
return await childProcessEnded;
}
/**
* Executes a command and returns either a non-streaming result or a streaming interface.
*/
private async _execCommand(options: IExecOptions): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
const commandToExecute = this.shellEnv.createEnvExecString(options.commandString);
const shellLogInstance = new ShellLog();
let stderrBuffer = '';
const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB
let bufferExceeded = false;
// Handle PTY mode if requested
if (options.usePty) {
return await this._execCommandPty(options, commandToExecute, shellLogInstance);
}
const execChildProcess = cp.spawn(commandToExecute, [], {
shell: true,
cwd: process.cwd(),
env: options.env || process.env,
detached: false,
signal: options.signal,
});
this.smartexit.addProcess(execChildProcess);
// Handle timeout
let timeoutHandle: NodeJS.Timeout | null = null;
if (options.timeout) {
timeoutHandle = setTimeout(() => {
if (options.debug) {
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
}
execChildProcess.kill('SIGTERM');
}, options.timeout);
}
// Connect stdin if passthrough is enabled (but not for interactive control)
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
process.stdin.pipe(execChildProcess.stdin);
}
// Create input methods for interactive control
const sendInput = async (input: string): Promise<void> => {
if (!execChildProcess.stdin) {
throw new Error('stdin is not available for this process');
}
if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) {
throw new Error('stdin has been destroyed or is not writable');
}
return new Promise((resolve, reject) => {
execChildProcess.stdin.write(input, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
};
const sendLine = async (line: string): Promise<void> => {
return sendInput(line + '\n');
};
const endInput = (): void => {
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
execChildProcess.stdin.end();
}
};
// Capture stdout and stderr output
execChildProcess.stdout.on('data', (data) => {
if (!options.silent) {
shellLogInstance.writeToConsole(data);
}
if (options.onData) {
options.onData(data);
}
if (!bufferExceeded) {
shellLogInstance.addToBuffer(data);
if (shellLogInstance.logStore.length > maxBuffer) {
bufferExceeded = true;
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
}
}
});
execChildProcess.stderr.on('data', (data) => {
if (!options.silent) {
shellLogInstance.writeToConsole(data);
}
const dataStr = data.toString();
stderrBuffer += dataStr;
if (options.onData) {
options.onData(data);
}
if (!bufferExceeded) {
shellLogInstance.addToBuffer(data);
if (shellLogInstance.logStore.length > maxBuffer) {
bufferExceeded = true;
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
}
}
});
// Wrap child process termination into a Promise
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
this.smartexit.removeProcess(execChildProcess);
// Safely unpipe stdin when process ends if passthrough was enabled
if (options.passthrough && !options.interactiveControl) {
try {
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
process.stdin.unpipe(execChildProcess.stdin);
}
} catch (err) {
if (options.debug) {
console.log(`[smartshell] Error unpiping stdin: ${err}`);
}
}
}
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
const execResult: IExecResult = {
exitCode,
stdout: shellLogInstance.logStore.toString(),
signal: signal || undefined,
stderr: stderrBuffer,
};
if (options.strict && exitCode !== 0) {
const errorMsg = signal
? `Command "${options.commandString}" terminated by signal ${signal}`
: `Command "${options.commandString}" exited with code ${exitCode}`;
reject(new Error(errorMsg));
} else {
resolve(execResult);
}
};
execChildProcess.once('exit', handleExit);
execChildProcess.once('error', (error) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
this.smartexit.removeProcess(execChildProcess);
// Safely unpipe stdin when process errors if passthrough was enabled
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
try {
if (!execChildProcess.stdin.destroyed) {
process.stdin.unpipe(execChildProcess.stdin);
}
} catch (err) {
if (options.debug) {
console.log(`[smartshell] Error unpiping stdin on error: ${err}`);
}
}
}
reject(error);
});
});
// If interactive control is enabled but not streaming, return interactive interface
if (options.interactiveControl && !options.streaming) {
return {
exitCode: 0, // Will be updated when process ends
stdout: '', // Will be updated when process ends
sendInput,
sendLine,
endInput,
finalPromise: childProcessEnded,
} as IExecResultInteractive;
}
// If streaming mode is enabled, return a streaming interface
if (options.streaming) {
return {
childProcess: execChildProcess,
finalPromise: childProcessEnded,
sendInput,
sendLine,
endInput,
kill: async () => {
if (options.debug) {
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
}
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
},
terminate: async () => {
if (options.debug) {
console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
}
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
},
keyboardInterrupt: async () => {
if (options.debug) {
console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
}
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
},
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
if (options.debug) {
console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
}
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
},
} as IExecResultStreaming;
@@ -169,7 +547,9 @@ export class Smartshell {
}
public async exec(commandString: string): Promise<IExecResult> {
return (await this._exec({ commandString })) as IExecResult;
const result = await this._exec({ commandString });
// Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult
return result as IExecResult;
}
public async execSilent(commandString: string): Promise<IExecResult> {
@@ -204,23 +584,290 @@ export class Smartshell {
return await this._exec({ commandString, streaming: true, passthrough: true }) as IExecResultStreaming;
}
public async execInteractiveControl(commandString: string): Promise<IExecResultInteractive> {
return await this._exec({ commandString, interactiveControl: true }) as IExecResultInteractive;
}
public async execStreamingInteractiveControl(commandString: string): Promise<IExecResultStreaming> {
return await this._exec({ commandString, streaming: true, interactiveControl: true }) as IExecResultStreaming;
}
public async execInteractiveControlPty(commandString: string): Promise<IExecResultInteractive> {
return await this._exec({ commandString, interactiveControl: true, usePty: true }) as IExecResultInteractive;
}
public async execStreamingInteractiveControlPty(commandString: string): Promise<IExecResultStreaming> {
return await this._exec({ commandString, streaming: true, interactiveControl: true, usePty: true }) as IExecResultStreaming;
}
/**
* Executes a command with args array (shell:false) for security
* This is the recommended API for untrusted input
*/
public async execSpawn(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args'> = {}): Promise<IExecResult> {
const result = await this._execSpawn({ command, args, ...options });
// Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult
return result as IExecResult;
}
/**
* Executes a command with args array in streaming mode
*/
public async execSpawnStreaming(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args' | 'streaming'> = {}): Promise<IExecResultStreaming> {
return await this._execSpawn({ command, args, streaming: true, ...options }) as IExecResultStreaming;
}
/**
* Executes a command with args array with interactive control
*/
public async execSpawnInteractiveControl(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args' | 'interactiveControl'> = {}): Promise<IExecResultInteractive> {
return await this._execSpawn({ command, args, interactiveControl: true, ...options }) as IExecResultInteractive;
}
public async execAndWaitForLine(
commandString: string,
regex: RegExp,
silent: boolean = false
silent: boolean = false,
options: { timeout?: number; terminateOnMatch?: boolean } = {}
): Promise<void> {
const execStreamingResult = await this.execStreaming(commandString, silent);
return new Promise<void>((resolve) => {
execStreamingResult.childProcess.stdout.on('data', (chunk: Buffer | string) => {
return new Promise<void>((resolve, reject) => {
let matched = false;
let timeoutHandle: NodeJS.Timeout | null = null;
// Set up timeout if specified
if (options.timeout) {
timeoutHandle = setTimeout(async () => {
if (!matched) {
matched = true;
// Remove listener to prevent memory leak
execStreamingResult.childProcess.stdout.removeAllListeners('data');
await execStreamingResult.terminate();
reject(new Error(`Timeout waiting for pattern after ${options.timeout}ms`));
}
}, options.timeout);
}
const dataHandler = async (chunk: Buffer | string) => {
const data = typeof chunk === 'string' ? chunk : chunk.toString();
if (regex.test(data)) {
if (!matched && regex.test(data)) {
matched = true;
// Clear timeout if set
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
// Remove listener to prevent memory leak
execStreamingResult.childProcess.stdout.removeListener('data', dataHandler);
// Terminate process if requested
if (options.terminateOnMatch) {
await execStreamingResult.terminate();
await execStreamingResult.finalPromise;
}
resolve();
}
};
execStreamingResult.childProcess.stdout.on('data', dataHandler);
// Also resolve/reject when process ends
execStreamingResult.finalPromise.then(() => {
if (!matched) {
matched = true;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
reject(new Error('Process ended without matching pattern'));
}
}).catch((err) => {
if (!matched) {
matched = true;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
reject(err);
}
});
});
}
public async execAndWaitForLineSilent(commandString: string, regex: RegExp): Promise<void> {
return this.execAndWaitForLine(commandString, regex, true);
public async execAndWaitForLineSilent(commandString: string, regex: RegExp, options?: { timeout?: number; terminateOnMatch?: boolean }): Promise<void> {
return this.execAndWaitForLine(commandString, regex, true, options);
}
private nodePty: any = null;
private async lazyLoadNodePty(): Promise<any> {
if (this.nodePty) {
return this.nodePty;
}
try {
// Try to load node-pty if available
// @ts-ignore - node-pty is optional
this.nodePty = await import('node-pty');
return this.nodePty;
} catch (error) {
throw new Error(
'node-pty is required for PTY support but is not installed.\n' +
'Please install it as an optional dependency:\n' +
' pnpm add --save-optional node-pty\n' +
'Note: node-pty requires compilation and may have platform-specific requirements.'
);
}
}
private async _execCommandPty(
options: IExecOptions,
commandToExecute: string,
shellLogInstance: ShellLog
): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
const pty = await this.lazyLoadNodePty();
// Platform-aware shell selection
let shell: string;
let shellArgs: string[];
if (options.ptyShell) {
// User-provided shell override
shell = options.ptyShell;
shellArgs = ['-c', commandToExecute];
} else if (process.platform === 'win32') {
// Windows: Use PowerShell by default, or cmd as fallback
const powershell = process.env.PROGRAMFILES
? `${process.env.PROGRAMFILES}\\PowerShell\\7\\pwsh.exe`
: 'powershell.exe';
// Check if PowerShell Core exists, otherwise use Windows PowerShell
const fs = await import('fs');
if (fs.existsSync(powershell)) {
shell = powershell;
shellArgs = ['-NoProfile', '-NonInteractive', '-Command', commandToExecute];
} else if (process.env.COMSPEC) {
shell = process.env.COMSPEC;
shellArgs = ['/d', '/s', '/c', commandToExecute];
} else {
shell = 'cmd.exe';
shellArgs = ['/d', '/s', '/c', commandToExecute];
}
} else {
// POSIX: Use SHELL env var or bash as default
shell = process.env.SHELL || '/bin/bash';
shellArgs = ['-c', commandToExecute];
}
// Create PTY process
const ptyProcess = pty.spawn(shell, shellArgs, {
name: options.ptyTerm || 'xterm-256color',
cols: options.ptyCols || 120,
rows: options.ptyRows || 30,
cwd: process.cwd(),
env: options.env || process.env,
});
// Add to smartexit (wrap in a minimal object with pid)
this.smartexit.addProcess({ pid: ptyProcess.pid } as any);
// Handle output (stdout and stderr are combined in PTY)
ptyProcess.onData((data: string) => {
if (!options.silent) {
shellLogInstance.writeToConsole(data);
}
shellLogInstance.addToBuffer(data);
});
// Wrap PTY termination into a Promise
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
this.smartexit.removeProcess({ pid: ptyProcess.pid } as any);
const execResult: IExecResult = {
exitCode: exitCode ?? (signal ? 1 : 0),
stdout: shellLogInstance.logStore.toString(),
};
if (options.strict && exitCode !== 0) {
reject(new Error(`Command "${options.commandString}" exited with code ${exitCode}`));
} else {
resolve(execResult);
}
});
});
// Create input methods for PTY
const sendInput = async (input: string): Promise<void> => {
return new Promise((resolve, reject) => {
try {
ptyProcess.write(input);
resolve();
} catch (error) {
reject(error);
}
});
};
const sendLine = async (line: string): Promise<void> => {
// Use \r for PTY (carriage return is typical for terminal line discipline)
return sendInput(line + '\r');
};
const endInput = (): void => {
// Send EOF (Ctrl+D) to PTY
ptyProcess.write('\x04');
};
// If interactive control is enabled but not streaming, return interactive interface
if (options.interactiveControl && !options.streaming) {
return {
exitCode: 0, // Will be updated when process ends
stdout: '', // Will be updated when process ends
sendInput,
sendLine,
endInput,
finalPromise: childProcessEnded,
} as IExecResultInteractive;
}
// If streaming mode is enabled, return a streaming interface
if (options.streaming) {
return {
childProcess: { pid: ptyProcess.pid } as any, // Minimal compatibility object
finalPromise: childProcessEnded,
sendInput,
sendLine,
endInput,
kill: async () => {
if (options.debug) {
console.log(`[smartshell] Killing PTY process ${ptyProcess.pid}`);
}
ptyProcess.kill();
},
terminate: async () => {
if (options.debug) {
console.log(`[smartshell] Terminating PTY process ${ptyProcess.pid}`);
}
ptyProcess.kill('SIGTERM');
},
keyboardInterrupt: async () => {
if (options.debug) {
console.log(`[smartshell] Sending SIGINT to PTY process ${ptyProcess.pid}`);
}
ptyProcess.kill('SIGINT');
},
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
if (options.debug) {
console.log(`[smartshell] Sending ${signal} to PTY process ${ptyProcess.pid}`);
}
ptyProcess.kill(signal as any);
},
} as IExecResultStreaming;
}
// For non-streaming mode, wait for the process to complete
return await childProcessEnded;
}
}