diff --git a/tests/tools/test_subagent_sudo_prompt.py b/tests/tools/test_subagent_sudo_prompt.py index 5387b6bd90..36fc508f97 100644 --- a/tests/tools/test_subagent_sudo_prompt.py +++ b/tests/tools/test_subagent_sudo_prompt.py @@ -29,8 +29,6 @@ def _clean_sudo_state(monkeypatch): """Isolate sudo-related process/thread state per test.""" monkeypatch.delenv("SUDO_PASSWORD", raising=False) monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) - # Host sudoers NOPASSWD must not short-circuit the path under test. - monkeypatch.setattr(tts, "_sudo_nopasswd_works", lambda: False) tts._reset_cached_sudo_passwords() tt.set_sudo_password_callback(None) yield diff --git a/tools/environments/base.py b/tools/environments/base.py index 4bda4793ac..6fb1966f21 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -591,27 +591,20 @@ class BaseEnvironment(ABC): pass def _prepare_command(self, command: str) -> tuple[str, str | None]: - """Prepare sudo using this environment's passwordless-sudo probe.""" + """Rewrite sudo for a piped password, or leave it alone when this backend has NOPASSWD.""" from tools.terminal_tool_sudo import _transform_sudo_command + return _transform_sudo_command(command, sudo_nopasswd_check=self._sudo_nopasswd_works) - return _transform_sudo_command( - command, - sudo_nopasswd_check=self._sudo_nopasswd_works, - ) + _SUDO_PROBE_TIMEOUT_S = 3 def _sudo_nopasswd_works(self) -> bool: - """Probe passwordless sudo inside this execution environment.""" + """``sudo -n true`` inside THIS backend (host sudo state must not leak into a sandbox). + Fails closed: any error or a timed-out probe means "assume a password is needed".""" if not self._sudo_nopasswd_probe_supported: return False try: - proc = self._run_bash( - "sudo -n true", - login=False, - timeout=3, - stdin_data=None, - ) - result = self._wait_for_process(proc, timeout=3) - return result.get("returncode") == 0 + proc = self._run_bash("sudo -n true", timeout=self._SUDO_PROBE_TIMEOUT_S) + return self._wait_for_process(proc, timeout=self._SUDO_PROBE_TIMEOUT_S).get("returncode") == 0 except Exception: return False diff --git a/tools/terminal_tool_sudo.py b/tools/terminal_tool_sudo.py index c775c290d6..15c64c3a1c 100644 --- a/tools/terminal_tool_sudo.py +++ b/tools/terminal_tool_sudo.py @@ -13,8 +13,7 @@ import subprocess import sys import threading import time -from typing import Callable -from collections.abc import Iterator +from collections.abc import Callable, Iterator from utils import env_var_enabled @@ -363,23 +362,6 @@ def _count_real_sudo_invocations(command: str) -> int: return _rewrite_real_sudo_invocations(command)[1] -def _sudo_nopasswd_works() -> bool: - """True when local sudo currently works without prompting. Local backend only — Docker/SSH/ - Modal must not inherit host sudo state. Re-probes every call (no cache) so an expired sudo - timestamp can't make a later command silently block waiting for a password.""" - from tools.terminal_tool import _tenv - if (_tenv("TERMINAL_ENV", "local").strip().lower() or "local") != "local": - return False - try: - probe = subprocess.run( - ["sudo", "-n", "true"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, timeout=3, check=False, - ) - return probe.returncode == 0 - except Exception: - return False - - def _rewrite_compound_background(command: str) -> str: """Wrap `A && B &` (or `A || B &`) to `A && { B & }` at depth 0. Bash binds `&&` tighter than `&`, so `A && B &` backgrounds a subshell that runs B in the foreground and waits for @@ -447,7 +429,9 @@ def _transform_sudo_command( password in the command string themselves. With no password available the command is returned unchanged and ``sudo_stdin`` is None, so it fails gracefully with "sudo: a password is required". Password sources, in order: configured SUDO_PASSWORD, the session cache, then - an interactive prompt (45s timeout, cached on success) when a UI is reachable.""" + an interactive prompt (45s timeout, cached on success) when a UI is reachable. + ``sudo_nopasswd_check`` (supplied by ``BaseEnvironment``) runs ``sudo -n true`` inside the + selected backend; a True result skips the prompt and the ``-S`` rewrite entirely.""" from tools.terminal_tool import _get_sudo_password_callback if command is None: return None, None @@ -465,24 +449,19 @@ def _transform_sudo_command( has_configured_password = _configured_password is not None sudo_password = _configured_password if has_configured_password else _get_cached_sudo_password() - # sudoers NOPASSWD must not be forced through the prompt or the -S pipe. BaseEnvironment - # supplies a probe scoped to the selected backend; direct callers keep the local-host - # fallback. Re-probed every call so an expired sudo timestamp cannot silently block. - if not has_configured_password and not sudo_password: - nopasswd_check = sudo_nopasswd_check or _sudo_nopasswd_works - try: - if nopasswd_check(): - return command, None - except Exception: - pass - # delegate_task children inherit HERMES_INTERACTIVE=1 (and possibly a stale thread-local # callback on a recycled worker) but have no user on the other side — always headless; - # configured password, session cache and the NOPASSWD probe still apply. + # configured password and session cache still apply. should_prompt_for_sudo = ( env_var_enabled("HERMES_INTERACTIVE") or _get_sudo_password_callback() is not None ) and not _in_delegated_child_context() if not has_configured_password and not sudo_password and should_prompt_for_sudo: + # sudoers NOPASSWD must not be forced through the prompt or the -S pipe. The probe is + # a round trip on the selected backend (an ssh exec for SSH), so it only runs when a + # prompt would otherwise fire: headless callers end up at ``(command, None)`` either + # way. Re-probed every call so an expired sudo timestamp cannot silently block. + if sudo_nopasswd_check is not None and sudo_nopasswd_check(): + return command, None sudo_password = _prompt_for_sudo_password(timeout_seconds=45) if sudo_password: _set_cached_sudo_password(sudo_password)