2986 lines
115 KiB
Python
2986 lines
115 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import builtins
|
|
import importlib
|
|
import io
|
|
import json
|
|
import shlex
|
|
import sys
|
|
import tarfile
|
|
import types
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any, Literal, cast
|
|
|
|
import pytest
|
|
from pydantic import BaseModel, Field, PrivateAttr
|
|
|
|
from agents import Agent
|
|
from agents.run_context import RunContextWrapper
|
|
from agents.run_state import RunState
|
|
from agents.sandbox import Manifest, SandboxPathGrant
|
|
from agents.sandbox.capabilities import Shell
|
|
from agents.sandbox.capabilities.tools.shell_tool import ExecCommandArgs, ExecCommandTool
|
|
from agents.sandbox.entries import File, InContainerMountStrategy, Mount, MountpointMountPattern
|
|
from agents.sandbox.entries.mounts.base import InContainerMountAdapter
|
|
from agents.sandbox.manifest import Environment
|
|
from agents.sandbox.materialization import MaterializedFile
|
|
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
|
from agents.sandbox.session.dependencies import Dependencies
|
|
from agents.sandbox.session.sandbox_client import BaseSandboxClientOptions
|
|
from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase
|
|
from agents.sandbox.types import ExposedPortEndpoint
|
|
from tests.utils.factories import make_run_state
|
|
|
|
|
|
class _RestorableSnapshot(SnapshotBase):
|
|
type: Literal["test-restorable-runloop"] = "test-restorable-runloop"
|
|
payload: bytes = b"restored"
|
|
|
|
async def persist(
|
|
self,
|
|
data: io.IOBase,
|
|
*,
|
|
dependencies: Dependencies | None = None,
|
|
) -> None:
|
|
_ = (data, dependencies)
|
|
|
|
async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
|
|
_ = dependencies
|
|
return io.BytesIO(self.payload)
|
|
|
|
async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
|
|
_ = dependencies
|
|
return True
|
|
|
|
|
|
class _DependencyAwareSnapshot(SnapshotBase):
|
|
type: Literal["test-restorable-runloop-deps"] = "test-restorable-runloop-deps"
|
|
payload: bytes = b"restored"
|
|
_restorable_dependencies: list[Dependencies | None] = PrivateAttr(default_factory=list)
|
|
_restore_dependencies: list[Dependencies | None] = PrivateAttr(default_factory=list)
|
|
|
|
@property
|
|
def restorable_dependencies(self) -> list[Dependencies | None]:
|
|
return self._restorable_dependencies
|
|
|
|
@property
|
|
def restore_dependencies(self) -> list[Dependencies | None]:
|
|
return self._restore_dependencies
|
|
|
|
async def persist(
|
|
self,
|
|
data: io.IOBase,
|
|
*,
|
|
dependencies: Dependencies | None = None,
|
|
) -> None:
|
|
_ = (data, dependencies)
|
|
|
|
async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
|
|
self._restore_dependencies.append(dependencies)
|
|
return io.BytesIO(self.payload)
|
|
|
|
async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
|
|
self._restorable_dependencies.append(dependencies)
|
|
return True
|
|
|
|
|
|
class _FakeRunloopError(Exception):
|
|
pass
|
|
|
|
|
|
class _FakeAPIError(_FakeRunloopError):
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
body: object | None = None,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.request = types.SimpleNamespace(url=url, method=method)
|
|
self.body = body
|
|
|
|
|
|
class _FakeAPIConnectionError(_FakeAPIError):
|
|
def __init__(
|
|
self,
|
|
message: str = "Connection error.",
|
|
*,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
) -> None:
|
|
super().__init__(message, url=url, method=method, body=None)
|
|
|
|
|
|
class _FakeAPITimeoutError(_FakeAPIConnectionError):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
) -> None:
|
|
super().__init__("Request timed out.", url=url, method=method)
|
|
|
|
|
|
class _FakeAPIStatusError(_FakeAPIError):
|
|
def __init__(
|
|
self,
|
|
status_code: int,
|
|
*,
|
|
body: object | None = None,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
message: str | None = None,
|
|
) -> None:
|
|
super().__init__(message or f"HTTP {status_code}", url=url, method=method, body=body)
|
|
self.status_code = status_code
|
|
self.response = types.SimpleNamespace(
|
|
status_code=status_code,
|
|
request=types.SimpleNamespace(url=url, method=method),
|
|
)
|
|
|
|
|
|
class _FakeAPIResponseValidationError(_FakeAPIError):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
status_code: int = 500,
|
|
body: object | None = None,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
message: str = "Data returned by API invalid for expected schema.",
|
|
) -> None:
|
|
super().__init__(message, url=url, method=method, body=body)
|
|
self.status_code = status_code
|
|
self.response = types.SimpleNamespace(
|
|
status_code=status_code,
|
|
request=types.SimpleNamespace(url=url, method=method),
|
|
)
|
|
|
|
|
|
class _FakeAuthenticationError(_FakeAPIStatusError):
|
|
def __init__(
|
|
self,
|
|
message: str = "authentication failed",
|
|
*,
|
|
body: object | None = None,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
) -> None:
|
|
super().__init__(401, body=body, url=url, method=method, message=message)
|
|
|
|
|
|
class _FakeBadRequestError(_FakeAPIStatusError):
|
|
def __init__(
|
|
self,
|
|
message: str = "bad request",
|
|
*,
|
|
body: object | None = None,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
) -> None:
|
|
super().__init__(400, body=body, url=url, method=method, message=message)
|
|
|
|
|
|
class _FakeInternalServerError(_FakeAPIStatusError):
|
|
def __init__(
|
|
self,
|
|
message: str = "internal server error",
|
|
*,
|
|
body: object | None = None,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
) -> None:
|
|
super().__init__(500, body=body, url=url, method=method, message=message)
|
|
|
|
|
|
class _FakeNotFoundError(_FakeAPIStatusError):
|
|
def __init__(
|
|
self,
|
|
message: str = "not found",
|
|
*,
|
|
body: object | None = None,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "GET",
|
|
) -> None:
|
|
super().__init__(404, body=body, url=url, method=method, message=message)
|
|
|
|
|
|
class _FakePermissionDeniedError(_FakeAPIStatusError):
|
|
def __init__(
|
|
self,
|
|
message: str = "permission denied",
|
|
*,
|
|
body: object | None = None,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
) -> None:
|
|
super().__init__(403, body=body, url=url, method=method, message=message)
|
|
|
|
|
|
class _FakeRateLimitError(_FakeAPIStatusError):
|
|
def __init__(
|
|
self,
|
|
message: str = "rate limited",
|
|
*,
|
|
body: object | None = None,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
) -> None:
|
|
super().__init__(429, body=body, url=url, method=method, message=message)
|
|
|
|
|
|
class _FakeUnprocessableEntityError(_FakeAPIStatusError):
|
|
def __init__(
|
|
self,
|
|
message: str = "unprocessable entity",
|
|
*,
|
|
body: object | None = None,
|
|
url: str = "https://api.runloop.ai/v1/test",
|
|
method: str = "POST",
|
|
) -> None:
|
|
super().__init__(422, body=body, url=url, method=method, message=message)
|
|
|
|
|
|
class _FakeExecutionResult:
|
|
def __init__(self, *, stdout: str = "", stderr: str = "", exit_code: int | None = 0) -> None:
|
|
self._stdout = stdout
|
|
self._stderr = stderr
|
|
self.exit_code = exit_code
|
|
|
|
async def stdout(self, num_lines: int | None = None) -> str:
|
|
_ = num_lines
|
|
return self._stdout
|
|
|
|
async def stderr(self, num_lines: int | None = None) -> str:
|
|
_ = num_lines
|
|
return self._stderr
|
|
|
|
|
|
class _FakeExecution:
|
|
_counter = 0
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
devbox: _FakeDevbox,
|
|
devbox_id: str,
|
|
command: str,
|
|
stdout_cb: object | None,
|
|
stderr_cb: object | None,
|
|
shell_name: str | None,
|
|
attach_stdin: bool,
|
|
home_dir: str,
|
|
) -> None:
|
|
type(self)._counter += 1
|
|
self._devbox = devbox
|
|
self.execution_id = f"exec-{type(self)._counter}"
|
|
self.devbox_id = devbox_id
|
|
self.command = command
|
|
self.shell_name = shell_name
|
|
self.attach_stdin = attach_stdin
|
|
self._stdout_cb = stdout_cb
|
|
self._stderr_cb = stderr_cb
|
|
self._done = asyncio.Event()
|
|
self._stdout = ""
|
|
self._stderr = ""
|
|
self._exit_code: int | None = None
|
|
self._killed = False
|
|
self._home_dir = home_dir
|
|
self._interactive = attach_stdin and (
|
|
"python3 -i" in command or "python3" == command.strip()
|
|
)
|
|
self._sleep_forever = "sleep-forever" in command
|
|
if self._interactive:
|
|
self._emit(stdout_cb, ">>> ")
|
|
elif "emit-after-result" in command:
|
|
asyncio.get_running_loop().call_soon(self._emit, stdout_cb, "final chunk\n")
|
|
self._exit_code = 0
|
|
self._done.set()
|
|
elif "echo hello" in command:
|
|
self._stdout = "hello\n"
|
|
self._emit(stdout_cb, self._stdout)
|
|
self._exit_code = 0
|
|
self._done.set()
|
|
elif " tar -C " in command or command.startswith("tar -C "):
|
|
self._apply_tar_extract()
|
|
self._exit_code = 0
|
|
self._done.set()
|
|
elif self._is_resolve_workspace_path_command(command):
|
|
self._resolve_workspace_path(command)
|
|
self._done.set()
|
|
elif " cat -- " in command or command.startswith("cat -- "):
|
|
self._stdout = self._read_file_text(command)
|
|
self._emit(stdout_cb, self._stdout)
|
|
self._exit_code = 0
|
|
self._done.set()
|
|
elif " rm -f -- " in command or command.startswith("rm -f -- "):
|
|
self._remove_file(command)
|
|
self._exit_code = 0
|
|
self._done.set()
|
|
elif "pwd" in command:
|
|
self._stdout = f"{self._home_dir}\n"
|
|
self._emit(stdout_cb, self._stdout)
|
|
self._exit_code = 0
|
|
self._done.set()
|
|
elif self._sleep_forever:
|
|
return
|
|
else:
|
|
self._exit_code = 0
|
|
self._done.set()
|
|
|
|
def _emit(self, callback: object | None, text: str) -> None:
|
|
if callback is None:
|
|
return
|
|
cast(Any, callback)(text)
|
|
|
|
def _command_tokens(self) -> list[str]:
|
|
return shlex.split(self.command)
|
|
|
|
def _path_relative_to_home(self, raw_path: str) -> str:
|
|
normalized = PurePosixPath(raw_path)
|
|
home = PurePosixPath(self._home_dir)
|
|
try:
|
|
relative = normalized.relative_to(home)
|
|
except ValueError:
|
|
return normalized.as_posix().lstrip("/")
|
|
rel_str = relative.as_posix()
|
|
return rel_str if rel_str else "."
|
|
|
|
def _is_resolve_workspace_path_command(self, command: str) -> bool:
|
|
tokens = shlex.split(command)
|
|
return any(
|
|
token.startswith("/tmp/openai-agents/bin/resolve-workspace-path-")
|
|
and len(tokens) >= index + 4
|
|
for index, token in enumerate(tokens)
|
|
)
|
|
|
|
def _resolve_fake_path(self, raw_path: str, *, depth: int = 0) -> PurePosixPath:
|
|
if depth > 64:
|
|
raise RuntimeError(f"symlink resolution depth exceeded: {raw_path}")
|
|
|
|
path = PurePosixPath(raw_path)
|
|
if not path.is_absolute():
|
|
path = PurePosixPath(self._home_dir) / path
|
|
|
|
parts = path.parts
|
|
current = PurePosixPath("/")
|
|
for index, part in enumerate(parts[1:], start=1):
|
|
current = current / part
|
|
target = self._devbox.symlinks.get(current.as_posix())
|
|
if target is None:
|
|
continue
|
|
|
|
target_path = PurePosixPath(target)
|
|
if not target_path.is_absolute():
|
|
target_path = current.parent / target_path
|
|
for remaining in parts[index + 1 :]:
|
|
target_path /= remaining
|
|
return self._resolve_fake_path(target_path.as_posix(), depth=depth + 1)
|
|
|
|
return path
|
|
|
|
@staticmethod
|
|
def _fake_path_is_under(path: PurePosixPath, root: PurePosixPath) -> bool:
|
|
return path == root or root in path.parents
|
|
|
|
def _resolve_workspace_path(self, command: str) -> None:
|
|
tokens = self._command_tokens()
|
|
helper_index = next(
|
|
index
|
|
for index, token in enumerate(tokens)
|
|
if token.startswith("/tmp/openai-agents/bin/resolve-workspace-path-")
|
|
)
|
|
root = self._resolve_fake_path(tokens[helper_index + 1])
|
|
candidate = self._resolve_fake_path(tokens[helper_index + 2])
|
|
for_write = tokens[helper_index + 3]
|
|
grant_tokens = tokens[helper_index + 4 :]
|
|
|
|
if self._fake_path_is_under(candidate, root):
|
|
self._stdout = f"{candidate.as_posix()}\n"
|
|
self._exit_code = 0
|
|
return
|
|
|
|
best_grant: tuple[PurePosixPath, str, str] | None = None
|
|
for index in range(0, len(grant_tokens), 2):
|
|
grant_original = grant_tokens[index]
|
|
read_only = grant_tokens[index + 1]
|
|
grant_root = self._resolve_fake_path(grant_original)
|
|
if not self._fake_path_is_under(candidate, grant_root):
|
|
continue
|
|
if best_grant is None or len(grant_root.parts) > len(best_grant[0].parts):
|
|
best_grant = (grant_root, grant_original, read_only)
|
|
|
|
if best_grant is not None:
|
|
_grant_root, grant_original, read_only = best_grant
|
|
if for_write == "1" and read_only == "1":
|
|
self._stderr = (
|
|
f"read-only extra path grant: {grant_original}\n"
|
|
f"resolved path: {candidate.as_posix()}\n"
|
|
)
|
|
self._exit_code = 114
|
|
return
|
|
self._stdout = f"{candidate.as_posix()}\n"
|
|
self._exit_code = 0
|
|
return
|
|
|
|
self._stderr = f"workspace escape: {candidate.as_posix()}\n"
|
|
self._exit_code = 111
|
|
|
|
def _apply_tar_extract(self) -> None:
|
|
tokens = self._command_tokens()
|
|
tar_index = tokens.index("tar")
|
|
root = tokens[tar_index + 2]
|
|
archive_path = tokens[tar_index + 4]
|
|
archive_rel = self._path_relative_to_home(archive_path)
|
|
root_rel = self._path_relative_to_home(root)
|
|
payload = self._devbox.files[archive_rel]
|
|
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive:
|
|
for member in archive.getmembers():
|
|
if member.isdir():
|
|
continue
|
|
fileobj = archive.extractfile(member)
|
|
if fileobj is None:
|
|
continue
|
|
target = PurePosixPath(member.name)
|
|
if root_rel != ".":
|
|
target = PurePosixPath(root_rel) / target
|
|
self._devbox.files[target.as_posix()] = fileobj.read()
|
|
|
|
def _read_file_text(self, command: str) -> str:
|
|
tokens = shlex.split(command)
|
|
path = tokens[-1]
|
|
rel_path = self._path_relative_to_home(path)
|
|
return self._devbox.files.get(rel_path, b"").decode("utf-8", errors="replace")
|
|
|
|
def _remove_file(self, command: str) -> None:
|
|
tokens = shlex.split(command)
|
|
path = tokens[-1]
|
|
rel_path = self._path_relative_to_home(path)
|
|
self._devbox.files.pop(rel_path, None)
|
|
|
|
async def result(self, timeout: float | None = None) -> _FakeExecutionResult:
|
|
_ = timeout
|
|
await self._done.wait()
|
|
return _FakeExecutionResult(
|
|
stdout=self._stdout,
|
|
stderr=self._stderr,
|
|
exit_code=self._exit_code,
|
|
)
|
|
|
|
async def kill(self, timeout: float | None = None) -> None:
|
|
_ = timeout
|
|
self._killed = True
|
|
self._exit_code = -9
|
|
self._done.set()
|
|
|
|
async def send_input(self, text: str) -> None:
|
|
if not self._interactive:
|
|
return
|
|
if text == "5 + 5\n":
|
|
self._stdout += "10\n>>> "
|
|
self._emit(self._stdout_cb, "10\n>>> ")
|
|
return
|
|
if text in {"exit()\n", "exit\n"}:
|
|
self._exit_code = 0
|
|
self._done.set()
|
|
return
|
|
|
|
|
|
class _FakeExecutionsAPI:
|
|
send_std_in_calls: list[tuple[str, str, str]]
|
|
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.send_std_in_calls = []
|
|
|
|
async def send_std_in(
|
|
self,
|
|
execution_id: str,
|
|
*,
|
|
devbox_id: str,
|
|
text: str | None = None,
|
|
timeout: float | None = None,
|
|
**_: object,
|
|
) -> object:
|
|
del timeout
|
|
self.send_std_in_calls.append((execution_id, devbox_id, text or ""))
|
|
execution = self._owner.executions[execution_id]
|
|
await execution.send_input(text or "")
|
|
return types.SimpleNamespace(success=True)
|
|
|
|
|
|
class _FakeFileInterface:
|
|
def __init__(self, devbox: _FakeDevbox) -> None:
|
|
self._devbox = devbox
|
|
|
|
def _file_key(self, path: str) -> str:
|
|
normalized = PurePosixPath(path)
|
|
home = PurePosixPath(self._devbox.home_dir)
|
|
try:
|
|
relative = normalized.relative_to(home)
|
|
except ValueError:
|
|
return normalized.as_posix()
|
|
rel_str = relative.as_posix()
|
|
return rel_str if rel_str else "."
|
|
|
|
async def download(self, *, path: str, timeout: float | None = None, **_: object) -> bytes:
|
|
del timeout
|
|
self._devbox.file_download_paths.append(path)
|
|
key = self._file_key(path)
|
|
if key not in self._devbox.files:
|
|
raise _FakeNotFoundError(path)
|
|
return self._devbox.files[key]
|
|
|
|
async def upload(
|
|
self,
|
|
*,
|
|
path: str,
|
|
file: bytes,
|
|
timeout: float | None = None,
|
|
**_: object,
|
|
) -> object:
|
|
del timeout
|
|
self._devbox.file_upload_paths.append(path)
|
|
self._devbox.files[self._file_key(path)] = bytes(file)
|
|
return {}
|
|
|
|
|
|
class _FakeNetworkInterface:
|
|
def __init__(self, devbox: _FakeDevbox) -> None:
|
|
self._devbox = devbox
|
|
|
|
async def enable_tunnel(self, **params: object) -> object:
|
|
self._devbox.enable_tunnel_calls.append(dict(params))
|
|
self._devbox.tunnel_key = "test-key"
|
|
return types.SimpleNamespace(tunnel_key="test-key")
|
|
|
|
|
|
class _FakeCommandInterface:
|
|
def __init__(self, devbox: _FakeDevbox) -> None:
|
|
self._devbox = devbox
|
|
|
|
async def exec(self, command: str, **params: object) -> _FakeExecutionResult:
|
|
execution = _FakeExecution(
|
|
devbox=self._devbox,
|
|
devbox_id=self._devbox.id,
|
|
command=command,
|
|
stdout_cb=params.get("stdout"),
|
|
stderr_cb=params.get("stderr"),
|
|
shell_name=cast(str | None, params.get("shell_name")),
|
|
attach_stdin=bool(params.get("attach_stdin", False)),
|
|
home_dir=self._devbox.home_dir,
|
|
)
|
|
self._devbox.owner.executions[execution.execution_id] = execution
|
|
self._devbox.exec_calls.append((command, dict(params)))
|
|
return await execution.result()
|
|
|
|
async def exec_async(self, command: str, **params: object) -> _FakeExecution:
|
|
execution = _FakeExecution(
|
|
devbox=self._devbox,
|
|
devbox_id=self._devbox.id,
|
|
command=command,
|
|
stdout_cb=params.get("stdout"),
|
|
stderr_cb=params.get("stderr"),
|
|
shell_name=cast(str | None, params.get("shell_name")),
|
|
attach_stdin=bool(params.get("attach_stdin", False)),
|
|
home_dir=self._devbox.home_dir,
|
|
)
|
|
self._devbox.owner.executions[execution.execution_id] = execution
|
|
self._devbox.exec_async_calls.append((command, dict(params)))
|
|
return execution
|
|
|
|
|
|
class _FakeDevbox:
|
|
def __init__(
|
|
self,
|
|
owner: _FakeAsyncRunloopSDK,
|
|
*,
|
|
devbox_id: str,
|
|
status: str = "running",
|
|
snapshot_source_id: str | None = None,
|
|
environment_variables: dict[str, str] | None = None,
|
|
launch_parameters: dict[str, object] | None = None,
|
|
) -> None:
|
|
self.owner = owner
|
|
self.id = devbox_id
|
|
self.status = status
|
|
self.snapshot_source_id = snapshot_source_id
|
|
self.environment_variables = dict(environment_variables or {})
|
|
self.launch_parameters = dict(launch_parameters or {})
|
|
user_parameters = self.launch_parameters.get("user_parameters")
|
|
if isinstance(user_parameters, dict):
|
|
username = user_parameters.get("username")
|
|
uid = user_parameters.get("uid")
|
|
if username == "root" and uid == 0:
|
|
self.home_dir = "/root"
|
|
elif isinstance(username, str) and username:
|
|
self.home_dir = f"/home/{username}"
|
|
else:
|
|
self.home_dir = "/home/user"
|
|
else:
|
|
self.home_dir = "/home/user"
|
|
self.files: dict[str, bytes] = {}
|
|
self.symlinks: dict[str, str] = {}
|
|
self.file_download_paths: list[str] = []
|
|
self.file_upload_paths: list[str] = []
|
|
self.tunnel_key: str | None = None
|
|
self.enable_tunnel_calls: list[dict[str, object]] = []
|
|
self.exec_calls: list[tuple[str, dict[str, object]]] = []
|
|
self.exec_async_calls: list[tuple[str, dict[str, object]]] = []
|
|
self.snapshot_calls: list[dict[str, object]] = []
|
|
self.shutdown_calls = 0
|
|
self.suspend_calls = 0
|
|
self.resume_calls = 0
|
|
self.await_running_calls = 0
|
|
self.resume_returns_before_running = False
|
|
self.cmd = _FakeCommandInterface(self)
|
|
self.file = _FakeFileInterface(self)
|
|
self.net = _FakeNetworkInterface(self)
|
|
|
|
async def get_info(self, timeout: float | None = None, **_: object) -> object:
|
|
del timeout
|
|
tunnel = (
|
|
types.SimpleNamespace(tunnel_key=self.tunnel_key)
|
|
if self.tunnel_key is not None
|
|
else None
|
|
)
|
|
return types.SimpleNamespace(status=self.status, tunnel=tunnel)
|
|
|
|
async def get_tunnel_url(
|
|
self,
|
|
port: int,
|
|
timeout: float | None = None,
|
|
**_: object,
|
|
) -> str | None:
|
|
del timeout
|
|
if self.tunnel_key is None:
|
|
return None
|
|
return f"https://{port}-{self.tunnel_key}.tunnel.runloop.ai"
|
|
|
|
async def snapshot_disk(self, **params: object) -> object:
|
|
self.snapshot_calls.append(dict(params))
|
|
snapshot_id = f"snap-{len(self.snapshot_calls)}"
|
|
return types.SimpleNamespace(id=snapshot_id)
|
|
|
|
async def shutdown(self, timeout: float | None = None, **_: object) -> object:
|
|
del timeout
|
|
self.shutdown_calls += 1
|
|
self.status = "shutdown"
|
|
return types.SimpleNamespace(status=self.status)
|
|
|
|
async def suspend(self, timeout: float | None = None, **_: object) -> object:
|
|
del timeout
|
|
self.suspend_calls += 1
|
|
self.status = "suspended"
|
|
return types.SimpleNamespace(status=self.status)
|
|
|
|
async def await_suspended(self) -> object:
|
|
return types.SimpleNamespace(status="suspended")
|
|
|
|
async def await_running(self, **_: object) -> object:
|
|
self.await_running_calls += 1
|
|
self.status = "running"
|
|
return types.SimpleNamespace(status=self.status)
|
|
|
|
async def resume(self, timeout: float | None = None, **_: object) -> object:
|
|
del timeout
|
|
self.resume_calls += 1
|
|
if self.resume_returns_before_running:
|
|
self.status = "resuming"
|
|
return types.SimpleNamespace(status=self.status)
|
|
self.status = "running"
|
|
return types.SimpleNamespace(status=self.status)
|
|
|
|
|
|
class _FakeDevboxOps:
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.create_calls: list[dict[str, object]] = []
|
|
self.create_from_snapshot_calls: list[tuple[str, dict[str, object]]] = []
|
|
self.from_id_calls: list[str] = []
|
|
self.devboxes: dict[str, _FakeDevbox] = {}
|
|
self._counter = 0
|
|
|
|
def _new_devbox(
|
|
self,
|
|
*,
|
|
snapshot_source_id: str | None = None,
|
|
environment_variables: dict[str, str] | None = None,
|
|
launch_parameters: dict[str, object] | None = None,
|
|
) -> _FakeDevbox:
|
|
self._counter += 1
|
|
devbox = _FakeDevbox(
|
|
self._owner,
|
|
devbox_id=f"devbox-{self._counter}",
|
|
snapshot_source_id=snapshot_source_id,
|
|
environment_variables=environment_variables,
|
|
launch_parameters=launch_parameters,
|
|
)
|
|
self.devboxes[devbox.id] = devbox
|
|
return devbox
|
|
|
|
async def create(self, **params: object) -> _FakeDevbox:
|
|
self.create_calls.append(dict(params))
|
|
return self._new_devbox(
|
|
environment_variables=cast(dict[str, str] | None, params.get("environment_variables")),
|
|
launch_parameters=cast(dict[str, object] | None, params.get("launch_parameters")),
|
|
)
|
|
|
|
async def create_from_snapshot(self, snapshot_id: str, **params: object) -> _FakeDevbox:
|
|
self.create_from_snapshot_calls.append((snapshot_id, dict(params)))
|
|
return self._new_devbox(
|
|
snapshot_source_id=snapshot_id,
|
|
environment_variables=cast(dict[str, str] | None, params.get("environment_variables")),
|
|
launch_parameters=cast(dict[str, object] | None, params.get("launch_parameters")),
|
|
)
|
|
|
|
def from_id(self, devbox_id: str) -> _FakeDevbox:
|
|
self.from_id_calls.append(devbox_id)
|
|
if devbox_id not in self.devboxes:
|
|
raise _FakeNotFoundError(devbox_id)
|
|
return self.devboxes[devbox_id]
|
|
|
|
|
|
class _FakeBlueprint:
|
|
def __init__(
|
|
self, owner: _FakeAsyncRunloopSDK, *, blueprint_id: str, name: str | None = None
|
|
) -> None:
|
|
self.owner = owner
|
|
self.id = blueprint_id
|
|
self.name = name or blueprint_id
|
|
self.logs_calls: list[dict[str, object]] = []
|
|
self.delete_calls: list[dict[str, object]] = []
|
|
|
|
async def get_info(self, **_: object) -> object:
|
|
return types.SimpleNamespace(id=self.id, name=self.name, status="build_complete")
|
|
|
|
async def logs(self, **params: object) -> object:
|
|
self.logs_calls.append(dict(params))
|
|
return types.SimpleNamespace(items=[f"log:{self.id}"])
|
|
|
|
async def delete(self, **params: object) -> object:
|
|
self.delete_calls.append(dict(params))
|
|
return types.SimpleNamespace(id=self.id, deleted=True)
|
|
|
|
|
|
class _FakeBlueprintOps:
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.create_calls: list[dict[str, object]] = []
|
|
self.list_calls: list[dict[str, object]] = []
|
|
self.from_id_calls: list[str] = []
|
|
self.blueprints: dict[str, _FakeBlueprint] = {}
|
|
self._counter = 0
|
|
|
|
def _new_blueprint(self, *, name: str | None = None) -> _FakeBlueprint:
|
|
self._counter += 1
|
|
blueprint = _FakeBlueprint(
|
|
self._owner,
|
|
blueprint_id=f"blueprint-{self._counter}",
|
|
name=name,
|
|
)
|
|
self.blueprints[blueprint.id] = blueprint
|
|
return blueprint
|
|
|
|
async def create(self, **params: object) -> _FakeBlueprint:
|
|
self.create_calls.append(dict(params))
|
|
return self._new_blueprint(name=cast(str | None, params.get("name")))
|
|
|
|
async def list(self, **params: object) -> list[_FakeBlueprint]:
|
|
self.list_calls.append(dict(params))
|
|
return list(self.blueprints.values())
|
|
|
|
def from_id(self, blueprint_id: str) -> _FakeBlueprint:
|
|
self.from_id_calls.append(blueprint_id)
|
|
return self.blueprints.setdefault(
|
|
blueprint_id,
|
|
_FakeBlueprint(self._owner, blueprint_id=blueprint_id, name=blueprint_id),
|
|
)
|
|
|
|
|
|
class _FakeBlueprintsAPI:
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.list_public_calls: list[dict[str, object]] = []
|
|
self.logs_calls: list[tuple[str, dict[str, object]]] = []
|
|
self.await_build_complete_calls: list[tuple[str, dict[str, object]]] = []
|
|
|
|
async def list_public(self, **params: object) -> object:
|
|
self.list_public_calls.append(dict(params))
|
|
return types.SimpleNamespace(data=list(self._owner.blueprint.blueprints.values()))
|
|
|
|
async def logs(self, blueprint_id: str, **params: object) -> object:
|
|
self.logs_calls.append((blueprint_id, dict(params)))
|
|
return types.SimpleNamespace(items=[f"log:{blueprint_id}"])
|
|
|
|
async def await_build_complete(self, blueprint_id: str, **params: object) -> object:
|
|
self.await_build_complete_calls.append((blueprint_id, dict(params)))
|
|
blueprint = self._owner.blueprint.from_id(blueprint_id)
|
|
return types.SimpleNamespace(id=blueprint.id, status="build_complete")
|
|
|
|
|
|
class _FakeBenchmarkRun:
|
|
def __init__(self, *, run_id: str, benchmark_id: str) -> None:
|
|
self.id = run_id
|
|
self.benchmark_id = benchmark_id
|
|
|
|
async def get_info(self, **_: object) -> object:
|
|
return types.SimpleNamespace(id=self.id, benchmark_id=self.benchmark_id)
|
|
|
|
|
|
class _FakeBenchmark:
|
|
def __init__(
|
|
self, owner: _FakeAsyncRunloopSDK, *, benchmark_id: str, name: str | None = None
|
|
) -> None:
|
|
self.owner = owner
|
|
self.id = benchmark_id
|
|
self.name = name or benchmark_id
|
|
self.update_calls: list[dict[str, object]] = []
|
|
self.start_run_calls: list[dict[str, object]] = []
|
|
|
|
async def get_info(self, **_: object) -> object:
|
|
return types.SimpleNamespace(id=self.id, name=self.name)
|
|
|
|
async def update(self, **params: object) -> object:
|
|
self.update_calls.append(dict(params))
|
|
return types.SimpleNamespace(id=self.id, name=params.get("name", self.name))
|
|
|
|
async def start_run(self, **params: object) -> _FakeBenchmarkRun:
|
|
self.start_run_calls.append(dict(params))
|
|
return _FakeBenchmarkRun(run_id=f"run-{self.id}", benchmark_id=self.id)
|
|
|
|
async def list_runs(self, **_: object) -> list[_FakeBenchmarkRun]:
|
|
return [_FakeBenchmarkRun(run_id=f"run-{self.id}", benchmark_id=self.id)]
|
|
|
|
|
|
class _FakeBenchmarkOps:
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.create_calls: list[dict[str, object]] = []
|
|
self.list_calls: list[dict[str, object]] = []
|
|
self.from_id_calls: list[str] = []
|
|
self.benchmarks: dict[str, _FakeBenchmark] = {}
|
|
self._counter = 0
|
|
|
|
def _new_benchmark(self, *, name: str | None = None) -> _FakeBenchmark:
|
|
self._counter += 1
|
|
benchmark = _FakeBenchmark(
|
|
self._owner, benchmark_id=f"benchmark-{self._counter}", name=name
|
|
)
|
|
self.benchmarks[benchmark.id] = benchmark
|
|
return benchmark
|
|
|
|
async def create(self, **params: object) -> _FakeBenchmark:
|
|
self.create_calls.append(dict(params))
|
|
return self._new_benchmark(name=cast(str | None, params.get("name")))
|
|
|
|
async def list(self, **params: object) -> list[_FakeBenchmark]:
|
|
self.list_calls.append(dict(params))
|
|
return list(self.benchmarks.values())
|
|
|
|
def from_id(self, benchmark_id: str) -> _FakeBenchmark:
|
|
self.from_id_calls.append(benchmark_id)
|
|
return self.benchmarks.setdefault(
|
|
benchmark_id,
|
|
_FakeBenchmark(self._owner, benchmark_id=benchmark_id, name=benchmark_id),
|
|
)
|
|
|
|
|
|
class _FakeBenchmarksAPI:
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.list_public_calls: list[dict[str, object]] = []
|
|
self.definitions_calls: list[tuple[str, dict[str, object]]] = []
|
|
self.update_scenarios_calls: list[tuple[str, dict[str, object]]] = []
|
|
|
|
async def list_public(self, **params: object) -> object:
|
|
self.list_public_calls.append(dict(params))
|
|
return types.SimpleNamespace(data=list(self._owner.benchmark.benchmarks.values()))
|
|
|
|
async def definitions(self, benchmark_id: str, **params: object) -> object:
|
|
self.definitions_calls.append((benchmark_id, dict(params)))
|
|
return types.SimpleNamespace(definitions=[types.SimpleNamespace(id=f"def-{benchmark_id}")])
|
|
|
|
async def update_scenarios(self, benchmark_id: str, **params: object) -> object:
|
|
self.update_scenarios_calls.append((benchmark_id, dict(params)))
|
|
return types.SimpleNamespace(id=benchmark_id, **dict(params))
|
|
|
|
|
|
class _FakeSecret:
|
|
def __init__(
|
|
self, owner: _FakeAsyncRunloopSDK, *, name: str, value: str, secret_id: str
|
|
) -> None:
|
|
self.owner = owner
|
|
self.name = name
|
|
self.value = value
|
|
self.id = secret_id
|
|
self.update_calls: list[tuple[str, dict[str, object]]] = []
|
|
self.delete_calls: list[dict[str, object]] = []
|
|
|
|
async def get_info(self, **_: object) -> object:
|
|
return types.SimpleNamespace(id=self.id, name=self.name)
|
|
|
|
async def update(self, value: str, **params: object) -> _FakeSecret:
|
|
self.update_calls.append((value, dict(params)))
|
|
self.value = value
|
|
return self
|
|
|
|
async def delete(self, **params: object) -> object:
|
|
self.delete_calls.append(dict(params))
|
|
self.owner.secret.secrets.pop(self.name, None)
|
|
return types.SimpleNamespace(id=self.id, name=self.name, deleted=True)
|
|
|
|
|
|
class _FakeSecretOps:
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.create_calls: list[tuple[str, str, dict[str, object]]] = []
|
|
self.update_calls: list[tuple[str, str, dict[str, object]]] = []
|
|
self.delete_calls: list[tuple[str, dict[str, object]]] = []
|
|
self.list_calls: list[dict[str, object]] = []
|
|
self.secrets: dict[str, _FakeSecret] = {}
|
|
self._counter = 0
|
|
self.conflict_status_code = 409
|
|
self.conflict_body: object | None = {"error": "secret exists"}
|
|
self.conflict_message: str | None = None
|
|
|
|
def _new_secret(self, *, name: str, value: str) -> _FakeSecret:
|
|
self._counter += 1
|
|
secret = _FakeSecret(
|
|
self._owner, name=name, value=value, secret_id=f"secret-{self._counter}"
|
|
)
|
|
self.secrets[name] = secret
|
|
return secret
|
|
|
|
async def create(self, name: str, value: str, **params: object) -> _FakeSecret:
|
|
self.create_calls.append((name, value, dict(params)))
|
|
if name in self.secrets:
|
|
raise _FakeAPIStatusError(
|
|
self.conflict_status_code,
|
|
body=self.conflict_body,
|
|
message=self.conflict_message,
|
|
)
|
|
return self._new_secret(name=name, value=value)
|
|
|
|
async def list(self, **params: object) -> list[_FakeSecret]:
|
|
self.list_calls.append(dict(params))
|
|
return list(self.secrets.values())
|
|
|
|
async def update(self, secret: _FakeSecret | str, value: str, **params: object) -> _FakeSecret:
|
|
name = secret.name if isinstance(secret, _FakeSecret) else secret
|
|
self.update_calls.append((name, value, dict(params)))
|
|
secret_obj = self.secrets[name]
|
|
secret_obj.value = value
|
|
return secret_obj
|
|
|
|
async def delete(self, secret: _FakeSecret | str, **params: object) -> object:
|
|
name = secret.name if isinstance(secret, _FakeSecret) else secret
|
|
self.delete_calls.append((name, dict(params)))
|
|
secret_obj = self.secrets.pop(name)
|
|
return types.SimpleNamespace(id=secret_obj.id, name=name, deleted=True)
|
|
|
|
|
|
class _FakeSecretsAPI:
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.retrieve_calls: list[tuple[str, dict[str, object]]] = []
|
|
|
|
async def retrieve(self, name: str, **params: object) -> object:
|
|
self.retrieve_calls.append((name, dict(params)))
|
|
secret = self._owner.secret.secrets[name]
|
|
return types.SimpleNamespace(id=secret.id, name=secret.name)
|
|
|
|
|
|
class _FakeNetworkPolicy:
|
|
def __init__(
|
|
self, owner: _FakeAsyncRunloopSDK, *, policy_id: str, name: str | None = None
|
|
) -> None:
|
|
self.owner = owner
|
|
self.id = policy_id
|
|
self.name = name or policy_id
|
|
self.update_calls: list[dict[str, object]] = []
|
|
self.delete_calls: list[dict[str, object]] = []
|
|
|
|
async def get_info(self, **_: object) -> object:
|
|
return types.SimpleNamespace(id=self.id, name=self.name)
|
|
|
|
async def update(self, **params: object) -> object:
|
|
self.update_calls.append(dict(params))
|
|
return types.SimpleNamespace(id=self.id, name=params.get("name", self.name))
|
|
|
|
async def delete(self, **params: object) -> object:
|
|
self.delete_calls.append(dict(params))
|
|
self.owner.network_policy.policies.pop(self.id, None)
|
|
return types.SimpleNamespace(id=self.id, deleted=True)
|
|
|
|
|
|
class _FakeNetworkPolicyOps:
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.create_calls: list[dict[str, object]] = []
|
|
self.list_calls: list[dict[str, object]] = []
|
|
self.from_id_calls: list[str] = []
|
|
self.policies: dict[str, _FakeNetworkPolicy] = {}
|
|
self._counter = 0
|
|
|
|
def _new_policy(self, *, name: str | None = None) -> _FakeNetworkPolicy:
|
|
self._counter += 1
|
|
policy = _FakeNetworkPolicy(self._owner, policy_id=f"policy-{self._counter}", name=name)
|
|
self.policies[policy.id] = policy
|
|
return policy
|
|
|
|
async def create(self, **params: object) -> _FakeNetworkPolicy:
|
|
self.create_calls.append(dict(params))
|
|
return self._new_policy(name=cast(str | None, params.get("name")))
|
|
|
|
async def list(self, **params: object) -> list[_FakeNetworkPolicy]:
|
|
self.list_calls.append(dict(params))
|
|
return list(self.policies.values())
|
|
|
|
def from_id(self, network_policy_id: str) -> _FakeNetworkPolicy:
|
|
self.from_id_calls.append(network_policy_id)
|
|
return self.policies.setdefault(
|
|
network_policy_id,
|
|
_FakeNetworkPolicy(self._owner, policy_id=network_policy_id, name=network_policy_id),
|
|
)
|
|
|
|
|
|
class _FakeNetworkPoliciesAPI:
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.retrieve_calls: list[tuple[str, dict[str, object]]] = []
|
|
|
|
async def retrieve(self, network_policy_id: str, **params: object) -> object:
|
|
self.retrieve_calls.append((network_policy_id, dict(params)))
|
|
policy = self._owner.network_policy.from_id(network_policy_id)
|
|
return types.SimpleNamespace(id=policy.id, name=policy.name)
|
|
|
|
|
|
class _FakeAxonSql:
|
|
def __init__(self) -> None:
|
|
self.query_calls: list[dict[str, object]] = []
|
|
self.batch_calls: list[dict[str, object]] = []
|
|
|
|
async def query(self, **params: object) -> object:
|
|
self.query_calls.append(dict(params))
|
|
return types.SimpleNamespace(rows=[["ok"]])
|
|
|
|
async def batch(self, **params: object) -> object:
|
|
self.batch_calls.append(dict(params))
|
|
return types.SimpleNamespace(results=[types.SimpleNamespace(success=True)])
|
|
|
|
|
|
class _FakeAxon:
|
|
def __init__(
|
|
self, owner: _FakeAsyncRunloopSDK, *, axon_id: str, name: str | None = None
|
|
) -> None:
|
|
self.owner = owner
|
|
self.id = axon_id
|
|
self.name = name or axon_id
|
|
self.publish_calls: list[dict[str, object]] = []
|
|
self.sql = _FakeAxonSql()
|
|
|
|
async def get_info(self, **_: object) -> object:
|
|
return types.SimpleNamespace(id=self.id, name=self.name)
|
|
|
|
async def publish(self, **params: object) -> object:
|
|
self.publish_calls.append(dict(params))
|
|
return types.SimpleNamespace(published=True)
|
|
|
|
|
|
class _FakeAxonOps:
|
|
def __init__(self, owner: _FakeAsyncRunloopSDK) -> None:
|
|
self._owner = owner
|
|
self.create_calls: list[dict[str, object]] = []
|
|
self.list_calls: list[dict[str, object]] = []
|
|
self.from_id_calls: list[str] = []
|
|
self.axons: dict[str, _FakeAxon] = {}
|
|
self._counter = 0
|
|
|
|
def _new_axon(self, *, name: str | None = None) -> _FakeAxon:
|
|
self._counter += 1
|
|
axon = _FakeAxon(self._owner, axon_id=f"axon-{self._counter}", name=name)
|
|
self.axons[axon.id] = axon
|
|
return axon
|
|
|
|
async def create(self, **params: object) -> _FakeAxon:
|
|
self.create_calls.append(dict(params))
|
|
return self._new_axon(name=cast(str | None, params.get("name")))
|
|
|
|
async def list(self, **params: object) -> list[_FakeAxon]:
|
|
self.list_calls.append(dict(params))
|
|
return list(self.axons.values())
|
|
|
|
def from_id(self, axon_id: str) -> _FakeAxon:
|
|
self.from_id_calls.append(axon_id)
|
|
return self.axons.setdefault(
|
|
axon_id,
|
|
_FakeAxon(self._owner, axon_id=axon_id, name=axon_id),
|
|
)
|
|
|
|
|
|
class _FakeLaunchAfterIdle(BaseModel):
|
|
idle_time_seconds: int
|
|
on_idle: Literal["shutdown", "suspend"]
|
|
|
|
def to_dict(
|
|
self,
|
|
*,
|
|
mode: str = "python",
|
|
exclude_none: bool = False,
|
|
exclude_defaults: bool = False,
|
|
) -> dict[str, object]:
|
|
return cast(
|
|
dict[str, object],
|
|
self.model_dump(
|
|
mode=cast(Literal["json", "python"], mode),
|
|
exclude_none=exclude_none,
|
|
exclude_defaults=exclude_defaults,
|
|
),
|
|
)
|
|
|
|
|
|
class _FakeUserParameters(BaseModel):
|
|
username: str
|
|
uid: int
|
|
|
|
def to_dict(
|
|
self,
|
|
*,
|
|
mode: str = "python",
|
|
exclude_none: bool = False,
|
|
exclude_defaults: bool = False,
|
|
) -> dict[str, object]:
|
|
return cast(
|
|
dict[str, object],
|
|
self.model_dump(
|
|
mode=cast(Literal["json", "python"], mode),
|
|
exclude_none=exclude_none,
|
|
exclude_defaults=exclude_defaults,
|
|
),
|
|
)
|
|
|
|
|
|
class _FakeLaunchParameters(BaseModel):
|
|
network_policy_id: str | None = None
|
|
resource_size_request: (
|
|
Literal["X_SMALL", "SMALL", "MEDIUM", "LARGE", "X_LARGE", "XX_LARGE", "CUSTOM_SIZE"] | None
|
|
) = None
|
|
custom_cpu_cores: float | None = None
|
|
custom_gb_memory: int | None = None
|
|
custom_disk_size: int | None = None
|
|
architecture: Literal["x86_64", "arm64"] | None = None
|
|
keep_alive_time_seconds: int | None = None
|
|
after_idle: _FakeLaunchAfterIdle | dict[str, object] | None = None
|
|
launch_commands: list[str] | tuple[str, ...] | None = None
|
|
required_services: list[str] | tuple[str, ...] | None = None
|
|
user_parameters: dict[str, object] | None = None
|
|
|
|
def to_dict(
|
|
self,
|
|
*,
|
|
mode: str = "python",
|
|
exclude_none: bool = False,
|
|
exclude_defaults: bool = False,
|
|
) -> dict[str, object]:
|
|
return cast(
|
|
dict[str, object],
|
|
self.model_dump(
|
|
mode=cast(Literal["json", "python"], mode),
|
|
exclude_none=exclude_none,
|
|
exclude_defaults=exclude_defaults,
|
|
),
|
|
)
|
|
|
|
|
|
class _FakeAsyncRunloopSDK:
|
|
created_instances: list[_FakeAsyncRunloopSDK] = []
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
bearer_token: str | None = None,
|
|
base_url: str | None = None,
|
|
**_: object,
|
|
) -> None:
|
|
self.bearer_token = bearer_token
|
|
self.base_url = base_url or "https://api.runloop.ai"
|
|
self.executions: dict[str, _FakeExecution] = {}
|
|
self.devbox = _FakeDevboxOps(self)
|
|
self.blueprint = _FakeBlueprintOps(self)
|
|
self.benchmark = _FakeBenchmarkOps(self)
|
|
self.secret = _FakeSecretOps(self)
|
|
self.network_policy = _FakeNetworkPolicyOps(self)
|
|
self.axon = _FakeAxonOps(self)
|
|
self.api = types.SimpleNamespace(
|
|
devboxes=types.SimpleNamespace(executions=_FakeExecutionsAPI(self)),
|
|
blueprints=_FakeBlueprintsAPI(self),
|
|
benchmarks=_FakeBenchmarksAPI(self),
|
|
secrets=_FakeSecretsAPI(self),
|
|
network_policies=_FakeNetworkPoliciesAPI(self),
|
|
)
|
|
type(self).created_instances.append(self)
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
|
|
def _load_runloop_module(monkeypatch: pytest.MonkeyPatch) -> Any:
|
|
_FakeAsyncRunloopSDK.created_instances.clear()
|
|
_FakeExecution._counter = 0
|
|
fake_runloop: Any = types.ModuleType("runloop_api_client")
|
|
fake_runloop.APIConnectionError = _FakeAPIConnectionError
|
|
fake_runloop.APIResponseValidationError = _FakeAPIResponseValidationError
|
|
fake_runloop.APITimeoutError = _FakeAPITimeoutError
|
|
fake_runloop.APIStatusError = _FakeAPIStatusError
|
|
fake_runloop.AuthenticationError = _FakeAuthenticationError
|
|
fake_runloop.BadRequestError = _FakeBadRequestError
|
|
fake_runloop.InternalServerError = _FakeInternalServerError
|
|
fake_runloop.NotFoundError = _FakeNotFoundError
|
|
fake_runloop.PermissionDeniedError = _FakePermissionDeniedError
|
|
fake_runloop.RateLimitError = _FakeRateLimitError
|
|
fake_runloop.RunloopError = _FakeRunloopError
|
|
fake_runloop.UnprocessableEntityError = _FakeUnprocessableEntityError
|
|
|
|
fake_sdk: Any = types.ModuleType("runloop_api_client.sdk")
|
|
fake_sdk.AsyncRunloopSDK = _FakeAsyncRunloopSDK
|
|
|
|
fake_types: Any = types.ModuleType("runloop_api_client.types")
|
|
fake_types.AfterIdle = _FakeLaunchAfterIdle
|
|
fake_types.LaunchParameters = _FakeLaunchParameters
|
|
fake_shared: Any = types.ModuleType("runloop_api_client.types.shared")
|
|
fake_launch_parameters_module: Any = types.ModuleType(
|
|
"runloop_api_client.types.shared.launch_parameters"
|
|
)
|
|
fake_launch_parameters_module.UserParameters = _FakeUserParameters
|
|
fake_shared.launch_parameters = fake_launch_parameters_module
|
|
fake_types.shared = fake_shared
|
|
|
|
monkeypatch.setitem(sys.modules, "runloop_api_client", fake_runloop)
|
|
monkeypatch.setitem(sys.modules, "runloop_api_client.sdk", fake_sdk)
|
|
monkeypatch.setitem(sys.modules, "runloop_api_client.types", fake_types)
|
|
monkeypatch.setitem(sys.modules, "runloop_api_client.types.shared", fake_shared)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"runloop_api_client.types.shared.launch_parameters",
|
|
fake_launch_parameters_module,
|
|
)
|
|
sys.modules.pop("agents.extensions.sandbox.runloop.sandbox", None)
|
|
sys.modules.pop("agents.extensions.sandbox.runloop", None)
|
|
return importlib.import_module("agents.extensions.sandbox.runloop.sandbox")
|
|
|
|
|
|
def _build_tar_bytes(files: dict[str, bytes]) -> bytes:
|
|
buffer = io.BytesIO()
|
|
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
|
for name, payload in files.items():
|
|
info = tarfile.TarInfo(name=name)
|
|
info.size = len(payload)
|
|
archive.addfile(info, io.BytesIO(payload))
|
|
return buffer.getvalue()
|
|
|
|
|
|
def test_runloop_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
package_module = importlib.import_module("agents.extensions.sandbox.runloop")
|
|
|
|
assert package_module.RunloopSandboxClient is runloop_module.RunloopSandboxClient
|
|
assert package_module.RunloopPlatformClient is runloop_module.RunloopPlatformClient
|
|
assert package_module.RunloopLaunchParameters is runloop_module.RunloopLaunchParameters
|
|
assert package_module.RunloopAfterIdle is runloop_module.RunloopAfterIdle
|
|
assert package_module.RunloopUserParameters is runloop_module.RunloopUserParameters
|
|
|
|
|
|
class _RecordingMount(Mount):
|
|
type: str = "runloop_recording_mount"
|
|
mount_strategy: InContainerMountStrategy = Field(
|
|
default_factory=lambda: InContainerMountStrategy(pattern=MountpointMountPattern())
|
|
)
|
|
_mounted_paths: list[Path] = PrivateAttr(default_factory=list)
|
|
_unmounted_paths: list[Path] = PrivateAttr(default_factory=list)
|
|
|
|
def supported_in_container_patterns(
|
|
self,
|
|
) -> tuple[builtins.type[MountpointMountPattern], ...]:
|
|
return (MountpointMountPattern,)
|
|
|
|
def in_container_adapter(self) -> InContainerMountAdapter:
|
|
mount = self
|
|
|
|
class _Adapter(InContainerMountAdapter):
|
|
def validate(self, strategy: InContainerMountStrategy) -> None:
|
|
_ = strategy
|
|
|
|
async def activate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> list[MaterializedFile]:
|
|
_ = (strategy, session, base_dir)
|
|
path = mount._resolve_mount_path(session, dest)
|
|
mount._mounted_paths.append(path)
|
|
return []
|
|
|
|
async def deactivate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> None:
|
|
_ = (strategy, session, base_dir)
|
|
path = mount._resolve_mount_path(session, dest)
|
|
mount._unmounted_paths.append(path)
|
|
|
|
async def teardown_for_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = (strategy, session)
|
|
mount._unmounted_paths.append(path)
|
|
|
|
async def restore_after_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = (strategy, session)
|
|
mount._mounted_paths.append(path)
|
|
|
|
return _Adapter(self)
|
|
|
|
|
|
class TestRunloopSandbox:
|
|
@pytest.mark.asyncio
|
|
async def test_runloop_does_not_advertise_pty_support(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
|
|
assert session.supports_pty() is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_uses_runloop_default_workspace_root(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
|
|
assert session.state.manifest.root == runloop_module.DEFAULT_RUNLOOP_WORKSPACE_ROOT
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_uses_root_workspace_root_when_root_launch_enabled(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(
|
|
user_parameters=runloop_module.RunloopUserParameters(
|
|
username="root",
|
|
uid=0,
|
|
),
|
|
)
|
|
)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
|
|
assert session.state.manifest.root == runloop_module.DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT
|
|
assert sdk.devbox.create_calls[0]["launch_parameters"] == {
|
|
"user_parameters": {"username": "root", "uid": 0}
|
|
}
|
|
|
|
def test_runloop_sdk_backed_user_parameters_construct_from_extension_exports(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
user_parameters = runloop_module.RunloopUserParameters(username="user", uid=1000)
|
|
|
|
assert user_parameters.username == "user"
|
|
assert user_parameters.uid == 1000
|
|
assert user_parameters.to_dict(mode="json", exclude_none=True) == {
|
|
"username": "user",
|
|
"uid": 1000,
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_normalizes_dict_user_parameters(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(
|
|
user_parameters={"username": "root", "uid": 0},
|
|
)
|
|
)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
|
|
assert sdk.devbox.create_calls[0]["launch_parameters"] == {
|
|
"user_parameters": {"username": "root", "uid": 0}
|
|
}
|
|
assert session.state.user_parameters is not None
|
|
assert session.state.user_parameters.username == "root"
|
|
assert session.state.user_parameters.uid == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_manifest_exec_succeeds_immediately_after_start_non_root(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(root=f"{runloop_module.DEFAULT_RUNLOOP_WORKSPACE_ROOT}/project"),
|
|
options=runloop_module.RunloopSandboxClientOptions(),
|
|
)
|
|
await session.start()
|
|
result = await session.exec("pwd", shell=False)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
command, _ = devbox.exec_calls[-1]
|
|
|
|
assert result.ok()
|
|
assert "cd /home/user/project &&" in command
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_manifest_exec_succeeds_immediately_after_start_root(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(root="/root/project"),
|
|
options=runloop_module.RunloopSandboxClientOptions(
|
|
user_parameters=runloop_module.RunloopUserParameters(
|
|
username="root",
|
|
uid=0,
|
|
)
|
|
),
|
|
)
|
|
await session.start()
|
|
result = await session.exec("pwd", shell=False)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
command, _ = devbox.exec_calls[-1]
|
|
|
|
assert result.ok()
|
|
assert "cd /root/project &&" in command
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_merges_env_vars_with_manifest_precedence(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
await client.create(
|
|
manifest=Manifest(
|
|
root=runloop_module.DEFAULT_RUNLOOP_WORKSPACE_ROOT,
|
|
environment=Environment(value={"SHARED": "manifest", "ONLY_MANIFEST": "1"}),
|
|
),
|
|
options=runloop_module.RunloopSandboxClientOptions(
|
|
env_vars={"SHARED": "option", "ONLY_OPTION": "1"},
|
|
),
|
|
)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
|
|
assert sdk.devbox.create_calls
|
|
create_params = sdk.devbox.create_calls[0]
|
|
assert create_params["environment_variables"] == {
|
|
"SHARED": "manifest",
|
|
"ONLY_MANIFEST": "1",
|
|
"ONLY_OPTION": "1",
|
|
}
|
|
|
|
def test_runloop_client_options_preserve_positional_exposed_ports(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
options = runloop_module.RunloopSandboxClientOptions(
|
|
None,
|
|
None,
|
|
None,
|
|
False,
|
|
None,
|
|
None,
|
|
(8765,),
|
|
)
|
|
|
|
assert options.exposed_ports == (8765,)
|
|
|
|
def test_runloop_client_options_append_new_fields_after_existing_positionals(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
options = runloop_module.RunloopSandboxClientOptions(
|
|
None,
|
|
None,
|
|
None,
|
|
False,
|
|
None,
|
|
None,
|
|
(8765,),
|
|
None,
|
|
launch_parameters=runloop_module.RunloopLaunchParameters(
|
|
network_policy_id="np-123",
|
|
),
|
|
managed_secrets={"API_KEY": "secret"},
|
|
)
|
|
|
|
assert options.exposed_ports == (8765,)
|
|
assert options.launch_parameters is not None
|
|
assert options.launch_parameters.network_policy_id == "np-123"
|
|
assert options.managed_secrets == {"API_KEY": "secret"}
|
|
|
|
def test_runloop_sdk_backed_launch_models_construct_from_extension_exports(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
after_idle = runloop_module.RunloopAfterIdle(idle_time_seconds=300, on_idle="suspend")
|
|
launch_parameters = runloop_module.RunloopLaunchParameters(
|
|
network_policy_id="np-123",
|
|
after_idle=after_idle,
|
|
launch_commands=["echo hi"],
|
|
)
|
|
|
|
assert after_idle.idle_time_seconds == 300
|
|
assert launch_parameters.after_idle is not None
|
|
assert launch_parameters.after_idle.on_idle == "suspend"
|
|
assert launch_parameters.to_dict(mode="json", exclude_none=True)["launch_commands"] == [
|
|
"echo hi"
|
|
]
|
|
|
|
def test_runloop_tunnel_config_remains_extension_model(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
tunnel = runloop_module.RunloopTunnelConfig(auth_mode="authenticated")
|
|
|
|
assert isinstance(tunnel, BaseModel)
|
|
assert tunnel.model_dump(mode="json", exclude_none=True) == {"auth_mode": "authenticated"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_passes_runloop_native_launch_options_and_persists_secret_refs(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(
|
|
name="native-runloop",
|
|
user_parameters=runloop_module.RunloopUserParameters(username="user", uid=1000),
|
|
launch_parameters=runloop_module.RunloopLaunchParameters(
|
|
network_policy_id="np-123",
|
|
resource_size_request="MEDIUM",
|
|
custom_cpu_cores=2,
|
|
custom_gb_memory=8,
|
|
custom_disk_size=16,
|
|
architecture="arm64",
|
|
keep_alive_time_seconds=600,
|
|
after_idle=runloop_module.RunloopAfterIdle(
|
|
idle_time_seconds=300,
|
|
on_idle="suspend",
|
|
),
|
|
launch_commands=("echo hi",),
|
|
required_services=("postgres",),
|
|
),
|
|
tunnel=runloop_module.RunloopTunnelConfig(
|
|
auth_mode="authenticated",
|
|
http_keep_alive=True,
|
|
wake_on_http=True,
|
|
),
|
|
gateways={
|
|
"GWS_OPENAI": runloop_module.RunloopGatewaySpec(
|
|
gateway="openai-gateway",
|
|
secret="OPENAI_GATEWAY_SECRET",
|
|
)
|
|
},
|
|
mcp={
|
|
"MCP_TOKEN": runloop_module.RunloopMcpSpec(
|
|
mcp_config="github-readonly",
|
|
secret="MCP_SECRET",
|
|
)
|
|
},
|
|
metadata={"team": "agents"},
|
|
managed_secrets={"API_KEY": "super-secret"},
|
|
),
|
|
)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
|
|
assert sdk.secret.create_calls == [("API_KEY", "super-secret", {"timeout": 30.0})]
|
|
assert sdk.devbox.create_calls
|
|
create_params = sdk.devbox.create_calls[0]
|
|
assert create_params["launch_parameters"] == {
|
|
"network_policy_id": "np-123",
|
|
"resource_size_request": "MEDIUM",
|
|
"custom_cpu_cores": 2.0,
|
|
"custom_gb_memory": 8,
|
|
"custom_disk_size": 16,
|
|
"architecture": "arm64",
|
|
"keep_alive_time_seconds": 600,
|
|
"after_idle": {"idle_time_seconds": 300, "on_idle": "suspend"},
|
|
"launch_commands": ["echo hi"],
|
|
"required_services": ["postgres"],
|
|
"user_parameters": {"username": "user", "uid": 1000},
|
|
}
|
|
assert create_params["tunnel"] == {
|
|
"auth_mode": "authenticated",
|
|
"http_keep_alive": True,
|
|
"wake_on_http": True,
|
|
}
|
|
assert create_params["gateways"] == {
|
|
"GWS_OPENAI": {"gateway": "openai-gateway", "secret": "OPENAI_GATEWAY_SECRET"}
|
|
}
|
|
assert create_params["mcp"] == {
|
|
"MCP_TOKEN": {"mcp_config": "github-readonly", "secret": "MCP_SECRET"}
|
|
}
|
|
assert create_params["metadata"] == {"team": "agents"}
|
|
assert create_params["secrets"] == {"API_KEY": "API_KEY"}
|
|
assert session.state.secret_refs == {"API_KEY": "API_KEY"}
|
|
assert session.state.metadata == {"team": "agents"}
|
|
assert "super-secret" not in json.dumps(session.state.model_dump(mode="json"))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_normalizes_dict_launch_parameters_and_tunnel_options(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(
|
|
launch_parameters={
|
|
"network_policy_id": "np-123",
|
|
"launch_commands": ["echo hi"],
|
|
},
|
|
tunnel={
|
|
"auth_mode": "authenticated",
|
|
"wake_on_http": True,
|
|
},
|
|
)
|
|
)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
|
|
assert sdk.devbox.create_calls[0]["launch_parameters"] == {
|
|
"network_policy_id": "np-123",
|
|
"launch_commands": ["echo hi"],
|
|
}
|
|
assert sdk.devbox.create_calls[0]["tunnel"] == {
|
|
"auth_mode": "authenticated",
|
|
"wake_on_http": True,
|
|
}
|
|
assert session.state.launch_parameters is not None
|
|
assert session.state.launch_parameters.network_policy_id == "np-123"
|
|
assert session.state.tunnel is not None
|
|
assert session.state.tunnel.auth_mode == "authenticated"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_normalizes_dict_launch_parameters_and_tunnel_from_parsed_options(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
options = cast(
|
|
Any,
|
|
BaseSandboxClientOptions.parse(
|
|
{
|
|
"type": "runloop",
|
|
"launch_parameters": {
|
|
"network_policy_id": "np-456",
|
|
"required_services": ["postgres"],
|
|
},
|
|
"tunnel": {
|
|
"auth_mode": "open",
|
|
"http_keep_alive": True,
|
|
},
|
|
}
|
|
),
|
|
)
|
|
|
|
assert options.type == "runloop"
|
|
assert options.launch_parameters is not None
|
|
assert options.tunnel is not None
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=options)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
|
|
assert sdk.devbox.create_calls[0]["launch_parameters"] == {
|
|
"network_policy_id": "np-456",
|
|
"required_services": ["postgres"],
|
|
}
|
|
assert sdk.devbox.create_calls[0]["tunnel"] == {
|
|
"auth_mode": "open",
|
|
"http_keep_alive": True,
|
|
}
|
|
assert session.state.launch_parameters is not None
|
|
assert session.state.launch_parameters.network_policy_id == "np-456"
|
|
assert session.state.tunnel is not None
|
|
assert session.state.tunnel.auth_mode == "open"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_state_round_trip_preserves_runloop_session_state_without_secret_values(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
agent = Agent(name="TestAgent")
|
|
context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={})
|
|
state: RunState[dict[str, str], Agent[Any]] = make_run_state(
|
|
agent,
|
|
context=context,
|
|
original_input="test",
|
|
)
|
|
client = runloop_module.RunloopSandboxClient(bearer_token="test-token")
|
|
session_state = runloop_module.RunloopSandboxSessionState(
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="runloop-state"),
|
|
devbox_id="devbox-123",
|
|
launch_parameters=runloop_module.RunloopLaunchParameters(network_policy_id="np-123"),
|
|
secret_refs={"API_KEY": "API_KEY"},
|
|
)
|
|
serialized_session_state = client.serialize_session_state(session_state)
|
|
state._sandbox = {
|
|
"backend_id": "runloop",
|
|
"current_agent_key": agent.name,
|
|
"current_agent_name": agent.name,
|
|
"session_state": serialized_session_state,
|
|
"sessions_by_agent": {
|
|
agent.name: {
|
|
"agent_name": agent.name,
|
|
"session_state": serialized_session_state,
|
|
}
|
|
},
|
|
}
|
|
|
|
restored = await RunState.from_json(agent, state.to_json())
|
|
|
|
assert restored._sandbox is not None
|
|
restored_session_payload = cast(dict[str, object], restored._sandbox["session_state"])
|
|
assert restored_session_payload["secret_refs"] == {"API_KEY": "API_KEY"}
|
|
assert "managed_secrets" not in restored_session_payload
|
|
assert "secret-value" not in json.dumps(restored_session_payload)
|
|
|
|
restored_session_state = client.deserialize_session_state(restored_session_payload)
|
|
assert isinstance(restored_session_state, runloop_module.RunloopSandboxSessionState)
|
|
assert restored_session_state.secret_refs == {"API_KEY": "API_KEY"}
|
|
assert restored_session_state.launch_parameters is not None
|
|
assert restored_session_state.launch_parameters.network_policy_id == "np-123"
|
|
|
|
await client.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_upserts_managed_secret_when_secret_exists(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
sdk.secret._new_secret(name="API_KEY", value="old-value")
|
|
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(
|
|
managed_secrets={"API_KEY": "new-value"},
|
|
)
|
|
)
|
|
|
|
assert sdk.secret.create_calls == [("API_KEY", "new-value", {"timeout": 30.0})]
|
|
assert sdk.secret.update_calls == [("API_KEY", "new-value", {"timeout": 30.0})]
|
|
assert session.state.secret_refs == {"API_KEY": "API_KEY"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_upserts_managed_secret_when_runloop_returns_bad_request_exists(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
sdk.secret._new_secret(name="API_KEY", value="old-value")
|
|
sdk.secret.conflict_status_code = 400
|
|
sdk.secret.conflict_body = {
|
|
"message": "Secret with name 'API_KEY' already exists",
|
|
}
|
|
sdk.secret.conflict_message = "Secret with name 'API_KEY' already exists"
|
|
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(
|
|
managed_secrets={"API_KEY": "new-value"},
|
|
)
|
|
)
|
|
|
|
assert sdk.secret.create_calls == [("API_KEY", "new-value", {"timeout": 30.0})]
|
|
assert sdk.secret.update_calls == [("API_KEY", "new-value", {"timeout": 30.0})]
|
|
assert session.state.secret_refs == {"API_KEY": "API_KEY"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_and_snapshot_restore_reuse_runloop_native_options(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(
|
|
name="native-runloop",
|
|
launch_parameters=runloop_module.RunloopLaunchParameters(
|
|
network_policy_id="np-123",
|
|
launch_commands=("echo hi",),
|
|
),
|
|
tunnel=runloop_module.RunloopTunnelConfig(auth_mode="open"),
|
|
gateways={
|
|
"GWS_OPENAI": runloop_module.RunloopGatewaySpec(
|
|
gateway="openai-gateway",
|
|
secret="OPENAI_GATEWAY_SECRET",
|
|
)
|
|
},
|
|
mcp={
|
|
"MCP_TOKEN": runloop_module.RunloopMcpSpec(
|
|
mcp_config="github-readonly",
|
|
secret="MCP_SECRET",
|
|
)
|
|
},
|
|
metadata={"team": "agents"},
|
|
managed_secrets={"API_KEY": "super-secret"},
|
|
),
|
|
)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
sdk.devbox.devboxes[session.state.devbox_id].status = "shutdown"
|
|
sdk.devbox.create_calls.clear()
|
|
|
|
resumed = await client.resume(session.state)
|
|
await resumed._inner.hydrate_workspace( # noqa: SLF001
|
|
io.BytesIO(runloop_module._encode_runloop_snapshot_ref(snapshot_id="snap-123")) # noqa: SLF001
|
|
)
|
|
|
|
assert sdk.devbox.create_calls == [
|
|
{
|
|
"timeout": session.state.timeouts.create_s,
|
|
"name": "native-runloop",
|
|
"launch_parameters": {
|
|
"network_policy_id": "np-123",
|
|
"launch_commands": ["echo hi"],
|
|
},
|
|
"tunnel": {"auth_mode": "open"},
|
|
"gateways": {
|
|
"GWS_OPENAI": {
|
|
"gateway": "openai-gateway",
|
|
"secret": "OPENAI_GATEWAY_SECRET",
|
|
}
|
|
},
|
|
"mcp": {
|
|
"MCP_TOKEN": {
|
|
"mcp_config": "github-readonly",
|
|
"secret": "MCP_SECRET",
|
|
}
|
|
},
|
|
"metadata": {"team": "agents"},
|
|
"secrets": {"API_KEY": "API_KEY"},
|
|
}
|
|
]
|
|
assert sdk.devbox.create_from_snapshot_calls == [
|
|
(
|
|
"snap-123",
|
|
{
|
|
"timeout": session.state.timeouts.resume_s,
|
|
"name": "native-runloop",
|
|
"launch_parameters": {
|
|
"network_policy_id": "np-123",
|
|
"launch_commands": ["echo hi"],
|
|
},
|
|
"tunnel": {"auth_mode": "open"},
|
|
"gateways": {
|
|
"GWS_OPENAI": {
|
|
"gateway": "openai-gateway",
|
|
"secret": "OPENAI_GATEWAY_SECRET",
|
|
}
|
|
},
|
|
"mcp": {
|
|
"MCP_TOKEN": {
|
|
"mcp_config": "github-readonly",
|
|
"secret": "MCP_SECRET",
|
|
}
|
|
},
|
|
"metadata": {"team": "agents"},
|
|
"secrets": {"API_KEY": "API_KEY"},
|
|
},
|
|
)
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_platform_blueprints_and_benchmarks_clients(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
blueprint = await client.platform.blueprints.create(name="bp1")
|
|
listed_blueprints = await client.platform.blueprints.list(limit=5)
|
|
public_blueprints = await client.platform.blueprints.list_public(limit=10)
|
|
await client.platform.blueprints.logs(blueprint.id)
|
|
build_info = await client.platform.blueprints.await_build_complete(blueprint.id)
|
|
await client.platform.blueprints.delete(blueprint.id)
|
|
|
|
benchmark = await client.platform.benchmarks.create(
|
|
name="bm1",
|
|
required_secret_names=["API_KEY"],
|
|
)
|
|
listed_benchmarks = await client.platform.benchmarks.list(limit=5)
|
|
public_benchmarks = await client.platform.benchmarks.list_public(limit=10)
|
|
await client.platform.benchmarks.update(benchmark.id, description="desc")
|
|
definitions = await client.platform.benchmarks.definitions(benchmark.id)
|
|
run = await client.platform.benchmarks.start_run(benchmark.id, run_name="eval")
|
|
scenario_update = await client.platform.benchmarks.update_scenarios(
|
|
benchmark.id,
|
|
scenarios_to_add=["scenario-1"],
|
|
)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
|
|
assert blueprint in listed_blueprints
|
|
assert public_blueprints.data
|
|
assert build_info.status == "build_complete"
|
|
assert sdk.api.blueprints.logs_calls == [(blueprint.id, {})]
|
|
assert sdk.api.blueprints.await_build_complete_calls == [(blueprint.id, {})]
|
|
assert benchmark in listed_benchmarks
|
|
assert public_benchmarks.data
|
|
assert definitions.definitions[0].id == f"def-{benchmark.id}"
|
|
assert run.benchmark_id == benchmark.id
|
|
assert scenario_update.scenarios_to_add == ["scenario-1"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_platform_secrets_network_policies_and_axons_clients(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
assert not hasattr(client.platform.axons, "subscribe_sse")
|
|
secret = await client.platform.secrets.create(name="SECRET_A", value="secret-value")
|
|
listed_secrets = await client.platform.secrets.list()
|
|
secret_info = await client.platform.secrets.get("SECRET_A")
|
|
updated_secret = await client.platform.secrets.update(
|
|
name="SECRET_A",
|
|
value="secret-value-2",
|
|
)
|
|
deleted_secret = await client.platform.secrets.delete("SECRET_A")
|
|
|
|
policy = await client.platform.network_policies.create(name="policy-a", allow_all=True)
|
|
listed_policies = await client.platform.network_policies.list()
|
|
await client.platform.network_policies.update(policy.id, description="limited")
|
|
deleted_policy = await client.platform.network_policies.delete(policy.id)
|
|
|
|
axon = await client.platform.axons.create(name="axon-a")
|
|
listed_axons = await client.platform.axons.list()
|
|
publish_result = await client.platform.axons.publish(
|
|
axon.id,
|
|
event_type="task_done",
|
|
origin="AGENT_EVENT",
|
|
payload="{}",
|
|
source="agent",
|
|
)
|
|
query_result = await client.platform.axons.query_sql(axon.id, sql="select 1")
|
|
batch_result = await client.platform.axons.batch_sql(
|
|
axon.id,
|
|
statements=[{"sql": "select 1"}],
|
|
)
|
|
|
|
assert secret in listed_secrets
|
|
assert secret_info.name == "SECRET_A"
|
|
assert updated_secret.name == "SECRET_A"
|
|
assert deleted_secret.name == "SECRET_A"
|
|
assert policy in listed_policies
|
|
assert deleted_policy.id == policy.id
|
|
assert axon in listed_axons
|
|
assert publish_result.published is True
|
|
assert query_result.rows == [["ok"]]
|
|
assert batch_result.results[0].success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_reconnects_suspended_devbox_and_skips_start(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(pause_on_exit=True),
|
|
)
|
|
state = session.state
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
sdk.devbox.create_calls.clear()
|
|
sdk.devbox.devboxes[state.devbox_id].status = "suspended"
|
|
|
|
resumed = await client.resume(state)
|
|
|
|
assert sdk.devbox.from_id_calls == [state.devbox_id]
|
|
assert sdk.devbox.create_calls == []
|
|
assert resumed._inner._skip_start is True # noqa: SLF001
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_reconnects_running_devbox_without_pause(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
await session.start()
|
|
state = session.state
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[state.devbox_id]
|
|
devbox.files["existing.txt"] = b"keep"
|
|
sdk.devbox.create_calls.clear()
|
|
|
|
resumed = await client.resume(state)
|
|
await resumed.start()
|
|
|
|
assert sdk.devbox.from_id_calls == [state.devbox_id]
|
|
assert sdk.devbox.create_calls == []
|
|
assert resumed.state.devbox_id == state.devbox_id
|
|
assert resumed._inner._skip_start is False # noqa: SLF001
|
|
assert devbox.files["existing.txt"] == b"keep"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_reconnected_devbox_without_pause_does_not_reprovision_accounts(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
session.state.snapshot = _RestorableSnapshot(id="snapshot-mismatch")
|
|
state = session.state
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
sdk.devbox.create_calls.clear()
|
|
|
|
resumed = await client.resume(state)
|
|
inner = resumed._inner
|
|
provision_called = False
|
|
|
|
async def _cannot_skip(self: object, *, is_running: bool) -> bool:
|
|
return False
|
|
|
|
async def _restore(self: object) -> None:
|
|
return None
|
|
|
|
async def _provision_accounts() -> None:
|
|
nonlocal provision_called
|
|
provision_called = True
|
|
|
|
async def _reapply(self: object) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
inner,
|
|
"_can_skip_snapshot_restore_on_resume",
|
|
types.MethodType(_cannot_skip, inner),
|
|
)
|
|
monkeypatch.setattr(
|
|
inner,
|
|
"_restore_snapshot_into_workspace_on_resume",
|
|
types.MethodType(_restore, inner),
|
|
)
|
|
monkeypatch.setattr(inner, "provision_manifest_accounts", _provision_accounts)
|
|
monkeypatch.setattr(
|
|
inner,
|
|
"_reapply_ephemeral_manifest_on_resume",
|
|
types.MethodType(_reapply, inner),
|
|
)
|
|
|
|
await resumed.start()
|
|
|
|
assert sdk.devbox.from_id_calls == [state.devbox_id]
|
|
assert sdk.devbox.create_calls == []
|
|
assert provision_called is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_recreates_terminal_devbox_without_pause(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
state = session.state
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
sdk.devbox.devboxes[state.devbox_id].status = "shutdown"
|
|
sdk.devbox.create_calls.clear()
|
|
original_devbox_id = state.devbox_id
|
|
|
|
resumed = await client.resume(state)
|
|
|
|
assert sdk.devbox.from_id_calls == [original_devbox_id]
|
|
assert len(sdk.devbox.create_calls) == 1
|
|
assert resumed.state.devbox_id != original_devbox_id
|
|
assert resumed._inner._skip_start is False # noqa: SLF001
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_waits_for_devbox_running_before_skip_start(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(pause_on_exit=True),
|
|
)
|
|
session.state.snapshot = _RestorableSnapshot(id="resume-race")
|
|
state = session.state
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
sdk.devbox.create_calls.clear()
|
|
devbox = sdk.devbox.devboxes[state.devbox_id]
|
|
devbox.status = "suspended"
|
|
devbox.resume_returns_before_running = True
|
|
|
|
resumed = await client.resume(state)
|
|
inner = resumed._inner
|
|
|
|
async def _can_skip(self: object, *, is_running: bool) -> bool:
|
|
return is_running
|
|
|
|
async def _reapply(self: object) -> None:
|
|
return None
|
|
|
|
async def _restore(self: object) -> None:
|
|
raise AssertionError("resume should wait for running instead of restoring snapshot")
|
|
|
|
monkeypatch.setattr(
|
|
inner,
|
|
"_can_skip_snapshot_restore_on_resume",
|
|
types.MethodType(_can_skip, inner),
|
|
)
|
|
monkeypatch.setattr(
|
|
inner,
|
|
"_reapply_ephemeral_manifest_on_resume",
|
|
types.MethodType(_reapply, inner),
|
|
)
|
|
monkeypatch.setattr(
|
|
inner,
|
|
"_restore_snapshot_into_workspace_on_resume",
|
|
types.MethodType(_restore, inner),
|
|
)
|
|
|
|
await resumed.start()
|
|
|
|
assert devbox.resume_calls == 1
|
|
assert devbox.await_running_calls == 1
|
|
assert devbox.status == "running"
|
|
assert sdk.devbox.create_calls == []
|
|
assert resumed._inner._skip_start is True # noqa: SLF001
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skip_start_resume_passes_dependencies_to_snapshot_restorable(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
dependencies = Dependencies().bind_value("test.dep", object())
|
|
|
|
async with runloop_module.RunloopSandboxClient(dependencies=dependencies) as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(pause_on_exit=True),
|
|
)
|
|
snapshot = _DependencyAwareSnapshot(id="dep-aware")
|
|
session.state.snapshot = snapshot
|
|
state = session.state
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
sdk.devbox.devboxes[state.devbox_id].status = "suspended"
|
|
|
|
resumed = await client.resume(state)
|
|
inner = resumed._inner
|
|
|
|
async def _can_skip(self: object, *, is_running: bool) -> bool:
|
|
return is_running
|
|
|
|
async def _reapply(self: object) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
inner,
|
|
"_can_skip_snapshot_restore_on_resume",
|
|
types.MethodType(_can_skip, inner),
|
|
)
|
|
monkeypatch.setattr(
|
|
inner,
|
|
"_reapply_ephemeral_manifest_on_resume",
|
|
types.MethodType(_reapply, inner),
|
|
)
|
|
|
|
await resumed.start()
|
|
|
|
assert snapshot.restorable_dependencies
|
|
assert snapshot.restorable_dependencies[-1] is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_root_launch_exec_and_io_use_root_home(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(root="/root/project"),
|
|
options=runloop_module.RunloopSandboxClientOptions(
|
|
user_parameters=runloop_module.RunloopUserParameters(
|
|
username="root",
|
|
uid=0,
|
|
)
|
|
),
|
|
)
|
|
await session.start()
|
|
await session.exec("pwd && echo hello", shell=True)
|
|
exec_sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
exec_devbox = exec_sdk.devbox.devboxes[session.state.devbox_id]
|
|
command, _ = exec_devbox.exec_calls[-1]
|
|
await session.write("/root/project/output.txt", io.BytesIO(b"hello"))
|
|
payload = await session.read("/root/project/output.txt")
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
assert payload.read() == b"hello"
|
|
assert "cd /root/project &&" in command
|
|
assert devbox.files["project/output.txt"] == b"hello"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_shuts_down_runloop_devbox(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(),
|
|
)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
await client.delete(session)
|
|
|
|
assert devbox.shutdown_calls == 1
|
|
assert devbox.status == "shutdown"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_exposed_port_enables_tunnel_and_formats_endpoint(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(exposed_ports=(4500,)),
|
|
)
|
|
await session.start()
|
|
endpoint = await session.resolve_exposed_port(4500)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
assert endpoint == ExposedPortEndpoint(
|
|
host="4500-test-key.tunnel.runloop.ai",
|
|
port=443,
|
|
tls=True,
|
|
)
|
|
assert devbox.enable_tunnel_calls
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_timeout_raises_for_runloop_one_shot_exec(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
await session.start()
|
|
with pytest.raises(runloop_module.ExecTimeoutError):
|
|
await session.exec("sleep-forever", shell=False, timeout=0.01)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
executions = list(sdk.executions.values())
|
|
|
|
assert executions
|
|
assert any("sleep-forever" in execution.command for execution in executions)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_maps_runloop_http_408_to_timeout_with_provider_context(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
await session.start()
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
async def _raise_timeout(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise _FakeAPIStatusError(
|
|
408,
|
|
body={"error": "execution timed out"},
|
|
url=f"https://api.runloop.ai/v1/devboxes/{devbox.id}/execute",
|
|
method="POST",
|
|
)
|
|
|
|
monkeypatch.setattr(devbox.cmd, "exec", _raise_timeout)
|
|
|
|
with pytest.raises(runloop_module.ExecTimeoutError) as exc_info:
|
|
await session.exec("pwd", shell=False, timeout=3.0)
|
|
|
|
assert exc_info.value.context["http_status"] == 408
|
|
assert exc_info.value.context["cause_type"] == "_FakeAPIStatusError"
|
|
assert exc_info.value.context["request_method"] == "POST"
|
|
assert exc_info.value.context["request_url"] == (
|
|
f"https://api.runloop.ai/v1/devboxes/{devbox.id}/execute"
|
|
)
|
|
assert exc_info.value.context["provider_body"] == {"error": "execution timed out"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_maps_runloop_http_error_to_transport_with_provider_context(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
await session.start()
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
async def _raise_rate_limit(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise _FakeAPIStatusError(
|
|
429,
|
|
body={"error": "rate limited"},
|
|
url=f"https://api.runloop.ai/v1/devboxes/{devbox.id}/execute",
|
|
method="POST",
|
|
)
|
|
|
|
monkeypatch.setattr(devbox.cmd, "exec", _raise_rate_limit)
|
|
|
|
with pytest.raises(runloop_module.ExecTransportError) as exc_info:
|
|
await session.exec("pwd", shell=False)
|
|
|
|
assert exc_info.value.context["http_status"] == 429
|
|
assert exc_info.value.context["cause_type"] == "_FakeAPIStatusError"
|
|
assert exc_info.value.context["provider_body"] == {"error": "rate limited"}
|
|
assert exc_info.value.context["detail"] == "exec_failed"
|
|
assert exc_info.value.retryable is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_marks_typed_runloop_bad_request_non_retryable(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
await session.start()
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
async def _raise_bad_request(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise _FakeBadRequestError(
|
|
body={"error": "invalid command"},
|
|
url=f"https://api.runloop.ai/v1/devboxes/{devbox.id}/execute",
|
|
method="POST",
|
|
)
|
|
|
|
monkeypatch.setattr(devbox.cmd, "exec", _raise_bad_request)
|
|
|
|
with pytest.raises(runloop_module.ExecTransportError) as exc_info:
|
|
await session.exec("pwd", shell=False)
|
|
|
|
assert exc_info.value.context["http_status"] == 400
|
|
assert exc_info.value.context["cause_type"] == "_FakeBadRequestError"
|
|
assert exc_info.value.context["provider_body"] == {"error": "invalid command"}
|
|
assert exc_info.value.context["detail"] == "exec_failed"
|
|
assert exc_info.value.retryable is False
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "expected_retryable"),
|
|
[
|
|
(400, False),
|
|
(401, False),
|
|
(403, False),
|
|
(404, False),
|
|
(408, True),
|
|
(422, False),
|
|
(429, True),
|
|
(500, True),
|
|
(502, True),
|
|
(503, True),
|
|
(504, True),
|
|
],
|
|
)
|
|
def test_runloop_retryability_status_table(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
status: int,
|
|
expected_retryable: bool,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
error = _FakeAPIStatusError(status, body={"error": f"HTTP {status}"})
|
|
|
|
assert runloop_module._runloop_provider_retryability(error) is expected_retryable
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_wraps_command_with_workspace_context(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(
|
|
root=f"{runloop_module.DEFAULT_RUNLOOP_WORKSPACE_ROOT}/project",
|
|
environment=Environment(value={"ONLY_MANIFEST": "1"}),
|
|
),
|
|
options=runloop_module.RunloopSandboxClientOptions(env_vars={"ONLY_OPTION": "2"}),
|
|
)
|
|
await session.start()
|
|
await session.exec("pwd && echo hello", shell=True)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
assert devbox.exec_calls
|
|
command, params = devbox.exec_calls[-1]
|
|
assert "cd /home/user/project &&" in command
|
|
assert "env --" in command
|
|
assert "ONLY_MANIFEST=1" in command
|
|
assert "ONLY_OPTION=2" in command
|
|
assert "attach_stdin" not in params
|
|
assert "polling_config" in params
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_and_write_use_normalized_absolute_paths(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
await session.start()
|
|
await session.write(
|
|
"/home/user/project/output.txt",
|
|
io.BytesIO(b"hello"),
|
|
)
|
|
payload = await session.read("/home/user/project/output.txt")
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
assert payload.read() == b"hello"
|
|
assert devbox.files["project/output.txt"] == b"hello"
|
|
assert devbox.file_upload_paths == ["/home/user/project/output.txt"]
|
|
assert devbox.file_download_paths == ["/home/user/project/output.txt"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_and_write_extra_path_grant_use_file_api_directly(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(
|
|
root="/home/user/project",
|
|
extra_path_grants=(SandboxPathGrant(path="/tmp"),),
|
|
),
|
|
options=runloop_module.RunloopSandboxClientOptions(),
|
|
)
|
|
await session.start()
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
exec_count = len(devbox.exec_calls)
|
|
|
|
await session.write("/tmp/output.txt", io.BytesIO(b"hello"))
|
|
payload = await session.read("/tmp/output.txt")
|
|
|
|
assert payload.read() == b"hello"
|
|
assert devbox.files["/tmp/output.txt"] == b"hello"
|
|
assert devbox.file_upload_paths == ["/tmp/output.txt"]
|
|
assert devbox.file_download_paths == ["/tmp/output.txt"]
|
|
assert len(devbox.exec_calls) == exec_count + 7
|
|
assert devbox.exec_calls[exec_count + 4][0] == "mkdir -p -- /tmp"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(
|
|
root="/home/user/project",
|
|
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),),
|
|
),
|
|
options=runloop_module.RunloopSandboxClientOptions(),
|
|
)
|
|
await session.start()
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
devbox.symlinks["/home/user/project/link"] = "/tmp/protected"
|
|
|
|
with pytest.raises(runloop_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.write("link/result.txt", io.BytesIO(b"blocked"))
|
|
|
|
assert devbox.file_upload_paths == []
|
|
assert str(exc_info.value) == (
|
|
"failed to write archive for path: /home/user/project/link/result.txt"
|
|
)
|
|
assert exc_info.value.context == {
|
|
"path": "/home/user/project/link/result.txt",
|
|
"reason": "read_only_extra_path_grant",
|
|
"grant_path": "/tmp/protected",
|
|
"resolved_path": "/tmp/protected/result.txt",
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_wraps_runloop_http_error_with_provider_context(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
await session.start()
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
async def _raise_download_error(**kwargs: object) -> bytes:
|
|
_ = kwargs
|
|
raise _FakeAPIStatusError(
|
|
500,
|
|
body={"error": "download failed"},
|
|
url=f"https://api.runloop.ai/v1/devboxes/{devbox.id}/files/project/output.txt",
|
|
method="GET",
|
|
)
|
|
|
|
monkeypatch.setattr(devbox.file, "download", _raise_download_error)
|
|
|
|
with pytest.raises(runloop_module.WorkspaceArchiveReadError) as exc_info:
|
|
await session.read("/home/user/project/output.txt")
|
|
|
|
assert exc_info.value.context["http_status"] == 500
|
|
assert exc_info.value.context["cause_type"] == "_FakeAPIStatusError"
|
|
assert exc_info.value.context["provider_body"] == {"error": "download failed"}
|
|
assert exc_info.value.context["detail"] == "file_download_failed"
|
|
assert exc_info.value.retryable is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_wraps_runloop_http_error_with_provider_context(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
await session.start()
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
async def _raise_upload_error(**kwargs: object) -> object:
|
|
_ = kwargs
|
|
raise _FakeAPIStatusError(
|
|
429,
|
|
body={"error": "upload rate limited"},
|
|
url=f"https://api.runloop.ai/v1/devboxes/{devbox.id}/files/project/output.txt",
|
|
method="PUT",
|
|
)
|
|
|
|
monkeypatch.setattr(devbox.file, "upload", _raise_upload_error)
|
|
|
|
with pytest.raises(runloop_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.write("/home/user/project/output.txt", io.BytesIO(b"hello"))
|
|
|
|
assert exc_info.value.context["http_status"] == 429
|
|
assert exc_info.value.context["cause_type"] == "_FakeAPIStatusError"
|
|
assert exc_info.value.context["provider_body"] == {"error": "upload rate limited"}
|
|
assert exc_info.value.context["detail"] == "file_upload_failed"
|
|
assert exc_info.value.retryable is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manifest_apply_preserves_existing_files_in_non_empty_directory(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(
|
|
root=f"{runloop_module.DEFAULT_RUNLOOP_WORKSPACE_ROOT}/project",
|
|
entries={"new.txt": File(content=b"new")},
|
|
),
|
|
options=runloop_module.RunloopSandboxClientOptions(),
|
|
)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
devbox.files["project/existing.txt"] = b"keep"
|
|
|
|
await session.start()
|
|
|
|
assert devbox.files["project/existing.txt"] == b"keep"
|
|
assert devbox.files["project/new.txt"] == b"new"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_workspace_returns_native_snapshot_ref_and_hydrate_recreates_devbox(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
old_devbox_id = session.state.devbox_id
|
|
archive = await session.persist_workspace()
|
|
snapshot_id = runloop_module._decode_runloop_snapshot_ref(archive.read()) # noqa: SLF001
|
|
await session.hydrate_workspace(
|
|
io.BytesIO(runloop_module._encode_runloop_snapshot_ref(snapshot_id="snap-1")) # noqa: SLF001
|
|
)
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
|
|
assert snapshot_id == "snap-1"
|
|
assert sdk.devbox.create_from_snapshot_calls == [
|
|
("snap-1", {"timeout": session.state.timeouts.resume_s})
|
|
]
|
|
assert session.state.devbox_id != old_devbox_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_snapshot_on_resume_bypasses_workspace_clear(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(),
|
|
)
|
|
session.state.snapshot = _RestorableSnapshot(
|
|
id="runloop-snapshot",
|
|
payload=runloop_module._encode_runloop_snapshot_ref(snapshot_id="snap-9"), # noqa: SLF001
|
|
)
|
|
state = session.state
|
|
resumed = await client.resume(state)
|
|
inner = resumed._inner
|
|
|
|
async def _unexpected_clear() -> None:
|
|
raise AssertionError("workspace clear should be bypassed for Runloop restore")
|
|
|
|
inner._clear_workspace_root_on_resume = _unexpected_clear # noqa: SLF001
|
|
await inner._restore_snapshot_into_workspace_on_resume() # noqa: SLF001
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
|
|
assert sdk.devbox.create_from_snapshot_calls == [
|
|
("snap-9", {"timeout": state.timeouts.resume_s})
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_tar_snapshot_on_resume_clears_workspace_before_hydrate(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(root=f"{runloop_module.DEFAULT_RUNLOOP_WORKSPACE_ROOT}/project"),
|
|
options=runloop_module.RunloopSandboxClientOptions(),
|
|
)
|
|
session.state.snapshot = _RestorableSnapshot(
|
|
id="tar-snapshot",
|
|
payload=_build_tar_bytes({"new.txt": b"new"}),
|
|
)
|
|
resumed = await client.resume(session.state)
|
|
inner = resumed._inner
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[resumed.state.devbox_id]
|
|
devbox.files["project/existing.txt"] = b"stale"
|
|
cleared = False
|
|
|
|
async def _clear_workspace_root_on_resume() -> None:
|
|
nonlocal cleared
|
|
cleared = True
|
|
devbox.files.pop("project/existing.txt", None)
|
|
|
|
inner._clear_workspace_root_on_resume = ( # noqa: SLF001
|
|
_clear_workspace_root_on_resume
|
|
)
|
|
await inner._restore_snapshot_into_workspace_on_resume() # noqa: SLF001
|
|
|
|
assert cleared is True
|
|
assert devbox.files["project/new.txt"] == b"new"
|
|
assert "project/existing.txt" not in devbox.files
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_snapshot_on_resume_passes_dependencies_to_snapshot_restore(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
dependencies = Dependencies().bind_value("test.dep", object())
|
|
|
|
async with runloop_module.RunloopSandboxClient(dependencies=dependencies) as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
snapshot = _DependencyAwareSnapshot(
|
|
id="dep-aware-restore",
|
|
payload=runloop_module._encode_runloop_snapshot_ref(snapshot_id="snap-dep"), # noqa: SLF001
|
|
)
|
|
session.state.snapshot = snapshot
|
|
resumed = await client.resume(session.state)
|
|
|
|
await resumed._inner._restore_snapshot_into_workspace_on_resume() # noqa: SLF001
|
|
|
|
assert snapshot.restore_dependencies
|
|
assert snapshot.restore_dependencies[-1] is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hydrate_workspace_wraps_provider_error_with_snapshot_context(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
|
|
async def _raise_restore_error(snapshot_id: str, **kwargs: object) -> object:
|
|
_ = (snapshot_id, kwargs)
|
|
raise _FakeAPIStatusError(
|
|
500,
|
|
body={"error": "restore failed"},
|
|
url="https://api.runloop.ai/v1/devboxes/from_snapshot",
|
|
method="POST",
|
|
)
|
|
|
|
monkeypatch.setattr(sdk.devbox, "create_from_snapshot", _raise_restore_error)
|
|
|
|
with pytest.raises(runloop_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.hydrate_workspace(
|
|
io.BytesIO(runloop_module._encode_runloop_snapshot_ref(snapshot_id="snap-7")) # noqa: SLF001
|
|
)
|
|
|
|
assert exc_info.value.context["reason"] == "snapshot_restore_failed"
|
|
assert exc_info.value.context["snapshot_id"] == "snap-7"
|
|
assert exc_info.value.context["http_status"] == 500
|
|
assert exc_info.value.context["cause_type"] == "_FakeAPIStatusError"
|
|
assert exc_info.value.context["provider_body"] == {"error": "restore failed"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hydrate_workspace_accepts_tar_fallback_payload(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
archive = _build_tar_bytes({"notes/output.txt": b"from tar"})
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
await session.hydrate_workspace(io.BytesIO(archive))
|
|
payload = await session.read("/home/user/notes/output.txt")
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
assert payload.read() == b"from tar"
|
|
assert f".sandbox-runloop-hydrate-{session.state.session_id.hex}.tar" not in devbox.files
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hydrate_workspace_rejects_invalid_non_snapshot_non_tar_payload(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
|
|
with pytest.raises(runloop_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.hydrate_workspace(io.BytesIO(b"not-a-valid-tar"))
|
|
|
|
assert exc_info.value.context["reason"] == "unsafe_or_invalid_tar"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_workspace_remounts_mounts_after_snapshot(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
mount = _RecordingMount()
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(
|
|
root=runloop_module.DEFAULT_RUNLOOP_WORKSPACE_ROOT,
|
|
entries={"mount": mount},
|
|
),
|
|
options=runloop_module.RunloopSandboxClientOptions(),
|
|
)
|
|
archive = await session.persist_workspace()
|
|
|
|
assert runloop_module._decode_runloop_snapshot_ref(archive.read()) == "snap-1" # noqa: SLF001
|
|
mount_path = Path(f"{runloop_module.DEFAULT_RUNLOOP_WORKSPACE_ROOT}/mount")
|
|
assert mount._unmounted_paths == [mount_path]
|
|
assert mount._mounted_paths == [mount_path]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_exposed_port_wraps_provider_error_with_context(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(exposed_ports=(4500,))
|
|
)
|
|
await session.start()
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
async def _raise_tunnel_error(*args: object, **kwargs: object) -> str | None:
|
|
_ = (args, kwargs)
|
|
raise _FakeAPIStatusError(
|
|
429,
|
|
body={"error": "tunnel rate limited"},
|
|
url=f"https://api.runloop.ai/v1/devboxes/{devbox.id}",
|
|
method="GET",
|
|
)
|
|
|
|
monkeypatch.setattr(devbox, "get_tunnel_url", _raise_tunnel_error)
|
|
|
|
with pytest.raises(runloop_module.ExposedPortUnavailableError) as exc_info:
|
|
await session.resolve_exposed_port(4500)
|
|
|
|
assert exc_info.value.context["http_status"] == 429
|
|
assert exc_info.value.context["cause_type"] == "_FakeAPIStatusError"
|
|
assert exc_info.value.context["provider_body"] == {"error": "tunnel rate limited"}
|
|
assert exc_info.value.context["detail"] == "get_tunnel_url_failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_exposed_port_keeps_invalid_url_detail_for_parse_errors(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(
|
|
options=runloop_module.RunloopSandboxClientOptions(exposed_ports=(4500,))
|
|
)
|
|
await session.start()
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
|
|
async def _invalid_tunnel_url(*args: object, **kwargs: object) -> str | None:
|
|
_ = (args, kwargs)
|
|
return "https://"
|
|
|
|
monkeypatch.setattr(devbox, "get_tunnel_url", _invalid_tunnel_url)
|
|
|
|
with pytest.raises(runloop_module.ExposedPortUnavailableError) as exc_info:
|
|
await session.resolve_exposed_port(4500)
|
|
|
|
assert exc_info.value.context["detail"] == "invalid_tunnel_url"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runloop_shell_capability_does_not_expose_write_stdin(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
capability = Shell()
|
|
capability.bind(session)
|
|
tools = capability.tools()
|
|
|
|
assert [tool.name for tool in tools] == ["exec_command"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_command_tool_uses_one_shot_exec_for_tty_requests(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
runloop_module = _load_runloop_module(monkeypatch)
|
|
|
|
async with runloop_module.RunloopSandboxClient() as client:
|
|
session = await client.create(options=runloop_module.RunloopSandboxClientOptions())
|
|
await session.start()
|
|
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
|
|
devbox = sdk.devbox.devboxes[session.state.devbox_id]
|
|
exec_calls_before = len(devbox.exec_calls)
|
|
exec_async_calls_before = len(devbox.exec_async_calls)
|
|
|
|
output = await ExecCommandTool(session=session).run(
|
|
ExecCommandArgs(cmd="echo hello", tty=True, yield_time_ms=50)
|
|
)
|
|
|
|
assert "Process exited with code 0" in output
|
|
assert "Process running with session ID" not in output
|
|
assert "hello" in output
|
|
assert len(devbox.exec_calls) == exec_calls_before + 1
|
|
assert len(devbox.exec_async_calls) == exec_async_calls_before
|