c5a4d559a2
- 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.
418 lines
15 KiB
Python
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
|