Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

[project]
name = "ansible-aom"
version = "0.102.14"
version = "0.102.15"
description = "Ansible Output Monitor - nom-style TUI for ansible-playbook"
readme = "README.md"
license = "GPL-3.0-or-later"
Expand Down
41 changes: 32 additions & 9 deletions src/ansible_aom/ansible/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ def _drive(
cpu_sample_every = max(1, int(2.0 / max(timeout, 0.05)))
timeout_count = 0
dead_child_drain_pending = False
jsonl_prefix = ""

# R8: post-stats EOF watchdog. The parser flips its phase to
# POST_RUN_RECAP exactly when it consumes a ``v2_playbook_on_stats``
Expand Down Expand Up @@ -702,13 +703,14 @@ def _drive(
try:
idx = child.expect(patterns, timeout=read_timeout)
except pexpect.exceptions.EOF:
_flush_pending(child, parser, state, renderer, sink, diag=diag)
_flush_pending(child, parser, state, renderer, sink, prefix=jsonl_prefix, diag=diag)
break

if idx == newline_idx:
before: str = cast("str", child.before or "")
after: str = cast("str", child.after or "")
line = before + after
line = jsonl_prefix + before + after
jsonl_prefix = ""
_trace("newline", line=line[:200])
# pexpect's `before` carries the entire unread chunk since
# the last match; when that chunk spans multiple JSONL
Expand Down Expand Up @@ -747,11 +749,11 @@ def _drive(
dead_child_drain_pending = True
elif idx == eof_idx:
_trace("eof", leftover=(child.before or "")[:200])
_flush_pending(child, parser, state, renderer, sink, diag=diag)
_flush_pending(child, parser, state, renderer, sink, prefix=jsonl_prefix, diag=diag)
break
elif idx == timeout_idx:
if dead_child_drain_pending:
_flush_pending(child, parser, state, renderer, sink, diag=diag)
_flush_pending(child, parser, state, renderer, sink, prefix=jsonl_prefix, diag=diag)
break
# R8: post-stats timeout = EOF watchdog fired. The child
# refused to close its PTY within ``_EOF_WATCHDOG_S`` of
Expand Down Expand Up @@ -794,9 +796,16 @@ def _drive(
prior=(prior or "")[:120],
block=(prompt_block or "")[:200],
)
stall_count = _handle_timeout_branch(
child, renderer, sink, stall_count, prior, prompt_block
)
pending = _peek_unread(child) or (child.before or "")
if jsonl_prefix or pending.lstrip().startswith("{"):
# A partial event is not a stalled plaintext prompt. Keep it
# intact until its newline/EOF, even across long quiet gaps.
renderer.tick()
stall_count = 0
else:
stall_count = _handle_timeout_branch(
child, renderer, sink, stall_count, prior, prompt_block
)
if diag is not None:
diag.note_timeout()
diag.note_stall(stall_count if stall_count > 0 else 0)
Expand All @@ -813,7 +822,20 @@ def _drive(
# haven't routed yet) and the matched prompt itself.
prompt_before: str = cast("str", child.before or "")
prompt_after: str = cast("str", child.after or "")
prompt = prompt_before + prompt_after
prompt = jsonl_prefix + prompt_before + prompt_after
complete_lines, separator, last_line = prompt.rpartition("\n")
if separator:
for sub_line in complete_lines.splitlines():
if sub_line:
_feed(sub_line, parser, state, renderer, sink, diag=diag)
prompt = last_line
jsonl_prefix = ""
# sudo -V and other module results can contain literal password
# prompts inside JSON strings. Matching one must not consume the
# event or enter getpass; rejoin it at the next newline/EOF.
if prompt.lstrip().startswith("{"):
jsonl_prefix = prompt
continue
_trace("password-pattern", prompt=prompt[:200])
renderer.note_pty_bytes()
_drain_warnings(parser, renderer, sink, flush=True)
Expand Down Expand Up @@ -992,6 +1014,7 @@ def _flush_pending(
renderer: Renderer,
sink: _SessionSink | _NullSink,
*,
prefix: str = "",
diag: diagnostics.RunDiagnostics | None = None,
) -> None:
"""Drain any final bytes left in the buffer when the subprocess ends.
Expand All @@ -1012,7 +1035,7 @@ def _flush_pending(
trailing events are silently dropped when the child exits between
``expect()`` returning and the ``isalive()`` check.
"""
leftover = (child.before or "") + (_peek_unread(child) or "")
leftover = prefix + (child.before or "") + (_peek_unread(child) or "")
if leftover.strip():
for sub_line in leftover.splitlines():
if sub_line:
Expand Down
126 changes: 126 additions & 0 deletions tests/integration/test_runner_jsonl_prompts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Real PTY regressions for JSONL framing before password detection."""

from __future__ import annotations

import json
import sys
from unittest.mock import MagicMock

import pexpect
import pytest

from ansible_aom.ansible.runner import _SEARCH_WINDOW_BYTES, _drive, _NullSink
from ansible_aom.core.models import RunState
from ansible_aom.core.parser import PtyStreamParser


@pytest.mark.parametrize("padding", [0, 700, 5000])
@pytest.mark.parametrize("fragmented", [False, True])
@pytest.mark.parametrize("trailing_newline", [False, True])
def test_jsonl_password_text_is_parsed_without_requesting_input(
padding: int, fragmented: bool, trailing_newline: bool
) -> None:
# Given sudo -V output inside a JSON event, followed by normal task progress.
events = [
{"_event": "v2_playbook_on_start"},
{
"_event": "v2_runner_on_ok",
"hosts": {
"ipa1": {
"stdout": "x" * padding + "Default password prompt: [sudo] password for %p: ",
}
},
"task": {"id": "sudo", "name": "Detect sudo rlimit_core support"},
},
{"_event": "v2_playbook_on_stats", "stats": {}},
]
if not trailing_newline:
events.pop()
payload = "".join(json.dumps(event) + "\n" for event in events)
if not trailing_newline:
payload = payload.rstrip("\n")
split = payload.index("[sudo]") + len("[sudo] password for %p: ")
code = f"import sys, time; sys.stdout.write({payload!r}); sys.stdout.flush()"
if fragmented:
code = (
f"import sys, time; sys.stdout.write({payload[:split]!r}); sys.stdout.flush(); "
f"time.sleep(0.8); sys.stdout.write({payload[split:]!r}); sys.stdout.flush()"
)
renderer = MagicMock()
renderer.handle_password_prompt.return_value = ""

# When the real PTY delivers the structured stream to the runner.
with pexpect.spawn(
sys.executable,
["-c", code],
encoding="utf-8",
searchwindowsize=_SEARCH_WINDOW_BYTES,
) as child:
exit_code = _drive(
child, PtyStreamParser(), RunState(playbook="test.yml"), renderer, 0.1, _NullSink()
)

# Then every event is intact and no secret/input is requested or logged.
assert exit_code == 0
assert [call.args[0] for call in renderer.update_state.call_args_list] == events
renderer.handle_password_prompt.assert_not_called()
renderer.handle_interactive_prompt.assert_not_called()
renderer.print_log.assert_not_called()


@pytest.mark.parametrize("padding", [0, 6000])
def test_real_password_prompt_still_works_after_jsonl_password_text(padding: int) -> None:
events = [
{"_event": "v2_playbook_on_start"},
{"_event": "aom_test_event", "stdout": "Vault password: SSH password: " + "x" * padding},
]
payload = "".join(json.dumps(event) + "\n" for event in events)
code = (
"import sys, signal; signal.alarm(3); "
f"sys.stdout.write({payload!r} + 'BECOME password: '); "
"sys.stdout.flush(); answer = sys.stdin.readline().strip(); "
"sys.exit(0 if answer == 'test-secret' else 9)"
)
renderer = MagicMock()
renderer.handle_password_prompt.return_value = "test-secret"
with pexpect.spawn(
sys.executable,
["-c", code],
encoding="utf-8",
searchwindowsize=_SEARCH_WINDOW_BYTES,
echo=False,
) as child:
exit_code = _drive(
child, PtyStreamParser(), RunState(playbook="test.yml"), renderer, 0.1, _NullSink()
)

assert exit_code == 0
assert [call.args[0] for call in renderer.update_state.call_args_list] == events
renderer.handle_password_prompt.assert_called_once_with("BECOME password: ")


def test_backlogged_events_are_all_parsed(monkeypatch: pytest.MonkeyPatch) -> None:
from ansible_aom.ansible.runner import run_playbook
from ansible_aom.core.parser import PreParseResult

# Given many short events queued in one subprocess write.
events = [{"_event": "v2_playbook_on_start"}]
events.extend({"_event": "aom_test_event", "sequence": n} for n in range(40))
events.append({"_event": "v2_playbook_on_stats", "stats": {}})
payload = "".join(json.dumps(event) + "\n" for event in events)
monkeypatch.setattr(
"ansible_aom.ansible.runner._build_command",
lambda *_: (sys.executable, ["-c", f"import os; os.write(1, {payload.encode()!r})"]),
)
monkeypatch.setattr(
"ansible_aom.ansible.runner.run_preflight",
lambda **_: PreParseResult(plays=[], play_hosts=[]),
)
renderer = MagicMock()

# When the production spawn configuration reads that backlog.
exit_code = run_playbook("test.yml", [], renderer, record=False)

# Then no earlier newline or event is skipped by the bounded search window.
assert exit_code == 0
assert [call.args[0] for call in renderer.update_state.call_args_list] == events
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading