Files
hermes-agent/tests/tools/test_subagent_sudo_prompt.py
T
Teknium 081cdd9911 fix(terminal): subagents no longer hijack the tty with an interactive sudo prompt
delegate_task children run on worker threads of the parent process and
inherit the process-wide HERMES_INTERACTIVE=1 the CLI sets at startup.
_transform_sudo_command's interactive gate therefore fired inside
children with no sudo callback registered, falling through to the raw
/dev/tty password prompt: a password box printed mid-TUI from a
background thread, parallel children racing for the tty, and each child
blocked for the full 45s timeout.

Gate the prompt (and the sibling 'you will be prompted again' message
after an auth failure) on agent.delegation_context.is_delegated_child_context(),
the ContextVar set around every child run and propagated through
contextvars.copy_context onto the executor thread. Children now behave
as headless for sudo: configured SUDO_PASSWORD, the session cache, and
the NOPASSWD probe still work; otherwise the command fails gracefully
with a subagent-specific tip.

A/B verified: 3 regression tests fail on merge-base, 7/7 pass at head.
2026-08-23 19:00:47 -07:00

140 lines
5.3 KiB
Python

"""delegate_task children must never trigger the interactive sudo prompt.
Subagents run on worker threads of the parent process and inherit
process-wide interactivity signals (``HERMES_INTERACTIVE=1`` set by the CLI
at startup). Before the fix, ``_transform_sudo_command`` passed the
interactive gate inside a child, found no thread-local sudo callback, and
fell through to the raw ``/dev/tty`` password prompt — printed mid-TUI from
a background thread and blocking the child for the full 45s timeout.
The delegated-child ContextVar (``agent.delegation_context``) is the
authoritative "this execution context has no user" signal: it is set around
every child run and propagates through ``contextvars.copy_context`` onto the
executor thread.
"""
import contextvars
import os
import threading
import pytest
from agent.delegation_context import delegated_child_context
from tools import terminal_tool as tt
@pytest.fixture(autouse=True)
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(tt, "_sudo_nopasswd_works", lambda: False)
tt._reset_cached_sudo_passwords()
tt.set_sudo_password_callback(None)
yield
tt.set_sudo_password_callback(None)
tt._reset_cached_sudo_passwords()
def _transform_in_child(command: str):
"""Run _transform_sudo_command the way delegate_tool runs children:
inside delegated_child_context(), through contextvars.copy_context(),
on a separate worker thread.
"""
result = {}
def _parent_side():
with delegated_child_context("child-session"):
ctx = contextvars.copy_context()
def _worker():
result["value"] = ctx.run(tt._transform_sudo_command, command)
t = threading.Thread(target=_worker)
t.start()
t.join(timeout=10)
assert not t.is_alive(), "child transform blocked (prompt fired?)"
_parent_side()
return result["value"]
class TestDelegatedChildNeverPrompts:
def test_interactive_env_does_not_prompt_in_child(self, monkeypatch):
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
calls = []
monkeypatch.setattr(
tt,
"_prompt_for_sudo_password",
lambda timeout_seconds=45: calls.append(1) or "hunter2",
)
transformed, sudo_stdin = _transform_in_child("sudo apt-get update")
assert calls == [], "child must not reach the interactive sudo prompt"
assert sudo_stdin is None
assert transformed == "sudo apt-get update"
def test_stale_thread_callback_does_not_prompt_in_child(self, monkeypatch):
# Precondition sanity: the interactive gate WOULD fire outside a child.
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
calls = []
def _run_child_with_stale_callback():
# Simulate a recycled worker thread that still holds a parent
# CLI callback in its thread-local slot.
tt.set_sudo_password_callback(lambda: calls.append(1) or "pw")
try:
with delegated_child_context("child-session"):
return tt._transform_sudo_command("sudo systemctl restart foo")
finally:
tt.set_sudo_password_callback(None)
transformed, sudo_stdin = _run_child_with_stale_callback()
assert calls == []
assert sudo_stdin is None
assert transformed == "sudo systemctl restart foo"
def test_parent_context_still_prompts(self, monkeypatch):
"""The fix must not break interactive prompting outside children."""
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
monkeypatch.setattr(
tt, "_prompt_for_sudo_password", lambda timeout_seconds=45: "hunter2"
)
transformed, sudo_stdin = tt._transform_sudo_command("sudo whoami")
assert sudo_stdin == "hunter2\n"
assert "sudo -S -p ''" in transformed
def test_configured_password_still_works_in_child(self, monkeypatch):
monkeypatch.setenv("SUDO_PASSWORD", "s3cret")
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
transformed, sudo_stdin = _transform_in_child("sudo whoami")
assert sudo_stdin == "s3cret\n"
assert "sudo -S -p ''" in transformed
class TestDelegatedChildFailureMessaging:
def test_child_gets_headless_sudo_tip(self):
with delegated_child_context("child-session"):
out = tt._handle_sudo_failure(
"sudo: a password is required", env_type="local"
)
assert "Subagents cannot prompt" in out
assert "SUDO_PASSWORD" in out
def test_parent_output_unchanged(self, monkeypatch):
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
out = tt._handle_sudo_failure("sudo: a password is required", env_type="local")
assert out == "sudo: a password is required"
def test_gateway_tip_preserved(self, monkeypatch):
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
out = tt._handle_sudo_failure("sudo: a password is required", env_type="local")
assert "To enable sudo over messaging" in out