From 4796104a0520ab88ef4d32686577feb7117640ac Mon Sep 17 00:00:00 2001 From: jiaoliao Date: Fri, 24 Jul 2026 14:33:55 +0800 Subject: [PATCH 1/4] add windows fs --- rock/rocklet/windows.py | 10 +- rock/sdk/sandbox/client.py | 4 +- rock/sdk/sandbox/file_system.py | 196 ++++++++++++++++++++- tests/unit/rocklet/test_windows.py | 45 +++++ tests/unit/sdk/sandbox/test_file_system.py | 149 +++++++++++++++- 5 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 tests/unit/rocklet/test_windows.py diff --git a/rock/rocklet/windows.py b/rock/rocklet/windows.py index b02d905fd9..1e92e6a2cc 100644 --- a/rock/rocklet/windows.py +++ b/rock/rocklet/windows.py @@ -55,6 +55,7 @@ class PowerShellSession(Session): _BEGIN_MARKER = "ROCKLET_PS_BEGIN_29234" _END_MARKER = "ROCKLET_PS_END_29234" _EXIT_MARKER = "ROCKLET_PS_EXIT_29234:" + _PROMPT_MARKER = "ROCKLET_PS_PROMPT_29234" def __init__(self, request: CreateBashSessionRequest): self.request = request @@ -127,10 +128,16 @@ async def start(self) -> CreateBashSessionResponse: # PowerShell caches its [Console].Out TextWriter at process init — late # OutputEncoding mutations don't affect already-cached writers used by # built-in formatters (Get-ChildItem, Format-Table, etc.). + # + # ConsoleHost normally writes both its prompt and every stdin command to + # stdout. Read redirected stdin ourselves to suppress the command echo, + # and use a private prompt marker that _run_command removes from output. ps_setup = ( "chcp 65001 > $null; " "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; " - "$OutputEncoding = [System.Text.Encoding]::UTF8" + "$OutputEncoding = [System.Text.Encoding]::UTF8; " + f"function global:prompt {{ '{self._PROMPT_MARKER}' }}; " + "function global:PSConsoleHostReadLine { [Console]::In.ReadLine() }" ) self._process = subprocess.Popen( [ps_cmd, "-NoLogo", "-NoProfile", "-NoExit", "-Command", ps_setup], @@ -227,6 +234,7 @@ def _run_command(self, action: BashAction) -> BashObservation: raise SessionNotInitializedError("PowerShell process terminated unexpectedly") continue + line = line.replace(self._PROMPT_MARKER, "") if self._BEGIN_MARKER in line: started = True continue diff --git a/rock/sdk/sandbox/client.py b/rock/sdk/sandbox/client.py index 3cc74d6722..494259d3a4 100644 --- a/rock/sdk/sandbox/client.py +++ b/rock/sdk/sandbox/client.py @@ -49,7 +49,7 @@ from rock.sdk.sandbox.agent.rock_agent import RockAgent from rock.sdk.sandbox.config import SandboxConfig, SandboxGroupConfig from rock.sdk.sandbox.deploy import Deploy -from rock.sdk.sandbox.file_system import FileSystem, LinuxFileSystem +from rock.sdk.sandbox.file_system import FileSystem, LinuxFileSystem, WindowsFileSystem from rock.sdk.sandbox.model_service.base import ModelService from rock.sdk.sandbox.network import Network from rock.sdk.sandbox.oss_client import OssClient @@ -95,7 +95,7 @@ def __init__(self, config: SandboxConfig): self.remote_user = LinuxRemoteUser(self) self.process = Process(self) self.network = Network(self) - self.fs = LinuxFileSystem(self) + self.fs = WindowsFileSystem(self) if self.config.image_os.casefold() == "windows" else LinuxFileSystem(self) self.runtime_envs = {} self.deploy = Deploy(self) self.agent = RockAgent(self) diff --git a/rock/sdk/sandbox/file_system.py b/rock/sdk/sandbox/file_system.py index 58a0720d84..0f56a897ae 100644 --- a/rock/sdk/sandbox/file_system.py +++ b/rock/sdk/sandbox/file_system.py @@ -1,20 +1,28 @@ +import base64 import shlex +import shutil import tarfile import tempfile import time from abc import ABC, abstractmethod -from pathlib import Path +from pathlib import Path, PureWindowsPath -from rock.actions import CreateBashSessionRequest, Observation +from rock.actions import BashAction, CreateBashSessionRequest, Observation from rock.actions.sandbox.base import AbstractSandbox -from rock.actions.sandbox.request import ChmodRequest, ChownRequest, Command +from rock.actions.sandbox.request import ChmodRequest, ChownRequest, Command, UploadMode from rock.actions.sandbox.response import ChmodResponse, ChownResponse, CommandResponse, DownloadFileResponse from rock.logger import init_logger +from rock.sdk.common.exceptions import BadRequestRockError from rock.sdk.sandbox.constants import ENSURE_OSSUTIL_SCRIPT logger = init_logger(__name__) +def _powershell_literal(value: str) -> str: + """Quote a value for use as a literal in a PowerShell command.""" + return "'" + value.replace("'", "''") + "'" + + class FileSystem(ABC): sandbox: AbstractSandbox = None @@ -216,3 +224,185 @@ async def ensure_ossutil(self) -> bool: logger.warning(f"ossutil verify failed: {verify.stderr}") return False return True + + +class WindowsFileSystem(FileSystem): + """File system operations for Windows sandboxes.""" + + async def chown(self, request: ChownRequest) -> ChownResponse: + paths = request.paths + if paths is None or len(paths) == 0: + raise BadRequestRockError("paths is empty") + + responses: list[str] = [] + for path in paths: + command = ["icacls", path, "/setowner", request.remote_user] + if request.recursive: + command.append("/T") + logger.info(f"chown command: {command}") + + response: CommandResponse = await self.sandbox.execute(Command(command=command)) + responses.append(str(response)) + if response.exit_code != 0: + return ChownResponse(success=False, message="\n".join(responses)) + return ChownResponse(success=True, message="\n".join(responses)) + + async def chmod(self, request: ChmodRequest) -> ChmodResponse: + paths = request.paths + if paths is None or len(paths) == 0: + raise BadRequestRockError("paths is empty") + + try: + mode = int(request.mode, 8) + if mode < 0 or mode > 0o7777: + raise ValueError + except ValueError: + return ChmodResponse(success=False, message=f"Windows chmod requires an octal mode: {request.mode}") + + attribute = "-R" if mode & 0o222 else "+R" + responses: list[str] = [] + for path in paths: + if request.recursive: + target = _powershell_literal(path) + command = ( + "$ErrorActionPreference = 'Stop'; " + f"$target = {target}; " + f"& attrib.exe {attribute} $target; " + 'if ($LASTEXITCODE -ne 0) { throw "attrib failed with exit code $LASTEXITCODE" }; ' + "if (Test-Path -LiteralPath $target -PathType Container) { " + f"& attrib.exe {attribute} (Join-Path -Path $target -ChildPath '*') /S /D; " + 'if ($LASTEXITCODE -ne 0) { throw "attrib failed with exit code $LASTEXITCODE" } ' + "}" + ) + logger.info(f"chmod command: {command}") + try: + response = await self.sandbox.arun(cmd=command) + except Exception as e: + responses.append(str(e)) + return ChmodResponse(success=False, message="\n".join(responses)) + else: + command = ["attrib", attribute, path] + logger.info(f"chmod command: {command}") + response = await self.sandbox.execute(Command(command=command)) + + responses.append(str(response)) + if response.exit_code != 0: + return ChmodResponse(success=False, message="\n".join(responses)) + return ChmodResponse(success=True, message="\n".join(responses)) + + async def upload_dir( + self, + source_dir: str | Path, + target_dir: str, + extract_timeout: int = 600, + ) -> Observation: + """Upload a local directory as a ZIP archive and extract it with PowerShell.""" + local_zip_path: Path | None = None + remote_zip_path: str | None = None + session: str | None = None + + try: + src = Path(source_dir).expanduser().resolve() + if not src.exists(): + return Observation(exit_code=1, failure_reason=f"source_dir not found: {src}") + if not src.is_dir(): + return Observation(exit_code=1, failure_reason=f"source_dir must be a directory: {src}") + if not isinstance(target_dir, str) or not PureWindowsPath(target_dir).is_absolute(): + return Observation( + exit_code=1, + failure_reason=f"target_dir must be absolute Windows path: {target_dir}", + ) + + ts = str(time.time_ns()) + local_zip_path = Path(tempfile.gettempdir()) / f"rock_upload_{ts}.zip" + session = f"powershell-{ts}" + + await self.sandbox.create_session(CreateBashSessionRequest(session=session)) + + check = await self.sandbox.arun( + cmd=( + "if (-not (Get-Command Expand-Archive -ErrorAction SilentlyContinue)) " + "{ throw 'Expand-Archive is not available' }; " + "[System.IO.Path]::GetTempPath()" + ), + session=session, + ) + if check.exit_code != 0: + return Observation(exit_code=1, failure_reason="sandbox has no Expand-Archive command") + remote_temp_dir = check.output.strip() + if not PureWindowsPath(remote_temp_dir).is_absolute(): + return Observation(exit_code=1, failure_reason=f"invalid sandbox temporary path: {remote_temp_dir}") + remote_zip_path = str(PureWindowsPath(remote_temp_dir) / f"rock_upload_{ts}.zip") + + shutil.make_archive(str(local_zip_path.with_suffix("")), "zip", root_dir=src) + upload_response = await self.sandbox.upload_by_path( + file_path=str(local_zip_path), + target_path=remote_zip_path, + upload_mode=UploadMode.DIRECT, + ) + if not upload_response.success: + return Observation(exit_code=1, failure_reason=f"zip upload failed: {upload_response.message}") + + archive = _powershell_literal(remote_zip_path) + target = _powershell_literal(target_dir) + extract = await self.sandbox.run_in_session( + BashAction( + command=( + "$ErrorActionPreference = 'Stop'; " + f"if (Test-Path -LiteralPath {target}) " + f"{{ Remove-Item -LiteralPath {target} -Recurse -Force }}; " + f"New-Item -ItemType Directory -Path {target} -Force | Out-Null; " + f"Expand-Archive -LiteralPath {archive} -DestinationPath {target} -Force" + ), + session=session, + timeout=extract_timeout, + ), + ) + if extract.exit_code != 0: + return Observation(exit_code=1, failure_reason=f"zip extract failed: {extract.output}") + + return Observation(exit_code=0, output=f"uploaded {src} -> {target_dir} via zip") + except Exception as e: + return Observation(exit_code=1, failure_reason=f"upload_dir unexpected error: {e}") + finally: + if remote_zip_path and session: + try: + await self.sandbox.arun( + cmd=( + f"Remove-Item -LiteralPath {_powershell_literal(remote_zip_path)} " + "-Force -ErrorAction SilentlyContinue" + ), + session=session, + ) + except Exception: + pass + if local_zip_path: + try: + local_zip_path.unlink(missing_ok=True) + except Exception: + pass + + async def download_file( + self, + remote_path: str, + local_path: str | Path, + ) -> DownloadFileResponse: + """Download a Windows sandbox file through binary-safe Base64 output.""" + try: + remote = _powershell_literal(remote_path) + response = await self.sandbox.arun( + cmd=f"[Convert]::ToBase64String([System.IO.File]::ReadAllBytes({remote}))" + ) + if response.exit_code != 0: + return DownloadFileResponse( + success=False, + message=f"Failed to read remote file: {response.output}", + ) + + content = base64.b64decode("".join(response.output.split()), validate=True) + local = Path(local_path).expanduser().resolve() + local.parent.mkdir(parents=True, exist_ok=True) + local.write_bytes(content) + return DownloadFileResponse(success=True, message=f"Successfully downloaded {remote_path} to {local}") + except Exception as e: + return DownloadFileResponse(success=False, message=f"Failed to download {remote_path}: {e}") diff --git a/tests/unit/rocklet/test_windows.py b/tests/unit/rocklet/test_windows.py new file mode 100644 index 0000000000..22cfdf6e26 --- /dev/null +++ b/tests/unit/rocklet/test_windows.py @@ -0,0 +1,45 @@ +import io +from unittest.mock import MagicMock + +from rock.admin.proto.request import SandboxBashAction, SandboxCreateBashSessionRequest +from rock.rocklet.windows import PowerShellSession + + +async def test_powershell_session_configures_non_echoing_input(monkeypatch): + process = MagicMock() + process.stdin = io.StringIO() + process.stdout = io.StringIO() + popen = MagicMock(return_value=process) + monkeypatch.setattr("rock.rocklet.windows.subprocess.Popen", popen) + monkeypatch.setattr("rock.rocklet.windows.time.sleep", lambda _: None) + monkeypatch.setattr(PowerShellSession, "_find_powershell", staticmethod(lambda: "powershell")) + monkeypatch.setattr(PowerShellSession, "_drain_queue", lambda self, timeout=0.1: "") + + session = PowerShellSession(SandboxCreateBashSessionRequest(session="test", sandbox_id="sandbox")) + await session.start() + + command = popen.call_args.args[0] + setup = command[command.index("-Command") + 1] + assert "function global:PSConsoleHostReadLine { [Console]::In.ReadLine() }" in setup + assert PowerShellSession._PROMPT_MARKER in setup + + +def test_powershell_session_removes_internal_prompt_from_output(monkeypatch): + session = PowerShellSession(SandboxCreateBashSessionRequest(session="test", sandbox_id="sandbox")) + process = MagicMock() + process.stdin = io.StringIO() + session._process = process + monkeypatch.setattr(session, "_drain_queue", lambda timeout=0.1: "") + + prompt = "ROCKLET_PS_PROMPT_29234" + session._output_queue.put(f"{prompt}{session._BEGIN_MARKER}\n") + session._output_queue.put(f"{prompt}ROCK_REAL_OUTPUT\n") + session._output_queue.put(f"{prompt}{session._EXIT_MARKER}0\n") + session._output_queue.put(f"{prompt}{session._END_MARKER}\n") + + result = session._run_command( + SandboxBashAction(command="Write-Output 'ROCK_REAL_OUTPUT'", session="test", sandbox_id="sandbox") + ) + + assert result.output == "ROCK_REAL_OUTPUT" + assert result.exit_code == 0 diff --git a/tests/unit/sdk/sandbox/test_file_system.py b/tests/unit/sdk/sandbox/test_file_system.py index 59421a4f82..81f4b6837e 100644 --- a/tests/unit/sdk/sandbox/test_file_system.py +++ b/tests/unit/sdk/sandbox/test_file_system.py @@ -1,10 +1,20 @@ -"""Tests for LinuxFileSystem OSS methods.""" +"""Tests for platform-specific FileSystem implementations.""" +import base64 +import tempfile +import time +import zipfile from unittest.mock import AsyncMock, MagicMock +import pytest + +from rock.actions import BashAction, Observation +from rock.actions.sandbox.request import ChmodRequest, ChownRequest, Command, UploadMode from rock.actions.sandbox.response import DownloadFileResponse +from rock.sdk.common.exceptions import BadRequestRockError from rock.sdk.sandbox.client import Sandbox -from rock.sdk.sandbox.file_system import LinuxFileSystem +from rock.sdk.sandbox.config import SandboxConfig +from rock.sdk.sandbox.file_system import LinuxFileSystem, WindowsFileSystem def _sandbox(exit_code=0): @@ -33,6 +43,18 @@ def _sandbox_with_oss(oss_setup_returns: bool = True, ossutil_ok: bool = True, d return sb +def _windows_sandbox(*, exit_code: int = 0, arun_output: str = ""): + sb = MagicMock() + sb.execute = AsyncMock( + return_value=MagicMock(exit_code=exit_code, stdout="", stderr="", __str__=lambda _: "command response") + ) + sb.create_session = AsyncMock() + sb.arun = AsyncMock(return_value=Observation(exit_code=0, output=arun_output)) + sb.run_in_session = AsyncMock(return_value=Observation(exit_code=0)) + sb.upload_by_path = AsyncMock(return_value=MagicMock(success=True, message="ok")) + return sb + + class TestEnsureOssutil: async def test_success(self): assert await LinuxFileSystem(_sandbox()).ensure_ossutil() is True @@ -71,3 +93,126 @@ async def test_returns_failure_when_ossutil_install_fails(self, tmp_path): assert resp.success is False assert "ossutil" in resp.message sb._oss.download_via_oss.assert_not_awaited() + + +class TestWindowsPermissions: + async def test_chown(self): + sb = _windows_sandbox() + response = await WindowsFileSystem(sb).chown( + ChownRequest(remote_user="rock", paths=[r"C:\work"], recursive=True) + ) + assert response.success is True + sb.execute.assert_awaited_once_with(Command(command=["icacls", r"C:\work", "/setowner", "rock", "/T"])) + + failed_response = await WindowsFileSystem(_windows_sandbox(exit_code=5)).chown( + ChownRequest(remote_user="rock", paths=[r"C:\work"], recursive=False) + ) + assert failed_response.success is False + assert "command response" in failed_response.message + + with pytest.raises(BadRequestRockError, match="paths is empty"): + await WindowsFileSystem(_windows_sandbox()).chown(ChownRequest(remote_user="rock", paths=[])) + + async def test_chmod(self): + sb = _windows_sandbox() + response = await WindowsFileSystem(sb).chmod(ChmodRequest(paths=[r"C:\work\a.txt"], mode="444")) + assert response.success is True + sb.execute.assert_awaited_once_with(Command(command=["attrib", "+R", r"C:\work\a.txt"])) + + recursive_sb = _windows_sandbox() + recursive_response = await WindowsFileSystem(recursive_sb).chmod( + ChmodRequest(paths=[r"C:\work"], mode="755", recursive=True) + ) + assert recursive_response.success is True + recursive_command = recursive_sb.arun.await_args.kwargs["cmd"] + assert "$target = 'C:\\work'" in recursive_command + assert "& attrib.exe -R $target" in recursive_command + assert "Test-Path -LiteralPath $target -PathType Container" in recursive_command + assert "& attrib.exe -R (Join-Path -Path $target -ChildPath '*') /S /D" in recursive_command + recursive_sb.execute.assert_not_awaited() + + failed_sb = _windows_sandbox() + failed_sb.arun.side_effect = RuntimeError("attrib failed") + failed_response = await WindowsFileSystem(failed_sb).chmod( + ChmodRequest(paths=[r"C:\work"], mode="755", recursive=True) + ) + assert failed_response.success is False + assert "attrib failed" in failed_response.message + + invalid_response = await WindowsFileSystem(_windows_sandbox()).chmod( + ChmodRequest(paths=[r"C:\work"], mode="u+x") + ) + assert invalid_response.success is False + assert "octal mode" in invalid_response.message + + with pytest.raises(BadRequestRockError, match="paths is empty"): + await WindowsFileSystem(_windows_sandbox()).chmod(ChmodRequest(paths=[], mode="755")) + + +class TestWindowsTransfers: + async def test_upload_dir(self, tmp_path, monkeypatch): + source = tmp_path / "source" + source.mkdir() + (source / "hello.txt").write_text("hello") + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + monkeypatch.setattr(time, "time_ns", lambda: 123) + uploaded_names = [] + uploaded_mode = None + sb = _windows_sandbox() + sb.arun.side_effect = [ + Observation(exit_code=0, output="C:\\SandboxTemp\\"), + Observation(exit_code=0), + ] + + async def capture_upload(file_path, target_path, upload_mode=None): + nonlocal uploaded_mode + with zipfile.ZipFile(file_path) as archive: + uploaded_names.extend(archive.namelist()) + assert target_path == r"C:\SandboxTemp\rock_upload_123.zip" + uploaded_mode = upload_mode + return MagicMock(success=True, message="ok") + + sb.upload_by_path.side_effect = capture_upload + + response = await WindowsFileSystem(sb).upload_dir( + source, + "C:\\target'; Write-Output pwned; '", + extract_timeout=37, + ) + + assert response.exit_code == 0 + assert uploaded_names == ["hello.txt"] + assert uploaded_mode == UploadMode.DIRECT + extract_action = sb.run_in_session.await_args.args[0] + assert isinstance(extract_action, BashAction) + assert "Expand-Archive" in extract_action.command + assert extract_action.timeout == 37 + assert "'C:\\target''; Write-Output pwned; '''" in extract_action.command + assert sb.arun.await_count == 2 + assert not (tmp_path / "rock_upload_123.zip").exists() + + invalid_response = await WindowsFileSystem(_windows_sandbox()).upload_dir(source, "relative\\target") + assert invalid_response.exit_code == 1 + assert "absolute Windows path" in invalid_response.failure_reason + + async def test_download_file(self, tmp_path): + encoded = base64.b64encode(b"\x00rock").decode() + sb = _windows_sandbox(arun_output=encoded) + target = tmp_path / "nested" / "download.bin" + response = await WindowsFileSystem(sb).download_file(r"C:\remote.bin", target) + assert response.success is True + assert target.read_bytes() == b"\x00rock" + assert "[System.IO.File]::ReadAllBytes('C:\\remote.bin')" in sb.arun.await_args.kwargs["cmd"] + + invalid_target = tmp_path / "invalid.bin" + invalid_response = await WindowsFileSystem(_windows_sandbox(arun_output="not base64!")).download_file( + r"C:\remote.bin", + invalid_target, + ) + assert invalid_response.success is False + assert not invalid_target.exists() + + +def test_sandbox_selects_file_system_by_image_os(): + assert isinstance(Sandbox(SandboxConfig(image_os="WiNdOwS")).fs, WindowsFileSystem) + assert isinstance(Sandbox(SandboxConfig()).fs, LinuxFileSystem) From e71572b1afb4aadec852cffabff39efc44169caf Mon Sep 17 00:00:00 2001 From: jiaoliao Date: Fri, 24 Jul 2026 16:05:16 +0800 Subject: [PATCH 2/4] update version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 118901da41..e4bb3c54c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" authors = [{ name = "chatos@alibaba" }] requires-python = "<4.0,>=3.10" name = "rl-rock" -version = "1.10.2" +version = "1.11.0-rc.1" description = "ROCK-Reinforcement Open Construction Kit" readme = "README.md" dependencies = [ From f274a6b2b59995c12ae3c25050b49a0747f3e300 Mon Sep 17 00:00:00 2001 From: jiaoliao Date: Fri, 24 Jul 2026 16:29:02 +0800 Subject: [PATCH 3/4] update version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e4bb3c54c1..3e10deec78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" authors = [{ name = "chatos@alibaba" }] requires-python = "<4.0,>=3.10" name = "rl-rock" -version = "1.11.0-rc.1" +version = "1.11.0" description = "ROCK-Reinforcement Open Construction Kit" readme = "README.md" dependencies = [ From 970a76e0c11ea9b38035f3591b72504382fea6af Mon Sep 17 00:00:00 2001 From: jiaoliao Date: Mon, 27 Jul 2026 14:21:40 +0800 Subject: [PATCH 4/4] docs: clarify upload_dir replace semantics --- .../Python SDK References/file_system.md | 4 ++++ .../Python SDK References/file_system.md | 5 +++++ docs/versions.json | 1 + rock/sdk/sandbox/file_system.py | 14 ++++++++++++-- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/version-1.11.x/References/Python SDK References/file_system.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/version-1.11.x/References/Python SDK References/file_system.md index 741b14f5e4..ba51805d1b 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/version-1.11.x/References/Python SDK References/file_system.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/version-1.11.x/References/Python SDK References/file_system.md @@ -71,6 +71,10 @@ await sandbox.fs.chmod( ## upload_dir - 上传目录 +`upload_dir` 使用的是**替换语义(replace)**,而不是合并语义。如果 +`target_dir` 已存在,上传内容解压前会删除整个目标目录及其中的所有内容。 +`source_dir` 中不存在的原有文件不会被保留。 + ```python import os from pathlib import Path diff --git a/docs/versioned_docs/version-1.11.x/References/Python SDK References/file_system.md b/docs/versioned_docs/version-1.11.x/References/Python SDK References/file_system.md index a64228e8f1..213d65507c 100644 --- a/docs/versioned_docs/version-1.11.x/References/Python SDK References/file_system.md +++ b/docs/versioned_docs/version-1.11.x/References/Python SDK References/file_system.md @@ -71,6 +71,11 @@ await sandbox.fs.chmod( ## upload_dir - Upload Directory +`upload_dir` uses **replace semantics**, not merge semantics. If `target_dir` +already exists, the entire target directory and all of its contents are removed +before the uploaded directory is extracted. Existing files that are not present +in `source_dir` are not preserved. + ```python import os from pathlib import Path diff --git a/docs/versions.json b/docs/versions.json index 11b83c85a5..63ea151b4a 100644 --- a/docs/versions.json +++ b/docs/versions.json @@ -1,4 +1,5 @@ [ + "1.11.x", "1.10.x", "1.9.x", "1.8.x", diff --git a/rock/sdk/sandbox/file_system.py b/rock/sdk/sandbox/file_system.py index 0f56a897ae..e6df26a5cd 100644 --- a/rock/sdk/sandbox/file_system.py +++ b/rock/sdk/sandbox/file_system.py @@ -44,6 +44,12 @@ async def upload_dir( target_dir: str, extract_timeout: int = 600, ) -> Observation: + """Upload a directory using replace semantics. + + If ``target_dir`` already exists, it and all of its contents are + removed before ``source_dir`` is extracted. Existing contents are not + merged with the uploaded directory. + """ pass @abstractmethod @@ -103,7 +109,7 @@ async def upload_dir( - Check 'tar' exists; if not, return Observation with exit_code != 0 - Pack source_dir fully into a tar.gz locally - Upload to sandbox /tmp - - Extract into target_dir + - Replace target_dir and extract into the newly created directory - Always cleanup local tar.gz Returns: @@ -296,7 +302,11 @@ async def upload_dir( target_dir: str, extract_timeout: int = 600, ) -> Observation: - """Upload a local directory as a ZIP archive and extract it with PowerShell.""" + """Upload a directory as ZIP and replace the target using PowerShell. + + If ``target_dir`` already exists, it and all of its contents are + removed before extraction. Existing contents are not merged. + """ local_zip_path: Path | None = None remote_zip_path: str | None = None session: str | None = None