feat(cli): add shell noise filtering and improve command execution with interactive login shell
This commit is contained in:
parent
f14ff3e041
commit
fb7df099e0
2 changed files with 49 additions and 10 deletions
|
|
@ -9,6 +9,23 @@ import time
|
||||||
|
|
||||||
from tools.environments.base import BaseEnvironment
|
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):
|
class LocalEnvironment(BaseEnvironment):
|
||||||
"""Run commands directly on the host machine.
|
"""Run commands directly on the host machine.
|
||||||
|
|
@ -18,7 +35,7 @@ class LocalEnvironment(BaseEnvironment):
|
||||||
- Background stdout drain thread to prevent pipe buffer deadlocks
|
- Background stdout drain thread to prevent pipe buffer deadlocks
|
||||||
- stdin_data support for piping content (bypasses ARG_MAX limits)
|
- stdin_data support for piping content (bypasses ARG_MAX limits)
|
||||||
- sudo -S transform via SUDO_PASSWORD env var
|
- 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):
|
def __init__(self, cwd: str = "", timeout: int = 60, env: dict = None):
|
||||||
|
|
@ -34,14 +51,15 @@ class LocalEnvironment(BaseEnvironment):
|
||||||
exec_command = self._prepare_command(command)
|
exec_command = self._prepare_command(command)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use the user's login shell so that rc files (.profile, .bashrc,
|
# Use the user's shell as an interactive login shell (-lic) so
|
||||||
# .zprofile, .zshrc, etc.) are sourced and user-installed tools
|
# that ALL rc files are sourced — including content after the
|
||||||
# (nvm, pyenv, cargo, etc.) are available. Without this, Python's
|
# interactive guard in .bashrc (case $- in *i*)..esac) where
|
||||||
# Popen(shell=True) uses /bin/sh which is dash on Debian/Ubuntu
|
# tools like nvm, pyenv, and cargo install their init scripts.
|
||||||
# and old bash on macOS — neither sources the user's environment.
|
# -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"
|
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[user_shell, "-lc", exec_command],
|
[user_shell, "-lic", exec_command],
|
||||||
text=True,
|
text=True,
|
||||||
cwd=work_dir,
|
cwd=work_dir,
|
||||||
env=os.environ | self.env,
|
env=os.environ | self.env,
|
||||||
|
|
@ -106,7 +124,8 @@ class LocalEnvironment(BaseEnvironment):
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
reader.join(timeout=5)
|
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:
|
except Exception as e:
|
||||||
return {"output": f"Execution error: {str(e)}", "returncode": 1}
|
return {"output": f"Execution error: {str(e)}", "returncode": 1}
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ class ProcessRegistry:
|
||||||
- Cleanup thread (sandbox reaping coordination)
|
- 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):
|
def __init__(self):
|
||||||
self._running: Dict[str, ProcessSession] = {}
|
self._running: Dict[str, ProcessSession] = {}
|
||||||
self._finished: 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)
|
# Side-channel for check_interval watchers (gateway reads after agent run)
|
||||||
self.pending_watchers: List[Dict[str, Any]] = []
|
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 -----
|
# ----- Spawn -----
|
||||||
|
|
||||||
def spawn_local(
|
def spawn_local(
|
||||||
|
|
@ -130,7 +146,7 @@ class ProcessRegistry:
|
||||||
import ptyprocess
|
import ptyprocess
|
||||||
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||||
pty_proc = ptyprocess.PtyProcess.spawn(
|
pty_proc = ptyprocess.PtyProcess.spawn(
|
||||||
[user_shell, "-lc", command],
|
[user_shell, "-lic", command],
|
||||||
cwd=session.cwd,
|
cwd=session.cwd,
|
||||||
env=os.environ | (env_vars or {}),
|
env=os.environ | (env_vars or {}),
|
||||||
dimensions=(30, 120),
|
dimensions=(30, 120),
|
||||||
|
|
@ -166,7 +182,7 @@ class ProcessRegistry:
|
||||||
# ensures rc files are sourced and user tools are available.
|
# ensures rc files are sourced and user tools are available.
|
||||||
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[user_shell, "-lc", command],
|
[user_shell, "-lic", command],
|
||||||
text=True,
|
text=True,
|
||||||
cwd=session.cwd,
|
cwd=session.cwd,
|
||||||
env=os.environ | (env_vars or {}),
|
env=os.environ | (env_vars or {}),
|
||||||
|
|
@ -272,11 +288,15 @@ class ProcessRegistry:
|
||||||
|
|
||||||
def _reader_loop(self, session: ProcessSession):
|
def _reader_loop(self, session: ProcessSession):
|
||||||
"""Background thread: read stdout from a local Popen process."""
|
"""Background thread: read stdout from a local Popen process."""
|
||||||
|
first_chunk = True
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = session.process.stdout.read(4096)
|
chunk = session.process.stdout.read(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
|
if first_chunk:
|
||||||
|
chunk = self._clean_shell_noise(chunk)
|
||||||
|
first_chunk = False
|
||||||
with session._lock:
|
with session._lock:
|
||||||
session.output_buffer += chunk
|
session.output_buffer += chunk
|
||||||
if len(session.output_buffer) > session.max_output_chars:
|
if len(session.output_buffer) > session.max_output_chars:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue