diff --git a/tests/code_executors/container/test_container_cli.py b/tests/code_executors/container/test_container_cli.py index 19c30aff9..ea5c337d8 100644 --- a/tests/code_executors/container/test_container_cli.py +++ b/tests/code_executors/container/test_container_cli.py @@ -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) diff --git a/tests/code_executors/container/test_container_ws_runtime.py b/tests/code_executors/container/test_container_ws_runtime.py index cfef20c2b..cc0e991c0 100644 --- a/tests/code_executors/container/test_container_ws_runtime.py +++ b/tests/code_executors/container/test_container_ws_runtime.py @@ -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() @@ -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() @@ -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() @@ -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 @@ -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() @@ -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() @@ -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( @@ -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( diff --git a/tests/code_executors/local/test_local_ws_runtime.py b/tests/code_executors/local/test_local_ws_runtime.py index 657194a49..c31958e13 100644 --- a/tests/code_executors/local/test_local_ws_runtime.py +++ b/tests/code_executors/local/test_local_ws_runtime.py @@ -6,6 +6,7 @@ import os import shutil +import stat import tempfile from pathlib import Path from unittest.mock import AsyncMock @@ -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") diff --git a/trpc_agent_sdk/code_executors/container/_container_cli.py b/trpc_agent_sdk/code_executors/container/_container_cli.py index 39447ad6e..8737f7e10 100644 --- a/trpc_agent_sdk/code_executors/container/_container_cli.py +++ b/trpc_agent_sdk/code_executors/container/_container_cli.py @@ -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, diff --git a/trpc_agent_sdk/code_executors/container/_container_ws_runtime.py b/trpc_agent_sdk/code_executors/container/_container_ws_runtime.py index 29a16b74a..023958dd4 100644 --- a/trpc_agent_sdk/code_executors/container/_container_ws_runtime.py +++ b/trpc_agent_sdk/code_executors/container/_container_ws_runtime.py @@ -17,12 +17,15 @@ import io import json import os +import posixpath import tarfile import time from dataclasses import dataclass from dataclasses import field from datetime import datetime from pathlib import Path +from pathlib import PurePosixPath +from pathlib import PureWindowsPath from typing import Any from typing import Dict from typing import List @@ -94,6 +97,38 @@ def _shell_quote(s: str) -> str: return "'" + s.replace("'", "'\\''") + "'" +def _container_path(*parts: str) -> str: + """按 POSIX 规则拼接容器内部路径,避免 Windows 宿主生成反斜杠。""" + + return str(PurePosixPath(*(str(part).replace("\\", "/") for part in parts))) + + +def _container_parent(path: str) -> str: + """返回容器内部路径的 POSIX 父目录。""" + + return str(PurePosixPath(path.replace("\\", "/")).parent) + + +def _container_relative_path(path: Optional[str], *, allow_current: bool = False) -> str: + """归一化容器相对路径,并拒绝绝对路径或 workspace 逃逸。""" + + raw_path = "" if path is None else str(path).strip() + if not raw_path: + if allow_current: + return "" + raise ValueError("container relative path must not be empty") + normalized = posixpath.normpath(raw_path.replace("\\", "/")) + if normalized in ("", "."): + if allow_current: + return "" + raise ValueError("container relative path must not be empty") + if posixpath.isabs(normalized) or PureWindowsPath(raw_path).is_absolute(): + raise ValueError(f"container relative path escapes its root: {path}") + if normalized == ".." or normalized.startswith("../"): + raise ValueError(f"container relative path escapes its root: {path}") + return normalized + + class ContainerWorkspaceManager(BaseWorkspaceManager): """ Docker container-based workspace manager implementation. @@ -133,17 +168,17 @@ async def create_workspace(self, exec_id: str, ctx: Optional[InvocationContext] safe_id = self._sanitize(exec_id) suffix = time.time_ns() - ws_path = str(Path(self.config.run_container_base) / f"ws_{safe_id}_{suffix}") + ws_path = _container_path(self.config.run_container_base, f"ws_{safe_id}_{suffix}") # Create standard directory layout cmd_str = ("set -e; " f"mkdir -p {_shell_quote(ws_path)} " - f"{_shell_quote(str(Path(ws_path) / DIR_SKILLS))} " - f"{_shell_quote(str(Path(ws_path) / DIR_WORK))} " - f"{_shell_quote(str(Path(ws_path) / DIR_RUNS))} " - f"{_shell_quote(str(Path(ws_path) / DIR_OUT))}; " - f"[ -f {_shell_quote(str(Path(ws_path) / META_FILE_NAME))} ] || " - f"echo '{{}}' > {_shell_quote(str(Path(ws_path) / META_FILE_NAME))}") + f"{_shell_quote(_container_path(ws_path, DIR_SKILLS))} " + f"{_shell_quote(_container_path(ws_path, DIR_WORK))} " + f"{_shell_quote(_container_path(ws_path, DIR_RUNS))} " + f"{_shell_quote(_container_path(ws_path, DIR_OUT))}; " + f"[ -f {_shell_quote(_container_path(ws_path, META_FILE_NAME))} ] || " + f"echo '{{}}' > {_shell_quote(_container_path(ws_path, META_FILE_NAME))}") cmd = ["/bin/bash", "-lc", cmd_str] result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) @@ -157,7 +192,7 @@ async def create_workspace(self, exec_id: str, ctx: Optional[InvocationContext] logger.info("Auto-mapping inputs for workspace %s", exec_id) specs = [ WorkspaceInputSpec(src=f"host://{self.config.inputs_host_base}", - dst=str(Path("work") / "inputs"), + dst=_container_path("work", "inputs"), mode="link") ] await self.fs.stage_inputs(ws, specs, ctx) @@ -270,15 +305,18 @@ async def stage_directory(self, RuntimeError: If staging fails """ src_abs_path = os.path.abspath(src) - container_dst = str(Path(ws.path) / dst) if dst else ws.path + dst_rel = _container_relative_path(dst, allow_current=True) + container_dst = _container_path(ws.path, dst_rel) if dst_rel else _container_path(ws.path) # Fast path: within skills mount if opt.allow_mount and self.config.skills_host_base: rel_path = get_rel_path(self.config.skills_host_base, src_abs_path) if rel_path: - container_src = str(Path(self.config.skills_container_base) / rel_path) - cmd_str = f"mkdir -p '{container_dst}' && cp -a '{container_src}/.' '{container_dst}'" + container_src = _container_path(self.config.skills_container_base, rel_path) + container_contents = f"{container_src.rstrip('/')}/." + cmd_str = (f"mkdir -p {_shell_quote(container_dst)} && " + f"cp -a {_shell_quote(container_contents)} {_shell_quote(container_dst)}") if opt.read_only: - cmd_str += f" && chmod -R a-w '{container_dst}'" + cmd_str += f" && chmod -R a-w {_shell_quote(container_dst)}" cmd = ["/bin/bash", "-lc", cmd_str] result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) @@ -290,7 +328,7 @@ async def stage_directory(self, await self._put_directory(ws, src_abs_path, dst) if opt.read_only: - cmd = ["/bin/bash", "-lc", f"chmod -R a-w '{container_dst}'"] + cmd = ["/bin/bash", "-lc", f"chmod -R a-w {_shell_quote(container_dst)}"] result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) if result.exit_code != 0: raise RuntimeError(f"Failed to chmod directory: {result.stderr}") @@ -343,8 +381,9 @@ async def stage_inputs(self, md = await self._load_workspace_metadata(ws) for spec in specs: mode = (spec.mode or "").lower().strip() or "copy" - dst_rel = (spec.dst or "").strip() or str(Path(DIR_WORK) / "inputs" / self._input_base(spec.src)) - dst_abs = str(Path(ws.path) / dst_rel) + dst_rel = (spec.dst or "").strip() or _container_path(DIR_WORK, "inputs", self._input_base(spec.src)) + dst_rel = _container_relative_path(dst_rel) + dst_abs = _container_path(ws.path, dst_rel) resolved = "" version: Optional[int] = None @@ -366,13 +405,13 @@ async def stage_inputs(self, await self._stage_host_input(ws, host_path, dst_abs, mode, dst_rel) resolved = host_path elif spec.src.startswith("workspace://"): - rel = spec.src.removeprefix("workspace://") - src = str(Path(ws.path) / rel) + rel = _container_relative_path(spec.src.removeprefix("workspace://")) + src = _container_path(ws.path, rel) await self._stage_workspace_input(src, dst_abs, mode) resolved = rel elif spec.src.startswith("skill://"): - rest = spec.src.removeprefix("skill://") - src = str(Path(ws.path) / DIR_SKILLS / rest) + rest = _container_relative_path(spec.src.removeprefix("skill://")) + src = _container_path(ws.path, DIR_SKILLS, rest) await self._stage_workspace_input(src, dst_abs, mode) resolved = src else: @@ -495,19 +534,27 @@ async def _put_directory(self, ws: WorkspaceInfo, src: str, dst: str) -> None: if not src or not str(src).strip(): raise ValueError("source path is empty") abs_src = os.path.abspath(src) - container_dst = str(Path(ws.path) / dst) if dst else ws.path + dst_rel = _container_relative_path(dst, allow_current=True) + container_dst = _container_path(ws.path, dst_rel) if dst_rel else _container_path(ws.path) if self.config.skills_host_base: rel_path = get_rel_path(self.config.skills_host_base, abs_src) if rel_path: - container_src = str(Path(self.config.skills_container_base) / rel_path) + container_src = _container_path(self.config.skills_container_base, rel_path) + container_contents = f"{container_src.rstrip('/')}/." # Create destination directory - cmd = ["/bin/bash", "-lc", f"mkdir -p '{container_dst}' && cp -a '{container_src}/.' '{container_dst}'"] + cmd = [ + "/bin/bash", + "-lc", + (f"mkdir -p {_shell_quote(container_dst)} && " + f"cp -a {_shell_quote(container_contents)} {_shell_quote(container_dst)}"), + ] result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) if result.exit_code == 0: return None logger.debug("Failed to stage directory via mount copy, fallback to tar: %s", result.stderr) - cmd = ["/bin/bash", "-lc", f"[ -e '{container_dst}' ] || mkdir -p '{container_dst}'"] + quoted_dst = _shell_quote(container_dst) + cmd = ["/bin/bash", "-lc", f"[ -e {quoted_dst} ] || mkdir -p {quoted_dst}"] result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) if result.exit_code: raise RuntimeError(f"Failed to stage directory: {result.stderr}") @@ -524,8 +571,9 @@ async def _put_directory(self, ws: WorkspaceInfo, src: str, dst: str) -> None: async def _put_bytes_tar(self, data: bytes, dest: str, mode: int = 0o644) -> None: """Copy bytes to container using tar.""" + container_dest = _container_path(dest) # Create a tar with single file named as dest's base - base = Path(dest).name + base = PurePosixPath(container_dest).name tar_buffer = io.BytesIO() with tarfile.open(fileobj=tar_buffer, mode='w') as tar: tarinfo = tarfile.TarInfo(name=base) @@ -538,30 +586,33 @@ async def _put_bytes_tar(self, data: bytes, dest: str, mode: int = 0o644) -> Non # Ensure parent directory exists. Parent can be a symlink (for example # work/inputs in container mode when auto_inputs is enabled), so avoid # running plain `mkdir -p ` which may return "File exists". - parent = Path(dest).parent - cmd = ["/bin/bash", "-lc", f"[ -e '{parent.as_posix()}' ] || mkdir -p '{parent.as_posix()}'"] + parent = _container_parent(container_dest) + quoted_parent = _shell_quote(parent) + cmd = ["/bin/bash", "-lc", f"[ -e {quoted_parent} ] || mkdir -p {quoted_parent}"] result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) if result.exit_code: raise RuntimeError(f"Failed to stage directory: {result.stderr}") - success = self.container.client.api.put_archive(self.container.container.id, parent.as_posix(), tar_buffer) + success = self.container.client.api.put_archive(self.container.container.id, parent, tar_buffer) if not success: raise RuntimeError(f"Failed to copy bytes to {dest}") async def _stage_host_input(self, ws: WorkspaceInfo, host: str, dst: str, mode: str, dst_rel: str) -> None: - """Stage input from host path.""" + """将宿主输入暂存到容器,并在命令边界归一化目标路径。""" + container_dst = _container_path(dst) if self.config.inputs_host_base: rel_path = get_rel_path(self.config.inputs_host_base, host) if rel_path: - container_src = str(Path(self.config.inputs_container_base) / rel_path) + container_src = _container_path(self.config.inputs_container_base, rel_path) + parent = _container_parent(container_dst) if mode == "link": - cmd_str = (f"parent='{Path(dst).parent}'; " + cmd_str = (f"parent={_shell_quote(parent)}; " f"[ -e \"$parent\" ] || mkdir -p \"$parent\"; " - f"ln -sfn '{container_src}' '{dst}'") + f"ln -sfn {_shell_quote(container_src)} {_shell_quote(container_dst)}") else: - cmd_str = (f"parent='{Path(dst).parent}'; " + cmd_str = (f"parent={_shell_quote(parent)}; " f"[ -e \"$parent\" ] || mkdir -p \"$parent\"; " - f"cp -a '{container_src}' '{dst}'") + f"cp -a {_shell_quote(container_src)} {_shell_quote(container_dst)}") cmd = ["/bin/bash", "-lc", cmd_str] result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) @@ -569,17 +620,20 @@ async def _stage_host_input(self, ws: WorkspaceInfo, host: str, dst: str, mode: raise RuntimeError(f"Failed to stage host input: {result.stderr}") return # Fallback to tar copy - await self._put_directory(ws, host, str(Path(dst_rel).parent)) + await self._put_directory(ws, host, _container_parent(dst_rel)) async def _stage_workspace_input(self, src: str, dst: str, mode: str) -> None: - """Stage input from workspace path.""" - parent = Path(dst).parent + """暂存 workspace 输入,并在命令边界归一化源路径和目标路径。""" + container_src = _container_path(src) + container_dst = _container_path(dst) + parent = _container_parent(container_dst) + quoted_parent = _shell_quote(parent) if mode == "link": - cmd_str = (f"[ -e '{parent}' ] || mkdir -p '{parent}'; " - f"ln -sfn '{src}' '{dst}'") + cmd_str = (f"[ -e {quoted_parent} ] || mkdir -p {quoted_parent}; " + f"ln -sfn {_shell_quote(container_src)} {_shell_quote(container_dst)}") else: - cmd_str = (f"[ -e '{parent}' ] || mkdir -p '{parent}'; " - f"cp -a '{src}' '{dst}'") + cmd_str = (f"[ -e {quoted_parent} ] || mkdir -p {quoted_parent}; " + f"cp -a {_shell_quote(container_src)} {_shell_quote(container_dst)}") cmd = ["/bin/bash", "-lc", cmd_str] # await _exec_cmd(self.container, cmd, self.config.command_args) @@ -678,7 +732,7 @@ def _pinned_artifact_version(md: Any, artifact_name: str, dst: str) -> Optional[ async def _load_workspace_metadata(self, ws: WorkspaceInfo): now = datetime.now() - cmd = ["/bin/bash", "-lc", f"cat {_shell_quote(str(Path(ws.path) / META_FILE_NAME))}"] + cmd = ["/bin/bash", "-lc", f"cat {_shell_quote(_container_path(ws.path, META_FILE_NAME))}"] result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) if result.exit_code != 0 or not result.stdout.strip(): return WorkspaceMetadata(version=1, created_at=now, updated_at=now, last_access=now, skills={}) @@ -707,7 +761,7 @@ async def _save_workspace_metadata(self, ws: WorkspaceInfo, md: Any) -> None: if md.skills is None: md.skills = {} payload = json.dumps(md.model_dump(exclude_none=True, by_alias=True, mode="json"), ensure_ascii=False, indent=2) - await self._put_bytes_tar(payload.encode("utf-8"), str(Path(ws.path) / META_FILE_NAME), mode=0o600) + await self._put_bytes_tar(payload.encode("utf-8"), _container_path(ws.path, META_FILE_NAME), mode=0o600) @staticmethod def _detect_mime_type(data: bytes) -> str: @@ -768,7 +822,8 @@ async def run_program(self, RuntimeError: If execution fails """ spec = self._apply_provider_env(spec, ctx) - cwd = f"{ws.path}/{spec.cwd}" if spec.cwd else ws.path + cwd_rel = _container_relative_path(spec.cwd, allow_current=True) + cwd = _container_path(ws.path, cwd_rel) if cwd_rel else _container_path(ws.path) # Prepare directories skills_dir = f"{ws.path}/{DIR_SKILLS}" diff --git a/trpc_agent_sdk/code_executors/local/_local_ws_runtime.py b/trpc_agent_sdk/code_executors/local/_local_ws_runtime.py index 466e6a01d..ff5bdf5d4 100644 --- a/trpc_agent_sdk/code_executors/local/_local_ws_runtime.py +++ b/trpc_agent_sdk/code_executors/local/_local_ws_runtime.py @@ -15,6 +15,7 @@ import os import re import shutil +import stat import sys import tempfile import time @@ -142,9 +143,20 @@ async def cleanup(self, exec_id: str, ctx: Optional[InvocationContext] = None) - path = Path(ws.path) if path.exists(): - shutil.rmtree(path) + shutil.rmtree(path, onerror=self._remove_read_only_path) self.ws_paths.pop(exec_id, None) + @staticmethod + def _remove_read_only_path(function, path: str, exception_info) -> None: + """仅对权限错误恢复写权限,其他清理异常保留原始失败语义。""" + + exception = exception_info[1] + if not isinstance(exception, PermissionError): + raise exception.with_traceback(exception_info[2]) + current_mode = os.stat(path, follow_symlinks=False).st_mode + os.chmod(path, current_mode | stat.S_IWRITE) + function(path) + class LocalWorkspaceFS(BaseWorkspaceFS): """Local workspace file system for executing commands in skill workspaces."""