diff --git a/hyperbrowser/client/managers/async_manager/sandbox.py b/hyperbrowser/client/managers/async_manager/sandbox.py index 1e74edce..6c68707a 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -217,16 +217,24 @@ async def create_runtime_session( ) return _copy_model(self._runtime_session) - async def exec(self, input: Union[str, SandboxExecParams]): - if isinstance(input, str): - params = SandboxExecParams(command=input) - else: - if not isinstance(input, SandboxExecParams): - raise TypeError( - "input must be a command string or SandboxExecParams instance" - ) - params = input - return await self.processes.exec(params) + async def exec( + self, + input: Union[str, SandboxExecParams], + *, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + timeout_ms: Optional[int] = None, + timeout_sec: Optional[int] = None, + run_as: Optional[str] = None, + ): + return await self.processes.exec( + input, + cwd=cwd, + env=env, + timeout_ms=timeout_ms, + timeout_sec=timeout_sec, + run_as=run_as, + ) async def get_process(self, process_id: str) -> SandboxProcessHandle: return await self.processes.get(process_id) diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py index 359bd8c9..d807224f 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py @@ -5,7 +5,7 @@ import json import socket from datetime import datetime -from typing import AsyncIterator, Callable, List, Optional, Union +from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Union from urllib.parse import urlencode from websockets.asyncio.client import connect as async_ws_connect @@ -249,10 +249,21 @@ def __init__( transport: RuntimeTransport, get_connection_info, runtime_proxy_override: Optional[str] = None, + default_run_as: Optional[str] = None, ): self._transport = transport self._get_connection_info = get_connection_info self._runtime_proxy_override = runtime_proxy_override + self._default_run_as = default_run_as.strip() if default_run_as else None + + def with_run_as(self, run_as: Optional[str]): + normalized = run_as.strip() if run_as else None + return SandboxFilesApi( + self._transport, + self._get_connection_info, + self._runtime_proxy_override, + default_run_as=normalized, + ) async def list( self, @@ -266,17 +277,19 @@ async def list( payload = await self._transport.request_json( "/sandbox/files", - params={ - "path": path, - "depth": depth, - }, + params=self._with_run_as_params( + { + "path": path, + "depth": depth, + } + ), ) return [_normalize_file_info(entry) for entry in payload.get("entries", [])] async def get_info(self, path: str) -> SandboxFileInfo: payload = await self._transport.request_json( "/sandbox/files/stat", - params={"path": path}, + params=self._with_run_as_params({"path": path}), ) return _normalize_file_info(payload["file"]) @@ -357,10 +370,12 @@ async def write( payload = await self._transport.request_json( "/sandbox/files/write", method="POST", - json_body={ - "path": path_or_files, - **_encode_write_data(data), - }, + json_body=self._with_run_as_body( + { + "path": path_or_files, + **_encode_write_data(data), + } + ), headers={"content-type": "application/json"}, ) return _normalize_write_info(payload["files"][0]) @@ -377,7 +392,7 @@ async def write( payload = await self._transport.request_json( "/sandbox/files/write", method="POST", - json_body={"files": encoded_files}, + json_body=self._with_run_as_body({"files": encoded_files}), headers={"content-type": "application/json"}, ) return [_normalize_write_info(entry) for entry in payload.get("files", [])] @@ -419,7 +434,7 @@ async def upload(self, path: str, data: Union[str, bytes, bytearray]): payload = await self._transport.request_json( "/sandbox/files/upload", method="PUT", - params={"path": path}, + params=self._with_run_as_params({"path": path}), content=body, ) return SandboxFileTransferResult(**payload) @@ -427,7 +442,7 @@ async def upload(self, path: str, data: Union[str, bytes, bytearray]): async def download(self, path: str) -> bytes: return await self._transport.request_bytes( "/sandbox/files/download", - params={"path": path}, + params=self._with_run_as_params({"path": path}), ) async def make_dir( @@ -440,11 +455,13 @@ async def make_dir( payload = await self._transport.request_json( "/sandbox/files/mkdir", method="POST", - json_body={ - "path": path, - "parents": parents, - "mode": mode, - }, + json_body=self._with_run_as_body( + { + "path": path, + "parents": parents, + "mode": mode, + } + ), headers={"content-type": "application/json"}, ) return bool(payload.get("created")) @@ -475,7 +492,7 @@ async def rename( payload = await self._transport.request_json( "/sandbox/files/move", method="POST", - json_body=payload, + json_body=self._with_run_as_body(payload), headers={"content-type": "application/json"}, ) return _normalize_file_info(payload["entry"]) @@ -493,10 +510,12 @@ async def remove(self, path: str, *, recursive: Optional[bool] = None) -> None: await self._transport.request_json( "/sandbox/files/delete", method="POST", - json_body=SandboxFileDeleteParams( - path=path, - recursive=recursive, - ).model_dump(exclude_none=True), + json_body=self._with_run_as_body( + SandboxFileDeleteParams( + path=path, + recursive=recursive, + ).model_dump(exclude_none=True) + ), headers={"content-type": "application/json"}, ) @@ -527,12 +546,14 @@ async def copy( payload = await self._transport.request_json( "/sandbox/files/copy", method="POST", - json_body={ - "from": normalized.source, - "to": normalized.destination, - "recursive": normalized.recursive, - "overwrite": normalized.overwrite, - }, + json_body=self._with_run_as_body( + { + "from": normalized.source, + "to": normalized.destination, + "recursive": normalized.recursive, + "overwrite": normalized.overwrite, + } + ), headers={"content-type": "application/json"}, ) return _normalize_file_info(payload["entry"]) @@ -558,7 +579,7 @@ async def chmod( await self._transport.request_json( "/sandbox/files/chmod", method="POST", - json_body=normalized.model_dump(exclude_none=True), + json_body=self._with_run_as_body(normalized.model_dump(exclude_none=True)), headers={"content-type": "application/json"}, ) @@ -585,7 +606,7 @@ async def chown( await self._transport.request_json( "/sandbox/files/chown", method="POST", - json_body=normalized.model_dump(exclude_none=True), + json_body=self._with_run_as_body(normalized.model_dump(exclude_none=True)), headers={"content-type": "application/json"}, ) @@ -593,10 +614,12 @@ async def watch(self, path: str, *, recursive: Optional[bool] = None): payload = await self._transport.request_json( "/sandbox/files/watch", method="POST", - json_body={ - "path": path, - "recursive": recursive, - }, + json_body=self._with_run_as_body( + { + "path": path, + "recursive": recursive, + } + ), headers={"content-type": "application/json"}, ) return SandboxFileWatchHandle( @@ -646,11 +669,13 @@ async def upload_url( payload = await self._transport.request_json( "/sandbox/files/presign-upload", method="POST", - json_body=SandboxPresignFileParams( - path=path, - expires_in_seconds=expires_in_seconds, - one_time=one_time, - ).model_dump(exclude_none=True, by_alias=True), + json_body=self._with_run_as_body( + SandboxPresignFileParams( + path=path, + expires_in_seconds=expires_in_seconds, + one_time=one_time, + ).model_dump(exclude_none=True, by_alias=True) + ), headers={"content-type": "application/json"}, ) return SandboxPresignedUrl(**payload) @@ -665,11 +690,13 @@ async def download_url( payload = await self._transport.request_json( "/sandbox/files/presign-download", method="POST", - json_body=SandboxPresignFileParams( - path=path, - expires_in_seconds=expires_in_seconds, - one_time=one_time, - ).model_dump(exclude_none=True, by_alias=True), + json_body=self._with_run_as_body( + SandboxPresignFileParams( + path=path, + expires_in_seconds=expires_in_seconds, + one_time=one_time, + ).model_dump(exclude_none=True, by_alias=True) + ), headers={"content-type": "application/json"}, ) return SandboxPresignedUrl(**payload) @@ -685,12 +712,14 @@ async def _read_wire( payload = await self._transport.request_json( "/sandbox/files/read", method="POST", - json_body={ - "path": path, - "offset": offset, - "length": length, - "encoding": encoding, - }, + json_body=self._with_run_as_body( + { + "path": path, + "offset": offset, + "length": length, + "encoding": encoding, + } + ), headers={"content-type": "application/json"}, ) return SandboxFileReadResult(**payload) @@ -707,13 +736,31 @@ async def _write_single( payload = await self._transport.request_json( "/sandbox/files/write", method="POST", - json_body={ - "path": path, - "data": data, - "append": append, - "mode": mode, - "encoding": encoding, - }, + json_body=self._with_run_as_body( + { + "path": path, + "data": data, + "append": append, + "mode": mode, + "encoding": encoding, + } + ), headers={"content-type": "application/json"}, ) return _normalize_write_info(payload["files"][0]) + + def _with_run_as_params( + self, params: Dict[str, Union[str, int, bool, None]] + ) -> Dict[str, Union[str, int, bool, None]]: + if not self._default_run_as: + return params + enriched = dict(params) + enriched["runAs"] = self._default_run_as + return enriched + + def _with_run_as_body(self, body: Dict[str, Any]) -> Dict[str, Any]: + if not self._default_run_as: + return body + enriched = dict(body) + enriched["runAs"] = self._default_run_as + return enriched diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py index 3743f4bc..3b0a11bc 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py @@ -10,6 +10,7 @@ SandboxProcessStdinParams, SandboxProcessSummary, ) +from ...sandboxes.shared import _normalize_exec_params from .sandbox_transport import RuntimeTransport DEFAULT_PROCESS_KILL_WAIT_SECONDS = 5.0 @@ -147,24 +148,54 @@ class SandboxProcessesApi: def __init__(self, transport: RuntimeTransport): self._transport = transport - async def exec(self, input: SandboxExecParams) -> SandboxProcessResult: - if not isinstance(input, SandboxExecParams): - raise TypeError("input must be a SandboxExecParams instance") + async def exec( + self, + input: Union[str, SandboxExecParams], + *, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + timeout_ms: Optional[int] = None, + timeout_sec: Optional[int] = None, + run_as: Optional[str] = None, + ) -> SandboxProcessResult: + params = _normalize_exec_params( + input, + cwd=cwd, + env=env, + timeout_ms=timeout_ms, + timeout_sec=timeout_sec, + run_as=run_as, + ) payload = await self._transport.request_json( "/sandbox/exec", method="POST", - json_body=input.model_dump(exclude_none=True, by_alias=True), + json_body=params.model_dump(exclude_none=True, by_alias=True), headers={"content-type": "application/json"}, ) return SandboxProcessResult(**payload["result"]) - async def start(self, input: SandboxExecParams) -> SandboxProcessHandle: - if not isinstance(input, SandboxExecParams): - raise TypeError("input must be a SandboxExecParams instance") + async def start( + self, + input: Union[str, SandboxExecParams], + *, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + timeout_ms: Optional[int] = None, + timeout_sec: Optional[int] = None, + run_as: Optional[str] = None, + ) -> SandboxProcessHandle: + params = _normalize_exec_params( + input, + cwd=cwd, + env=env, + timeout_ms=timeout_ms, + timeout_sec=timeout_sec, + run_as=run_as, + ) payload = await self._transport.request_json( "/sandbox/processes", method="POST", - json_body=input.model_dump(exclude_none=True, by_alias=True), + json_body=params.model_dump(exclude_none=True, by_alias=True), headers={"content-type": "application/json"}, ) return SandboxProcessHandle( diff --git a/hyperbrowser/client/managers/sandboxes/shared.py b/hyperbrowser/client/managers/sandboxes/shared.py index 986a1958..0bc85fb7 100644 --- a/hyperbrowser/client/managers/sandboxes/shared.py +++ b/hyperbrowser/client/managers/sandboxes/shared.py @@ -1,11 +1,13 @@ import base64 import posixpath +import re from datetime import datetime, timedelta, timezone from typing import Dict, Optional, Union from urllib.parse import urlencode, urlsplit, urlunsplit from ....exceptions import HyperbrowserError from ....models.sandbox import ( + SandboxExecParams, SandboxFileInfo, SandboxFileWriteEntry, SandboxFileWriteInfo, @@ -18,12 +20,70 @@ ) DEFAULT_WATCH_TIMEOUT_MS = 60_000 +SHELL_SAFE_TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_@%+=:,./-]+$") def _copy_model(model): return model.model_copy(deep=True) +def _quote_shell_token(token: str) -> str: + if token == "": + return "''" + if SHELL_SAFE_TOKEN_PATTERN.fullmatch(token): + return token + return "'" + token.replace("'", "'\"'\"'") + "'" + + +def _normalize_legacy_process_fields(params: SandboxExecParams) -> SandboxExecParams: + updates = {} + + if params.args: + updates["command"] = " ".join( + _quote_shell_token(token) for token in [params.command, *params.args] + ) + + if params.args is not None: + updates["args"] = None + + if params.use_shell is not None: + updates["use_shell"] = None + + return params.model_copy(update=updates) if updates else params + + +def _normalize_exec_params( + input: Union[str, SandboxExecParams], + *, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + timeout_ms: Optional[int] = None, + timeout_sec: Optional[int] = None, + run_as: Optional[str] = None, +) -> SandboxExecParams: + if isinstance(input, str): + params = SandboxExecParams(command=input) + elif isinstance(input, SandboxExecParams): + params = input + else: + raise TypeError("input must be a command string or SandboxExecParams instance") + + updates = {} + if cwd is not None: + updates["cwd"] = cwd + if env is not None: + updates["env"] = env + if timeout_ms is not None: + updates["timeout_ms"] = timeout_ms + if timeout_sec is not None: + updates["timeout_sec"] = timeout_sec + if run_as is not None: + updates["run_as"] = run_as + + normalized = params.model_copy(update=updates) if updates else params + return _normalize_legacy_process_fields(normalized) + + def _build_sandbox_exposed_url(runtime, port: int) -> str: parsed = urlsplit(runtime.base_url) hostname = parsed.hostname diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index 1d4cb916..1856bc8b 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -217,16 +217,24 @@ def create_runtime_session( ) return _copy_model(self._runtime_session) - def exec(self, input: Union[str, SandboxExecParams]): - if isinstance(input, str): - params = SandboxExecParams(command=input) - else: - if not isinstance(input, SandboxExecParams): - raise TypeError( - "input must be a command string or SandboxExecParams instance" - ) - params = input - return self.processes.exec(params) + def exec( + self, + input: Union[str, SandboxExecParams], + *, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + timeout_ms: Optional[int] = None, + timeout_sec: Optional[int] = None, + run_as: Optional[str] = None, + ): + return self.processes.exec( + input, + cwd=cwd, + env=env, + timeout_ms=timeout_ms, + timeout_sec=timeout_sec, + run_as=run_as, + ) def get_process(self, process_id: str) -> SandboxProcessHandle: return self.processes.get(process_id) diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py index ba7fcd7e..e9ad175a 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py @@ -4,7 +4,7 @@ import socket import threading from datetime import datetime -from typing import Callable, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union from urllib.parse import urlencode from websockets.exceptions import ConnectionClosed @@ -238,10 +238,21 @@ def __init__( transport: RuntimeTransport, get_connection_info, runtime_proxy_override: Optional[str] = None, + default_run_as: Optional[str] = None, ): self._transport = transport self._get_connection_info = get_connection_info self._runtime_proxy_override = runtime_proxy_override + self._default_run_as = default_run_as.strip() if default_run_as else None + + def with_run_as(self, run_as: Optional[str]): + normalized = run_as.strip() if run_as else None + return SandboxFilesApi( + self._transport, + self._get_connection_info, + self._runtime_proxy_override, + default_run_as=normalized, + ) def exists(self, path: str) -> bool: try: @@ -260,7 +271,7 @@ def exists(self, path: str) -> bool: def get_info(self, path: str) -> SandboxFileInfo: payload = self._transport.request_json( "/sandbox/files/stat", - params={"path": path}, + params=self._with_run_as_params({"path": path}), ) return _normalize_file_info(payload["file"]) @@ -279,10 +290,12 @@ def list( payload = self._transport.request_json( "/sandbox/files", - params={ + params=self._with_run_as_params( + { "path": path, "depth": depth, - }, + } + ), ) return [_normalize_file_info(entry) for entry in payload.get("entries", [])] @@ -338,10 +351,12 @@ def write( payload = self._transport.request_json( "/sandbox/files/write", method="POST", - json_body={ - "path": path_or_files, - **_encode_write_data(data), - }, + json_body=self._with_run_as_body( + { + "path": path_or_files, + **_encode_write_data(data), + } + ), headers={"content-type": "application/json"}, ) return _normalize_write_info(payload["files"][0]) @@ -358,7 +373,7 @@ def write( payload = self._transport.request_json( "/sandbox/files/write", method="POST", - json_body={"files": encoded_files}, + json_body=self._with_run_as_body({"files": encoded_files}), headers={"content-type": "application/json"}, ) return [_normalize_write_info(entry) for entry in payload.get("files", [])] @@ -400,7 +415,7 @@ def upload(self, path: str, data: Union[str, bytes, bytearray]): payload = self._transport.request_json( "/sandbox/files/upload", method="PUT", - params={"path": path}, + params=self._with_run_as_params({"path": path}), content=body, ) return SandboxFileTransferResult(**payload) @@ -408,7 +423,7 @@ def upload(self, path: str, data: Union[str, bytes, bytearray]): def download(self, path: str) -> bytes: return self._transport.request_bytes( "/sandbox/files/download", - params={"path": path}, + params=self._with_run_as_params({"path": path}), ) def make_dir( @@ -421,11 +436,13 @@ def make_dir( payload = self._transport.request_json( "/sandbox/files/mkdir", method="POST", - json_body={ - "path": path, - "parents": parents, - "mode": mode, - }, + json_body=self._with_run_as_body( + { + "path": path, + "parents": parents, + "mode": mode, + } + ), headers={"content-type": "application/json"}, ) return bool(payload.get("created")) @@ -456,7 +473,7 @@ def rename( payload = self._transport.request_json( "/sandbox/files/move", method="POST", - json_body=payload, + json_body=self._with_run_as_body(payload), headers={"content-type": "application/json"}, ) return _normalize_file_info(payload["entry"]) @@ -474,10 +491,12 @@ def remove(self, path: str, *, recursive: Optional[bool] = None) -> None: self._transport.request_json( "/sandbox/files/delete", method="POST", - json_body=SandboxFileDeleteParams( - path=path, - recursive=recursive, - ).model_dump(exclude_none=True), + json_body=self._with_run_as_body( + SandboxFileDeleteParams( + path=path, + recursive=recursive, + ).model_dump(exclude_none=True) + ), headers={"content-type": "application/json"}, ) @@ -508,12 +527,14 @@ def copy( payload = self._transport.request_json( "/sandbox/files/copy", method="POST", - json_body={ - "from": normalized.source, - "to": normalized.destination, - "recursive": normalized.recursive, - "overwrite": normalized.overwrite, - }, + json_body=self._with_run_as_body( + { + "from": normalized.source, + "to": normalized.destination, + "recursive": normalized.recursive, + "overwrite": normalized.overwrite, + } + ), headers={"content-type": "application/json"}, ) return _normalize_file_info(payload["entry"]) @@ -539,7 +560,7 @@ def chmod( self._transport.request_json( "/sandbox/files/chmod", method="POST", - json_body=normalized.model_dump(exclude_none=True), + json_body=self._with_run_as_body(normalized.model_dump(exclude_none=True)), headers={"content-type": "application/json"}, ) @@ -566,7 +587,7 @@ def chown( self._transport.request_json( "/sandbox/files/chown", method="POST", - json_body=normalized.model_dump(exclude_none=True), + json_body=self._with_run_as_body(normalized.model_dump(exclude_none=True)), headers={"content-type": "application/json"}, ) @@ -574,10 +595,12 @@ def watch(self, path: str, *, recursive: Optional[bool] = None): payload = self._transport.request_json( "/sandbox/files/watch", method="POST", - json_body={ - "path": path, - "recursive": recursive, - }, + json_body=self._with_run_as_body( + { + "path": path, + "recursive": recursive, + } + ), headers={"content-type": "application/json"}, ) return SandboxFileWatchHandle( @@ -627,11 +650,13 @@ def upload_url( payload = self._transport.request_json( "/sandbox/files/presign-upload", method="POST", - json_body=SandboxPresignFileParams( - path=path, - expires_in_seconds=expires_in_seconds, - one_time=one_time, - ).model_dump(exclude_none=True, by_alias=True), + json_body=self._with_run_as_body( + SandboxPresignFileParams( + path=path, + expires_in_seconds=expires_in_seconds, + one_time=one_time, + ).model_dump(exclude_none=True, by_alias=True) + ), headers={"content-type": "application/json"}, ) return SandboxPresignedUrl(**payload) @@ -646,11 +671,13 @@ def download_url( payload = self._transport.request_json( "/sandbox/files/presign-download", method="POST", - json_body=SandboxPresignFileParams( - path=path, - expires_in_seconds=expires_in_seconds, - one_time=one_time, - ).model_dump(exclude_none=True, by_alias=True), + json_body=self._with_run_as_body( + SandboxPresignFileParams( + path=path, + expires_in_seconds=expires_in_seconds, + one_time=one_time, + ).model_dump(exclude_none=True, by_alias=True) + ), headers={"content-type": "application/json"}, ) return SandboxPresignedUrl(**payload) @@ -666,12 +693,14 @@ def _read_wire( payload = self._transport.request_json( "/sandbox/files/read", method="POST", - json_body={ - "path": path, - "offset": offset, - "length": length, - "encoding": encoding, - }, + json_body=self._with_run_as_body( + { + "path": path, + "offset": offset, + "length": length, + "encoding": encoding, + } + ), headers={"content-type": "application/json"}, ) return SandboxFileReadResult(**payload) @@ -688,13 +717,31 @@ def _write_single( payload = self._transport.request_json( "/sandbox/files/write", method="POST", - json_body={ - "path": path, - "data": data, - "append": append, - "mode": mode, - "encoding": encoding, - }, + json_body=self._with_run_as_body( + { + "path": path, + "data": data, + "append": append, + "mode": mode, + "encoding": encoding, + } + ), headers={"content-type": "application/json"}, ) return _normalize_write_info(payload["files"][0]) + + def _with_run_as_params( + self, params: Dict[str, Union[str, int, bool, None]] + ) -> Dict[str, Union[str, int, bool, None]]: + if not self._default_run_as: + return params + enriched = dict(params) + enriched["runAs"] = self._default_run_as + return enriched + + def _with_run_as_body(self, body: Dict[str, Any]) -> Dict[str, Any]: + if not self._default_run_as: + return body + enriched = dict(body) + enriched["runAs"] = self._default_run_as + return enriched diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py index 13dfc16c..ba424988 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py @@ -10,6 +10,7 @@ SandboxProcessStdinParams, SandboxProcessSummary, ) +from ...sandboxes.shared import _normalize_exec_params from .sandbox_transport import RuntimeTransport DEFAULT_PROCESS_KILL_WAIT_SECONDS = 5.0 @@ -143,24 +144,54 @@ class SandboxProcessesApi: def __init__(self, transport: RuntimeTransport): self._transport = transport - def exec(self, input: SandboxExecParams) -> SandboxProcessResult: - if not isinstance(input, SandboxExecParams): - raise TypeError("input must be a SandboxExecParams instance") + def exec( + self, + input: Union[str, SandboxExecParams], + *, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + timeout_ms: Optional[int] = None, + timeout_sec: Optional[int] = None, + run_as: Optional[str] = None, + ) -> SandboxProcessResult: + params = _normalize_exec_params( + input, + cwd=cwd, + env=env, + timeout_ms=timeout_ms, + timeout_sec=timeout_sec, + run_as=run_as, + ) payload = self._transport.request_json( "/sandbox/exec", method="POST", - json_body=input.model_dump(exclude_none=True, by_alias=True), + json_body=params.model_dump(exclude_none=True, by_alias=True), headers={"content-type": "application/json"}, ) return SandboxProcessResult(**payload["result"]) - def start(self, input: SandboxExecParams) -> SandboxProcessHandle: - if not isinstance(input, SandboxExecParams): - raise TypeError("input must be a SandboxExecParams instance") + def start( + self, + input: Union[str, SandboxExecParams], + *, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + timeout_ms: Optional[int] = None, + timeout_sec: Optional[int] = None, + run_as: Optional[str] = None, + ) -> SandboxProcessHandle: + params = _normalize_exec_params( + input, + cwd=cwd, + env=env, + timeout_ms=timeout_ms, + timeout_sec=timeout_sec, + run_as=run_as, + ) payload = self._transport.request_json( "/sandbox/processes", method="POST", - json_body=input.model_dump(exclude_none=True, by_alias=True), + json_body=params.model_dump(exclude_none=True, by_alias=True), headers={"content-type": "application/json"}, ) return SandboxProcessHandle( diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index bd82a925..a5e3a51f 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -300,6 +300,7 @@ class SandboxExecParams(SandboxBaseModel): env: Optional[Dict[str, str]] = None timeout_ms: Optional[int] = Field(default=None, serialization_alias="timeoutMs") timeout_sec: Optional[int] = None + run_as: Optional[str] = Field(default=None, serialization_alias="runAs") use_shell: Optional[bool] = Field(default=None, serialization_alias="useShell") diff --git a/pyproject.toml b/pyproject.toml index fd16276c..b5298aed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hyperbrowser" -version = "0.90.0" +version = "0.90.1" description = "Python SDK for hyperbrowser" authors = ["Nikhil Shahi "] license = "MIT" diff --git a/tests/sandbox/e2e/test_async_files.py b/tests/sandbox/e2e/test_async_files.py index 8eefac59..4e45e658 100644 --- a/tests/sandbox/e2e/test_async_files.py +++ b/tests/sandbox/e2e/test_async_files.py @@ -18,8 +18,8 @@ def _read_stream_text(stream) -> str: return stream.read().decode("utf-8") -def _bash_exec(command: str) -> SandboxExecParams: - return SandboxExecParams(command="bash", args=["-lc", command]) +def _bash_exec(command: str, run_as: str = "root") -> SandboxExecParams: + return SandboxExecParams(command="bash", args=["-lc", command], run_as=run_as) async def _await_future(future: asyncio.Future, timeout: float = 10.0): @@ -65,16 +65,17 @@ async def test_async_sandbox_files_e2e(): default_sandbox_params("py-async-files") ) await wait_for_runtime_ready_async(sandbox) + files = sandbox.files.with_run_as("root") - assert await sandbox.files.exists(f"{base_dir}/missing.txt") is False + assert await files.exists(f"{base_dir}/missing.txt") is False path = f"{base_dir}/dirs/root" - assert await sandbox.files.make_dir(path) is True - assert await sandbox.files.make_dir(path) is False + assert await files.make_dir(path) is True + assert await files.make_dir(path) is False info_path = f"{base_dir}/info/hello.txt" - await sandbox.files.write_text(info_path, "hello from sdk files") - info = await sandbox.files.get_info(info_path) + await files.write_text(info_path, "hello from sdk files") + info = await files.get_info(info_path) assert info.name == "hello.txt" assert info.path == info_path assert info.type == "file" @@ -86,18 +87,18 @@ async def test_async_sandbox_files_e2e(): assert info.modified_time is not None list_dir = f"{base_dir}/list" - await sandbox.files.make_dir(f"{list_dir}/nested/inner", parents=True) - await sandbox.files.write_text(f"{list_dir}/root.txt", "root") - await sandbox.files.write_text(f"{list_dir}/nested/child.txt", "child") - await sandbox.files.write_text( + await files.make_dir(f"{list_dir}/nested/inner", parents=True) + await files.write_text(f"{list_dir}/root.txt", "root") + await files.write_text(f"{list_dir}/nested/child.txt", "child") + await files.write_text( f"{list_dir}/nested/inner/grandchild.txt", "grandchild" ) - depth_one = await sandbox.files.list(list_dir, depth=1) + depth_one = await files.list(list_dir, depth=1) assert [entry.name for entry in depth_one] == ["nested", "root.txt"] assert [entry.type for entry in depth_one] == ["dir", "file"] - depth_two = await sandbox.files.list(list_dir, depth=2) + depth_two = await files.list(list_dir, depth=2) assert [entry.path for entry in depth_two] == [ f"{list_dir}/nested", f"{list_dir}/nested/child.txt", @@ -108,20 +109,20 @@ async def test_async_sandbox_files_e2e(): symlink_dir = f"{base_dir}/list-symlink" target = f"{symlink_dir}/target.txt" link = f"{symlink_dir}/link.txt" - await sandbox.files.make_dir(symlink_dir) - await sandbox.files.write_text(target, "payload") + await files.make_dir(symlink_dir) + await files.write_text(target, "payload") result = await sandbox.exec(_bash_exec(f'ln -sfn "{target}" "{link}"')) assert result.exit_code == 0 link_entry = next( entry - for entry in await sandbox.files.list(symlink_dir, depth=1) + for entry in await files.list(symlink_dir, depth=1) if entry.path == link ) assert link_entry.symlink_target == target symlink_target = f"{base_dir}/symlink/target.txt" symlink_link = f"{base_dir}/symlink/link.txt" - await sandbox.files.write_text(symlink_target, "target") + await files.write_text(symlink_target, "target") result = await sandbox.exec( _bash_exec( f'mkdir -p "{base_dir}/symlink" && ln -sfn "{symlink_target}" "{symlink_link}"' @@ -129,7 +130,7 @@ async def test_async_sandbox_files_e2e(): ) assert result.exit_code == 0 assert ( - await sandbox.files.get_info(symlink_link) + await files.get_info(symlink_link) ).symlink_target == symlink_target broken_target = f"{base_dir}/symlink-broken/missing-target.txt" @@ -140,39 +141,39 @@ async def test_async_sandbox_files_e2e(): ) ) assert result.exit_code == 0 - assert await sandbox.files.exists(broken_link) is True + assert await files.exists(broken_link) is True assert ( - await sandbox.files.get_info(broken_link) + await files.get_info(broken_link) ).symlink_target == broken_target read_path = f"{base_dir}/read/readme.txt" - await sandbox.files.write_text(read_path, "hello from sdk files") - assert await sandbox.files.read(read_path) == "hello from sdk files" + await files.write_text(read_path, "hello from sdk files") + assert await files.read(read_path) == "hello from sdk files" assert ( - await sandbox.files.read(read_path, format="text", offset=6, length=4) + await files.read(read_path, format="text", offset=6, length=4) == "from" ) assert ( - await sandbox.files.read(read_path, format="bytes") + await files.read(read_path, format="bytes") == b"hello from sdk files" ) assert ( - await sandbox.files.read(read_path, format="blob") + await files.read(read_path, format="blob") == b"hello from sdk files" ) assert ( - _read_stream_text(await sandbox.files.read(read_path, format="stream")) + _read_stream_text(await files.read(read_path, format="stream")) == "hello from sdk files" ) - single = await sandbox.files.write( + single = await files.write( f"{base_dir}/write/single.txt", "single file" ) assert single.name == "single.txt" assert single.path == f"{base_dir}/write/single.txt" - assert await sandbox.files.read_text(single.path) == "single file" + assert await files.read_text(single.path) == "single file" - batch = await sandbox.files.write( + batch = await files.write( [ SandboxFileWriteEntry( path=f"{base_dir}/write/batch-a.txt", @@ -186,37 +187,37 @@ async def test_async_sandbox_files_e2e(): ) assert [entry.name for entry in batch] == ["batch-a.txt", "batch-b.bin"] assert ( - await sandbox.files.read_text(f"{base_dir}/write/batch-a.txt") == "batch-a" + await files.read_text(f"{base_dir}/write/batch-a.txt") == "batch-a" ) - assert await sandbox.files.read_bytes(f"{base_dir}/write/batch-b.bin") == bytes( + assert await files.read_bytes(f"{base_dir}/write/batch-b.bin") == bytes( [1, 2, 3, 4] ) text_path = f"{base_dir}/write-options/text.txt" - await sandbox.files.write_text(text_path, "hello", mode="0640") - await sandbox.files.write_text(text_path, " world", append=True) - assert await sandbox.files.read_text(text_path) == "hello world" - assert (await sandbox.files.get_info(text_path)).mode == 0o640 + await files.write_text(text_path, "hello", mode="0640") + await files.write_text(text_path, " world", append=True) + assert await files.read_text(text_path) == "hello world" + assert (await files.get_info(text_path)).mode == 0o640 bytes_path = f"{base_dir}/write-options/bytes.bin" - await sandbox.files.write_bytes(bytes_path, bytes([1, 2]), mode="0600") - await sandbox.files.write_bytes(bytes_path, bytes([3]), append=True) - assert await sandbox.files.read_bytes(bytes_path) == bytes([1, 2, 3]) + await files.write_bytes(bytes_path, bytes([1, 2]), mode="0600") + await files.write_bytes(bytes_path, bytes([3]), append=True) + assert await files.read_bytes(bytes_path) == bytes([1, 2, 3]) transfer_path = f"{base_dir}/transfer/upload.txt" - uploaded = await sandbox.files.upload(transfer_path, "uploaded from sdk") + uploaded = await files.upload(transfer_path, "uploaded from sdk") assert uploaded.bytes_written > 0 - assert (await sandbox.files.download(transfer_path)).decode( + assert (await files.download(transfer_path)).decode( "utf-8" ) == "uploaded from sdk" file_path = f"{base_dir}/rename/hello.txt" renamed_path = f"{base_dir}/rename/hello-renamed.txt" - await sandbox.files.write_text(file_path, "rename me") - renamed = await sandbox.files.rename(file_path, renamed_path) + await files.write_text(file_path, "rename me") + renamed = await files.rename(file_path, renamed_path) assert renamed.path == renamed_path - assert await sandbox.files.exists(file_path) is False - assert await sandbox.files.read_text(renamed_path) == "rename me" + assert await files.exists(file_path) is False + assert await files.read_text(renamed_path) == "rename me" link_path = f"{base_dir}/rename/hello-link.txt" copied_link_path = f"{base_dir}/rename/hello-link-copy.txt" @@ -225,145 +226,145 @@ async def test_async_sandbox_files_e2e(): _bash_exec(f'ln -sfn "{renamed_path}" "{link_path}"') ) assert result.exit_code == 0 - copied_link = await sandbox.files.copy( + copied_link = await files.copy( source=link_path, destination=copied_link_path ) assert copied_link.path == copied_link_path assert ( - await sandbox.files.get_info(copied_link_path) + await files.get_info(copied_link_path) ).symlink_target == renamed_path - renamed_link = await sandbox.files.rename(copied_link_path, renamed_link_path) + renamed_link = await files.rename(copied_link_path, renamed_link_path) assert renamed_link.path == renamed_link_path assert ( - await sandbox.files.get_info(renamed_link_path) + await files.get_info(renamed_link_path) ).symlink_target == renamed_path target_dir = f"{base_dir}/rename-dir/target-dir" link_dir = f"{base_dir}/rename-dir/link-dir" renamed_link_dir = f"{base_dir}/rename-dir/link-dir-renamed" - await sandbox.files.make_dir(target_dir) - await sandbox.files.write_text(f"{target_dir}/child.txt", "child") + await files.make_dir(target_dir) + await files.write_text(f"{target_dir}/child.txt", "child") result = await sandbox.exec(_bash_exec(f'ln -sfn "{target_dir}" "{link_dir}"')) assert result.exit_code == 0 - renamed = await sandbox.files.rename(link_dir, renamed_link_dir) + renamed = await files.rename(link_dir, renamed_link_dir) assert renamed.path == renamed_link_dir assert ( - await sandbox.files.get_info(renamed_link_dir) + await files.get_info(renamed_link_dir) ).symlink_target == target_dir assert [ - entry.path for entry in await sandbox.files.list(renamed_link_dir, depth=1) + entry.path for entry in await files.list(renamed_link_dir, depth=1) ] == [f"{target_dir}/child.txt"] source_dir = f"{base_dir}/copy-tree/source" nested_dir = f"{source_dir}/nested" nested_target = f"{nested_dir}/target.txt" destination_dir = f"{base_dir}/copy-tree/destination" - await sandbox.files.make_dir(nested_dir) - await sandbox.files.write_text(nested_target, "payload") + await files.make_dir(nested_dir) + await files.write_text(nested_target, "payload") result = await sandbox.exec( _bash_exec(f'cd "{nested_dir}" && ln -sfn "target.txt" "link.txt"') ) assert result.exit_code == 0 - await sandbox.files.copy( + await files.copy( source=source_dir, destination=destination_dir, recursive=True ) copied_target = f"{destination_dir}/nested/target.txt" copied_link = f"{destination_dir}/nested/link.txt" - assert await sandbox.files.read_text(copied_target) == "payload" + assert await files.read_text(copied_target) == "payload" assert ( - await sandbox.files.get_info(copied_link) + await files.get_info(copied_link) ).symlink_target == copied_target loop_dir = f"{base_dir}/loop-list" loop_nested_dir = f"{loop_dir}/nested" - await sandbox.files.make_dir(loop_nested_dir) - await sandbox.files.write_text(f"{loop_nested_dir}/child.txt", "payload") + await files.make_dir(loop_nested_dir) + await files.write_text(f"{loop_nested_dir}/child.txt", "payload") result = await sandbox.exec( _bash_exec(f'cd "{loop_nested_dir}" && ln -sfn .. loop') ) assert result.exit_code == 0 - loop_entries = await sandbox.files.list(loop_dir, depth=4) + loop_entries = await files.list(loop_dir, depth=4) loop_paths = [entry.path for entry in loop_entries] assert f"{loop_nested_dir}/loop" in loop_paths assert not any("/loop/" in path for path in loop_paths) assert ( - await sandbox.files.get_info(f"{loop_nested_dir}/loop") + await files.get_info(f"{loop_nested_dir}/loop") ).symlink_target == loop_dir source_dir = f"{base_dir}/loop-copy/source" nested_dir = f"{source_dir}/nested" - await sandbox.files.make_dir(nested_dir) - await sandbox.files.write_text(f"{nested_dir}/child.txt", "payload") + await files.make_dir(nested_dir) + await files.write_text(f"{nested_dir}/child.txt", "payload") result = await sandbox.exec(_bash_exec(f'cd "{nested_dir}" && ln -sfn .. loop')) assert result.exit_code == 0 destination_dir = f"{base_dir}/loop-copy/destination" - await sandbox.files.copy( + await files.copy( source=source_dir, destination=destination_dir, recursive=True ) copied_loop = f"{destination_dir}/nested/loop" assert ( - await sandbox.files.get_info(copied_loop) + await files.get_info(copied_loop) ).symlink_target == destination_dir assert not any( "/loop/" in entry.path - for entry in await sandbox.files.list(destination_dir, depth=4) + for entry in await files.list(destination_dir, depth=4) ) source = f"{base_dir}/copy-overwrite/source.txt" existing_target = f"{base_dir}/copy-overwrite/existing-target.txt" destination_link = f"{base_dir}/copy-overwrite/destination-link.txt" - await sandbox.files.write_text(source, "source payload") - await sandbox.files.write_text(existing_target, "existing target") + await files.write_text(source, "source payload") + await files.write_text(existing_target, "existing target") result = await sandbox.exec( _bash_exec( f'mkdir -p "{base_dir}/copy-overwrite" && ln -sfn "{existing_target}" "{destination_link}"' ) ) assert result.exit_code == 0 - await sandbox.files.copy( + await files.copy( source=source, destination=destination_link, overwrite=True ) - assert await sandbox.files.read_text(destination_link) == "source payload" - assert await sandbox.files.read_text(existing_target) == "existing target" - assert (await sandbox.files.get_info(destination_link)).symlink_target is None + assert await files.read_text(destination_link) == "source payload" + assert await files.read_text(existing_target) == "existing target" + assert (await files.get_info(destination_link)).symlink_target is None move_source = f"{base_dir}/move-overwrite/source.txt" move_existing_target = f"{base_dir}/move-overwrite/existing-target.txt" move_destination_link = f"{base_dir}/move-overwrite/destination-link.txt" - await sandbox.files.write_text(move_source, "move source payload") - await sandbox.files.write_text(move_existing_target, "move existing target") + await files.write_text(move_source, "move source payload") + await files.write_text(move_existing_target, "move existing target") result = await sandbox.exec( _bash_exec( f'mkdir -p "{base_dir}/move-overwrite" && ln -sfn "{move_existing_target}" "{move_destination_link}"' ) ) assert result.exit_code == 0 - await sandbox.files.move( + await files.move( source=move_source, destination=move_destination_link, overwrite=True, ) assert ( - await sandbox.files.read_text(move_destination_link) + await files.read_text(move_destination_link) == "move source payload" ) assert ( - await sandbox.files.read_text(move_existing_target) + await files.read_text(move_existing_target) == "move existing target" ) assert ( - await sandbox.files.get_info(move_destination_link) + await files.get_info(move_destination_link) ).symlink_target is None - assert await sandbox.files.exists(move_source) is False + assert await files.exists(move_source) is False chmod_path = f"{base_dir}/chmod/file.txt" - await sandbox.files.write_text(chmod_path, "chmod me") - await sandbox.files.chmod(path=chmod_path, mode="0640") - assert (await sandbox.files.get_info(chmod_path)).mode == 0o640 + await files.write_text(chmod_path, "chmod me") + await files.chmod(path=chmod_path, mode="0640") + assert (await files.get_info(chmod_path)).mode == 0o640 try: await expect_hyperbrowser_error_async( "file chown", - lambda: sandbox.files.chown(path=chmod_path, uid=0, gid=0), + lambda: files.chown(path=chmod_path, uid=0, gid=0), status_code=400, service="runtime", retryable=False, @@ -372,75 +373,75 @@ async def test_async_sandbox_files_e2e(): except AssertionError as error: if "expected HyperbrowserError, but call succeeded" not in str(error): raise - assert (await sandbox.files.get_info(chmod_path)).name == "file.txt" + assert (await files.get_info(chmod_path)).name == "file.txt" remove_path = f"{base_dir}/remove/file.txt" - await sandbox.files.write_text(remove_path, "remove me") - await sandbox.files.remove(remove_path) - assert await sandbox.files.exists(remove_path) is False - await sandbox.files.remove(remove_path) - await sandbox.files.remove(f"{base_dir}/remove", recursive=True) - assert await sandbox.files.exists(f"{base_dir}/remove") is False + await files.write_text(remove_path, "remove me") + await files.remove(remove_path) + assert await files.exists(remove_path) is False + await files.remove(remove_path) + await files.remove(f"{base_dir}/remove", recursive=True) + assert await files.exists(f"{base_dir}/remove") is False target = f"{base_dir}/remove-link/target.txt" link = f"{base_dir}/remove-link/link.txt" - await sandbox.files.write_text(target, "keep me") + await files.write_text(target, "keep me") result = await sandbox.exec( _bash_exec( f'mkdir -p "{base_dir}/remove-link" && ln -sfn "{target}" "{link}"' ) ) assert result.exit_code == 0 - await sandbox.files.remove(link) - assert await sandbox.files.exists(link) is False - assert await sandbox.files.read_text(target) == "keep me" + await files.remove(link) + assert await files.exists(link) is False + assert await files.read_text(target) == "keep me" target_dir = f"{base_dir}/remove-recursive/target-dir" target_file = f"{target_dir}/child.txt" link_dir = f"{base_dir}/remove-recursive/link-dir" - await sandbox.files.make_dir(target_dir) - await sandbox.files.write_text(target_file, "keep tree") + await files.make_dir(target_dir) + await files.write_text(target_file, "keep tree") result = await sandbox.exec( _bash_exec( f'mkdir -p "{base_dir}/remove-recursive" && ln -sfn "{target_dir}" "{link_dir}"' ) ) assert result.exit_code == 0 - await sandbox.files.remove(link_dir, recursive=True) - assert await sandbox.files.exists(link_dir) is False - assert await sandbox.files.read_text(target_file) == "keep tree" + await files.remove(link_dir, recursive=True) + assert await files.exists(link_dir) is False + assert await files.read_text(target_file) == "keep tree" link = f"{base_dir}/escape/file-link" result = await sandbox.exec( _bash_exec(f'mkdir -p "{base_dir}/escape" && ln -sfn /etc/hosts "{link}"') ) assert result.exit_code == 0 - text = await sandbox.files.read_text(link) + text = await files.read_text(link) assert "localhost" in text - assert "localhost" in (await sandbox.files.download(link)).decode("utf-8") + assert "localhost" in (await files.download(link)).decode("utf-8") fixture = await _create_parent_symlink_escape_fixture( sandbox, base_dir, "parent-escape-read" ) assert ( - await sandbox.files.read_text(fixture["escaped_file"]) == "outside secret" + await files.read_text(fixture["escaped_file"]) == "outside secret" ) - assert (await sandbox.files.download(fixture["escaped_file"])).decode( + assert (await files.download(fixture["escaped_file"])).decode( "utf-8" ) == "outside secret" assert [ entry.path - for entry in await sandbox.files.list(fixture["link_dir"], depth=1) + for entry in await files.list(fixture["link_dir"], depth=1) ] == [f"{fixture['outside_dir']}/secret.txt"] seen = asyncio.get_running_loop().create_future() async def on_parent_event(event): - if event.type == "write" and event.name == "fresh.txt" and not seen.done(): + if event.type in {"create", "write"} and event.name == "fresh.txt" and not seen.done(): seen.set_result(event.name) - handle = await sandbox.files.watch_dir(fixture["link_dir"], on_parent_event) + handle = await files.watch_dir(fixture["link_dir"], on_parent_event) try: - await sandbox.files.write_text( + await files.write_text( f"{fixture['outside_dir']}/fresh.txt", "watch parent link" ) assert await _await_future(seen) == "fresh.txt" @@ -450,24 +451,24 @@ async def on_parent_event(event): fixture = await _create_parent_symlink_escape_fixture( sandbox, base_dir, "parent-escape-mutate" ) - info = await sandbox.files.get_info(fixture["escaped_file"]) + info = await files.get_info(fixture["escaped_file"]) assert info.type == "file" assert info.size == len("outside secret") - copied = await sandbox.files.copy( + copied = await files.copy( source=fixture["escaped_file"], destination=f"{base_dir}/parent-escape-mutate/copied.txt", ) assert copied.path == f"{base_dir}/parent-escape-mutate/copied.txt" - assert await sandbox.files.read_text(copied.path) == "outside secret" - renamed = await sandbox.files.rename( + assert await files.read_text(copied.path) == "outside secret" + renamed = await files.rename( fixture["escaped_file"], f"{base_dir}/parent-escape-mutate/renamed.txt", ) assert renamed.path == f"{base_dir}/parent-escape-mutate/renamed.txt" - assert await sandbox.files.exists(fixture["outside_file"]) is False - assert await sandbox.files.read_text(renamed.path) == "outside secret" - await sandbox.files.write_text(fixture["escaped_file"], "remove me") - await sandbox.files.remove(fixture["escaped_file"]) + assert await files.exists(fixture["outside_file"]) is False + assert await files.read_text(renamed.path) == "outside secret" + await files.write_text(fixture["escaped_file"], "remove me") + await files.remove(fixture["escaped_file"]) outside_read = await sandbox.exec( _bash_exec( f'if [ -e "{fixture["outside_file"]}" ]; then cat "{fixture["outside_file"]}"; else printf "__MISSING__"; fi' @@ -485,18 +486,18 @@ async def on_parent_event(event): ) ) assert result.exit_code == 0 - assert [entry.path for entry in await sandbox.files.list(link, depth=1)] == [ + assert [entry.path for entry in await files.list(link, depth=1)] == [ target_file ] seen = asyncio.get_running_loop().create_future() async def on_link_event(event): - if event.type == "write" and event.name == "file.txt" and not seen.done(): + if event.type in {"create", "write"} and event.name == "file.txt" and not seen.done(): seen.set_result(event.name) - handle = await sandbox.files.watch_dir(link, on_link_event) + handle = await files.watch_dir(link, on_link_event) try: - await sandbox.files.write_text( + await files.write_text( f"{target_dir}/file.txt", "watch through link" ) assert await _await_future(seen) == "file.txt" @@ -504,7 +505,7 @@ async def on_link_event(event): await handle.stop() watch_dir = f"{base_dir}/watch" - await sandbox.files.make_dir(f"{watch_dir}/nested", parents=True) + await files.make_dir(f"{watch_dir}/nested", parents=True) direct_future = asyncio.get_running_loop().create_future() recursive_future = asyncio.get_running_loop().create_future() @@ -524,15 +525,15 @@ async def on_recursive(event): ): recursive_future.set_result(event.name) - direct_handle = await sandbox.files.watch_dir(watch_dir, on_direct) - recursive_handle = await sandbox.files.watch_dir( + direct_handle = await files.watch_dir(watch_dir, on_direct) + recursive_handle = await files.watch_dir( watch_dir, on_recursive, recursive=True, ) try: - await sandbox.files.write_text(f"{watch_dir}/direct.txt", "watch me") - await sandbox.files.write_text( + await files.write_text(f"{watch_dir}/direct.txt", "watch me") + await files.write_text( f"{watch_dir}/nested/recursive.txt", "watch me too" ) assert await _await_future(direct_future) == "direct.txt" @@ -543,7 +544,7 @@ async def on_recursive(event): await expect_hyperbrowser_error_async( "watch missing directory", - lambda: sandbox.files.watch_dir( + lambda: files.watch_dir( f"{base_dir}/watch-missing", lambda event: None ), status_code=404, @@ -553,10 +554,10 @@ async def on_recursive(event): ) invalid_file_path = f"{base_dir}/watch-invalid/file.txt" - await sandbox.files.write_text(invalid_file_path, "not a directory") + await files.write_text(invalid_file_path, "not a directory") await expect_hyperbrowser_error_async( "watch file path", - lambda: sandbox.files.watch_dir(invalid_file_path, lambda event: None), + lambda: files.watch_dir(invalid_file_path, lambda event: None), status_code=400, service="runtime", retryable=False, @@ -564,7 +565,7 @@ async def on_recursive(event): ) path = f"{base_dir}/presign/file.txt" - upload = await sandbox.files.upload_url(path, one_time=True) + upload = await files.upload_url(path, one_time=True) assert upload.path == path assert upload.method == "PUT" upload_response = await asyncio.to_thread( @@ -574,9 +575,9 @@ async def on_recursive(event): body="presigned upload body", ) assert upload_response.status_code == 200 - assert await sandbox.files.read_text(path) == "presigned upload body" + assert await files.read_text(path) == "presigned upload body" - download = await sandbox.files.download_url(path, one_time=True) + download = await files.download_url(path, one_time=True) assert download.path == path assert download.method == "GET" download_response = await asyncio.to_thread( @@ -588,7 +589,7 @@ async def on_recursive(event): assert download_response.text == "presigned upload body" path = f"{base_dir}/presign-race/upload.txt" - upload = await sandbox.files.upload_url(path, one_time=True) + upload = await files.upload_url(path, one_time=True) first, second = await asyncio.gather( asyncio.to_thread( fetch_signed_url, @@ -604,11 +605,11 @@ async def on_recursive(event): ), ) assert sorted([first.status_code, second.status_code]) == [200, 401] - assert await sandbox.files.read_text(path) in {"first body", "second body"} + assert await files.read_text(path) in {"first body", "second body"} path = f"{base_dir}/presign-race/download.txt" - await sandbox.files.write_text(path, "download once") - download = await sandbox.files.download_url(path, one_time=True) + await files.write_text(path, "download once") + download = await files.download_url(path, one_time=True) first, second = await asyncio.gather( asyncio.to_thread(fetch_signed_url, download.url, method=download.method), asyncio.to_thread(fetch_signed_url, download.url, method=download.method), @@ -619,10 +620,10 @@ async def on_recursive(event): source = f"{base_dir}/rename-race/source.txt" left = f"{base_dir}/rename-race/left.txt" right = f"{base_dir}/rename-race/right.txt" - await sandbox.files.write_text(source, "race") + await files.write_text(source, "race") results = await asyncio.gather( - sandbox.files.rename(source, left), - sandbox.files.rename(source, right), + files.rename(source, left), + files.rename(source, right), return_exceptions=True, ) fulfilled = [result for result in results if not isinstance(result, Exception)] @@ -637,12 +638,12 @@ async def on_recursive(event): retryable=False, message_includes_any=["not found", "no such file"], ) - winner_path = left if await sandbox.files.exists(left) else right - assert await sandbox.files.read_text(winner_path) == "race" + winner_path = left if await files.exists(left) else right + assert await files.read_text(winner_path) == "race" await expect_hyperbrowser_error_async( "missing file read", - lambda: sandbox.files.read_text(f"{base_dir}/still-missing.txt"), + lambda: files.read_text(f"{base_dir}/still-missing.txt"), status_code=404, service="runtime", retryable=False, @@ -650,7 +651,7 @@ async def on_recursive(event): ) try: - await sandbox.files.list(base_dir, depth=0) + await files.list(base_dir, depth=0) except ValueError as error: assert "depth should be at least one" in str(error) else: diff --git a/tests/sandbox/e2e/test_async_lifecycle.py b/tests/sandbox/e2e/test_async_lifecycle.py index 61fed955..7aaa1574 100644 --- a/tests/sandbox/e2e/test_async_lifecycle.py +++ b/tests/sandbox/e2e/test_async_lifecycle.py @@ -1,8 +1,10 @@ +import asyncio from datetime import datetime, timedelta, timezone from uuid import uuid4 import pytest +from hyperbrowser.exceptions import HyperbrowserError from hyperbrowser.models import CreateSandboxParams, SandboxRuntimeSession from tests.helpers.config import DEFAULT_IMAGE_NAME, create_async_client @@ -16,6 +18,38 @@ ) CUSTOM_IMAGE_NAME = "node" +SNAPSHOT_SETTLE_DELAY_SECONDS = 30 +SNAPSHOT_CREATE_RETRY_TIMEOUT_SECONDS = 120 +SNAPSHOT_CREATE_RETRY_DELAY_SECONDS = 5 + + +def _is_snapshot_not_ready_error(error: BaseException) -> bool: + return ( + isinstance(error, HyperbrowserError) + and error.status_code == 404 + and "snapshot not found" in str(error).lower() + ) + + +async def _create_from_snapshot_eventually( + client, snapshot_name: str, snapshot_id: str +): + deadline = asyncio.get_running_loop().time() + SNAPSHOT_CREATE_RETRY_TIMEOUT_SECONDS + while True: + try: + return await client.sandboxes.create( + CreateSandboxParams( + snapshot_name=snapshot_name, + snapshot_id=snapshot_id, + ) + ) + except Exception as error: + if ( + asyncio.get_running_loop().time() >= deadline + or not _is_snapshot_not_ready_error(error) + ): + raise + await asyncio.sleep(SNAPSHOT_CREATE_RETRY_DELAY_SECONDS) @pytest.mark.anyio @@ -133,11 +167,11 @@ async def patched_get_detail(sandbox_id: str): await wait_for_created_snapshot_async( client, custom_image_memory_snapshot.snapshot_id ) - custom_snapshot_sandbox = await client.sandboxes.create( - CreateSandboxParams( - snapshot_name=custom_image_memory_snapshot.snapshot_name, - snapshot_id=custom_image_memory_snapshot.snapshot_id, - ), + await asyncio.sleep(SNAPSHOT_SETTLE_DELAY_SECONDS) + custom_snapshot_sandbox = await _create_from_snapshot_eventually( + client, + custom_image_memory_snapshot.snapshot_name, + custom_image_memory_snapshot.snapshot_id, ) assert custom_snapshot_sandbox.id assert custom_snapshot_sandbox.status == "active" @@ -236,11 +270,11 @@ async def patched_get_detail(sandbox_id: str): ) await wait_for_created_snapshot_async(client, memory_snapshot.snapshot_id) - secondary = await client.sandboxes.create( - CreateSandboxParams( - snapshot_name=memory_snapshot.snapshot_name, - snapshot_id=memory_snapshot.snapshot_id, - ), + await asyncio.sleep(SNAPSHOT_SETTLE_DELAY_SECONDS) + secondary = await _create_from_snapshot_eventually( + client, + memory_snapshot.snapshot_name, + memory_snapshot.snapshot_id, ) response = await secondary.stop() assert response.success is True diff --git a/tests/sandbox/e2e/test_async_process.py b/tests/sandbox/e2e/test_async_process.py index 6c5c4eaa..f2d1ab38 100644 --- a/tests/sandbox/e2e/test_async_process.py +++ b/tests/sandbox/e2e/test_async_process.py @@ -35,6 +35,15 @@ async def test_async_sandbox_process_e2e(): assert result.exit_code == 0 assert "process-exec-ok" in result.stdout + result = await sandbox.exec( + SandboxExecParams( + command="echo process-use-shell-ignored", + use_shell=False, + ) + ) + assert result.exit_code == 0 + assert "process-use-shell-ignored" in result.stdout + result = await sandbox.exec( SandboxExecParams( command="bash", diff --git a/tests/sandbox/e2e/test_files.py b/tests/sandbox/e2e/test_files.py index 85346331..bea7d21d 100644 --- a/tests/sandbox/e2e/test_files.py +++ b/tests/sandbox/e2e/test_files.py @@ -15,8 +15,8 @@ client = create_client() -def _bash_exec(command: str) -> SandboxExecParams: - return SandboxExecParams(command="bash", args=["-lc", command]) +def _bash_exec(command: str, run_as: str = "root") -> SandboxExecParams: + return SandboxExecParams(command="bash", args=["-lc", command], run_as=run_as) def _read_stream_text(stream) -> str: @@ -65,16 +65,17 @@ def test_sandbox_files_e2e(): try: sandbox = client.sandboxes.create(default_sandbox_params("py-sdk-files")) wait_for_runtime_ready(sandbox) + files = sandbox.files.with_run_as("root") - assert sandbox.files.exists(f"{base_dir}/missing.txt") is False + assert files.exists(f"{base_dir}/missing.txt") is False path = f"{base_dir}/dirs/root" - assert sandbox.files.make_dir(path) is True - assert sandbox.files.make_dir(path) is False + assert files.make_dir(path) is True + assert files.make_dir(path) is False info_path = f"{base_dir}/info/hello.txt" - sandbox.files.write_text(info_path, "hello from sdk files") - info = sandbox.files.get_info(info_path) + files.write_text(info_path, "hello from sdk files") + info = files.get_info(info_path) assert info.name == "hello.txt" assert info.path == info_path assert info.type == "file" @@ -86,18 +87,18 @@ def test_sandbox_files_e2e(): assert info.modified_time is not None list_dir = f"{base_dir}/list" - sandbox.files.make_dir(f"{list_dir}/nested/inner", parents=True) - sandbox.files.write_text(f"{list_dir}/root.txt", "root") - sandbox.files.write_text(f"{list_dir}/nested/child.txt", "child") - sandbox.files.write_text( + files.make_dir(f"{list_dir}/nested/inner", parents=True) + files.write_text(f"{list_dir}/root.txt", "root") + files.write_text(f"{list_dir}/nested/child.txt", "child") + files.write_text( f"{list_dir}/nested/inner/grandchild.txt", "grandchild" ) - depth_one = sandbox.files.list(list_dir, depth=1) + depth_one = files.list(list_dir, depth=1) assert [entry.name for entry in depth_one] == ["nested", "root.txt"] assert [entry.type for entry in depth_one] == ["dir", "file"] - depth_two = sandbox.files.list(list_dir, depth=2) + depth_two = files.list(list_dir, depth=2) assert [entry.path for entry in depth_two] == [ f"{list_dir}/nested", f"{list_dir}/nested/child.txt", @@ -108,27 +109,27 @@ def test_sandbox_files_e2e(): symlink_dir = f"{base_dir}/list-symlink" target = f"{symlink_dir}/target.txt" link = f"{symlink_dir}/link.txt" - sandbox.files.make_dir(symlink_dir) - sandbox.files.write_text(target, "payload") + files.make_dir(symlink_dir) + files.write_text(target, "payload") result = sandbox.exec(_bash_exec(f'ln -sfn "{target}" "{link}"')) assert result.exit_code == 0 link_entry = next( entry - for entry in sandbox.files.list(symlink_dir, depth=1) + for entry in files.list(symlink_dir, depth=1) if entry.path == link ) assert link_entry.symlink_target == target symlink_target = f"{base_dir}/symlink/target.txt" symlink_link = f"{base_dir}/symlink/link.txt" - sandbox.files.write_text(symlink_target, "target") + files.write_text(symlink_target, "target") result = sandbox.exec( _bash_exec( f'mkdir -p "{base_dir}/symlink" && ln -sfn "{symlink_target}" "{symlink_link}"' ) ) assert result.exit_code == 0 - assert sandbox.files.get_info(symlink_link).symlink_target == symlink_target + assert files.get_info(symlink_link).symlink_target == symlink_target broken_target = f"{base_dir}/symlink-broken/missing-target.txt" broken_link = f"{base_dir}/symlink-broken/link.txt" @@ -138,28 +139,28 @@ def test_sandbox_files_e2e(): ) ) assert result.exit_code == 0 - assert sandbox.files.exists(broken_link) is True - assert sandbox.files.get_info(broken_link).symlink_target == broken_target + assert files.exists(broken_link) is True + assert files.get_info(broken_link).symlink_target == broken_target read_path = f"{base_dir}/read/readme.txt" - sandbox.files.write_text(read_path, "hello from sdk files") - assert sandbox.files.read(read_path) == "hello from sdk files" + files.write_text(read_path, "hello from sdk files") + assert files.read(read_path) == "hello from sdk files" assert ( - sandbox.files.read(read_path, format="text", offset=6, length=4) == "from" + files.read(read_path, format="text", offset=6, length=4) == "from" ) - assert sandbox.files.read(read_path, format="bytes") == b"hello from sdk files" - assert sandbox.files.read(read_path, format="blob") == b"hello from sdk files" + assert files.read(read_path, format="bytes") == b"hello from sdk files" + assert files.read(read_path, format="blob") == b"hello from sdk files" assert ( - _read_stream_text(sandbox.files.read(read_path, format="stream")) + _read_stream_text(files.read(read_path, format="stream")) == "hello from sdk files" ) - single = sandbox.files.write(f"{base_dir}/write/single.txt", "single file") + single = files.write(f"{base_dir}/write/single.txt", "single file") assert single.name == "single.txt" assert single.path == f"{base_dir}/write/single.txt" - assert sandbox.files.read_text(single.path) == "single file" + assert files.read_text(single.path) == "single file" - batch = sandbox.files.write( + batch = files.write( [ SandboxFileWriteEntry( path=f"{base_dir}/write/batch-a.txt", @@ -172,157 +173,157 @@ def test_sandbox_files_e2e(): ] ) assert [entry.name for entry in batch] == ["batch-a.txt", "batch-b.bin"] - assert sandbox.files.read_text(f"{base_dir}/write/batch-a.txt") == "batch-a" - assert sandbox.files.read_bytes(f"{base_dir}/write/batch-b.bin") == bytes( + assert files.read_text(f"{base_dir}/write/batch-a.txt") == "batch-a" + assert files.read_bytes(f"{base_dir}/write/batch-b.bin") == bytes( [1, 2, 3, 4] ) text_path = f"{base_dir}/write-options/text.txt" - sandbox.files.write_text(text_path, "hello", mode="0640") - sandbox.files.write_text(text_path, " world", append=True) - assert sandbox.files.read_text(text_path) == "hello world" - assert sandbox.files.get_info(text_path).mode == 0o640 + files.write_text(text_path, "hello", mode="0640") + files.write_text(text_path, " world", append=True) + assert files.read_text(text_path) == "hello world" + assert files.get_info(text_path).mode == 0o640 bytes_path = f"{base_dir}/write-options/bytes.bin" - sandbox.files.write_bytes(bytes_path, bytes([1, 2]), mode="0600") - sandbox.files.write_bytes(bytes_path, bytes([3]), append=True) - assert sandbox.files.read_bytes(bytes_path) == bytes([1, 2, 3]) + files.write_bytes(bytes_path, bytes([1, 2]), mode="0600") + files.write_bytes(bytes_path, bytes([3]), append=True) + assert files.read_bytes(bytes_path) == bytes([1, 2, 3]) transfer_path = f"{base_dir}/transfer/upload.txt" - uploaded = sandbox.files.upload(transfer_path, "uploaded from sdk") + uploaded = files.upload(transfer_path, "uploaded from sdk") assert uploaded.bytes_written > 0 assert ( - sandbox.files.download(transfer_path).decode("utf-8") == "uploaded from sdk" + files.download(transfer_path).decode("utf-8") == "uploaded from sdk" ) file_path = f"{base_dir}/rename/hello.txt" renamed_path = f"{base_dir}/rename/hello-renamed.txt" - sandbox.files.write_text(file_path, "rename me") - renamed = sandbox.files.rename(file_path, renamed_path) + files.write_text(file_path, "rename me") + renamed = files.rename(file_path, renamed_path) assert renamed.path == renamed_path - assert sandbox.files.exists(file_path) is False - assert sandbox.files.read_text(renamed_path) == "rename me" + assert files.exists(file_path) is False + assert files.read_text(renamed_path) == "rename me" link_path = f"{base_dir}/rename/hello-link.txt" copied_link_path = f"{base_dir}/rename/hello-link-copy.txt" renamed_link_path = f"{base_dir}/rename/hello-link-renamed.txt" result = sandbox.exec(_bash_exec(f'ln -sfn "{renamed_path}" "{link_path}"')) assert result.exit_code == 0 - copied_link = sandbox.files.copy(source=link_path, destination=copied_link_path) + copied_link = files.copy(source=link_path, destination=copied_link_path) assert copied_link.path == copied_link_path - assert sandbox.files.get_info(copied_link_path).symlink_target == renamed_path - renamed_link = sandbox.files.rename(copied_link_path, renamed_link_path) + assert files.get_info(copied_link_path).symlink_target == renamed_path + renamed_link = files.rename(copied_link_path, renamed_link_path) assert renamed_link.path == renamed_link_path - assert sandbox.files.get_info(renamed_link_path).symlink_target == renamed_path + assert files.get_info(renamed_link_path).symlink_target == renamed_path target_dir = f"{base_dir}/rename-dir/target-dir" link_dir = f"{base_dir}/rename-dir/link-dir" renamed_link_dir = f"{base_dir}/rename-dir/link-dir-renamed" - sandbox.files.make_dir(target_dir) - sandbox.files.write_text(f"{target_dir}/child.txt", "child") + files.make_dir(target_dir) + files.write_text(f"{target_dir}/child.txt", "child") result = sandbox.exec(_bash_exec(f'ln -sfn "{target_dir}" "{link_dir}"')) assert result.exit_code == 0 - renamed = sandbox.files.rename(link_dir, renamed_link_dir) + renamed = files.rename(link_dir, renamed_link_dir) assert renamed.path == renamed_link_dir - assert sandbox.files.get_info(renamed_link_dir).symlink_target == target_dir + assert files.get_info(renamed_link_dir).symlink_target == target_dir assert [ - entry.path for entry in sandbox.files.list(renamed_link_dir, depth=1) + entry.path for entry in files.list(renamed_link_dir, depth=1) ] == [f"{target_dir}/child.txt"] source_dir = f"{base_dir}/copy-tree/source" nested_dir = f"{source_dir}/nested" nested_target = f"{nested_dir}/target.txt" destination_dir = f"{base_dir}/copy-tree/destination" - sandbox.files.make_dir(nested_dir) - sandbox.files.write_text(nested_target, "payload") + files.make_dir(nested_dir) + files.write_text(nested_target, "payload") result = sandbox.exec( _bash_exec(f'cd "{nested_dir}" && ln -sfn "target.txt" "link.txt"') ) assert result.exit_code == 0 - sandbox.files.copy( + files.copy( source=source_dir, destination=destination_dir, recursive=True ) copied_target = f"{destination_dir}/nested/target.txt" copied_link = f"{destination_dir}/nested/link.txt" - assert sandbox.files.read_text(copied_target) == "payload" - assert sandbox.files.get_info(copied_link).symlink_target == copied_target + assert files.read_text(copied_target) == "payload" + assert files.get_info(copied_link).symlink_target == copied_target loop_dir = f"{base_dir}/loop-list" loop_nested_dir = f"{loop_dir}/nested" - sandbox.files.make_dir(loop_nested_dir) - sandbox.files.write_text(f"{loop_nested_dir}/child.txt", "payload") + files.make_dir(loop_nested_dir) + files.write_text(f"{loop_nested_dir}/child.txt", "payload") result = sandbox.exec(_bash_exec(f'cd "{loop_nested_dir}" && ln -sfn .. loop')) assert result.exit_code == 0 - loop_entries = sandbox.files.list(loop_dir, depth=4) + loop_entries = files.list(loop_dir, depth=4) loop_paths = [entry.path for entry in loop_entries] assert f"{loop_nested_dir}/loop" in loop_paths assert not any("/loop/" in path for path in loop_paths) assert ( - sandbox.files.get_info(f"{loop_nested_dir}/loop").symlink_target == loop_dir + files.get_info(f"{loop_nested_dir}/loop").symlink_target == loop_dir ) source_dir = f"{base_dir}/loop-copy/source" nested_dir = f"{source_dir}/nested" - sandbox.files.make_dir(nested_dir) - sandbox.files.write_text(f"{nested_dir}/child.txt", "payload") + files.make_dir(nested_dir) + files.write_text(f"{nested_dir}/child.txt", "payload") result = sandbox.exec(_bash_exec(f'cd "{nested_dir}" && ln -sfn .. loop')) assert result.exit_code == 0 destination_dir = f"{base_dir}/loop-copy/destination" - sandbox.files.copy( + files.copy( source=source_dir, destination=destination_dir, recursive=True ) copied_loop = f"{destination_dir}/nested/loop" - assert sandbox.files.get_info(copied_loop).symlink_target == destination_dir + assert files.get_info(copied_loop).symlink_target == destination_dir assert not any( "/loop/" in entry.path - for entry in sandbox.files.list(destination_dir, depth=4) + for entry in files.list(destination_dir, depth=4) ) source = f"{base_dir}/copy-overwrite/source.txt" existing_target = f"{base_dir}/copy-overwrite/existing-target.txt" destination_link = f"{base_dir}/copy-overwrite/destination-link.txt" - sandbox.files.write_text(source, "source payload") - sandbox.files.write_text(existing_target, "existing target") + files.write_text(source, "source payload") + files.write_text(existing_target, "existing target") result = sandbox.exec( _bash_exec( f'mkdir -p "{base_dir}/copy-overwrite" && ln -sfn "{existing_target}" "{destination_link}"' ) ) assert result.exit_code == 0 - sandbox.files.copy(source=source, destination=destination_link, overwrite=True) - assert sandbox.files.read_text(destination_link) == "source payload" - assert sandbox.files.read_text(existing_target) == "existing target" - assert sandbox.files.get_info(destination_link).symlink_target is None + files.copy(source=source, destination=destination_link, overwrite=True) + assert files.read_text(destination_link) == "source payload" + assert files.read_text(existing_target) == "existing target" + assert files.get_info(destination_link).symlink_target is None move_source = f"{base_dir}/move-overwrite/source.txt" move_existing_target = f"{base_dir}/move-overwrite/existing-target.txt" move_destination_link = f"{base_dir}/move-overwrite/destination-link.txt" - sandbox.files.write_text(move_source, "move source payload") - sandbox.files.write_text(move_existing_target, "move existing target") + files.write_text(move_source, "move source payload") + files.write_text(move_existing_target, "move existing target") result = sandbox.exec( _bash_exec( f'mkdir -p "{base_dir}/move-overwrite" && ln -sfn "{move_existing_target}" "{move_destination_link}"' ) ) assert result.exit_code == 0 - sandbox.files.move( + files.move( source=move_source, destination=move_destination_link, overwrite=True, ) - assert sandbox.files.read_text(move_destination_link) == "move source payload" - assert sandbox.files.read_text(move_existing_target) == "move existing target" - assert sandbox.files.get_info(move_destination_link).symlink_target is None - assert sandbox.files.exists(move_source) is False + assert files.read_text(move_destination_link) == "move source payload" + assert files.read_text(move_existing_target) == "move existing target" + assert files.get_info(move_destination_link).symlink_target is None + assert files.exists(move_source) is False chmod_path = f"{base_dir}/chmod/file.txt" - sandbox.files.write_text(chmod_path, "chmod me") - sandbox.files.chmod(path=chmod_path, mode="0640") - assert sandbox.files.get_info(chmod_path).mode == 0o640 + files.write_text(chmod_path, "chmod me") + files.chmod(path=chmod_path, mode="0640") + assert files.get_info(chmod_path).mode == 0o640 try: expect_hyperbrowser_error( "file chown", - lambda: sandbox.files.chown(path=chmod_path, uid=0, gid=0), + lambda: files.chown(path=chmod_path, uid=0, gid=0), status_code=400, service="runtime", retryable=False, @@ -331,75 +332,75 @@ def test_sandbox_files_e2e(): except AssertionError as error: if "expected HyperbrowserError, but call succeeded" not in str(error): raise - assert sandbox.files.get_info(chmod_path).name == "file.txt" + assert files.get_info(chmod_path).name == "file.txt" remove_path = f"{base_dir}/remove/file.txt" - sandbox.files.write_text(remove_path, "remove me") - sandbox.files.remove(remove_path) - assert sandbox.files.exists(remove_path) is False - sandbox.files.remove(remove_path) - sandbox.files.remove(f"{base_dir}/remove", recursive=True) - assert sandbox.files.exists(f"{base_dir}/remove") is False + files.write_text(remove_path, "remove me") + files.remove(remove_path) + assert files.exists(remove_path) is False + files.remove(remove_path) + files.remove(f"{base_dir}/remove", recursive=True) + assert files.exists(f"{base_dir}/remove") is False target = f"{base_dir}/remove-link/target.txt" link = f"{base_dir}/remove-link/link.txt" - sandbox.files.write_text(target, "keep me") + files.write_text(target, "keep me") result = sandbox.exec( _bash_exec( f'mkdir -p "{base_dir}/remove-link" && ln -sfn "{target}" "{link}"' ) ) assert result.exit_code == 0 - sandbox.files.remove(link) - assert sandbox.files.exists(link) is False - assert sandbox.files.read_text(target) == "keep me" + files.remove(link) + assert files.exists(link) is False + assert files.read_text(target) == "keep me" target_dir = f"{base_dir}/remove-recursive/target-dir" target_file = f"{target_dir}/child.txt" link_dir = f"{base_dir}/remove-recursive/link-dir" - sandbox.files.make_dir(target_dir) - sandbox.files.write_text(target_file, "keep tree") + files.make_dir(target_dir) + files.write_text(target_file, "keep tree") result = sandbox.exec( _bash_exec( f'mkdir -p "{base_dir}/remove-recursive" && ln -sfn "{target_dir}" "{link_dir}"' ) ) assert result.exit_code == 0 - sandbox.files.remove(link_dir, recursive=True) - assert sandbox.files.exists(link_dir) is False - assert sandbox.files.read_text(target_file) == "keep tree" + files.remove(link_dir, recursive=True) + assert files.exists(link_dir) is False + assert files.read_text(target_file) == "keep tree" link = f"{base_dir}/escape/file-link" result = sandbox.exec( _bash_exec(f'mkdir -p "{base_dir}/escape" && ln -sfn /etc/hosts "{link}"') ) assert result.exit_code == 0 - text = sandbox.files.read_text(link) + text = files.read_text(link) assert "localhost" in text - assert "localhost" in sandbox.files.download(link).decode("utf-8") + assert "localhost" in files.download(link).decode("utf-8") fixture = _create_parent_symlink_escape_fixture( sandbox, base_dir, "parent-escape-read" ) - assert sandbox.files.read_text(fixture["escaped_file"]) == "outside secret" + assert files.read_text(fixture["escaped_file"]) == "outside secret" assert ( - sandbox.files.download(fixture["escaped_file"]).decode("utf-8") + files.download(fixture["escaped_file"]).decode("utf-8") == "outside secret" ) assert [ - entry.path for entry in sandbox.files.list(fixture["link_dir"], depth=1) + entry.path for entry in files.list(fixture["link_dir"], depth=1) ] == [f"{fixture['outside_dir']}/secret.txt"] seen = Queue(maxsize=1) - handle = sandbox.files.watch_dir( + handle = files.watch_dir( fixture["link_dir"], lambda event: ( seen.put_nowait(event.name) - if event.type == "write" and event.name == "fresh.txt" + if event.type in {"create", "write"} and event.name == "fresh.txt" else None ), ) try: - sandbox.files.write_text( + files.write_text( f"{fixture['outside_dir']}/fresh.txt", "watch parent link" ) assert _await_queue_value(seen) == "fresh.txt" @@ -409,24 +410,24 @@ def test_sandbox_files_e2e(): fixture = _create_parent_symlink_escape_fixture( sandbox, base_dir, "parent-escape-mutate" ) - info = sandbox.files.get_info(fixture["escaped_file"]) + info = files.get_info(fixture["escaped_file"]) assert info.type == "file" assert info.size == len("outside secret") - copied = sandbox.files.copy( + copied = files.copy( source=fixture["escaped_file"], destination=f"{base_dir}/parent-escape-mutate/copied.txt", ) assert copied.path == f"{base_dir}/parent-escape-mutate/copied.txt" - assert sandbox.files.read_text(copied.path) == "outside secret" - renamed = sandbox.files.rename( + assert files.read_text(copied.path) == "outside secret" + renamed = files.rename( fixture["escaped_file"], f"{base_dir}/parent-escape-mutate/renamed.txt", ) assert renamed.path == f"{base_dir}/parent-escape-mutate/renamed.txt" - assert sandbox.files.exists(fixture["outside_file"]) is False - assert sandbox.files.read_text(renamed.path) == "outside secret" - sandbox.files.write_text(fixture["escaped_file"], "remove me") - sandbox.files.remove(fixture["escaped_file"]) + assert files.exists(fixture["outside_file"]) is False + assert files.read_text(renamed.path) == "outside secret" + files.write_text(fixture["escaped_file"], "remove me") + files.remove(fixture["escaped_file"]) outside_read = sandbox.exec( _bash_exec( f'if [ -e "{fixture["outside_file"]}" ]; then cat "{fixture["outside_file"]}"; else printf "__MISSING__"; fi' @@ -444,48 +445,48 @@ def test_sandbox_files_e2e(): ) ) assert result.exit_code == 0 - assert [entry.path for entry in sandbox.files.list(link, depth=1)] == [ + assert [entry.path for entry in files.list(link, depth=1)] == [ target_file ] seen = Queue(maxsize=1) - handle = sandbox.files.watch_dir( + handle = files.watch_dir( link, lambda event: ( seen.put_nowait(event.name) - if event.type == "write" and event.name == "file.txt" + if event.type in {"create", "write"} and event.name == "file.txt" else None ), ) try: - sandbox.files.write_text(f"{target_dir}/file.txt", "watch through link") + files.write_text(f"{target_dir}/file.txt", "watch through link") assert _await_queue_value(seen) == "file.txt" finally: handle.stop() watch_dir = f"{base_dir}/watch" - sandbox.files.make_dir(f"{watch_dir}/nested", parents=True) + files.make_dir(f"{watch_dir}/nested", parents=True) direct_event = Queue(maxsize=1) recursive_event = Queue(maxsize=1) - direct_handle = sandbox.files.watch_dir( + direct_handle = files.watch_dir( watch_dir, lambda event: ( direct_event.put_nowait(event.name) - if event.type == "write" and event.name == "direct.txt" + if event.type in {"create", "write"} and event.name == "direct.txt" else None ), ) - recursive_handle = sandbox.files.watch_dir( + recursive_handle = files.watch_dir( watch_dir, lambda event: ( recursive_event.put_nowait(event.name) - if event.type == "write" and event.name == "nested/recursive.txt" + if event.type in {"create", "write"} and event.name == "nested/recursive.txt" else None ), recursive=True, ) try: - sandbox.files.write_text(f"{watch_dir}/direct.txt", "watch me") - sandbox.files.write_text( + files.write_text(f"{watch_dir}/direct.txt", "watch me") + files.write_text( f"{watch_dir}/nested/recursive.txt", "watch me too" ) assert _await_queue_value(direct_event) == "direct.txt" @@ -496,7 +497,7 @@ def test_sandbox_files_e2e(): expect_hyperbrowser_error( "watch missing directory", - lambda: sandbox.files.watch_dir( + lambda: files.watch_dir( f"{base_dir}/watch-missing", lambda event: None ), status_code=404, @@ -506,10 +507,10 @@ def test_sandbox_files_e2e(): ) invalid_file_path = f"{base_dir}/watch-invalid/file.txt" - sandbox.files.write_text(invalid_file_path, "not a directory") + files.write_text(invalid_file_path, "not a directory") expect_hyperbrowser_error( "watch file path", - lambda: sandbox.files.watch_dir(invalid_file_path, lambda event: None), + lambda: files.watch_dir(invalid_file_path, lambda event: None), status_code=400, service="runtime", retryable=False, @@ -517,7 +518,7 @@ def test_sandbox_files_e2e(): ) path = f"{base_dir}/presign/file.txt" - upload = sandbox.files.upload_url(path, one_time=True) + upload = files.upload_url(path, one_time=True) assert upload.path == path assert upload.method == "PUT" upload_response = fetch_signed_url( @@ -526,9 +527,9 @@ def test_sandbox_files_e2e(): body="presigned upload body", ) assert upload_response.status_code == 200 - assert sandbox.files.read_text(path) == "presigned upload body" + assert files.read_text(path) == "presigned upload body" - download = sandbox.files.download_url(path, one_time=True) + download = files.download_url(path, one_time=True) assert download.path == path assert download.method == "GET" download_response = fetch_signed_url(download.url, method=download.method) @@ -536,7 +537,7 @@ def test_sandbox_files_e2e(): assert download_response.text == "presigned upload body" path = f"{base_dir}/presign-race/upload.txt" - upload = sandbox.files.upload_url(path, one_time=True) + upload = files.upload_url(path, one_time=True) with ThreadPoolExecutor(max_workers=2) as executor: first_future = executor.submit( fetch_signed_url, @@ -553,11 +554,11 @@ def test_sandbox_files_e2e(): first = first_future.result() second = second_future.result() assert sorted([first.status_code, second.status_code]) == [200, 401] - assert sandbox.files.read_text(path) in {"first body", "second body"} + assert files.read_text(path) in {"first body", "second body"} path = f"{base_dir}/presign-race/download.txt" - sandbox.files.write_text(path, "download once") - download = sandbox.files.download_url(path, one_time=True) + files.write_text(path, "download once") + download = files.download_url(path, one_time=True) with ThreadPoolExecutor(max_workers=2) as executor: first_future = executor.submit( fetch_signed_url, download.url, method=download.method @@ -573,11 +574,11 @@ def test_sandbox_files_e2e(): source = f"{base_dir}/rename-race/source.txt" left = f"{base_dir}/rename-race/left.txt" right = f"{base_dir}/rename-race/right.txt" - sandbox.files.write_text(source, "race") + files.write_text(source, "race") with ThreadPoolExecutor(max_workers=2) as executor: futures = [ - executor.submit(sandbox.files.rename, source, left), - executor.submit(sandbox.files.rename, source, right), + executor.submit(files.rename, source, left), + executor.submit(files.rename, source, right), ] results = [] for future in futures: @@ -597,12 +598,12 @@ def test_sandbox_files_e2e(): retryable=False, message_includes_any=["not found", "no such file"], ) - winner_path = left if sandbox.files.exists(left) else right - assert sandbox.files.read_text(winner_path) == "race" + winner_path = left if files.exists(left) else right + assert files.read_text(winner_path) == "race" expect_hyperbrowser_error( "missing file read", - lambda: sandbox.files.read_text(f"{base_dir}/still-missing.txt"), + lambda: files.read_text(f"{base_dir}/still-missing.txt"), status_code=404, service="runtime", retryable=False, @@ -610,7 +611,7 @@ def test_sandbox_files_e2e(): ) try: - sandbox.files.list(base_dir, depth=0) + files.list(base_dir, depth=0) except ValueError as error: assert "depth should be at least one" in str(error) else: diff --git a/tests/sandbox/e2e/test_lifecycle.py b/tests/sandbox/e2e/test_lifecycle.py index 5c5583d2..f01100c8 100644 --- a/tests/sandbox/e2e/test_lifecycle.py +++ b/tests/sandbox/e2e/test_lifecycle.py @@ -1,6 +1,8 @@ +import time from datetime import datetime, timedelta, timezone from uuid import uuid4 +from hyperbrowser.exceptions import HyperbrowserError from hyperbrowser.models import CreateSandboxParams, SandboxRuntimeSession from tests.helpers.config import DEFAULT_IMAGE_NAME, create_client @@ -16,6 +18,33 @@ client = create_client() CUSTOM_IMAGE_NAME = "node" +SNAPSHOT_SETTLE_DELAY_SECONDS = 30 +SNAPSHOT_CREATE_RETRY_TIMEOUT_SECONDS = 120 +SNAPSHOT_CREATE_RETRY_DELAY_SECONDS = 5 + + +def _is_snapshot_not_ready_error(error: BaseException) -> bool: + return ( + isinstance(error, HyperbrowserError) + and error.status_code == 404 + and "snapshot not found" in str(error).lower() + ) + + +def _create_from_snapshot_eventually(snapshot_name: str, snapshot_id: str): + deadline = time.time() + SNAPSHOT_CREATE_RETRY_TIMEOUT_SECONDS + while True: + try: + return client.sandboxes.create( + CreateSandboxParams( + snapshot_name=snapshot_name, + snapshot_id=snapshot_id, + ) + ) + except Exception as error: + if time.time() >= deadline or not _is_snapshot_not_ready_error(error): + raise + time.sleep(SNAPSHOT_CREATE_RETRY_DELAY_SECONDS) def test_sandbox_lifecycle_e2e(): @@ -125,11 +154,10 @@ def patched_get_detail(sandbox_id: str): assert custom_image_memory_snapshot.image_namespace == custom_image["namespace"] wait_for_created_snapshot(client, custom_image_memory_snapshot.snapshot_id) - custom_snapshot_sandbox = client.sandboxes.create( - CreateSandboxParams( - snapshot_name=custom_image_memory_snapshot.snapshot_name, - snapshot_id=custom_image_memory_snapshot.snapshot_id, - ) + time.sleep(SNAPSHOT_SETTLE_DELAY_SECONDS) + custom_snapshot_sandbox = _create_from_snapshot_eventually( + custom_image_memory_snapshot.snapshot_name, + custom_image_memory_snapshot.snapshot_id, ) assert custom_snapshot_sandbox.id assert custom_snapshot_sandbox.status == "active" @@ -228,11 +256,10 @@ def patched_get_detail(sandbox_id: str): ) wait_for_created_snapshot(client, memory_snapshot.snapshot_id) - secondary = client.sandboxes.create( - CreateSandboxParams( - snapshot_name=memory_snapshot.snapshot_name, - snapshot_id=memory_snapshot.snapshot_id, - ) + time.sleep(SNAPSHOT_SETTLE_DELAY_SECONDS) + secondary = _create_from_snapshot_eventually( + memory_snapshot.snapshot_name, + memory_snapshot.snapshot_id, ) response = secondary.stop() assert response.success is True diff --git a/tests/sandbox/e2e/test_process.py b/tests/sandbox/e2e/test_process.py index 31c6668a..3c3f3aea 100644 --- a/tests/sandbox/e2e/test_process.py +++ b/tests/sandbox/e2e/test_process.py @@ -31,6 +31,15 @@ def test_sandbox_process_e2e(): assert result.exit_code == 0 assert "process-exec-ok" in result.stdout + result = sandbox.exec( + SandboxExecParams( + command="echo process-use-shell-ignored", + use_shell=False, + ) + ) + assert result.exit_code == 0 + assert "process-use-shell-ignored" in result.stdout + result = sandbox.exec( SandboxExecParams( command="bash", diff --git a/tests/test_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index 781be106..5ba42052 100644 --- a/tests/test_sandbox_wire_contract.py +++ b/tests/test_sandbox_wire_contract.py @@ -27,6 +27,7 @@ ) from hyperbrowser.models import ( CreateSandboxParams, + SandboxDetail, SandboxExposeParams, SandboxExecParams, SandboxFileWriteEntry, @@ -519,11 +520,13 @@ def test_sandbox_request_models_serialize_expected_wire_keys(): command="echo hi", timeout_ms=500, timeout_sec=7, + run_as="root", use_shell=True, ).model_dump(by_alias=True, exclude_none=True) == { "command": "echo hi", "timeoutMs": 500, "timeout_sec": 7, + "runAs": "root", "useShell": True, } @@ -706,9 +709,11 @@ def test_sync_sandbox_runtime_apis_use_expected_wire_keys(): processes = SandboxProcessesApi(transport) process_input = SandboxExecParams( - command="echo hi", + command="echo", + args=["hi world"], timeout_ms=500, timeout_sec=7, + run_as="root", use_shell=True, ) @@ -758,16 +763,16 @@ def test_sync_sandbox_runtime_apis_use_expected_wire_keys(): ) assert transport.calls[0]["json_body"] == { - "command": "echo hi", + "command": "echo 'hi world'", "timeoutMs": 500, "timeout_sec": 7, - "useShell": True, + "runAs": "root", } assert transport.calls[1]["json_body"] == { - "command": "echo hi", + "command": "echo 'hi world'", "timeoutMs": 500, "timeout_sec": 7, - "useShell": True, + "runAs": "root", } assert transport.calls[2]["json_body"] == { "timeoutMs": 250, @@ -821,6 +826,66 @@ def test_sync_sandbox_runtime_apis_use_expected_wire_keys(): } +def test_sync_sandbox_process_string_calls_support_run_as(): + transport = RecordingTransport() + processes = SandboxProcessesApi(transport) + + processes.exec( + "whoami", + cwd="/tmp", + env={"FOO": "bar"}, + timeout_ms=500, + timeout_sec=7, + run_as="root", + ) + processes.start( + "sleep 30", + cwd="/tmp", + run_as="root", + ) + + assert transport.calls[0]["json_body"] == { + "command": "whoami", + "cwd": "/tmp", + "env": {"FOO": "bar"}, + "timeoutMs": 500, + "timeout_sec": 7, + "runAs": "root", + } + assert transport.calls[1]["json_body"] == { + "command": "sleep 30", + "cwd": "/tmp", + "runAs": "root", + } + + +def test_sync_sandbox_handle_exec_string_call_supports_run_as(monkeypatch): + manager = SandboxManager(FakeSyncClient()) + sandbox = manager.attach(SandboxDetail(**SANDBOX_DETAIL_PAYLOAD)) + calls = [] + + def fake_exec(input, **kwargs): + calls.append((input, kwargs)) + return PROCESS_RESULT_PAYLOAD["result"] + + monkeypatch.setattr(sandbox.processes, "exec", fake_exec) + + sandbox.exec("whoami", run_as="root") + + assert calls == [ + ( + "whoami", + { + "cwd": None, + "env": None, + "timeout_ms": None, + "timeout_sec": None, + "run_as": "root", + }, + ) + ] + + @pytest.mark.anyio async def test_async_sandbox_control_manager_uses_expected_wire_keys(): client = FakeAsyncClient() @@ -921,9 +986,11 @@ async def test_async_sandbox_runtime_apis_use_expected_wire_keys(): processes = AsyncSandboxProcessesApi(transport) process_input = SandboxExecParams( - command="echo hi", + command="echo", + args=["hi world"], timeout_ms=500, timeout_sec=7, + run_as="root", use_shell=True, ) @@ -973,16 +1040,16 @@ async def test_async_sandbox_runtime_apis_use_expected_wire_keys(): ) assert transport.calls[0]["json_body"] == { - "command": "echo hi", + "command": "echo 'hi world'", "timeoutMs": 500, "timeout_sec": 7, - "useShell": True, + "runAs": "root", } assert transport.calls[1]["json_body"] == { - "command": "echo hi", + "command": "echo 'hi world'", "timeoutMs": 500, "timeout_sec": 7, - "useShell": True, + "runAs": "root", } assert transport.calls[2]["json_body"] == { "timeoutMs": 250, @@ -1036,6 +1103,40 @@ async def test_async_sandbox_runtime_apis_use_expected_wire_keys(): } +@pytest.mark.anyio +async def test_async_sandbox_process_string_calls_support_run_as(): + transport = AsyncRecordingTransport() + processes = AsyncSandboxProcessesApi(transport) + + await processes.exec( + "whoami", + cwd="/tmp", + env={"FOO": "bar"}, + timeout_ms=500, + timeout_sec=7, + run_as="root", + ) + await processes.start( + "sleep 30", + cwd="/tmp", + run_as="root", + ) + + assert transport.calls[0]["json_body"] == { + "command": "whoami", + "cwd": "/tmp", + "env": {"FOO": "bar"}, + "timeoutMs": 500, + "timeout_sec": 7, + "runAs": "root", + } + assert transport.calls[1]["json_body"] == { + "command": "sleep 30", + "cwd": "/tmp", + "runAs": "root", + } + + def test_sync_terminal_attach_includes_cursor(monkeypatch): captured = {}