Skip to content
Closed
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
12 changes: 9 additions & 3 deletions tests/code_executors/container/test_container_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,19 @@ def test_build_image_no_docker_path(self):
class TestContainerClientCleanup:

def test_cleanup_with_container(self):
"""清理后应释放容器引用,使析构期再次清理成为无操作。"""

cc = ContainerClient.__new__(ContainerClient)
cc._container = MagicMock()
container = MagicMock()
cc._container = container

cc._cleanup_container()

cc._container.stop.assert_called_once()
cc._container.remove.assert_called_once()
container.stop.assert_called_once()
container.remove.assert_called_once()
assert cc._container is None
cc._cleanup_container()
container.stop.assert_called_once()

def test_cleanup_without_container(self):
cc = ContainerClient.__new__(ContainerClient)
Expand Down
218 changes: 218 additions & 0 deletions tests/code_executors/container/test_container_ws_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ async def test_create_new_workspace(self):
assert "exec-1" in mgr.ws_paths
cc.exec_run.assert_called_once()

async def test_create_workspace_uses_posix_paths_for_container_commands(self):
"""容器内部路径即使在 Windows 宿主上也必须使用正斜杠。"""

cc = _mock_container_client()
mgr = ContainerWorkspaceManager(cc, RuntimeConfig(), MagicMock())

workspace = await mgr.create_workspace("exec-1")

command = cc.exec_run.call_args.kwargs["cmd"][2]
assert workspace.path.startswith("/tmp/run/ws_exec-1_")
assert "\\" not in command

async def test_create_workspace_idempotent(self):
cc = _mock_container_client()
cfg = RuntimeConfig()
Expand Down Expand Up @@ -403,6 +415,52 @@ async def test_put_files_failure(self):

class TestStageDirectory:

@pytest.mark.parametrize(
"unsafe_destination",
("../../../etc", "/etc", r"C:\Windows"),
)
async def test_rejects_parent_traversal_destination(self, tmp_path, unsafe_destination):
"""验证目录暂存会拒绝逃逸 workspace 的相对目标路径。"""
src = tmp_path / "source"
src.mkdir()
ws = _make_ws()
cc = _mock_container_client()
fs = ContainerWorkspaceFS(cc, RuntimeConfig())

with pytest.raises(ValueError, match="escapes"):
await fs.stage_directory(
ws,
str(src),
unsafe_destination,
WorkspaceStageOptions(),
)

cc.exec_run.assert_not_awaited()

async def test_quotes_mounted_source_and_destination(self, tmp_path):
"""验证目录暂存会安全转义挂载源路径和用户目标路径。"""
skills_root = tmp_path / "skill's root"
source = skills_root / "source"
source.mkdir(parents=True)
ws = _make_ws()
cc = _mock_container_client()
cfg = RuntimeConfig(
skills_host_base=str(skills_root),
skills_container_base="/mounted/skill's root",
)
fs = ContainerWorkspaceFS(cc, cfg)

await fs.stage_directory(
ws,
str(source),
"work/user's target",
WorkspaceStageOptions(allow_mount=True),
)

command = cc.exec_run.await_args.kwargs["cmd"][2]
assert _shell_quote("/mounted/skill's root/source/.") in command
assert _shell_quote("/tmp/run/ws_test_123/work/user's target") in command

async def test_stage_without_mount(self, tmp_path):
src = tmp_path / "src"
src.mkdir()
Expand Down Expand Up @@ -549,6 +607,33 @@ async def test_collect_deduplicates(self):

class TestStageInputs:

@pytest.mark.parametrize(
"unsafe_destination",
("../../../etc", "/etc", r"C:\Windows"),
)
async def test_rejects_parent_traversal_destination(self, unsafe_destination):
"""验证输入暂存会拒绝逃逸 workspace 的相对目标路径。"""
ws = _make_ws()
cc = _mock_container_client()
fs = ContainerWorkspaceFS(cc, RuntimeConfig())
specs = [
WorkspaceInputSpec(
src="workspace://work/source",
dst=unsafe_destination,
mode="copy",
)
]

with pytest.raises(ValueError, match="escapes"):
await fs.stage_inputs(ws, specs)

stage_commands = [
call.kwargs["cmd"][2]
for call in cc.exec_run.await_args_list
if "cp -a" in call.kwargs["cmd"][2]
]
assert stage_commands == []

async def test_stage_host_input(self):
ws = _make_ws()
cc = _mock_container_client()
Expand Down Expand Up @@ -939,6 +1024,19 @@ def test_copy_file_out_api_exception(self):

class TestPutBytesTar:

async def test_put_bytes_tar_quotes_parent_path(self):
"""验证 tar 暂存命令会安全转义包含单引号的父目录。"""
cc = _mock_container_client()
cc.client.api.put_archive.return_value = True
fs = ContainerWorkspaceFS(cc, RuntimeConfig())
destination = "/container/user's inputs/data.txt"

await fs._put_bytes_tar(b"hello", destination)

command = cc.exec_run.await_args.kwargs["cmd"][2]
assert _shell_quote("/container/user's inputs") in command
cc.client.api.put_archive.assert_called_once()

async def test_put_bytes_tar_success(self):
cc = _mock_container_client()
cc.client.api.put_archive.return_value = True
Expand Down Expand Up @@ -1051,6 +1149,60 @@ async def test_put_directory_no_dst(self, tmp_path):

class TestStageHostInput:

@pytest.mark.parametrize(
("mode", "operation"),
(("copy", "cp -a"), ("link", "ln -sfn")),
)
async def test_quotes_command_paths(self, mode, operation):
"""验证 host 输入命令会安全转义容器源路径和目标路径。"""
ws = _make_ws()
cc = _mock_container_client()
cfg = RuntimeConfig(
inputs_host_base="/host/inputs",
inputs_container_base="/container/user's inputs",
)
fs = ContainerWorkspaceFS(cc, cfg)
container_source = "/container/user's inputs/data"
container_target = "/tmp/run/workspace/work/user's target"

await fs._stage_host_input(
ws,
"/host/inputs/data",
container_target,
mode,
"work/user's target",
)

command = cc.exec_run.await_args.kwargs["cmd"][2]
assert f"{operation} {_shell_quote(container_source)}" in command
assert _shell_quote(container_target) in command

@pytest.mark.parametrize(
("mode", "operation"),
(("copy", "cp -a"), ("link", "ln -sfn")),
)
async def test_normalizes_command_destination(self, mode, operation):
"""验证 host 输入命令会在执行边界归一化容器目标路径。"""
ws = _make_ws()
cc = _mock_container_client()
cfg = RuntimeConfig(
inputs_host_base="/host/inputs",
inputs_container_base="/container/inputs",
)
fs = ContainerWorkspaceFS(cc, cfg)

await fs._stage_host_input(
ws,
"/host/inputs/data",
r"\tmp\run\workspace\work\target",
mode,
"work/target",
)

command = cc.exec_run.await_args.kwargs["cmd"][2]
assert "\\" not in command
assert f"{operation} '/container/inputs/data' '/tmp/run/workspace/work/target'" in command

async def test_with_inputs_host_base_copy(self):
ws = _make_ws()
cc = _mock_container_client()
Expand Down Expand Up @@ -1093,6 +1245,41 @@ async def test_fallback_to_tar_copy(self, tmp_path):

class TestStageWorkspaceInput:

@pytest.mark.parametrize(
("mode", "operation"),
(("copy", "cp -a"), ("link", "ln -sfn")),
)
async def test_quotes_command_paths(self, mode, operation):
"""验证 workspace 输入命令会安全转义容器源路径和目标路径。"""
cc = _mock_container_client()
fs = ContainerWorkspaceFS(cc, RuntimeConfig())
source = "/tmp/run/workspace/user's source"
target = "/tmp/run/workspace/work/user's target"

await fs._stage_workspace_input(source, target, mode)

command = cc.exec_run.await_args.kwargs["cmd"][2]
assert f"{operation} {_shell_quote(source)} {_shell_quote(target)}" in command

@pytest.mark.parametrize(
("mode", "operation"),
(("copy", "cp -a"), ("link", "ln -sfn")),
)
async def test_normalizes_command_paths(self, mode, operation):
"""验证 workspace 输入命令会在执行边界归一化源路径和目标路径。"""
cc = _mock_container_client()
fs = ContainerWorkspaceFS(cc, RuntimeConfig())

await fs._stage_workspace_input(
r"\tmp\run\workspace\source",
r"\tmp\run\workspace\work\target",
mode,
)

command = cc.exec_run.await_args.kwargs["cmd"][2]
assert "\\" not in command
assert f"{operation} '/tmp/run/workspace/source' '/tmp/run/workspace/work/target'" in command

async def test_copy_mode(self):
cc = _mock_container_client()
cfg = RuntimeConfig()
Expand Down Expand Up @@ -1152,6 +1339,22 @@ def test_string_with_special_chars(self):

class TestRunProgram:

@pytest.mark.parametrize(
"unsafe_cwd",
("../../etc", "/etc", r"C:\Windows"),
)
async def test_run_rejects_parent_traversal_cwd(self, unsafe_cwd):
"""验证程序执行会拒绝逃逸 workspace 的相对工作目录。"""
cc = _mock_container_client()
runner = ContainerProgramRunner(cc, RuntimeConfig())
ws = _make_ws()
spec = WorkspaceRunProgramSpec(cmd="ls", cwd=unsafe_cwd)

with pytest.raises(ValueError, match="escapes"):
await runner.run_program(ws, spec)

cc.exec_run.assert_not_awaited()

async def test_basic_run(self):
cc = _mock_container_client()
cc.exec_run = AsyncMock(return_value=CommandExecResult(
Expand Down Expand Up @@ -1180,6 +1383,21 @@ async def test_run_with_cwd(self):
result = await runner.run_program(ws, spec)
assert result.exit_code == 0

async def test_run_normalizes_container_cwd(self):
"""验证外部传入的工作目录会在生成容器命令前转换为 POSIX 路径。"""
cc = _mock_container_client()
cc.exec_run = AsyncMock(return_value=CommandExecResult(
stdout="", stderr="", exit_code=0, is_timeout=False))
runner = ContainerProgramRunner(cc, RuntimeConfig())
ws = _make_ws()

spec = WorkspaceRunProgramSpec(cmd="ls", cwd=r"work\subdir")
await runner.run_program(ws, spec)

command = cc.exec_run.await_args.kwargs["cmd"][2]
assert "\\" not in command
assert "cd '/tmp/run/ws_test_123/work/subdir'" in command

async def test_run_with_custom_env(self):
cc = _mock_container_client()
cc.exec_run = AsyncMock(return_value=CommandExecResult(
Expand Down
28 changes: 28 additions & 0 deletions tests/code_executors/local/test_local_ws_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import os
import shutil
import stat
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock
Expand Down Expand Up @@ -144,6 +145,33 @@ async def test_cleanup_existing_workspace(self):
assert not Path(ws.path).exists()
assert "exec-cleanup" not in self.manager.ws_paths

@pytest.mark.asyncio
async def test_cleanup_removes_read_only_staged_file(self):
"""本地 runtime 必须在 finally 清理只读 staged Skill 文件。"""

ws = await self.manager.create_workspace("exec-read-only")
staged_file = Path(ws.path) / "skills" / "code-review" / "SKILL.md"
staged_file.parent.mkdir(parents=True)
staged_file.write_text("readonly", encoding="utf-8")
staged_file.chmod(stat.S_IREAD)

await self.manager.cleanup("exec-read-only")

assert not Path(ws.path).exists()

def test_remove_read_only_path_reraises_non_permission_error(self):
"""验证只读清理回调不会用 chmod 异常掩盖非权限类原始错误。"""
original_error = FileNotFoundError("path disappeared")

with pytest.raises(FileNotFoundError) as caught:
self.manager._remove_read_only_path(
os.unlink,
str(Path(self.tmpdir) / "missing"),
(FileNotFoundError, original_error, None),
)

assert caught.value is original_error

@pytest.mark.asyncio
async def test_cleanup_nonexistent_workspace(self):
await self.manager.cleanup("nonexistent")
Expand Down
2 changes: 1 addition & 1 deletion trpc_agent_sdk/code_executors/container/_container_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ def _cleanup_container(self):
except Exception: # pylint: disable=broad-except
pass
logger.info("Container %s stopped and removed.", self._container.id)
# self._container = None
self._container = None

def _exec_run_with_stdin(
self,
Expand Down
Loading
Loading