fix: prevent TUI gateway stdin EOF crash across all TUI-context subprocess calls

When Hermes runs in TUI mode, the gateway child process communicates with
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
inherit this stdin fd can trigger a race condition where the child's stdin
read returns EOF, causing the gateway to exit cleanly (exit code 0) mid-tool-
execution.

This is the same root cause as issue #14036 (byterover plugin) and PR #39257
(SSH environment backend). This commit applies the fix — stdin=subprocess.DEVNULL
— to all 85 subprocess.run() and subprocess.Popen() calls that execute inside
the TUI gateway child process.

Scope: TUI-context code only (agent/, tools/, plugins/, tui_gateway/server.py).
CLI code (cli.py, hermes_cli/), tests, scripts, and gateway process management
are excluded — they don't run inside the TUI child and inherit the terminal's
stdin, not the JSON-RPC pipe.

85 call sites across 28 files. All files pass syntax check.
This commit is contained in:
m4dni5
2026-06-08 22:46:57 -07:00
committed by Teknium
parent 54318c65b0
commit d1f23bb2d5
27 changed files with 84 additions and 20 deletions
+2
View File
@@ -307,6 +307,7 @@ def _run_git(
timeout=timeout,
env=env,
cwd=str(normalized_working_dir),
stdin=subprocess.DEVNULL,
)
ok = result.returncode == 0
stdout = result.stdout.strip()
@@ -426,6 +427,7 @@ def _init_store(store: Path, working_dir: str) -> Optional[str]:
["git", "init", "--bare", str(store)],
capture_output=True, text=True,
env=init_env, timeout=_GIT_TIMEOUT,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
return f"Shadow store init failed: {result.stderr.strip()}"
+1
View File
@@ -1618,6 +1618,7 @@ def _is_usable_python(python_path: str) -> bool:
timeout=5,
capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
stdin=subprocess.DEVNULL,
)
return result.returncode == 0
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
+1
View File
@@ -65,6 +65,7 @@ def _run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str, str]:
text=True,
timeout=timeout,
check=False,
stdin=subprocess.DEVNULL,
)
return result.returncode, (result.stdout or "").strip(), (result.stderr or "").strip()
except FileNotFoundError:
+17 -1
View File
@@ -177,6 +177,7 @@ def reap_orphan_containers(
listing = subprocess.run(
[docker, "ps", "-a", *filters, "--format", "{{.ID}}"],
capture_output=True, text=True, timeout=15, check=False,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("orphan reaper docker ps failed: %s", e)
@@ -210,6 +211,7 @@ def reap_orphan_containers(
result = subprocess.run(
[docker, "rm", "-f", cid],
capture_output=True, text=True, timeout=30,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
removed += 1
@@ -239,6 +241,7 @@ def _container_finished_at(docker_exe: str, container_id: str):
result = subprocess.run(
[docker_exe, "inspect", "--format", "{{.State.FinishedAt}}", container_id],
capture_output=True, text=True, timeout=10, check=False,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("orphan reaper docker inspect %s failed: %s", container_id[:12], e)
@@ -381,6 +384,7 @@ def _image_uses_init_entrypoint(docker_exe: str, image: str) -> bool:
capture_output=True,
text=True,
timeout=15,
stdin=subprocess.DEVNULL,
)
except (subprocess.SubprocessError, OSError) as e:
logger.debug("Docker: could not inspect entrypoint for %s: %s", image, e)
@@ -453,6 +457,7 @@ def _ensure_docker_available() -> None:
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
logger.error(
@@ -833,6 +838,7 @@ class DockerEnvironment(BaseEnvironment):
text=True,
timeout=30,
check=True,
stdin=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(
@@ -871,6 +877,7 @@ class DockerEnvironment(BaseEnvironment):
text=True,
timeout=120, # image pull may take a while
check=True,
stdin=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
# Docker may create the container object before `docker run`
@@ -887,6 +894,7 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[self._docker_exe, "rm", "-f", container_name],
capture_output=True, timeout=10,
stdin=subprocess.DEVNULL,
)
raise
self._container_id = result.stdout.strip()
@@ -997,6 +1005,7 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[self._docker_exe, "start", cid],
capture_output=True, text=True, timeout=30, check=True,
stdin=subprocess.DEVNULL,
)
self._container_id = cid
logger.info("Recovery: restarted container %s", cid[:12])
@@ -1027,6 +1036,7 @@ class DockerEnvironment(BaseEnvironment):
]
result = subprocess.run(
run_cmd, capture_output=True, text=True, timeout=120, check=True,
stdin=subprocess.DEVNULL,
)
self._container_id = result.stdout.strip()
self._container_name = new_name
@@ -1081,6 +1091,7 @@ class DockerEnvironment(BaseEnvironment):
result = subprocess.run(
[docker, "info", "--format", "{{.Driver}}"],
capture_output=True, text=True, timeout=10,
stdin=subprocess.DEVNULL,
)
driver = result.stdout.strip().lower()
if driver != "overlay2":
@@ -1091,13 +1102,15 @@ class DockerEnvironment(BaseEnvironment):
probe = subprocess.run(
[docker, "create", "--storage-opt", "size=1m", "hello-world"],
capture_output=True, text=True, timeout=15,
stdin=subprocess.DEVNULL,
)
if probe.returncode == 0:
# Clean up the created container
container_id = probe.stdout.strip()
if container_id:
subprocess.run([docker, "rm", container_id],
capture_output=True, timeout=5)
capture_output=True, timeout=5,
stdin=subprocess.DEVNULL)
_storage_opt_ok = True
else:
_storage_opt_ok = False
@@ -1132,6 +1145,7 @@ class DockerEnvironment(BaseEnvironment):
text=True,
timeout=10,
check=False,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("docker ps probe failed: %s — will start a fresh container", e)
@@ -1248,6 +1262,7 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[docker_exe, "stop", "-t", "10", container_id],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("docker stop %s timed out / failed: %s", log_id, e)
@@ -1256,6 +1271,7 @@ class DockerEnvironment(BaseEnvironment):
subprocess.run(
[docker_exe, "rm", "-f", container_id],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("docker rm -f %s failed: %s", log_id, e)
+4 -1
View File
@@ -46,6 +46,7 @@ def _ensure_singularity_available() -> str:
try:
result = subprocess.run(
[exe, "version"], capture_output=True, text=True, timeout=10,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
raise RuntimeError(
@@ -136,6 +137,7 @@ def _get_or_build_sif(image: str, executable: str = "apptainer") -> str:
result = subprocess.run(
[executable, "build", str(sif_path), image],
capture_output=True, text=True, timeout=600, env=env,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
logger.warning("SIF build failed, falling back to docker:// URL")
@@ -218,7 +220,7 @@ class SingularityEnvironment(BaseEnvironment):
cmd.extend([str(self.image), self.instance_id])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
if result.returncode != 0:
raise RuntimeError(f"Failed to start instance: {result.stderr}")
self._instance_started = True
@@ -250,6 +252,7 @@ class SingularityEnvironment(BaseEnvironment):
subprocess.run(
[self.executable, "instance", "stop", self.instance_id],
capture_output=True, text=True, timeout=30,
stdin=subprocess.DEVNULL,
)
logger.info("Singularity instance %s stopped", self.instance_id)
except Exception as e:
+4
View File
@@ -365,6 +365,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
r = subprocess.run(
[uv_bin, "pip", "install", *specs],
capture_output=True, text=True, timeout=timeout, env=uv_env,
stdin=subprocess.DEVNULL,
)
if r.returncode == 0:
return _InstallResult(True, r.stdout or "", r.stderr or "")
@@ -378,6 +379,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
probe = subprocess.run(
pip_cmd + ["--version"],
capture_output=True, text=True, timeout=15,
stdin=subprocess.DEVNULL,
)
if probe.returncode != 0:
raise FileNotFoundError("pip not in venv")
@@ -386,6 +388,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, timeout=120, check=True,
stdin=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
return _InstallResult(False, "",
@@ -395,6 +398,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
r = subprocess.run(
pip_cmd + ["install", *specs],
capture_output=True, text=True, timeout=timeout,
stdin=subprocess.DEVNULL,
)
return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "")
except subprocess.TimeoutExpired as e:
+1
View File
@@ -472,6 +472,7 @@ class ProcessRegistry:
text=True,
timeout=10,
creationflags=windows_hide_flags(),
stdin=subprocess.DEVNULL,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
try:
+1
View File
@@ -301,6 +301,7 @@ class GitHubAuth:
result = subprocess.run(
["gh", "auth", "token"],
capture_output=True, text=True, timeout=5,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
+2 -2
View File
@@ -2436,13 +2436,13 @@ def check_terminal_requirements() -> bool:
if not docker:
logger.error("Docker executable not found in PATH or common install locations")
return False
result = subprocess.run([docker, "version"], capture_output=True, timeout=5)
result = subprocess.run([docker, "version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
return result.returncode == 0
elif env_type == "singularity":
executable = shutil.which("apptainer") or shutil.which("singularity")
if executable:
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5)
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
return result.returncode == 0
return False
+2
View File
@@ -288,6 +288,7 @@ def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool |
capture_output=True,
text=True,
timeout=15,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
logger.info("cosign provenance verification passed")
@@ -734,6 +735,7 @@ def check_command_security(command: str) -> dict:
capture_output=True,
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
except OSError as exc:
# Covers FileNotFoundError, PermissionError, exec format error.
+5 -4
View File
@@ -490,6 +490,7 @@ def _terminate_command_stt_process_tree(proc: subprocess.Popen) -> None:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
stdin=subprocess.DEVNULL,
)
except Exception:
proc.kill()
@@ -555,7 +556,7 @@ def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProces
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(command, **popen_kwargs)
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired as exc:
@@ -1186,7 +1187,7 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str],
command = [ffmpeg, "-y", "-i", file_path, converted_path]
try:
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300)
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
return converted_path, None
except subprocess.TimeoutExpired:
logger.error("ffmpeg conversion timed out for %s", file_path)
@@ -1232,9 +1233,9 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any]
# User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode.
use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip())
if use_shell:
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300)
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
else:
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300)
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
txt_files = sorted(Path(output_dir).glob("*.txt"))
+9 -6
View File
@@ -693,6 +693,7 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
stdin=subprocess.DEVNULL,
)
except Exception:
proc.kill()
@@ -745,7 +746,7 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(command, **popen_kwargs)
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired as exc:
@@ -882,6 +883,7 @@ def _convert_to_opus(mp3_path: str) -> Optional[str]:
["ffmpeg", "-i", mp3_path, "-acodec", "libopus",
"-ac", "1", "-b:a", "64k", "-vbr", "off", ogg_path, "-y"],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
logger.warning("ffmpeg conversion failed with return code %d: %s",
@@ -1504,7 +1506,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
]
else:
cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
result = subprocess.run(cmd, capture_output=True, timeout=30)
result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="ignore")[:300]
raise RuntimeError(f"ffmpeg conversion failed: {stderr}")
@@ -1587,7 +1589,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
"--device", device,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
if result.returncode != 0:
stderr = result.stderr.strip()
# Filter out the "OK:" line from stderr
@@ -1599,7 +1601,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
subprocess.run(conv_cmd, check=True, timeout=30)
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
os.remove(wav_path)
else:
# No ffmpeg — just rename the WAV to the expected path
@@ -1670,6 +1672,7 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
[_sys.executable, "-m", "piper.download_voices", voice,
"--download-dir", str(download_dir)],
capture_output=True, text=True, timeout=300,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
@@ -1757,7 +1760,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
subprocess.run(conv_cmd, check=True, timeout=30)
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
try:
os.remove(wav_path)
except OSError:
@@ -1823,7 +1826,7 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any])
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
subprocess.run(conv_cmd, check=True, timeout=30)
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
os.remove(wav_path)
else:
# No ffmpeg — rename the WAV to the expected path
+4 -3
View File
@@ -75,6 +75,7 @@ def _termux_api_app_installed() -> bool:
text=True,
timeout=5,
check=False,
stdin=subprocess.DEVNULL,
)
return "package:com.termux.api" in (result.stdout or "")
except Exception:
@@ -388,7 +389,7 @@ class TermuxAudioRecorder:
"-c", str(CHANNELS),
]
try:
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True)
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True, stdin=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
details = (e.stderr or e.stdout or str(e)).strip()
raise RuntimeError(f"Termux microphone start failed: {details}") from e
@@ -405,7 +406,7 @@ class TermuxAudioRecorder:
mic_cmd = _termux_microphone_command()
if not mic_cmd:
return
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False)
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False, stdin=subprocess.DEVNULL)
def stop(self) -> Optional[str]:
with self._lock:
@@ -1095,7 +1096,7 @@ def play_audio_file(file_path: str) -> bool:
exe = shutil.which(cmd[0])
if exe:
try:
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
with _playback_lock:
_active_playback = proc
proc.wait(timeout=300)