solari-e2b-compat 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solari_e2b/__init__.py +39 -0
- solari_e2b/_box.py +155 -0
- solari_e2b/_commands.py +307 -0
- solari_e2b/_config.py +128 -0
- solari_e2b/_filesystem.py +226 -0
- solari_e2b/_loop.py +49 -0
- solari_e2b/_sandbox.py +448 -0
- solari_e2b/_sync.py +160 -0
- solari_e2b/_template.py +445 -0
- solari_e2b/code_interpreter.py +374 -0
- solari_e2b/exceptions.py +168 -0
- solari_e2b/models.py +128 -0
- solari_e2b_compat-0.1.0.dist-info/METADATA +89 -0
- solari_e2b_compat-0.1.0.dist-info/RECORD +17 -0
- solari_e2b_compat-0.1.0.dist-info/WHEEL +4 -0
- solari_e2b_compat-0.1.0.dist-info/licenses/LICENSE-E2B +9 -0
- solari_e2b_compat-0.1.0.dist-info/licenses/NOTICE +13 -0
solari_e2b/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ruff: noqa: F401
|
|
2
|
+
"""solari_e2b: an E2B-compatible Python API on Solari Sandbox. Not affiliated with E2B. See README.md, COMPAT.md, NOTICE.
|
|
3
|
+
|
|
4
|
+
``from solari_e2b import Sandbox`` (blocking) or ``AsyncSandbox``; ``from solari_e2b.code_interpreter import Sandbox`` for
|
|
5
|
+
``run_code``."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ._commands import AsyncCommandHandle, AsyncCommandInputHandle, AsyncCommands, AsyncPty
|
|
10
|
+
from ._config import DEFAULT_BASE_URL, is_solari_base_url
|
|
11
|
+
from ._filesystem import AsyncFileStreamReader, AsyncFilesystem, AsyncWatchHandle
|
|
12
|
+
from ._sandbox import AsyncSandbox, AsyncSandboxPaginator, AsyncSnapshotPaginator
|
|
13
|
+
from ._sync import Sandbox
|
|
14
|
+
from ._template import (
|
|
15
|
+
AsyncTemplate,
|
|
16
|
+
BuildInfo,
|
|
17
|
+
LogEntry,
|
|
18
|
+
LogEntryEnd,
|
|
19
|
+
LogEntryStart,
|
|
20
|
+
ReadyCmd,
|
|
21
|
+
Template,
|
|
22
|
+
TemplateBase,
|
|
23
|
+
TemplateBuildStatusResponse,
|
|
24
|
+
default_build_logger,
|
|
25
|
+
wait_for_file,
|
|
26
|
+
wait_for_port,
|
|
27
|
+
wait_for_process,
|
|
28
|
+
wait_for_timeout,
|
|
29
|
+
wait_for_url,
|
|
30
|
+
)
|
|
31
|
+
from .exceptions import * # noqa: F401,F403
|
|
32
|
+
from .exceptions import _Unsupported
|
|
33
|
+
from .models import * # noqa: F401,F403
|
|
34
|
+
|
|
35
|
+
#: E2B's Volume and Secret APIs have no Solari equivalent yet: every call raises SolariCompatUnsupported.
|
|
36
|
+
Volume = _Unsupported("Volume")
|
|
37
|
+
Secret = _Unsupported("Secret")
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
solari_e2b/_box.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Per-sandbox plumbing shared by Sandbox, Commands, Filesystem and Pty: the live control handle (reconnecting after a
|
|
2
|
+
resume), the guest's $HOME, and the shell wrapper E2B-style ``commands.run`` needs."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from solari_core.errors import ConnectionError as SolariConnection, GatewayError, SolariError
|
|
12
|
+
|
|
13
|
+
from ._config import Conn
|
|
14
|
+
from .exceptions import translate
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
DEFAULT_HOME = "/home/user"
|
|
18
|
+
|
|
19
|
+
#: Native image sandboxes: a bash when the image has one, else sh. The command is "$1", so it is never re-parsed.
|
|
20
|
+
NATIVE_SHELL_SCRIPT = 'if [ -x /bin/bash ]; then exec /bin/bash -c "$1"; else exec /bin/sh -c "$1"; fi'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def shell_args(native: bool, command: str) -> Tuple[str, List[str]]:
|
|
24
|
+
"""``(program, args)`` for an E2B-style shell string: ``bash -lc`` normally, native-image safe otherwise."""
|
|
25
|
+
if native:
|
|
26
|
+
return "/bin/sh", ["-c", NATIVE_SHELL_SCRIPT, "sh", command]
|
|
27
|
+
return "/bin/bash", ["-lc", command]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def get_view(conn: Conn, sandbox_id: str) -> dict:
|
|
31
|
+
"""Raw ``GET /sandboxes/:id`` (the Python SDK view drops ``fromImage`` and the creation time)."""
|
|
32
|
+
return await conn.http.request("GET", f"/sandboxes/{quote(sandbox_id, safe='')}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fire(cb: Optional[Callable[..., Any]], *args: Any) -> None:
|
|
36
|
+
"""Invoke a user callback now if it is sync, or schedule it if it is async; an exception in it never breaks the stream."""
|
|
37
|
+
if cb is None:
|
|
38
|
+
return
|
|
39
|
+
try:
|
|
40
|
+
r = cb(*args)
|
|
41
|
+
if inspect.isawaitable(r):
|
|
42
|
+
asyncio.ensure_future(r)
|
|
43
|
+
except Exception: # noqa: BLE001
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def call_cb(cb: Optional[Callable[..., Any]], *args: Any) -> None:
|
|
48
|
+
"""Invoke a user callback (sync or async); an exception in it never breaks the stream."""
|
|
49
|
+
if cb is None:
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
r = cb(*args)
|
|
53
|
+
if inspect.isawaitable(r):
|
|
54
|
+
await r
|
|
55
|
+
except Exception: # noqa: BLE001
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Box:
|
|
60
|
+
def __init__(self, conn: Conn, sandbox_id: str, initial: Any = None) -> None:
|
|
61
|
+
self.conn = conn
|
|
62
|
+
self.id = sandbox_id
|
|
63
|
+
self._h: Optional["asyncio.Future[Any]"] = None
|
|
64
|
+
self._initial = initial
|
|
65
|
+
self._native: Optional["asyncio.Future[bool]"] = None
|
|
66
|
+
self._home: Optional["asyncio.Future[str]"] = None
|
|
67
|
+
|
|
68
|
+
async def handle(self) -> Any:
|
|
69
|
+
"""The connected control handle, cached."""
|
|
70
|
+
if self._h is None:
|
|
71
|
+
self._h = asyncio.ensure_future(self._attach())
|
|
72
|
+
try:
|
|
73
|
+
return await asyncio.shield(self._h)
|
|
74
|
+
except BaseException:
|
|
75
|
+
if self._h is not None and self._h.done():
|
|
76
|
+
self._h = None
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
async def _attach(self) -> Any:
|
|
80
|
+
sb = self._initial if self._initial is not None else await self.conn.solari.connect(self.id)
|
|
81
|
+
self._initial = None
|
|
82
|
+
await sb.connect()
|
|
83
|
+
return sb
|
|
84
|
+
|
|
85
|
+
def drop(self) -> None:
|
|
86
|
+
self._h = None
|
|
87
|
+
self._initial = None
|
|
88
|
+
|
|
89
|
+
async def close(self) -> None:
|
|
90
|
+
"""Close this process's control channel to the sandbox (if one is open) and forget it."""
|
|
91
|
+
h, self._h = self._h, None
|
|
92
|
+
initial, self._initial = self._initial, None
|
|
93
|
+
sb = initial
|
|
94
|
+
if h is not None and h.done() and not h.cancelled() and h.exception() is None:
|
|
95
|
+
sb = h.result()
|
|
96
|
+
if sb is not None:
|
|
97
|
+
try:
|
|
98
|
+
await sb.close()
|
|
99
|
+
except Exception: # noqa: BLE001
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
async def on(self, fn: Callable[[Any], Awaitable[T]], what: str = "other") -> T:
|
|
103
|
+
"""Run ``fn`` on the live handle; if the channel was closed under us (first call after a resume), reconnect and retry."""
|
|
104
|
+
attempt = 0
|
|
105
|
+
while True:
|
|
106
|
+
try:
|
|
107
|
+
return await fn(await self.handle())
|
|
108
|
+
except Exception as e: # noqa: BLE001
|
|
109
|
+
closed = isinstance(e, SolariError) and not isinstance(e, GatewayError) and (
|
|
110
|
+
isinstance(e, SolariConnection) or any(w in str(e).lower() for w in ("closed", "not connected", "disconnect", "websocket"))
|
|
111
|
+
)
|
|
112
|
+
if not closed or attempt >= 2:
|
|
113
|
+
raise translate(e, what) from (e if translate(e, what) is not e else None)
|
|
114
|
+
self.drop()
|
|
115
|
+
attempt += 1
|
|
116
|
+
await asyncio.sleep(0.5 * attempt)
|
|
117
|
+
|
|
118
|
+
async def is_native(self) -> bool:
|
|
119
|
+
if self._native is None:
|
|
120
|
+
self._native = asyncio.ensure_future(self._fetch_native())
|
|
121
|
+
try:
|
|
122
|
+
return await asyncio.shield(self._native)
|
|
123
|
+
except BaseException:
|
|
124
|
+
self._native = None
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
async def _fetch_native(self) -> bool:
|
|
128
|
+
return bool((await get_view(self.conn, self.id)).get("fromImage"))
|
|
129
|
+
|
|
130
|
+
async def home(self) -> str:
|
|
131
|
+
"""The guest's $HOME (E2B's default cwd and relative-path base), created if missing."""
|
|
132
|
+
if self._home is None:
|
|
133
|
+
self._home = asyncio.ensure_future(self._fetch_home())
|
|
134
|
+
try:
|
|
135
|
+
return await asyncio.shield(self._home)
|
|
136
|
+
except BaseException:
|
|
137
|
+
self._home = None
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
async def _fetch_home(self) -> str:
|
|
141
|
+
prog, args = shell_args(await self.is_native(), 'printf %s "$HOME"')
|
|
142
|
+
r = await self.on(lambda sb: sb.commands.run(prog, args=args))
|
|
143
|
+
home = r.stdout.strip() or DEFAULT_HOME
|
|
144
|
+
await self.on(lambda sb: sb.commands.run("/bin/mkdir", args=["-p", home]))
|
|
145
|
+
return home
|
|
146
|
+
|
|
147
|
+
async def abs(self, path: str) -> str:
|
|
148
|
+
if path.startswith("/"):
|
|
149
|
+
return path
|
|
150
|
+
home = await self.home()
|
|
151
|
+
if path == "~":
|
|
152
|
+
return home
|
|
153
|
+
if path.startswith("~/"):
|
|
154
|
+
return f"{home}/{path[2:]}"
|
|
155
|
+
return f"{home}/{path}"
|
solari_e2b/_commands.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""E2B ``sandbox.commands`` and ``sandbox.pty``. Solari identifies a running command by a string id that lives on the
|
|
2
|
+
creating control channel, so E2B's numeric ``pid`` values are process-local handles: list/connect/kill/send_stdin work for
|
|
3
|
+
commands started by this process (see COMPAT.md)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
from ._box import Box, fire, shell_args
|
|
11
|
+
from .exceptions import CommandExitException, CommandResult, NotFoundException, SandboxException, SolariCompatUnsupported, TimeoutException, translate
|
|
12
|
+
from .models import ProcessInfo, PtySize
|
|
13
|
+
|
|
14
|
+
DEFAULT_COMMAND_TIMEOUT = 60.0
|
|
15
|
+
_next_pid = 1000
|
|
16
|
+
_procs: Dict[str, Dict[int, "_Rec"]] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Rec:
|
|
20
|
+
def __init__(self, pid: int, info: ProcessInfo, handle: "AsyncCommandHandle", kind: str) -> None:
|
|
21
|
+
self.pid, self.info, self.handle, self.kind, self.exited = pid, info, handle, kind, False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _procs_of(sandbox_id: str) -> Dict[int, _Rec]:
|
|
25
|
+
return _procs.setdefault(sandbox_id, {})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _alloc_pid() -> int:
|
|
29
|
+
global _next_pid
|
|
30
|
+
_next_pid += 1
|
|
31
|
+
return _next_pid - 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AsyncCommandHandle:
|
|
35
|
+
"""What ``commands.run(..., background=True)`` and ``pty.create`` return."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
pid: int,
|
|
40
|
+
kill: Callable[[], Awaitable[None]],
|
|
41
|
+
stdin: Callable[[Union[str, bytes]], Awaitable[None]],
|
|
42
|
+
pty: bool = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._pid = pid
|
|
45
|
+
self._kill, self._stdin, self._pty = kill, stdin, pty
|
|
46
|
+
self._stdout = ""
|
|
47
|
+
self._stderr = ""
|
|
48
|
+
self._exit: Optional[int] = None
|
|
49
|
+
self._error: Optional[str] = None
|
|
50
|
+
self._done: "asyncio.Future[None]" = asyncio.get_running_loop().create_future()
|
|
51
|
+
self._on_stdout: List[Callable[..., Any]] = []
|
|
52
|
+
self._on_stderr: List[Callable[..., Any]] = []
|
|
53
|
+
self._on_pty: List[Callable[..., Any]] = []
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def pid(self) -> int:
|
|
57
|
+
return self._pid
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def stdout(self) -> str:
|
|
61
|
+
return self._stdout
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def stderr(self) -> str:
|
|
65
|
+
return self._stderr
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def exit_code(self) -> Optional[int]:
|
|
69
|
+
return self._exit
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def error(self) -> Optional[str]:
|
|
73
|
+
return self._error
|
|
74
|
+
|
|
75
|
+
# --- fed by the transport ---
|
|
76
|
+
def _data(self, stream: str, data: str) -> None:
|
|
77
|
+
if stream == "stdout":
|
|
78
|
+
self._stdout += data
|
|
79
|
+
cbs = self._on_stdout
|
|
80
|
+
else:
|
|
81
|
+
self._stderr += data
|
|
82
|
+
cbs = self._on_stderr
|
|
83
|
+
for cb in list(cbs):
|
|
84
|
+
fire(cb, data)
|
|
85
|
+
|
|
86
|
+
def _pty_data(self, data: bytes) -> None:
|
|
87
|
+
for cb in list(self._on_pty):
|
|
88
|
+
fire(cb, data)
|
|
89
|
+
|
|
90
|
+
def _exited(self, code: int, error: Optional[str] = None) -> None:
|
|
91
|
+
if self._exit is not None:
|
|
92
|
+
return
|
|
93
|
+
self._exit, self._error = code, error
|
|
94
|
+
if not self._done.done():
|
|
95
|
+
self._done.set_result(None)
|
|
96
|
+
|
|
97
|
+
async def wait(self, on_pty=None, on_stdout=None, on_stderr=None) -> CommandResult:
|
|
98
|
+
"""Wait for the command to end. Raises :class:`CommandExitException` on a non-zero exit, as E2B does."""
|
|
99
|
+
if on_stdout:
|
|
100
|
+
self._on_stdout.append(on_stdout)
|
|
101
|
+
if on_stderr:
|
|
102
|
+
self._on_stderr.append(on_stderr)
|
|
103
|
+
if on_pty:
|
|
104
|
+
self._on_pty.append(on_pty)
|
|
105
|
+
await asyncio.shield(self._done)
|
|
106
|
+
r = CommandResult(stderr=self._stderr, stdout=self._stdout, exit_code=self._exit or 0, error=self._error)
|
|
107
|
+
if r.exit_code != 0:
|
|
108
|
+
raise CommandExitException(r.stderr, r.stdout, r.exit_code, r.error)
|
|
109
|
+
return r
|
|
110
|
+
|
|
111
|
+
async def disconnect(self) -> None:
|
|
112
|
+
"""Stop receiving output; the command keeps running."""
|
|
113
|
+
self._on_stdout.clear()
|
|
114
|
+
self._on_stderr.clear()
|
|
115
|
+
self._on_pty.clear()
|
|
116
|
+
|
|
117
|
+
async def kill(self) -> bool:
|
|
118
|
+
"""SIGKILL. ``False`` when the command already ended."""
|
|
119
|
+
if self._exit is not None:
|
|
120
|
+
return False
|
|
121
|
+
try:
|
|
122
|
+
await self._kill()
|
|
123
|
+
except Exception as e: # noqa: BLE001
|
|
124
|
+
raise translate(e) from e
|
|
125
|
+
if self._pty:
|
|
126
|
+
self._exited(137, "killed")
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
async def send_stdin(self, data: Union[str, bytes], request_timeout: Optional[float] = None) -> None:
|
|
130
|
+
try:
|
|
131
|
+
await self._stdin(data)
|
|
132
|
+
except Exception as e: # noqa: BLE001
|
|
133
|
+
raise translate(e) from e
|
|
134
|
+
|
|
135
|
+
async def close_stdin(self, request_timeout: Optional[float] = None) -> None:
|
|
136
|
+
raise SolariCompatUnsupported("close_stdin", "Solari cannot close a command's stdin; end the program another way (for example a sentinel line)")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AsyncCommandInputHandle:
|
|
140
|
+
"""Ordered input stream, built over repeated writes (Solari writes are ordered per command)."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, write: Callable[[Union[str, bytes]], Awaitable[None]]) -> None:
|
|
143
|
+
self._write = write
|
|
144
|
+
self._closed = False
|
|
145
|
+
|
|
146
|
+
async def send(self, data: Union[str, bytes]) -> None:
|
|
147
|
+
if self._closed:
|
|
148
|
+
raise SandboxException("The input stream is closed")
|
|
149
|
+
await self._write(data)
|
|
150
|
+
|
|
151
|
+
async def close(self) -> None:
|
|
152
|
+
self._closed = True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class AsyncCommands:
|
|
156
|
+
def __init__(self, box: Box) -> None:
|
|
157
|
+
self._box = box
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def supports_stdin_close(self) -> bool:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
async def run(
|
|
164
|
+
self,
|
|
165
|
+
cmd: str,
|
|
166
|
+
background: Optional[bool] = None,
|
|
167
|
+
envs: Optional[Dict[str, str]] = None,
|
|
168
|
+
user: Optional[str] = None,
|
|
169
|
+
cwd: Optional[str] = None,
|
|
170
|
+
on_stdout: Optional[Callable[..., Any]] = None,
|
|
171
|
+
on_stderr: Optional[Callable[..., Any]] = None,
|
|
172
|
+
stdin: Optional[bool] = None,
|
|
173
|
+
timeout: Optional[float] = DEFAULT_COMMAND_TIMEOUT,
|
|
174
|
+
request_timeout: Optional[float] = None,
|
|
175
|
+
) -> Union[CommandResult, AsyncCommandHandle]:
|
|
176
|
+
handle = await self._start(cmd, envs, user, cwd, on_stdout, on_stderr)
|
|
177
|
+
if background:
|
|
178
|
+
return handle
|
|
179
|
+
if timeout:
|
|
180
|
+
try:
|
|
181
|
+
return await asyncio.wait_for(handle.wait(), timeout)
|
|
182
|
+
except asyncio.TimeoutError:
|
|
183
|
+
try:
|
|
184
|
+
await handle.kill()
|
|
185
|
+
except Exception: # noqa: BLE001
|
|
186
|
+
pass
|
|
187
|
+
raise TimeoutException(f"Command timed out after {timeout:g}s") from None
|
|
188
|
+
return await handle.wait()
|
|
189
|
+
|
|
190
|
+
async def _start(self, cmd, envs, user, cwd, on_stdout, on_stderr) -> AsyncCommandHandle:
|
|
191
|
+
native = await self._box.is_native()
|
|
192
|
+
program, args = shell_args(native, cmd)
|
|
193
|
+
# E2B runs commands from the user's home directory unless told otherwise.
|
|
194
|
+
wd = await self._box.abs(cwd) if cwd else await self._box.home()
|
|
195
|
+
core = await self._box.on(lambda sb: sb.commands.start(program, args=args, cwd=wd, env=envs, user=user))
|
|
196
|
+
pid = _alloc_pid()
|
|
197
|
+
handle = AsyncCommandHandle(pid, core.kill, core.stdin)
|
|
198
|
+
if on_stdout:
|
|
199
|
+
handle._on_stdout.append(on_stdout)
|
|
200
|
+
if on_stderr:
|
|
201
|
+
handle._on_stderr.append(on_stderr)
|
|
202
|
+
core.on_data(handle._data)
|
|
203
|
+
rec = _Rec(pid, ProcessInfo(pid=pid, cmd=program, args=args, envs=envs or {}, cwd=wd), handle, "cmd")
|
|
204
|
+
_procs_of(self._box.id)[pid] = rec
|
|
205
|
+
|
|
206
|
+
async def _watch() -> None:
|
|
207
|
+
try:
|
|
208
|
+
code = await core.wait()
|
|
209
|
+
rec.exited = True
|
|
210
|
+
handle._exited(code)
|
|
211
|
+
except Exception as e: # noqa: BLE001
|
|
212
|
+
rec.exited = True
|
|
213
|
+
handle._exited(1, str(e))
|
|
214
|
+
|
|
215
|
+
asyncio.ensure_future(_watch())
|
|
216
|
+
return handle
|
|
217
|
+
|
|
218
|
+
async def list(self, request_timeout: Optional[float] = None) -> List[ProcessInfo]:
|
|
219
|
+
"""Commands and PTYs this process started on the sandbox that have not ended."""
|
|
220
|
+
return [r.info for r in _procs_of(self._box.id).values() if not r.exited]
|
|
221
|
+
|
|
222
|
+
def _known(self, pid: int) -> _Rec:
|
|
223
|
+
rec = _procs_of(self._box.id).get(pid)
|
|
224
|
+
if rec is None:
|
|
225
|
+
raise NotFoundException(f"Process {pid} not found. Solari can only address commands started by this process (see COMPAT.md).")
|
|
226
|
+
return rec
|
|
227
|
+
|
|
228
|
+
async def kill(self, pid: int, request_timeout: Optional[float] = None) -> bool:
|
|
229
|
+
rec = _procs_of(self._box.id).get(pid)
|
|
230
|
+
if rec is None or rec.exited:
|
|
231
|
+
return False
|
|
232
|
+
return await rec.handle.kill()
|
|
233
|
+
|
|
234
|
+
async def send_stdin(self, pid: int, data: Union[str, bytes], request_timeout: Optional[float] = None) -> None:
|
|
235
|
+
await self._known(pid).handle.send_stdin(data)
|
|
236
|
+
|
|
237
|
+
def stream_input(self, pid: int, *, timeout: Optional[float] = None) -> AsyncCommandInputHandle:
|
|
238
|
+
return AsyncCommandInputHandle(self._known(pid).handle.send_stdin)
|
|
239
|
+
|
|
240
|
+
async def close_stdin(self, pid: int, request_timeout: Optional[float] = None) -> None:
|
|
241
|
+
await self._known(pid).handle.close_stdin()
|
|
242
|
+
|
|
243
|
+
async def connect(self, pid: int, timeout: Optional[float] = 60, request_timeout: Optional[float] = None, on_stdout=None, on_stderr=None) -> AsyncCommandHandle:
|
|
244
|
+
h = self._known(pid).handle
|
|
245
|
+
if on_stdout:
|
|
246
|
+
h._on_stdout.append(on_stdout)
|
|
247
|
+
if on_stderr:
|
|
248
|
+
h._on_stderr.append(on_stderr)
|
|
249
|
+
return h
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class AsyncPty:
|
|
253
|
+
def __init__(self, box: Box) -> None:
|
|
254
|
+
self._box = box
|
|
255
|
+
|
|
256
|
+
async def create(
|
|
257
|
+
self,
|
|
258
|
+
size: PtySize,
|
|
259
|
+
on_data: Callable[[bytes], Any],
|
|
260
|
+
user: Optional[str] = None,
|
|
261
|
+
cwd: Optional[str] = None,
|
|
262
|
+
envs: Optional[Dict[str, str]] = None,
|
|
263
|
+
timeout: Optional[float] = 60,
|
|
264
|
+
request_timeout: Optional[float] = None,
|
|
265
|
+
) -> AsyncCommandHandle:
|
|
266
|
+
if user:
|
|
267
|
+
raise SolariCompatUnsupported("pty.create user")
|
|
268
|
+
env = {"TERM": "xterm-256color", **(envs or {})}
|
|
269
|
+
core = await self._box.on(lambda sb: sb.pty.create(cols=size.cols, rows=size.rows, cwd=cwd, env=env))
|
|
270
|
+
pid = _alloc_pid()
|
|
271
|
+
handle = AsyncCommandHandle(pid, core.kill, core.write, pty=True)
|
|
272
|
+
handle._on_pty.append(on_data)
|
|
273
|
+
core.on_data(handle._pty_data)
|
|
274
|
+
handle._pty_core = core # type: ignore[attr-defined]
|
|
275
|
+
_procs_of(self._box.id)[pid] = _Rec(pid, ProcessInfo(pid=pid, cmd="pty", envs=envs or {}, cwd=cwd), handle, "pty")
|
|
276
|
+
return handle
|
|
277
|
+
|
|
278
|
+
def _pty(self, pid: int) -> _Rec:
|
|
279
|
+
rec = _procs_of(self._box.id).get(pid)
|
|
280
|
+
if rec is None or rec.kind != "pty":
|
|
281
|
+
raise NotFoundException(f"PTY {pid} not found. Solari can only address PTYs created by this process (see COMPAT.md).")
|
|
282
|
+
return rec
|
|
283
|
+
|
|
284
|
+
async def connect(self, pid: int, on_data: Callable[[bytes], Any], timeout: Optional[float] = 60, request_timeout: Optional[float] = None) -> AsyncCommandHandle:
|
|
285
|
+
rec = self._pty(pid)
|
|
286
|
+
rec.handle._on_pty.append(on_data)
|
|
287
|
+
return rec.handle
|
|
288
|
+
|
|
289
|
+
async def send_stdin(self, pid: int, data: bytes, request_timeout: Optional[float] = None) -> None:
|
|
290
|
+
await self._pty(pid).handle.send_stdin(data)
|
|
291
|
+
|
|
292
|
+
def stream_input(self, pid: int, *, timeout: Optional[float] = None) -> AsyncCommandInputHandle:
|
|
293
|
+
return AsyncCommandInputHandle(self._pty(pid).handle.send_stdin)
|
|
294
|
+
|
|
295
|
+
async def resize(self, pid: int, size: PtySize, request_timeout: Optional[float] = None) -> None:
|
|
296
|
+
core = self._pty(pid).handle._pty_core # type: ignore[attr-defined]
|
|
297
|
+
try:
|
|
298
|
+
await core.resize(size.cols, size.rows)
|
|
299
|
+
except Exception as e: # noqa: BLE001
|
|
300
|
+
raise translate(e) from e
|
|
301
|
+
|
|
302
|
+
async def kill(self, pid: int, request_timeout: Optional[float] = None) -> bool:
|
|
303
|
+
rec = _procs_of(self._box.id).get(pid)
|
|
304
|
+
if rec is None or rec.kind != "pty" or rec.exited:
|
|
305
|
+
return False
|
|
306
|
+
rec.exited = True
|
|
307
|
+
return await rec.handle.kill()
|
solari_e2b/_config.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Credential and base-URL resolution. Only a Solari key (``slr_…``) is ever used or sent; E2B's own environment variables
|
|
2
|
+
(``E2B_API_KEY``, ``E2B_API_URL``, ``E2B_DOMAIN``) are honoured only when they are safe to send a Solari key to."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from solari_core import HttpTransport, TemplateClient
|
|
15
|
+
from solari_sandbox import SandboxClient
|
|
16
|
+
|
|
17
|
+
from .exceptions import SandboxException, SolariCompatUnsupported
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("solari_e2b")
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.getsolari.com"
|
|
22
|
+
KEY_PREFIX = "slr_"
|
|
23
|
+
|
|
24
|
+
#: Per-call connection options the E2B SDK accepts (``**opts`` on most methods).
|
|
25
|
+
API_OPTS = frozenset(
|
|
26
|
+
{"api_key", "domain", "api_url", "request_timeout", "debug", "headers", "api_headers", "proxy", "logger", "sandbox_url", "validate_api_key", "retries", "http_version"}
|
|
27
|
+
)
|
|
28
|
+
#: Accepted and ignored: they tune E2B's own transport.
|
|
29
|
+
IGNORED_OPTS = frozenset({"request_timeout", "debug", "logger", "validate_api_key", "retries", "http_version", "sandbox_url"})
|
|
30
|
+
|
|
31
|
+
#: Test hook: a callable returning the ``httpx.AsyncClient`` the clients should use (a MockTransport client in tests).
|
|
32
|
+
_HTTP_FACTORY: Optional[Callable[[], httpx.AsyncClient]] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_solari_base_url(raw: str) -> bool:
|
|
36
|
+
"""https on getsolari.com or a subdomain, or loopback over http(s)."""
|
|
37
|
+
try:
|
|
38
|
+
u = urlparse(raw)
|
|
39
|
+
host = (u.hostname or "").lower().rstrip(".")
|
|
40
|
+
except ValueError:
|
|
41
|
+
return False
|
|
42
|
+
if u.scheme == "https" and (host == "getsolari.com" or host.endswith(".getsolari.com")):
|
|
43
|
+
return True
|
|
44
|
+
return u.scheme in ("http", "https") and (host in ("localhost", "::1") or host.replace(".", "").isdigit() and host.startswith("127."))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _describe(raw: str) -> str:
|
|
48
|
+
try:
|
|
49
|
+
return urlparse(raw).hostname or "unparsable URL"
|
|
50
|
+
except ValueError:
|
|
51
|
+
return "unparsable URL"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Conn:
|
|
56
|
+
api_key: str
|
|
57
|
+
base_url: str
|
|
58
|
+
solari: SandboxClient
|
|
59
|
+
http: HttpTransport
|
|
60
|
+
templates: TemplateClient
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_cache: Dict[Tuple[str, str, int], Conn] = {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve(opts: Dict[str, Any]) -> Tuple[str, str]:
|
|
67
|
+
if opts.get("headers"):
|
|
68
|
+
raise SolariCompatUnsupported("headers")
|
|
69
|
+
if opts.get("api_headers"):
|
|
70
|
+
raise SolariCompatUnsupported("api_headers")
|
|
71
|
+
if opts.get("proxy"):
|
|
72
|
+
raise SolariCompatUnsupported("proxy")
|
|
73
|
+
key: Optional[str] = None
|
|
74
|
+
for source, value in (("the api_key option", opts.get("api_key")), ("SOLARI_API_KEY", os.environ.get("SOLARI_API_KEY")), ("E2B_API_KEY", os.environ.get("E2B_API_KEY"))):
|
|
75
|
+
if not value:
|
|
76
|
+
continue
|
|
77
|
+
if value.startswith(KEY_PREFIX):
|
|
78
|
+
key = value
|
|
79
|
+
break
|
|
80
|
+
log.warning("Ignoring %s: it is not a Solari API key (%s…). It is not sent anywhere.", source, KEY_PREFIX)
|
|
81
|
+
if not key:
|
|
82
|
+
raise SandboxException(
|
|
83
|
+
"No Solari API key. Set SOLARI_API_KEY, or pass api_key='slr_live_…'. An E2B key (E2B_API_KEY) is never sent to Solari and cannot be used here."
|
|
84
|
+
)
|
|
85
|
+
explicit = opts.get("api_url") or (f"https://api.{opts['domain']}" if opts.get("domain") else None)
|
|
86
|
+
base: Optional[str] = None
|
|
87
|
+
if explicit:
|
|
88
|
+
if not is_solari_base_url(explicit):
|
|
89
|
+
raise SandboxException(f"Refusing to send a Solari API key to {_describe(explicit)}: it does not point at Solari.")
|
|
90
|
+
base = explicit
|
|
91
|
+
inherited_raw = os.environ.get("E2B_API_URL") or (f"https://api.{os.environ['E2B_DOMAIN']}" if os.environ.get("E2B_DOMAIN") else None)
|
|
92
|
+
inherited: Optional[str] = None
|
|
93
|
+
if inherited_raw:
|
|
94
|
+
if is_solari_base_url(inherited_raw):
|
|
95
|
+
inherited = inherited_raw
|
|
96
|
+
elif not base and not os.environ.get("SOLARI_BASE_URL"):
|
|
97
|
+
log.warning(
|
|
98
|
+
"Ignoring E2B_API_URL/E2B_DOMAIN (%s): it does not point at Solari, and your Solari API key is never sent anywhere else. Using %s.",
|
|
99
|
+
_describe(inherited_raw),
|
|
100
|
+
DEFAULT_BASE_URL,
|
|
101
|
+
)
|
|
102
|
+
return key, (base or os.environ.get("SOLARI_BASE_URL") or inherited or DEFAULT_BASE_URL).rstrip("/")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_conn(**opts: Any) -> Conn:
|
|
106
|
+
"""Connection for one call, cached per key + URL + running event loop (httpx clients are loop-bound)."""
|
|
107
|
+
key, base = _resolve(opts)
|
|
108
|
+
loop_id = id(asyncio.get_running_loop())
|
|
109
|
+
ck = (key, base, loop_id)
|
|
110
|
+
c = _cache.get(ck)
|
|
111
|
+
if c is None:
|
|
112
|
+
http = _HTTP_FACTORY() if _HTTP_FACTORY else None
|
|
113
|
+
c = Conn(
|
|
114
|
+
api_key=key,
|
|
115
|
+
base_url=base,
|
|
116
|
+
solari=SandboxClient(api_key=key, base_url=base, http=http),
|
|
117
|
+
http=HttpTransport(api_key=key, base_url=base, http=http),
|
|
118
|
+
templates=TemplateClient(api_key=key, base_url=base, http=http),
|
|
119
|
+
)
|
|
120
|
+
_cache[ck] = c
|
|
121
|
+
return c
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def split_opts(kwargs: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
125
|
+
"""Separate connection options from the rest of a call's keyword arguments."""
|
|
126
|
+
opts = {k: v for k, v in kwargs.items() if k in API_OPTS}
|
|
127
|
+
rest = {k: v for k, v in kwargs.items() if k not in API_OPTS}
|
|
128
|
+
return opts, rest
|