refactor(coding-agent): extracted bash execution into dedicated executor

- Extracted bash execution logic from bash tool into dedicated BashExecutor with configurable cwd and timeout options.
- Added timeout handling with automatic process termination and timeout-specific result tracking.
- Simplified streaming output handling by replacing rolling buffer with string concatenation.
- Reformatted indentation from spaces to tabs across shell utility files.
This commit is contained in:
can1357
2026-01-03 11:07:03 +01:00
parent f3a36eb433
commit 31cd0cebf9
6 changed files with 276 additions and 365 deletions
+105 -105
View File
@@ -1,11 +1,11 @@
import { existsSync, accessSync, constants } from "node:fs";
import { accessSync, constants, existsSync } from "node:fs";
import { SettingsManager } from "../core/settings-manager";
export interface ShellConfig {
shell: string;
args: string[];
env: Record<string, string | undefined>;
prefix: string | undefined;
shell: string;
args: string[];
env: Record<string, string | undefined>;
prefix: string | undefined;
}
let cachedShellConfig: ShellConfig | null = null;
@@ -14,28 +14,28 @@ let cachedShellConfig: ShellConfig | null = null;
* Check if a shell binary is executable.
*/
function isExecutable(path: string): boolean {
try {
accessSync(path, constants.X_OK);
return true;
} catch {
return false;
}
try {
accessSync(path, constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Build the spawn environment (cached).
*/
function buildSpawnEnv(shell: string): Record<string, string | undefined> {
const noCI = process.env.PI_BASH_NO_CI || process.env.CLAUDE_BASH_NO_CI;
return {
...process.env,
SHELL: shell,
GIT_EDITOR: "true",
GPG_TTY: "not a tty",
PICODE: "1",
CLAUDECODE: "1",
...(noCI ? {} : { CI: "true" }),
};
const noCI = process.env.PI_BASH_NO_CI || process.env.CLAUDE_BASH_NO_CI;
return {
...process.env,
SHELL: shell,
GIT_EDITOR: "true",
GPG_TTY: "not a tty",
PICODE: "1",
CLAUDECODE: "1",
...(noCI ? {} : { CI: "true" }),
};
}
/**
@@ -43,15 +43,15 @@ function buildSpawnEnv(shell: string): Record<string, string | undefined> {
* Supports PI_BASH_NO_LOGIN and CLAUDE_BASH_NO_LOGIN to skip -l.
*/
function getShellArgs(): string[] {
const noLogin = process.env.PI_BASH_NO_LOGIN || process.env.CLAUDE_BASH_NO_LOGIN;
return noLogin ? ["-c"] : ["-l", "-c"];
const noLogin = process.env.PI_BASH_NO_LOGIN || process.env.CLAUDE_BASH_NO_LOGIN;
return noLogin ? ["-c"] : ["-l", "-c"];
}
/**
* Get shell prefix for wrapping commands (profilers, strace, etc.).
*/
function getShellPrefix(): string | undefined {
return process.env.PI_SHELL_PREFIX || process.env.CLAUDE_CODE_SHELL_PREFIX;
return process.env.PI_SHELL_PREFIX || process.env.CLAUDE_CODE_SHELL_PREFIX;
}
/**
@@ -76,12 +76,12 @@ function findBashOnPath(): string | null {
* Build full shell config from a shell path.
*/
function buildConfig(shell: string): ShellConfig {
return {
shell,
args: getShellArgs(),
env: buildSpawnEnv(shell),
prefix: getShellPrefix(),
};
return {
shell,
args: getShellArgs(),
env: buildSpawnEnv(shell),
prefix: getShellPrefix(),
};
}
/**
@@ -93,92 +93,92 @@ function buildConfig(shell: string): ShellConfig {
* 4. Fallback: sh
*/
export function getShellConfig(): ShellConfig {
if (cachedShellConfig) {
return cachedShellConfig;
}
if (cachedShellConfig) {
return cachedShellConfig;
}
const settings = SettingsManager.create();
const customShellPath = settings.getShellPath();
const settings = SettingsManager.create();
const customShellPath = settings.getShellPath();
// 1. Check user-specified shell path
if (customShellPath) {
if (existsSync(customShellPath)) {
cachedShellConfig = buildConfig(customShellPath);
return cachedShellConfig;
}
throw new Error(
`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.pi/agent/settings.json`,
);
}
// 1. Check user-specified shell path
if (customShellPath) {
if (existsSync(customShellPath)) {
cachedShellConfig = buildConfig(customShellPath);
return cachedShellConfig;
}
throw new Error(
`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.pi/agent/settings.json`,
);
}
if (process.platform === "win32") {
// 2. Try Git Bash in known locations
const paths: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
if (process.platform === "win32") {
// 2. Try Git Bash in known locations
const paths: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
for (const path of paths) {
if (existsSync(path)) {
cachedShellConfig = buildConfig(path);
return cachedShellConfig;
}
}
for (const path of paths) {
if (existsSync(path)) {
cachedShellConfig = buildConfig(path);
return cachedShellConfig;
}
}
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findBashOnPath();
if (bashOnPath) {
cachedShellConfig = buildConfig(bashOnPath);
return cachedShellConfig;
}
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findBashOnPath();
if (bashOnPath) {
cachedShellConfig = buildConfig(bashOnPath);
return cachedShellConfig;
}
throw new Error(
`No bash shell found. Options:\n` +
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
` 3. Set shellPath in ~/.pi/agent/settings.json\n\n` +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
);
}
throw new Error(
`No bash shell found. Options:\n` +
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
` 3. Set shellPath in ~/.pi/agent/settings.json\n\n` +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
);
}
// Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
const userShell = process.env.SHELL;
const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
if (isValidShell && isExecutable(userShell)) {
cachedShellConfig = buildConfig(userShell);
return cachedShellConfig;
}
// Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
const userShell = process.env.SHELL;
const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
if (isValidShell && isExecutable(userShell)) {
cachedShellConfig = buildConfig(userShell);
return cachedShellConfig;
}
// Fallback paths (Claude's approach: check known locations)
const fallbackPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
const preferZsh = !userShell?.includes("bash");
const shellOrder = preferZsh ? ["zsh", "bash"] : ["bash", "zsh"];
// Fallback paths (Claude's approach: check known locations)
const fallbackPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
const preferZsh = !userShell?.includes("bash");
const shellOrder = preferZsh ? ["zsh", "bash"] : ["bash", "zsh"];
for (const shellName of shellOrder) {
for (const dir of fallbackPaths) {
const shellPath = `${dir}/${shellName}`;
if (isExecutable(shellPath)) {
cachedShellConfig = buildConfig(shellPath);
return cachedShellConfig;
}
}
}
for (const shellName of shellOrder) {
for (const dir of fallbackPaths) {
const shellPath = `${dir}/${shellName}`;
if (isExecutable(shellPath)) {
cachedShellConfig = buildConfig(shellPath);
return cachedShellConfig;
}
}
}
// Last resort: use Bun.which
const bashPath = Bun.which("bash");
if (bashPath) {
cachedShellConfig = buildConfig(bashPath);
return cachedShellConfig;
}
// Last resort: use Bun.which
const bashPath = Bun.which("bash");
if (bashPath) {
cachedShellConfig = buildConfig(bashPath);
return cachedShellConfig;
}
const shPath = Bun.which("sh");
cachedShellConfig = buildConfig(shPath || "sh");
return cachedShellConfig;
const shPath = Bun.which("sh");
cachedShellConfig = buildConfig(shPath || "sh");
return cachedShellConfig;
}
/**