diff --git a/tools/environments/local.py b/tools/environments/local.py index 428d31294f..6d7e8da3c6 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -9,6 +9,23 @@ import time from tools.environments.base import BaseEnvironment +# Noise lines emitted by interactive shells when stdin is not a terminal. +# Filtered from output to keep tool results clean. +_SHELL_NOISE = frozenset({ + "bash: no job control in this shell", + "bash: no job control in this shell\n", + "no job control in this shell", + "no job control in this shell\n", +}) + + +def _clean_shell_noise(output: str) -> str: + """Strip shell startup warnings that leak when using -i without a TTY.""" + lines = output.split("\n", 2) # only check first two lines + if lines and lines[0].strip() in _SHELL_NOISE: + return "\n".join(lines[1:]) + return output + class LocalEnvironment(BaseEnvironment): """Run commands directly on the host machine. @@ -18,7 +35,7 @@ class LocalEnvironment(BaseEnvironment): - Background stdout drain thread to prevent pipe buffer deadlocks - stdin_data support for piping content (bypasses ARG_MAX limits) - sudo -S transform via SUDO_PASSWORD env var - - Uses bash login shell so user env (.profile/.bashrc) is available + - Uses interactive login shell so full user env is available """ def __init__(self, cwd: str = "", timeout: int = 60, env: dict = None): @@ -34,14 +51,15 @@ class LocalEnvironment(BaseEnvironment): exec_command = self._prepare_command(command) try: - # Use the user's login shell so that rc files (.profile, .bashrc, - # .zprofile, .zshrc, etc.) are sourced and user-installed tools - # (nvm, pyenv, cargo, etc.) are available. Without this, Python's - # Popen(shell=True) uses /bin/sh which is dash on Debian/Ubuntu - # and old bash on macOS — neither sources the user's environment. + # Use the user's shell as an interactive login shell (-lic) so + # that ALL rc files are sourced — including content after the + # interactive guard in .bashrc (case $- in *i*)..esac) where + # tools like nvm, pyenv, and cargo install their init scripts. + # -l alone isn't enough: .profile sources .bashrc, but the guard + # returns early because the shell isn't interactive. user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash" proc = subprocess.Popen( - [user_shell, "-lc", exec_command], + [user_shell, "-lic", exec_command], text=True, cwd=work_dir, env=os.environ | self.env, @@ -106,7 +124,8 @@ class LocalEnvironment(BaseEnvironment): time.sleep(0.2) reader.join(timeout=5) - return {"output": "".join(_output_chunks), "returncode": proc.returncode} + output = _clean_shell_noise("".join(_output_chunks)) + return {"output": output, "returncode": proc.returncode} except Exception as e: return {"output": f"Execution error: {str(e)}", "returncode": 1} diff --git a/tools/process_registry.py b/tools/process_registry.py index 230afd19c1..00a8a3257a 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -86,6 +86,14 @@ class ProcessRegistry: - Cleanup thread (sandbox reaping coordination) """ + # Noise lines emitted by interactive shells when stdin is not a terminal. + _SHELL_NOISE = frozenset({ + "bash: no job control in this shell", + "bash: no job control in this shell\n", + "no job control in this shell", + "no job control in this shell\n", + }) + def __init__(self): self._running: Dict[str, ProcessSession] = {} self._finished: Dict[str, ProcessSession] = {} @@ -94,6 +102,14 @@ class ProcessRegistry: # Side-channel for check_interval watchers (gateway reads after agent run) self.pending_watchers: List[Dict[str, Any]] = [] + @staticmethod + def _clean_shell_noise(text: str) -> str: + """Strip shell startup warnings from the beginning of output.""" + lines = text.split("\n", 2) + if lines and lines[0].strip() in ProcessRegistry._SHELL_NOISE: + return "\n".join(lines[1:]) + return text + # ----- Spawn ----- def spawn_local( @@ -130,7 +146,7 @@ class ProcessRegistry: import ptyprocess user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash" pty_proc = ptyprocess.PtyProcess.spawn( - [user_shell, "-lc", command], + [user_shell, "-lic", command], cwd=session.cwd, env=os.environ | (env_vars or {}), dimensions=(30, 120), @@ -166,7 +182,7 @@ class ProcessRegistry: # ensures rc files are sourced and user tools are available. user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash" proc = subprocess.Popen( - [user_shell, "-lc", command], + [user_shell, "-lic", command], text=True, cwd=session.cwd, env=os.environ | (env_vars or {}), @@ -272,11 +288,15 @@ class ProcessRegistry: def _reader_loop(self, session: ProcessSession): """Background thread: read stdout from a local Popen process.""" + first_chunk = True try: while True: chunk = session.process.stdout.read(4096) if not chunk: break + if first_chunk: + chunk = self._clean_shell_noise(chunk) + first_chunk = False with session._lock: session.output_buffer += chunk if len(session.output_buffer) > session.max_output_chars: