Files
EvoScientist/tests/test_backends.py
T
X-iZhang c5a4d559a2 Refactor test cases for improved readability and consistency
- Added blank lines for better separation of test cases in multiple test files.
- Reformatted event handling in tests for clarity and consistency.
- Ensured consistent use of multi-line formatting for dictionary arguments in event handling.
- Improved assertions and test descriptions for better understanding.
- Updated test cases across various modules including test_stream_state, test_stream_utils, test_summarization, test_thread_selector, test_tool_error_handler, test_tui_widgets, test_ui_runtime, and test_wechat_channel.
2026-03-15 21:12:51 +00:00

418 lines
15 KiB
Python

"""Tests for EvoScientist/backends.py — validate_command, path conversion, resolve_path."""
import re
from pathlib import Path
from EvoScientist.backends import (
validate_command,
convert_virtual_paths_in_command,
CustomSandboxBackend,
)
# === validate_command ===
class TestValidateCommand:
def test_safe_ls(self):
assert validate_command("ls -la") is None
def test_safe_python(self):
assert validate_command("python script.py") is None
def test_safe_pip(self):
assert validate_command("pip install pandas") is None
def test_blocked_traversal(self):
result = validate_command("cat ../../../etc/passwd")
assert result is not None
assert "blocked" in result.lower()
def test_blocked_sudo(self):
result = validate_command("sudo rm -rf /")
assert result is not None
assert "blocked" in result.lower()
def test_blocked_chmod(self):
result = validate_command("chmod 777 file.py")
assert result is not None
def test_blocked_dd(self):
result = validate_command("dd if=/dev/zero of=file bs=1M count=100")
assert result is not None
def test_blocked_home_tilde(self):
result = validate_command("cat ~/secrets.txt")
assert result is not None
def test_blocked_rm_rf_absolute(self):
result = validate_command("rm -rf /important")
assert result is not None
def test_blocked_cd_absolute(self):
result = validate_command("cd /etc && cat passwd")
assert result is not None
def test_safe_echo(self):
assert validate_command("echo hello world") is None
def test_safe_grep(self):
assert validate_command("grep -r 'pattern' .") is None
# === convert_virtual_paths_in_command ===
class TestConvertVirtualPaths:
def test_absolute_to_relative(self):
result = convert_virtual_paths_in_command("python /main.py")
assert result == "python ./main.py"
def test_nested_path(self):
result = convert_virtual_paths_in_command("cat /data/file.txt")
assert result == "cat ./data/file.txt"
def test_root_only(self):
result = convert_virtual_paths_in_command("ls /")
assert result == "ls ."
def test_no_change_relative(self):
result = convert_virtual_paths_in_command("python main.py")
assert result == "python main.py"
def test_url_preserved(self):
result = convert_virtual_paths_in_command("curl https://example.com/path")
# URLs should not be converted
assert "https://example.com/path" in result
def test_no_op_no_paths(self):
result = convert_virtual_paths_in_command("echo hello")
assert result == "echo hello"
def test_system_path_with_workspace_converted(self):
"""Hallucinated system path containing workspace dir should be fixed."""
result = convert_virtual_paths_in_command(
"mkdir -p /Users/user/project/workspace/swarm-discussion",
workspace_name="workspace",
)
assert result == "mkdir -p ./swarm-discussion"
def test_system_path_with_workspace_nested(self):
result = convert_virtual_paths_in_command(
"python /home/user/workspace/src/main.py",
workspace_name="workspace",
)
assert result == "python ./src/main.py"
def test_system_path_workspace_only(self):
result = convert_virtual_paths_in_command(
"ls /Users/user/Downloads/project/workspace",
workspace_name="workspace",
)
assert result == "ls ."
def test_system_path_with_shell_expansion(self):
"""Paths with $(whoami) or similar should still be caught."""
result = convert_virtual_paths_in_command(
"mkdir -p /Users/$(whoami)/workspace/notes",
workspace_name="workspace",
)
assert result == "mkdir -p ./notes"
def test_system_path_custom_workspace_name(self):
"""Should work with any workspace directory name, not just 'workspace'."""
result = convert_virtual_paths_in_command(
"mkdir -p /Users/user/my-project/data",
workspace_name="my-project",
)
assert result == "mkdir -p ./data"
def test_system_path_custom_workspace_name_only(self):
result = convert_virtual_paths_in_command(
"ls /home/user/experiment-1",
workspace_name="experiment-1",
)
assert result == "ls ."
def test_system_path_no_workspace_name_fallthrough(self):
"""Without workspace_name, system paths get normal ./ treatment."""
result = convert_virtual_paths_in_command(
"cat /Users/user/workspace/file.txt",
workspace_name=None,
)
assert result == "cat ./Users/user/workspace/file.txt"
def test_system_path_without_workspace_unchanged(self):
"""System paths not referencing workspace fall through to normal ./"""
result = convert_virtual_paths_in_command(
"cat /tmp/somefile",
workspace_name="workspace",
)
assert result == "cat ./tmp/somefile"
# === CustomSandboxBackend._resolve_path ===
class TestResolvePath:
def test_strip_workspace_prefix(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
# /workspace/main.py should resolve to root/main.py
resolved = backend._resolve_path("/workspace/main.py")
assert str(resolved).endswith("main.py")
assert "workspace/workspace" not in str(resolved)
def test_workspace_root(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
resolved = backend._resolve_path("/workspace")
# Should resolve to root dir
assert resolved == backend._resolve_path("/")
def test_system_path_with_workspace_marker(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
resolved = backend._resolve_path("/Users/someone/project/workspace/main.py")
assert str(resolved).endswith("main.py")
def test_system_path_without_workspace(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
resolved = backend._resolve_path("/Users/someone/file.py")
# Falls back to basename
assert str(resolved).endswith("file.py")
def test_custom_workspace_name_prefix_stripped(self, tmp_path):
"""_resolve_path uses the actual dir name, not hardcoded 'workspace'."""
ws = tmp_path / "my-project"
ws.mkdir()
backend = CustomSandboxBackend(root_dir=str(ws), virtual_mode=True)
resolved = backend._resolve_path("/my-project/main.py")
assert str(resolved).endswith("main.py")
assert "my-project/my-project" not in str(resolved)
def test_custom_workspace_name_system_path(self, tmp_path):
ws = tmp_path / "experiment-1"
ws.mkdir()
backend = CustomSandboxBackend(root_dir=str(ws), virtual_mode=True)
resolved = backend._resolve_path("/Users/someone/experiment-1/data/out.csv")
assert str(resolved).endswith("data/out.csv")
def test_normal_virtual_path(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
resolved = backend._resolve_path("/src/main.py")
assert str(resolved).endswith("src/main.py")
# === CustomSandboxBackend.id ===
class TestSandboxId:
def test_sandbox_has_id(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
assert isinstance(backend.id, str)
assert backend.id.startswith("evosci-")
assert len(backend.id) == len("evosci-") + 8
def test_sandbox_id_is_stable(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
assert backend.id == backend.id # same instance → same id
def test_sandbox_id_unique(self, tmp_workspace):
b1 = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
b2 = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
assert b1.id != b2.id
def test_sandbox_id_hex_suffix(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
suffix = backend.id[len("evosci-") :]
assert re.fullmatch(r"[0-9a-f]{8}", suffix)
# === execute() literal cwd sanitization ===
class TestExecuteCwdSanitization:
def test_literal_workspace_path_replaced(self, tmp_workspace):
"""execute() should replace literal workspace root path with ./"""
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
# Create a subdir via the sanitized path
resp = backend.execute(f"mkdir -p {tmp_workspace}/test-sanitized && echo ok")
assert resp.exit_code == 0
# The dir should be created at workspace/test-sanitized, not nested
assert (Path(tmp_workspace) / "test-sanitized").is_dir()
assert not (Path(tmp_workspace) / tmp_workspace.lstrip("/")).exists()
# === execute() output truncation ===
class TestExecuteTruncation:
def test_execute_truncates_large_output(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace,
virtual_mode=True,
max_output_bytes=100,
)
# Generate output larger than 100 bytes
resp = backend.execute("python3 -c \"print('A' * 200)\"")
assert resp.truncated is True
assert "... Output truncated at 100 bytes" in resp.output
# Output body (before truncation message) should be ≤ 100 bytes
before_marker = resp.output.split("\n\n... Output truncated")[0]
assert len(before_marker) <= 100
def test_execute_no_truncation_small_output(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace,
virtual_mode=True,
max_output_bytes=100_000,
)
resp = backend.execute("echo hello")
assert resp.truncated is False
assert "truncated" not in resp.output.lower()
# === execute() stderr attribution ===
class TestExecuteStderr:
def test_execute_stderr_attribution(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace,
virtual_mode=True,
)
resp = backend.execute(
"python3 -c \"import sys; sys.stderr.write('warning\\n')\""
)
assert "[stderr] warning" in resp.output
def test_execute_nonzero_exit_code_in_output(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace,
virtual_mode=True,
)
resp = backend.execute('python3 -c "raise SystemExit(42)"')
assert resp.exit_code == 42
assert "Exit code: 42" in resp.output
def test_execute_mixed_stdout_stderr(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace,
virtual_mode=True,
)
resp = backend.execute(
"python3 -c \"import sys; print('out'); sys.stderr.write('err\\n')\""
)
assert "out" in resp.output
assert "[stderr] err" in resp.output
def test_execute_success_no_exit_code(self, tmp_workspace):
backend = CustomSandboxBackend(
root_dir=tmp_workspace,
virtual_mode=True,
)
resp = backend.execute("echo ok")
assert resp.exit_code == 0
assert "Exit code:" not in resp.output
# === execute() timeout kwarg ===
class TestExecuteTimeout:
def test_execute_accepts_timeout_kwarg(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
resp = backend.execute("echo hello", timeout=60)
assert resp.exit_code == 0
assert "hello" in resp.output
def test_execute_timeout_none_uses_default(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, virtual_mode=True)
resp = backend.execute("echo ok", timeout=None)
assert resp.exit_code == 0
def test_execute_accepts_timeout_introspection(self):
from deepagents.backends.protocol import execute_accepts_timeout
execute_accepts_timeout.cache_clear()
assert execute_accepts_timeout(CustomSandboxBackend) is True
# === '..' traversal false-positive fix ===
class TestTraversalFalsePositiveFix:
def test_dotdot_in_filename_allowed(self):
assert validate_command("echo foo..bar.txt") is None
def test_dotdot_path_component_still_blocked(self):
result = validate_command("cat ../secret")
assert result is not None
assert "blocked" in result.lower()
def test_dotdot_nested_still_blocked(self):
result = validate_command("cat foo/../../etc/passwd")
assert result is not None
# === Pipeline command validation ===
class TestPipelineCommandValidation:
def test_pipe_blocked_command(self):
"""sudo after pipe should be caught."""
result = validate_command("echo hi | sudo tee /etc/passwd")
assert result is not None
assert "sudo" in result
def test_chained_blocked_command(self):
"""chmod after && should be caught."""
result = validate_command("echo ok && chmod 777 file")
assert result is not None
assert "chmod" in result
def test_semicolon_blocked_command(self):
"""dd after ; should be caught."""
result = validate_command("echo start ; dd if=/dev/zero of=disk")
assert result is not None
assert "dd" in result
def test_safe_pipe_allowed(self):
"""Normal pipes should be fine."""
assert validate_command("cat file.txt | grep pattern") is None
def test_safe_chain_allowed(self):
"""Normal && chains should be fine."""
assert validate_command("mkdir build && cd build") is None
def test_quoted_pipe_not_split(self):
"""Pipe inside quotes is not a shell operator."""
assert validate_command("echo 'hello | world'") is None
# === execute() timeout recovery guidance ===
class TestExecuteTimeoutRecovery:
def test_timeout_includes_recovery_guidance(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, timeout=1)
resp = backend.execute("sleep 10")
assert resp.exit_code == 124
assert "Recovery" in resp.output
assert "background" in resp.output.lower()
def test_timeout_includes_background_command(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, timeout=1)
resp = backend.execute("sleep 10")
assert "sleep 10" in resp.output
assert "> /output.log 2>&1 &" in resp.output
def test_timeout_preserves_original_error(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace, timeout=1)
resp = backend.execute("sleep 10")
assert "timed out" in resp.output.lower()
def test_non_timeout_not_enhanced(self, tmp_workspace):
backend = CustomSandboxBackend(root_dir=tmp_workspace)
resp = backend.execute("python3 -c 'raise SystemExit(1)'")
assert resp.exit_code == 1
assert "Recovery" not in resp.output