"""Step definitions for container_executor_coverage.feature. These steps target specific uncovered lines in container_executor.py: - Lines 184-185: Symlink default sandbox path rejection - Lines 536-545: subprocess.TimeoutExpired handling in _run_command - Lines 555-562: stdout truncation defense-in-depth - Lines 563-570: stderr truncation defense-in-depth - Lines 736-740: _read_bounded drain and partial chunk paths - Line 759 branches: _looks_like_path with \\r and \\t - Line 712: _parse_output RecursionError/MemoryError fallback - Lines 582-590: _run_command OSError at Popen level """ from __future__ import annotations import io import subprocess from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch from behave import given, then, when from cleveragents.tool.container_executor import ( _MAX_OUTPUT_BYTES, ContainerConfig, ContainerExecutionError, ContainerToolExecutor, _looks_like_path, _read_bounded, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _FAKE_DEVCONTAINER_BIN = "/usr/local/bin/devcontainer" def _make_executor( host_sandbox_path: str = "/tmp/sandbox", workspace_folder: str = "/workspace", container_id: str = "test-ctr", timeout_seconds: int = 120, ) -> ContainerToolExecutor: """Create a ContainerToolExecutor with a fake devcontainer binary.""" config = ContainerConfig( workspace_folder=workspace_folder, container_id=container_id, host_sandbox_path=host_sandbox_path, timeout_seconds=timeout_seconds, ) executor = ContainerToolExecutor(config) executor._devcontainer_bin = _FAKE_DEVCONTAINER_BIN return executor # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("the container executor coverage module is imported") def step_coverage_module_imported(context: Any) -> None: """Ensure the module is importable.""" assert ContainerToolExecutor is not None assert _read_bounded is not None assert _looks_like_path is not None # ========================================================================= # Symlink default sandbox path rejection (lines 184-185) # ========================================================================= @given("the default sandbox path is a symlink") def step_default_sandbox_is_symlink(context: Any) -> None: """Set up the mock so that Path('/tmp/sandbox').is_symlink() returns True.""" context.symlink_patcher = patch.object(Path, "is_symlink", return_value=True) context.symlink_patcher.start() context.add_cleanup(context.symlink_patcher.stop) @when("I try to create a ContainerToolExecutor with empty host_sandbox_path") def step_create_executor_empty_sandbox(context: Any) -> None: """Attempt to create executor with empty host_sandbox_path (uses default).""" config = ContainerConfig( workspace_folder="/workspace", container_id="test", host_sandbox_path="", ) try: ContainerToolExecutor(config) context.symlink_error = None except ContainerExecutionError as exc: context.symlink_error = exc @then("a ContainerExecutionError should be raised about symlink default sandbox") def step_check_symlink_error(context: Any) -> None: assert context.symlink_error is not None, ( "Expected ContainerExecutionError for symlink default sandbox" ) assert "symlink" in str(context.symlink_error).lower(), ( f"Expected 'symlink' in error: {context.symlink_error}" ) # ========================================================================= # Real subprocess.TimeoutExpired in _run_command (lines 536-545) # ========================================================================= @given( "I have a container executor with a Popen mock that raises TimeoutExpired on wait" ) def step_executor_popen_timeout_expired(context: Any) -> None: """Mock subprocess.Popen so proc.wait() raises TimeoutExpired. The code calls proc.wait(timeout=...) in the try block which raises TimeoutExpired, then calls proc.kill() and proc.wait() (no timeout) in the except block. The second wait() must succeed. """ executor = _make_executor(timeout_seconds=5) mock_proc = MagicMock() mock_proc.stdout = io.BytesIO(b"partial output") mock_proc.stderr = io.BytesIO(b"partial error") mock_proc.stdin = None mock_proc.returncode = -9 mock_proc.wait = MagicMock( side_effect=[ subprocess.TimeoutExpired(cmd="test", timeout=5), None, ] ) mock_proc.kill = MagicMock() patcher = patch( "cleveragents.tool.container_executor.subprocess.Popen", return_value=mock_proc, ) patcher.start() context.add_cleanup(patcher.stop) context.executor = executor context.mock_proc = mock_proc @when("I invoke _run_command with the timeout-raising command") def step_invoke_run_command_timeout(context: Any) -> None: context.exec_result = context.executor._run_command(["fake", "command"], timeout=5) @then("the exec result should indicate timed_out true") def step_check_timed_out_true(context: Any) -> None: assert context.exec_result.timed_out is True, ( f"Expected timed_out=True, got {context.exec_result.timed_out}" ) @then('the exec result stderr should mention "timed out"') def step_check_exec_result_stderr_timeout(context: Any) -> None: assert "timed out" in context.exec_result.stderr.lower(), ( f"Expected 'timed out' in stderr: {context.exec_result.stderr!r}" ) @then("the exec result exit_code should be -1") def step_check_exec_result_exit_code_neg1(context: Any) -> None: assert context.exec_result.exit_code == -1, ( f"Expected exit_code=-1, got {context.exec_result.exit_code}" ) @then("the mock process should have been killed") def step_check_process_killed(context: Any) -> None: context.mock_proc.kill.assert_called_once() # ========================================================================= # stdout truncation defense-in-depth (lines 555-562) # ========================================================================= @given("I have a container executor with _read_bounded returning oversized stdout") def step_executor_oversized_stdout(context: Any) -> None: """Mock _read_bounded to return data larger than MAX_OUTPUT_BYTES for stdout.""" executor = _make_executor(timeout_seconds=30) oversized = b"X" * (_MAX_OUTPUT_BYTES + 2048) normal_stderr = b"ok" call_count = {"n": 0} def fake_read_bounded(stream: Any, max_bytes: int) -> bytes: call_count["n"] += 1 if call_count["n"] == 1: return oversized # stdout return normal_stderr # stderr mock_proc = MagicMock() mock_proc.stdout = io.BytesIO(b"placeholder") mock_proc.stderr = io.BytesIO(b"placeholder") mock_proc.stdin = None mock_proc.returncode = 0 mock_proc.wait = MagicMock(return_value=0) popen_patcher = patch( "cleveragents.tool.container_executor.subprocess.Popen", return_value=mock_proc, ) read_patcher = patch( "cleveragents.tool.container_executor._read_bounded", side_effect=fake_read_bounded, ) popen_patcher.start() read_patcher.start() context.add_cleanup(popen_patcher.stop) context.add_cleanup(read_patcher.stop) context.executor = executor @when("I invoke _run_command for the oversized stdout scenario") def step_run_command_oversized_stdout(context: Any) -> None: context.exec_result = context.executor._run_command(["fake", "cmd"], timeout=30) @then("the exec result stdout length in bytes should not exceed MAX_OUTPUT_BYTES") def step_check_stdout_truncated(context: Any) -> None: stdout_bytes = context.exec_result.stdout.encode("utf-8", errors="replace") assert len(stdout_bytes) <= _MAX_OUTPUT_BYTES, ( f"Expected stdout <= {_MAX_OUTPUT_BYTES} bytes, got {len(stdout_bytes)}" ) # ========================================================================= # stderr truncation defense-in-depth (lines 563-570) # ========================================================================= @given("I have a container executor with _read_bounded returning oversized stderr") def step_executor_oversized_stderr(context: Any) -> None: """Mock _read_bounded to return data larger than MAX_OUTPUT_BYTES for stderr.""" executor = _make_executor(timeout_seconds=30) normal_stdout = b"ok" oversized = b"E" * (_MAX_OUTPUT_BYTES + 4096) call_count = {"n": 0} def fake_read_bounded(stream: Any, max_bytes: int) -> bytes: call_count["n"] += 1 if call_count["n"] == 1: return normal_stdout # stdout return oversized # stderr mock_proc = MagicMock() mock_proc.stdout = io.BytesIO(b"placeholder") mock_proc.stderr = io.BytesIO(b"placeholder") mock_proc.stdin = None mock_proc.returncode = 0 mock_proc.wait = MagicMock(return_value=0) popen_patcher = patch( "cleveragents.tool.container_executor.subprocess.Popen", return_value=mock_proc, ) read_patcher = patch( "cleveragents.tool.container_executor._read_bounded", side_effect=fake_read_bounded, ) popen_patcher.start() read_patcher.start() context.add_cleanup(popen_patcher.stop) context.add_cleanup(read_patcher.stop) context.executor = executor @when("I invoke _run_command for the oversized stderr scenario") def step_run_command_oversized_stderr(context: Any) -> None: context.exec_result = context.executor._run_command(["fake", "cmd"], timeout=30) @then("the exec result stderr length in bytes should not exceed MAX_OUTPUT_BYTES") def step_check_stderr_truncated(context: Any) -> None: stderr_bytes = context.exec_result.stderr.encode("utf-8", errors="replace") assert len(stderr_bytes) <= _MAX_OUTPUT_BYTES, ( f"Expected stderr <= {_MAX_OUTPUT_BYTES} bytes, got {len(stderr_bytes)}" ) # ========================================================================= # _read_bounded drain and partial chunk (lines 736-740) # ========================================================================= @given("I have a stream with data exceeding the byte cap") def step_stream_exceeding_cap(context: Any) -> None: """Create a BytesIO stream with substantially more data than the cap.""" context.test_max_bytes = 100 # Provide 300 bytes so that after reading 100, we continue draining context.bounded_stream = io.BytesIO(b"A" * 300) @when("I call _read_bounded with a small max_bytes limit") def step_call_read_bounded_small(context: Any) -> None: context.bounded_result = _read_bounded( context.bounded_stream, context.test_max_bytes ) @then("the returned bytes should be exactly max_bytes long") def step_check_bounded_exact_length(context: Any) -> None: assert len(context.bounded_result) == context.test_max_bytes, ( f"Expected {context.test_max_bytes} bytes, got {len(context.bounded_result)}" ) @then("the stream should have been fully consumed") def step_check_stream_consumed(context: Any) -> None: remaining = context.bounded_stream.read() assert len(remaining) == 0, ( f"Expected stream to be fully consumed, but {len(remaining)} bytes remain" ) @given("I have a stream where a chunk crosses the max_bytes boundary") def step_stream_chunk_crosses_boundary(context: Any) -> None: """Create a stream where a single 64KB chunk would exceed the cap. _read_bounded reads in _READ_CHUNK_SIZE (65536) byte chunks. If max_bytes is less than the chunk size, the first chunk itself crosses the boundary, exercising the partial-slice path. """ context.boundary_max_bytes = 100 # Provide one full chunk worth of data (> 100 bytes) context.boundary_stream = io.BytesIO(b"B" * 70_000) @when("I call _read_bounded with a boundary-crossing limit") def step_call_read_bounded_boundary(context: Any) -> None: context.bounded_result = _read_bounded( context.boundary_stream, context.boundary_max_bytes ) @then("the returned bytes should be exactly the boundary limit") def step_check_boundary_exact(context: Any) -> None: assert len(context.bounded_result) == context.boundary_max_bytes, ( f"Expected {context.boundary_max_bytes} bytes, " f"got {len(context.bounded_result)}" ) # ========================================================================= # _looks_like_path branch coverage for \r and \t (line 759) # ========================================================================= @when("I check whether a string with carriage return looks like a path") def step_looks_like_path_cr(context: Any) -> None: context.path_check_result = _looks_like_path("/some/path\rwith cr") @when("I check whether a string with tab character looks like a path") def step_looks_like_path_tab(context: Any) -> None: context.path_check_result = _looks_like_path("/some/path\twith tab") @when("I check whether a relative string looks like a path") def step_looks_like_path_relative(context: Any) -> None: context.path_check_result = _looks_like_path("relative/path/file.py") @then("the path check result should be false") def step_check_path_result_false(context: Any) -> None: assert context.path_check_result is False, ( "Expected _looks_like_path to return False" ) # ========================================================================= # _parse_output RecursionError / MemoryError fallback (line 712) # ========================================================================= @given("json.loads is patched to raise RecursionError") def step_patch_json_recursion(context: Any) -> None: patcher = patch( "cleveragents.tool.container_executor.json.loads", side_effect=RecursionError("maximum recursion depth exceeded"), ) patcher.start() context.add_cleanup(patcher.stop) @when("I call _parse_output with a non-empty string") def step_call_parse_output_nonempty(context: Any) -> None: context.parse_input_str = " some non-json content " context.parse_result = ContainerToolExecutor._parse_output(context.parse_input_str) @then("the parsed result should contain raw_output with the stripped string") def step_check_raw_output_stripped(context: Any) -> None: assert "raw_output" in context.parse_result, ( f"Expected 'raw_output' key, got {context.parse_result}" ) assert context.parse_result["raw_output"] == context.parse_input_str.strip(), ( f"Expected stripped input, got {context.parse_result['raw_output']!r}" ) @given("json.loads is patched to raise MemoryError") def step_patch_json_memory(context: Any) -> None: patcher = patch( "cleveragents.tool.container_executor.json.loads", side_effect=MemoryError("out of memory"), ) patcher.start() context.add_cleanup(patcher.stop) @when("I call _parse_output with a non-empty JSON-like string") def step_call_parse_output_json_like(context: Any) -> None: context.parse_input_str = ' {"key": "value"} ' context.parse_result = ContainerToolExecutor._parse_output(context.parse_input_str) # ========================================================================= # _run_command OSError at Popen level (lines 582-590) # ========================================================================= @given("I have a container executor with Popen raising OSError") def step_executor_popen_oserror(context: Any) -> None: executor = _make_executor(timeout_seconds=10) patcher = patch( "cleveragents.tool.container_executor.subprocess.Popen", side_effect=OSError("No such file or directory"), ) patcher.start() context.add_cleanup(patcher.stop) context.executor = executor @when("I invoke _run_command for the OSError scenario") def step_run_command_oserror(context: Any) -> None: context.exec_result = context.executor._run_command( ["nonexistent", "command"], timeout=10 ) @then("the exec result should indicate failure with OSError message") def step_check_oserror_result(context: Any) -> None: assert context.exec_result.exit_code == -1, ( f"Expected exit_code=-1, got {context.exec_result.exit_code}" ) assert "No such file" in context.exec_result.stderr, ( f"Expected OSError message in stderr: {context.exec_result.stderr!r}" ) @then("the exec result timed_out should be false") def step_check_oserror_not_timed_out(context: Any) -> None: assert context.exec_result.timed_out is False # ========================================================================= # _map_value_host_to_container with non-path strings (line 624) # ========================================================================= @given("I have a coverage boost container executor with path mapping") def step_coverage_boost_executor_path_mapping(context: Any) -> None: context.cov_executor = _make_executor() @when("I map an input dict containing non-path string values") def step_map_input_non_path_strings(context: Any) -> None: inputs = { "message": "hello world", "flag": "true", "number": 42, "url": "https://example.com/api", "abs_path_outside": "/usr/lib/something", } context.mapped_non_path_inputs = context.cov_executor._map_input_paths(inputs) @then("non-path string values should remain unchanged") def step_check_non_path_inputs_unchanged(context: Any) -> None: m = context.mapped_non_path_inputs assert m["message"] == "hello world" assert m["flag"] == "true" assert m["number"] == 42 assert m["url"] == "https://example.com/api" # Absolute path outside host root should remain unchanged assert m["abs_path_outside"] == "/usr/lib/something" # ========================================================================= # _map_value_container_to_host with non-path strings # ========================================================================= @when("I map an output dict containing non-path string values") def step_map_output_non_path_strings(context: Any) -> None: output = { "message": "result text", "code": "200", "count": 5, "external_path": "/usr/local/bin/tool", } context.mapped_non_path_outputs = context.cov_executor._map_output_paths(output) @then("non-path output string values should remain unchanged") def step_check_non_path_outputs_unchanged(context: Any) -> None: m = context.mapped_non_path_outputs assert m["message"] == "result text" assert m["code"] == "200" assert m["count"] == 5 assert m["external_path"] == "/usr/local/bin/tool" # ========================================================================= # _run_command stdin_data piping (lines 524-528) # ========================================================================= @given("I have a container executor with a Popen mock that accepts stdin") def step_executor_popen_stdin(context: Any) -> None: executor = _make_executor(timeout_seconds=10) mock_stdin = MagicMock() mock_stdin.closed = False mock_proc = MagicMock() mock_proc.stdout = io.BytesIO(b'{"status": "ok"}') mock_proc.stderr = io.BytesIO(b"") mock_proc.stdin = mock_stdin mock_proc.returncode = 0 mock_proc.wait = MagicMock(return_value=0) patcher = patch( "cleveragents.tool.container_executor.subprocess.Popen", return_value=mock_proc, ) patcher.start() context.add_cleanup(patcher.stop) context.executor = executor context.mock_stdin = mock_stdin @when("I invoke _run_command with stdin_data provided") def step_run_command_with_stdin(context: Any) -> None: context.exec_result = context.executor._run_command( ["fake", "cmd"], timeout=10, stdin_data='{"input": "test"}' ) @then("stdin should have been written with the encoded data") def step_check_stdin_written(context: Any) -> None: context.mock_stdin.write.assert_called_once_with(b'{"input": "test"}') @then("stdin should have been closed after writing") def step_check_stdin_closed(context: Any) -> None: context.mock_stdin.close.assert_called() # ========================================================================= # _build_exec_command with low timeout # ========================================================================= @given("I have a coverage boost container executor with devcontainer binary") def step_coverage_boost_executor_with_bin(context: Any) -> None: context.cov_executor = _make_executor(container_id="low-timeout-ctr") @when("I build an exec command with timeout value {timeout:d}") def step_build_exec_low_timeout(context: Any, timeout: int) -> None: cmd, stdin_data = context.cov_executor._build_exec_command( "test_tool", {"key": "val"}, timeout ) context.built_cmd = cmd context.built_stdin = stdin_data @then("the container-side timeout in the command should be 1 or greater") def step_check_container_timeout_minimum(context: Any) -> None: # The command ends with "sh -c timeout N cleveragents-tool-exec ..." # Find the "timeout" argument in the sh -c string sh_c_arg = context.built_cmd[-1] # last element is the sh -c argument # Parse "timeout N cleveragents-tool-exec ..." parts = sh_c_arg.split() timeout_idx = parts.index("timeout") container_timeout = int(parts[timeout_idx + 1]) assert container_timeout >= 1, ( f"Expected container timeout >= 1, got {container_timeout}" ) # ========================================================================= # _run_command stream cleanup in finally block (lines 547-551) # ========================================================================= @given("I have a container executor with a Popen mock with open streams") def step_executor_popen_open_streams(context: Any) -> None: executor = _make_executor(timeout_seconds=10) # Create mock streams that report as not closed and return data # then empty bytes (so _read_bounded terminates its loop) mock_stdout = MagicMock() mock_stdout.closed = False mock_stdout.read = MagicMock(side_effect=[b"output data", b""]) mock_stderr = MagicMock() mock_stderr.closed = False mock_stderr.read = MagicMock(side_effect=[b"error data", b""]) mock_stdin = MagicMock() mock_stdin.closed = False mock_proc = MagicMock() mock_proc.stdout = mock_stdout mock_proc.stderr = mock_stderr mock_proc.stdin = mock_stdin mock_proc.returncode = 0 mock_proc.wait = MagicMock(return_value=0) patcher = patch( "cleveragents.tool.container_executor.subprocess.Popen", return_value=mock_proc, ) patcher.start() context.add_cleanup(patcher.stop) context.executor = executor context.mock_stdout = mock_stdout context.mock_stderr = mock_stderr context.mock_stdin = mock_stdin @when("I invoke _run_command for the stream cleanup scenario") def step_run_command_stream_cleanup(context: Any) -> None: context.exec_result = context.executor._run_command( ["fake", "cmd"], timeout=10, stdin_data="some data" ) @then("all process streams should have been closed") def step_check_all_streams_closed(context: Any) -> None: # The finally block should close all open streams context.mock_stdout.close.assert_called() context.mock_stderr.close.assert_called() context.mock_stdin.close.assert_called()