Skip to content

Commit cc57bb1

Browse files
authored
fix: #2962 normalize sandbox paths and add Windows CI (#2963)
1 parent da82b2c commit cc57bb1

40 files changed

+1370
-511
lines changed

.github/workflows/tests.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,33 @@ jobs:
104104
if: steps.changes.outputs.run != 'true'
105105
run: echo "Skipping tests for non-code changes."
106106

107+
tests-windows:
108+
runs-on: windows-latest
109+
env:
110+
OPENAI_API_KEY: fake-for-tests
111+
steps:
112+
- name: Checkout repository
113+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
114+
- name: Detect code changes
115+
id: changes
116+
shell: bash
117+
run: ./.github/scripts/detect-changes.sh code "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}"
118+
- name: Setup uv
119+
if: steps.changes.outputs.run == 'true'
120+
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57
121+
with:
122+
enable-cache: true
123+
python-version: "3.13"
124+
- name: Install dependencies
125+
if: steps.changes.outputs.run == 'true'
126+
run: uv sync --all-extras --all-packages --group dev
127+
- name: Run tests
128+
if: steps.changes.outputs.run == 'true'
129+
run: uv run pytest
130+
- name: Skip tests
131+
if: steps.changes.outputs.run != 'true'
132+
run: echo "Skipping tests for non-code changes."
133+
107134
build-docs:
108135
runs-on: ubuntu-latest
109136
env:

src/agents/extensions/sandbox/blaxel/mounts.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from ....sandbox.materialization import MaterializedFile
3232
from ....sandbox.session.base_sandbox_session import BaseSandboxSession
3333
from ....sandbox.types import FileMode, Permissions
34+
from ....sandbox.workspace_paths import sandbox_path_str
3435

3536
logger = logging.getLogger(__name__)
3637

@@ -81,7 +82,7 @@ async def activate(
8182
_assert_blaxel_session(session)
8283
_ = base_dir
8384
mount_path = mount._resolve_mount_path(session, dest)
84-
config = _build_mount_config(mount, mount_path=str(mount_path))
85+
config = _build_mount_config(mount, mount_path=mount_path.as_posix())
8586
await _mount_bucket(session, config)
8687
return []
8788

@@ -95,7 +96,7 @@ async def deactivate(
9596
_assert_blaxel_session(session)
9697
_ = base_dir
9798
mount_path = mount._resolve_mount_path(session, dest)
98-
await _unmount_bucket(session, str(mount_path))
99+
await _unmount_bucket(session, mount_path.as_posix())
99100

100101
async def teardown_for_snapshot(
101102
self,
@@ -105,7 +106,7 @@ async def teardown_for_snapshot(
105106
) -> None:
106107
_assert_blaxel_session(session)
107108
_ = mount
108-
await _unmount_bucket(session, str(path))
109+
await _unmount_bucket(session, sandbox_path_str(path))
109110

110111
async def restore_after_snapshot(
111112
self,
@@ -114,7 +115,7 @@ async def restore_after_snapshot(
114115
path: Path,
115116
) -> None:
116117
_assert_blaxel_session(session)
117-
config = _build_mount_config(mount, mount_path=str(path))
118+
config = _build_mount_config(mount, mount_path=sandbox_path_str(path))
118119
await _mount_bucket(session, config)
119120

120121
def build_docker_volume_driver_config(
@@ -606,7 +607,9 @@ def _resolve_config(
606607
message="BlaxelDriveMountStrategy requires a BlaxelDriveMount entry",
607608
context={"mount_type": mount.type},
608609
)
609-
mount_path = mount.drive_mount_path or str(mount._resolve_mount_path(session, dest))
610+
mount_path = mount.drive_mount_path or sandbox_path_str(
611+
mount._resolve_mount_path(session, dest)
612+
)
610613
return BlaxelDriveMountConfig(
611614
drive_name=mount.drive_name,
612615
mount_path=mount_path,
@@ -619,7 +622,7 @@ def _effective_mount_path(mount: Mount, fallback: Path) -> str:
619622
"""Return the actual mount path, preferring ``drive_mount_path`` over the manifest path."""
620623
if isinstance(mount, BlaxelDriveMount) and mount.drive_mount_path:
621624
return mount.drive_mount_path
622-
return str(fallback)
625+
return sandbox_path_str(fallback)
623626

624627
@staticmethod
625628
def _resolve_config_from_source(mount: Mount, mount_path: str) -> BlaxelDriveMountConfig:

src/agents/extensions/sandbox/blaxel/sandbox.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
retry_async,
6666
)
6767
from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes
68+
from ....sandbox.workspace_paths import coerce_posix_path, posix_path_as_path, sandbox_path_str
6869

6970
DEFAULT_BLAXEL_WORKSPACE_ROOT = "/workspace"
7071
logger = logging.getLogger(__name__)
@@ -319,7 +320,7 @@ async def start(self) -> None:
319320
# Ensure workspace root exists before BaseSandboxSession.start() materializes
320321
# the manifest. Blaxel base images run as root and do not ship a pre-created
321322
# workspace directory.
322-
root = self.state.manifest.root
323+
root = sandbox_path_str(self.state.manifest.root)
323324
try:
324325
await self._sandbox.process.exec(
325326
{
@@ -368,7 +369,7 @@ async def mkdir(
368369
if path == Path("/"):
369370
return
370371
try:
371-
await self._sandbox.fs.mkdir(str(path))
372+
await self._sandbox.fs.mkdir(sandbox_path_str(path))
372373
except Exception as e:
373374
raise WorkspaceArchiveWriteError(
374375
path=path,
@@ -377,14 +378,14 @@ async def mkdir(
377378
) from e
378379

379380
async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase:
380-
path = Path(path)
381+
error_path = posix_path_as_path(coerce_posix_path(path))
381382
if user is not None:
382383
workspace_path = await self._check_read_with_exec(path, user=user)
383384
else:
384385
workspace_path = await self._validate_path_access(path)
385386

386387
try:
387-
data: Any = await self._sandbox.fs.read_binary(str(workspace_path))
388+
data: Any = await self._sandbox.fs.read_binary(sandbox_path_str(workspace_path))
388389
if isinstance(data, str):
389390
data = data.encode("utf-8")
390391
return io.BytesIO(bytes(data))
@@ -397,8 +398,8 @@ async def read(self, path: Path | str, *, user: str | User | None = None) -> io.
397398
status = first_arg.get("status")
398399
error_str = str(e).lower()
399400
if status == 404 or "not found" in error_str or "no such file" in error_str:
400-
raise WorkspaceReadNotFoundError(path=path, cause=e) from e
401-
raise WorkspaceArchiveReadError(path=path, cause=e) from e
401+
raise WorkspaceReadNotFoundError(path=error_path, cause=e) from e
402+
raise WorkspaceArchiveReadError(path=error_path, cause=e) from e
402403

403404
async def write(
404405
self,
@@ -407,19 +408,19 @@ async def write(
407408
*,
408409
user: str | User | None = None,
409410
) -> None:
410-
path = Path(path)
411+
error_path = posix_path_as_path(coerce_posix_path(path))
411412
if user is not None:
412413
await self._check_write_with_exec(path, user=user)
413414

414415
payload = data.read()
415416
if isinstance(payload, str):
416417
payload = payload.encode("utf-8")
417418
if not isinstance(payload, bytes | bytearray):
418-
raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
419+
raise WorkspaceWriteTypeError(path=error_path, actual_type=type(payload).__name__)
419420

420421
workspace_path = await self._validate_path_access(path, for_write=True)
421422
try:
422-
await self._sandbox.fs.write_binary(str(workspace_path), bytes(payload))
423+
await self._sandbox.fs.write_binary(sandbox_path_str(workspace_path), bytes(payload))
423424
except Exception as e:
424425
raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
425426

@@ -525,11 +526,11 @@ def _tar_exclude_args(self) -> list[str]:
525526
)
526527
)
527528
async def persist_workspace(self) -> io.IOBase:
528-
root = Path(self.state.manifest.root)
529+
root = self._workspace_root_path()
529530
tar_path = f"/tmp/bl-persist-{self.state.session_id.hex}.tar"
530531
excludes = " ".join(self._tar_exclude_args())
531532
tar_cmd = (
532-
f"tar {excludes} -C {shlex.quote(str(root))} -cf {shlex.quote(tar_path)} ."
533+
f"tar {excludes} -C {shlex.quote(root.as_posix())} -cf {shlex.quote(tar_path)} ."
533534
).strip()
534535

535536
unmounted_mounts: list[tuple[Mount, Path]] = []
@@ -596,7 +597,7 @@ async def persist_workspace(self) -> io.IOBase:
596597
return io.BytesIO(raw)
597598

598599
async def hydrate_workspace(self, data: io.IOBase) -> None:
599-
root = self.state.manifest.root
600+
root = self._workspace_root_path()
600601
tar_path = f"/tmp/bl-hydrate-{self.state.session_id.hex}.tar"
601602
payload = data.read()
602603
if isinstance(payload, str):
@@ -608,7 +609,7 @@ async def hydrate_workspace(self, data: io.IOBase) -> None:
608609
validate_tar_bytes(bytes(payload))
609610
except UnsafeTarMemberError as e:
610611
raise WorkspaceArchiveWriteError(
611-
path=Path(root),
612+
path=root,
612613
context={
613614
"reason": "unsafe_or_invalid_tar",
614615
"member": e.member,
@@ -623,12 +624,12 @@ async def hydrate_workspace(self, data: io.IOBase) -> None:
623624
result = await self._exec_internal(
624625
"sh",
625626
"-c",
626-
f"tar -C {shlex.quote(root)} -xf {shlex.quote(tar_path)}",
627+
f"tar -C {shlex.quote(root.as_posix())} -xf {shlex.quote(tar_path)}",
627628
timeout=self.state.timeouts.workspace_tar_s,
628629
)
629630
if result.exit_code != 0:
630631
raise WorkspaceArchiveWriteError(
631-
path=Path(root),
632+
path=root,
632633
context={
633634
"reason": "tar_extract_failed",
634635
"output": result.stderr.decode("utf-8", errors="replace"),
@@ -637,7 +638,7 @@ async def hydrate_workspace(self, data: io.IOBase) -> None:
637638
except WorkspaceArchiveWriteError:
638639
raise
639640
except Exception as e:
640-
raise WorkspaceArchiveWriteError(path=Path(root), cause=e) from e
641+
raise WorkspaceArchiveWriteError(path=root, cause=e) from e
641642
finally:
642643
try:
643644
await self._exec_internal(

0 commit comments

Comments
 (0)