Skip to content

Commit 12b5471

Browse files
authored
fix: prepare Daytona workspace root before start (#2956)
1 parent cc57bb1 commit 12b5471

2 files changed

Lines changed: 100 additions & 0 deletions

File tree

src/agents/extensions/sandbox/daytona/sandbox.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
WorkspaceArchiveReadError,
3636
WorkspaceArchiveWriteError,
3737
WorkspaceReadNotFoundError,
38+
WorkspaceStartError,
3839
WorkspaceWriteTypeError,
3940
)
4041
from ....sandbox.manifest import Manifest
@@ -362,6 +363,33 @@ async def _validate_path_access(self, path: Path | str, *, for_write: bool = Fal
362363
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
363364
return (RESOLVE_WORKSPACE_PATH_HELPER,)
364365

366+
async def _prepare_workspace_root(self) -> None:
367+
"""Create the workspace root before SDK exec calls use it as cwd."""
368+
root = Path(self.state.manifest.root)
369+
try:
370+
envs = await self._resolved_envs()
371+
result = await self._sandbox.process.exec(
372+
f"mkdir -p -- {shlex.quote(str(root))}",
373+
env=envs or None,
374+
timeout=self.state.timeouts.fast_op_s,
375+
)
376+
except Exception as e:
377+
raise WorkspaceStartError(path=root, cause=e) from e
378+
379+
exit_code = int(getattr(result, "exit_code", 0) or 0)
380+
if exit_code != 0:
381+
raise WorkspaceStartError(
382+
path=root,
383+
context={
384+
"reason": "workspace_root_nonzero_exit",
385+
"exit_code": exit_code,
386+
"output": str(getattr(result, "result", "") or ""),
387+
},
388+
)
389+
390+
async def _prepare_backend_workspace(self) -> None:
391+
await self._prepare_workspace_root()
392+
365393
async def mkdir(
366394
self,
367395
path: Path | str,

tests/extensions/test_sandbox_daytona.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,14 @@ def __init__(self) -> None:
117117
self._pty_handles: dict[str, _FakePtyHandle] = {}
118118
self.create_pty_session_error: BaseException | None = None
119119
self.symlinks: dict[str, str] = {}
120+
self.workspace_roots: set[str] = set()
121+
self.require_workspace_root_for_cd = False
120122

121123
async def exec(self, cmd: str, **kwargs: object) -> _FakeExecResult:
122124
self.exec_calls.append((cmd, dict(kwargs)))
125+
parts = shlex.split(cmd)
126+
if len(parts) >= 4 and parts[:3] == ["mkdir", "-p", "--"]:
127+
self.workspace_roots.add(parts[3])
123128
if "sleep 0.5" in cmd:
124129
await asyncio.sleep(0.5)
125130
result = self.next_result
@@ -150,6 +155,21 @@ async def execute_session_command(
150155
) -> object:
151156
self.execute_session_command_calls.append((session_id, request, dict(kwargs)))
152157
command = cast(str, getattr(request, "command", ""))
158+
parts = shlex.split(command)
159+
if (
160+
self.require_workspace_root_for_cd
161+
and len(parts) >= 3
162+
and parts[0] == "cd"
163+
and parts[2] == "&&"
164+
and parts[1] not in self.workspace_roots
165+
):
166+
return types.SimpleNamespace(
167+
cmd_id="cmd-123",
168+
exit_code=1,
169+
stdout="",
170+
stderr=f"cd: no such file or directory: {parts[1]}",
171+
output=f"cd: no such file or directory: {parts[1]}",
172+
)
153173
resolved = resolve_fake_workspace_path(
154174
command,
155175
symlinks=self.symlinks,
@@ -511,6 +531,58 @@ async def test_create_uses_daytona_safe_default_workspace_root(
511531

512532
assert session.state.manifest.root == daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT
513533

534+
@pytest.mark.asyncio
535+
async def test_start_prepares_workspace_root_before_runtime_helpers(
536+
self,
537+
monkeypatch: pytest.MonkeyPatch,
538+
) -> None:
539+
"""Verify Daytona creates the root before exec uses it as cwd."""
540+
541+
daytona_module = _load_daytona_module(monkeypatch)
542+
543+
async with daytona_module.DaytonaSandboxClient() as client:
544+
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
545+
sandbox = _FakeAsyncDaytona.current_sandbox
546+
assert sandbox is not None
547+
sandbox.process.require_workspace_root_for_cd = True
548+
549+
await session.start()
550+
551+
root = daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT
552+
assert root in sandbox.process.workspace_roots
553+
assert sandbox.process.exec_calls[0][0] == f"mkdir -p -- {root}"
554+
assert sandbox.process.execute_session_command_calls
555+
_session_id, request, _kwargs = sandbox.process.execute_session_command_calls[0]
556+
assert cast(str, cast(Any, request).command).startswith(f"cd {root} && ")
557+
assert session.state.workspace_root_ready is True
558+
559+
@pytest.mark.asyncio
560+
async def test_start_wraps_workspace_root_prepare_failure(
561+
self,
562+
monkeypatch: pytest.MonkeyPatch,
563+
) -> None:
564+
"""Verify Daytona surfaces root preparation failures as start errors."""
565+
566+
daytona_module = _load_daytona_module(monkeypatch)
567+
568+
async with daytona_module.DaytonaSandboxClient() as client:
569+
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
570+
sandbox = _FakeAsyncDaytona.current_sandbox
571+
assert sandbox is not None
572+
sandbox.process.next_result = _FakeExecResult(exit_code=2, result="mkdir failed")
573+
574+
with pytest.raises(daytona_module.WorkspaceStartError) as exc_info:
575+
await session.start()
576+
577+
assert exc_info.value.context == {
578+
"path": daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
579+
"reason": "workspace_root_nonzero_exit",
580+
"exit_code": 2,
581+
"output": "mkdir failed",
582+
}
583+
assert sandbox.process.execute_session_command_calls == []
584+
assert session.state.workspace_root_ready is False
585+
514586
@pytest.mark.asyncio
515587
async def test_create_passes_only_option_env_vars_to_daytona(
516588
self,

0 commit comments

Comments
 (0)