arcbox 0.1.1__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.
arcbox/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """arcbox — run isolated microVM sandboxes on the local ArcBox daemon.
2
+
3
+ Two mirrored surfaces: the sync classes (`Sandbox`, `ArcBox`, ...) and
4
+ their `Async*` counterparts. Public shapes are hand-written and mapped
5
+ at the transport boundary; everything under `arcbox._gen` is generated
6
+ wire code and is deliberately NOT exported. The error hierarchy lives
7
+ in :mod:`arcbox.errors`.
8
+ """
9
+
10
+ from arcbox._async._client import AsyncConnectClient
11
+ from arcbox._async.commands import AsyncCommandHandle, AsyncCommands, AsyncOutputStream
12
+ from arcbox._async.files import AsyncFiles
13
+ from arcbox._async.sandbox import AsyncArcBox, AsyncSandbox
14
+ from arcbox._connection import Connection
15
+ from arcbox._sync._client import ConnectClient
16
+ from arcbox._sync.commands import CommandHandle, Commands, OutputStream
17
+ from arcbox._sync.files import Files
18
+ from arcbox._sync.sandbox import ArcBox, Sandbox
19
+ from arcbox._types import (
20
+ MAX_FILE_BYTES,
21
+ CommandResult,
22
+ IdlePolicy,
23
+ OutputChannel,
24
+ OutputChunk,
25
+ SandboxInfo,
26
+ SandboxState,
27
+ SandboxSummary,
28
+ SignalName,
29
+ )
30
+
31
+ __all__ = [
32
+ "MAX_FILE_BYTES",
33
+ "ArcBox",
34
+ "AsyncArcBox",
35
+ "AsyncCommandHandle",
36
+ "AsyncCommands",
37
+ "AsyncConnectClient",
38
+ "AsyncFiles",
39
+ "AsyncOutputStream",
40
+ "AsyncSandbox",
41
+ "CommandHandle",
42
+ "CommandResult",
43
+ "Commands",
44
+ "ConnectClient",
45
+ "Connection",
46
+ "Files",
47
+ "IdlePolicy",
48
+ "OutputChannel",
49
+ "OutputChunk",
50
+ "OutputStream",
51
+ "Sandbox",
52
+ "SandboxInfo",
53
+ "SandboxState",
54
+ "SandboxSummary",
55
+ "SignalName",
56
+ ]
@@ -0,0 +1,5 @@
1
+ """One flavor of the SDK surface.
2
+
3
+ The async core lives in `arcbox/_async`; `arcbox/_sync` is generated
4
+ from it by scripts/gen_sync.py — edit only the async tree.
5
+ """
@@ -0,0 +1,235 @@
1
+ """Connect-over-httpx transport.
2
+
3
+ Unary RPCs are plain POSTs with binary-protobuf bodies
4
+ (``application/proto``); streaming RPCs speak the Connect streaming
5
+ envelope (``application/connect+proto``, framing in ``_envelope``).
6
+ The local tier dials the daemon's Unix socket through httpx's ``uds``
7
+ transport; the same code path serves a future remote tier over HTTPS.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from typing import TYPE_CHECKING, Generic, TypeVar
14
+
15
+ import httpx
16
+
17
+ from arcbox._connection import Connection, resolve_connection
18
+ from arcbox._envelope import (
19
+ FLAG_COMPRESSED,
20
+ FLAG_END_STREAM,
21
+ EnvelopeDecoder,
22
+ encode_envelope,
23
+ end_stream_error,
24
+ unary_error,
25
+ )
26
+ from arcbox.errors import ArcBoxError, InvalidArgumentError
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import AsyncIterator, Iterable, Mapping
30
+ from contextlib import AbstractAsyncContextManager
31
+ from types import TracebackType
32
+
33
+ from google.protobuf.message import Message
34
+
35
+ M = TypeVar("M", bound="Message")
36
+
37
+ _UNARY_HEADERS = {
38
+ "content-type": "application/proto",
39
+ "connect-protocol-version": "1",
40
+ }
41
+ _STREAM_HEADERS = {
42
+ "content-type": "application/connect+proto",
43
+ "connect-protocol-version": "1",
44
+ }
45
+
46
+
47
+ class _UseDefault:
48
+ """Sentinel: apply the connection's ``request_timeout``."""
49
+
50
+ def __repr__(self) -> str:
51
+ # Stable across the async and sync trees so signatures compare
52
+ # equal in the parity test (and read cleanly in help()).
53
+ return "USE_DEFAULT"
54
+
55
+
56
+ _USE_DEFAULT = _UseDefault()
57
+
58
+
59
+ class AsyncConnectClient:
60
+ """One resolved connection to a daemon; every handle shares it."""
61
+
62
+ def __init__(
63
+ self,
64
+ connection: Connection | None = None,
65
+ env: Mapping[str, str] | None = None,
66
+ ) -> None:
67
+ resolved = resolve_connection(connection, os.environ if env is None else env)
68
+ #: Per-unary-RPC deadline in seconds, when configured. Streams
69
+ #: and long-polls never receive it.
70
+ self.request_timeout = resolved.request_timeout
71
+ injected = connection.http_client if connection is not None else None
72
+ #: Whether this client constructed (and must eventually close)
73
+ #: its HTTP client; an injected one belongs to the caller.
74
+ self._owns_http = injected is None
75
+ if injected is not None:
76
+ required = httpx.AsyncClient
77
+ if not isinstance(injected, required):
78
+ raise InvalidArgumentError(
79
+ f"Connection.http_client must be an httpx.{required.__name__} for this surface"
80
+ )
81
+ self._http = injected
82
+ return
83
+ headers = {"authorization": f"Bearer {resolved.api_key}"} if resolved.api_key else None
84
+ transport = (
85
+ httpx.AsyncHTTPTransport(uds=resolved.socket_path)
86
+ if resolved.socket_path is not None
87
+ else None
88
+ )
89
+ self._http = httpx.AsyncClient(
90
+ base_url=resolved.base_url,
91
+ transport=transport,
92
+ headers=headers,
93
+ timeout=None,
94
+ )
95
+
96
+ async def aclose(self) -> None:
97
+ """Release the HTTP client's pooled connections. A no-op when the
98
+ client was injected — it belongs to the caller."""
99
+ if self._owns_http:
100
+ await self._http.aclose()
101
+
102
+ async def unary(
103
+ self,
104
+ path: str,
105
+ request: Message,
106
+ response_type: type[M],
107
+ timeout: float | _UseDefault | None = _USE_DEFAULT,
108
+ ) -> M:
109
+ """One unary RPC. ``timeout`` overrides the configured deadline;
110
+ an explicit ``None`` means no deadline (checkpoint restores take
111
+ as long as they take)."""
112
+ effective = self.request_timeout if isinstance(timeout, _UseDefault) else timeout
113
+ headers = dict(_UNARY_HEADERS)
114
+ if effective is not None:
115
+ headers["connect-timeout-ms"] = str(int(effective * 1000))
116
+ response = await self._http.post(
117
+ path,
118
+ content=request.SerializeToString(),
119
+ headers=headers,
120
+ timeout=None if effective is None else httpx.Timeout(effective),
121
+ )
122
+ if response.status_code != 200:
123
+ raise unary_error(response.status_code, response.content)
124
+ content_type = response.headers.get("content-type", "")
125
+ if not content_type.startswith("application/proto"):
126
+ raise ArcBoxError(f"unexpected unary response content-type {content_type!r}")
127
+ message = response_type()
128
+ message.ParseFromString(response.content)
129
+ return message
130
+
131
+ def stream(self, path: str, request: Message, response_type: type[M]) -> AsyncServerStream[M]:
132
+ """One server-streaming RPC. Entering the returned context sends
133
+ the request (that is what registers a subscription server-side);
134
+ iterate it for messages."""
135
+ return AsyncServerStream(self._http, path, request, response_type)
136
+
137
+ async def client_stream(
138
+ self, path: str, requests: Iterable[Message], response_type: type[M]
139
+ ) -> M:
140
+ """One client-streaming RPC with the full request sequence known
141
+ up front: the envelopes are sent as one body and the single
142
+ response message is returned."""
143
+ body = b"".join(encode_envelope(0, m.SerializeToString()) for m in requests)
144
+ response = await self._http.post(path, content=body, headers=_STREAM_HEADERS, timeout=None)
145
+ if response.status_code != 200:
146
+ raise unary_error(response.status_code, response.content)
147
+ decoder = EnvelopeDecoder()
148
+ message: M | None = None
149
+ ended = False
150
+ for flags, payload in decoder.feed(response.content):
151
+ if flags & FLAG_COMPRESSED:
152
+ raise ArcBoxError("received a compressed frame without negotiating compression")
153
+ if flags & FLAG_END_STREAM:
154
+ error = end_stream_error(payload)
155
+ if error is not None:
156
+ raise error
157
+ ended = True
158
+ else:
159
+ decoded = response_type()
160
+ decoded.ParseFromString(payload)
161
+ message = decoded
162
+ if not ended:
163
+ # Without the terminal frame a truncated body is
164
+ # indistinguishable from success — never report one as
165
+ # completed (WriteFile rides this path).
166
+ raise ArcBoxError("the stream ended without an EndStreamResponse")
167
+ if message is None:
168
+ raise ArcBoxError("the stream ended without a response message")
169
+ return message
170
+
171
+
172
+ class AsyncServerStream(Generic[M]):
173
+ """One server-streaming call: a context manager yielding an iterator.
174
+
175
+ The stream ends deterministically — with the terminal
176
+ EndStreamResponse frame or a typed error, never silence.
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ http: httpx.AsyncClient,
182
+ path: str,
183
+ request: Message,
184
+ response_type: type[M],
185
+ ) -> None:
186
+ self._http = http
187
+ self._path = path
188
+ self._body = encode_envelope(0, request.SerializeToString())
189
+ self._response_type = response_type
190
+ self._cm: AbstractAsyncContextManager[httpx.Response] | None = None
191
+ self._response: httpx.Response | None = None
192
+
193
+ async def __aenter__(self) -> AsyncServerStream[M]:
194
+ cm = self._http.stream(
195
+ "POST", self._path, content=self._body, headers=_STREAM_HEADERS, timeout=None
196
+ )
197
+ response = await cm.__aenter__()
198
+ if response.status_code != 200:
199
+ body = await response.aread()
200
+ await cm.__aexit__(None, None, None)
201
+ raise unary_error(response.status_code, body)
202
+ self._cm = cm
203
+ self._response = response
204
+ return self
205
+
206
+ async def __aexit__(
207
+ self,
208
+ exc_type: type[BaseException] | None,
209
+ exc: BaseException | None,
210
+ tb: TracebackType | None,
211
+ ) -> None:
212
+ if self._cm is not None:
213
+ await self._cm.__aexit__(exc_type, exc, tb)
214
+
215
+ def __aiter__(self) -> AsyncIterator[M]:
216
+ return self._messages()
217
+
218
+ async def _messages(self) -> AsyncIterator[M]:
219
+ response = self._response
220
+ if response is None:
221
+ raise ArcBoxError("stream iterated before it was entered")
222
+ decoder = EnvelopeDecoder()
223
+ async for data in response.aiter_bytes():
224
+ for flags, payload in decoder.feed(data):
225
+ if flags & FLAG_COMPRESSED:
226
+ raise ArcBoxError("received a compressed frame without negotiating compression")
227
+ if flags & FLAG_END_STREAM:
228
+ error = end_stream_error(payload)
229
+ if error is not None:
230
+ raise error
231
+ return
232
+ message = self._response_type()
233
+ message.ParseFromString(payload)
234
+ yield message
235
+ raise ArcBoxError("the stream ended without an EndStreamResponse")
@@ -0,0 +1,322 @@
1
+ """The ``sandbox.commands`` namespace: run processes inside one sandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ import time
8
+ import uuid
9
+ from typing import TYPE_CHECKING, Literal, overload
10
+
11
+ from google.protobuf import empty_pb2
12
+
13
+ from arcbox._boundary import wrap_errors
14
+ from arcbox._gen import process_pb2
15
+ from arcbox._types import (
16
+ SIGNAL_VALUES,
17
+ CommandResult,
18
+ OutputChunk,
19
+ command_result_from_execution,
20
+ )
21
+ from arcbox.errors import InvalidArgumentError, TimeoutError
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import AsyncGenerator, AsyncIterator, Mapping, Sequence
25
+ from types import TracebackType
26
+
27
+ from arcbox._types import OutputChannel, SignalName
28
+
29
+ from ._client import AsyncConnectClient, AsyncServerStream
30
+
31
+ _PROCESS = "/arcbox.sandbox.v1.SandboxProcessService/"
32
+
33
+ #: Longest single WaitExecution long-poll slice, so a dropped daemon
34
+ #: surfaces as an error instead of an infinite silent wait.
35
+ _WAIT_SLICE_SECONDS = 30
36
+
37
+
38
+ def _normalize_cmd(cmd: str | Sequence[str]) -> list[str]:
39
+ """``str`` is sugar for a shell command; a sequence is argv, executed
40
+ directly with no shell involved."""
41
+ return ["/bin/sh", "-lc", cmd] if isinstance(cmd, str) else list(cmd)
42
+
43
+
44
+ def _channel_name(channel: int) -> OutputChannel:
45
+ if channel == process_pb2.STDIO_CHANNEL_STDERR:
46
+ return "stderr"
47
+ if channel == process_pb2.STDIO_CHANNEL_PTY:
48
+ return "pty"
49
+ return "stdout"
50
+
51
+
52
+ def _decode_output(chunks: list[bytes]) -> str:
53
+ return b"".join(chunks).decode("utf-8", errors="replace")
54
+
55
+
56
+ class AsyncOutputStream:
57
+ """A command's output, iterable chunk by chunk.
58
+
59
+ Consuming it to the end releases the underlying HTTP stream
60
+ deterministically. When exiting early (``break``), iterate inside
61
+ the context-manager form — ``async with handle.output as stream`` in
62
+ the async flavor, ``with`` in the sync one — so the stream closes at
63
+ the break instead of whenever the generator finalizer runs."""
64
+
65
+ def __init__(self, chunks: AsyncGenerator[OutputChunk]) -> None:
66
+ self._chunks = chunks
67
+
68
+ def __aiter__(self) -> AsyncIterator[OutputChunk]:
69
+ return self._chunks
70
+
71
+ async def aclose(self) -> None:
72
+ """Release the underlying HTTP stream without consuming the rest."""
73
+ await self._chunks.aclose()
74
+
75
+ async def __aenter__(self) -> AsyncOutputStream:
76
+ return self
77
+
78
+ async def __aexit__(
79
+ self,
80
+ exc_type: type[BaseException] | None,
81
+ exc: BaseException | None,
82
+ tb: TracebackType | None,
83
+ ) -> None:
84
+ await self.aclose()
85
+
86
+
87
+ class AsyncCommandHandle:
88
+ """A handle to a running (or finished) command. The process is
89
+ decoupled from this object: dropping the handle never kills the
90
+ process."""
91
+
92
+ def __init__(self, client: AsyncConnectClient, sandbox_id: str, command_id: str) -> None:
93
+ self._client = client
94
+ self._sandbox_id = sandbox_id
95
+ #: Execution id, unique within the sandbox; addressable across clients.
96
+ self.command_id = command_id
97
+
98
+ @property
99
+ def output(self) -> AsyncOutputStream:
100
+ """Stream the command's output from the beginning — or from the
101
+ earliest byte the daemon still retains (8 MiB per channel);
102
+ replayed buffered output comes first, then live output follows;
103
+ the stream ends when the process exits (deterministic
104
+ termination — never silence). When breaking out early, iterate
105
+ via the context-manager form (see :class:`AsyncOutputStream`)."""
106
+ return AsyncOutputStream(self._stream_output())
107
+
108
+ async def _stream_output(self) -> AsyncGenerator[OutputChunk]:
109
+ with wrap_errors("commands.output"):
110
+ async with self._attach() as stream:
111
+ async for event in stream:
112
+ kind = event.WhichOneof("event")
113
+ if kind == "output":
114
+ chunk = event.output
115
+ yield OutputChunk(_channel_name(chunk.channel), chunk.data)
116
+ elif kind == "exited":
117
+ return
118
+
119
+ async def wait_for_exit(self, timeout: float | None = None) -> CommandResult:
120
+ """Wait until the command exits and return its result (server-side
121
+ long-poll; no client-side spinning). ``timeout`` bounds the WAIT,
122
+ not the process: on expiry a :class:`TimeoutError` is raised and
123
+ the process keeps running."""
124
+ deadline = None if timeout is None else time.monotonic() + timeout
125
+ with wrap_errors("commands.wait_for_exit"):
126
+ while True:
127
+ remaining = None if deadline is None else deadline - time.monotonic()
128
+ if remaining is not None and remaining <= 0:
129
+ raise TimeoutError(
130
+ "wait_for_exit(timeout) elapsed before the command exited",
131
+ suggestion=(
132
+ "increase the wait_for_exit timeout argument, or kill() the command"
133
+ ),
134
+ context={"command_id": self.command_id},
135
+ )
136
+ if remaining is None:
137
+ execution = await self._wait_slice(_WAIT_SLICE_SECONDS)
138
+ elif remaining >= 1:
139
+ execution = await self._wait_slice(min(_WAIT_SLICE_SECONDS, int(remaining)))
140
+ else:
141
+ # The wire's wait granularity is whole seconds. Poll
142
+ # immediately (an already-exited command returns at
143
+ # once); only if it is still running, sleep out the
144
+ # remainder and take one final poll so an exit inside
145
+ # the budget is still caught — past it, the deadline
146
+ # check above raises.
147
+ execution = await self._wait_slice(0)
148
+ if execution.state != process_pb2.EXECUTION_STATE_EXITED:
149
+ await asyncio.sleep(remaining)
150
+ execution = await self._wait_slice(0)
151
+ if execution.state == process_pb2.EXECUTION_STATE_EXITED:
152
+ break
153
+ return await self._collect_result(execution)
154
+
155
+ async def _wait_slice(self, slice_seconds: int) -> process_pb2.Execution:
156
+ """One WaitExecution long-poll (0 = an immediate state poll)."""
157
+ return await self._client.unary(
158
+ _PROCESS + "WaitExecution",
159
+ process_pb2.WaitExecutionRequest(
160
+ sandbox_id=self._sandbox_id,
161
+ execution_id=self.command_id,
162
+ timeout_seconds=slice_seconds,
163
+ ),
164
+ process_pb2.Execution,
165
+ # Exempt from request_timeout: this unary deliberately parks
166
+ # server-side for the slice; grant it that long plus grace.
167
+ timeout=float(slice_seconds + 5),
168
+ )
169
+
170
+ async def kill(self, signal: SignalName = "SIGTERM") -> None:
171
+ """Deliver a signal to the whole process group (default SIGTERM)."""
172
+ with wrap_errors("commands.kill"):
173
+ await self._client.unary(
174
+ _PROCESS + "SignalExecution",
175
+ process_pb2.SignalExecutionRequest(
176
+ sandbox_id=self._sandbox_id,
177
+ execution_id=self.command_id,
178
+ signal=SIGNAL_VALUES[signal],
179
+ ),
180
+ empty_pb2.Empty,
181
+ )
182
+
183
+ def _attach(self) -> AsyncServerStream[process_pb2.ExecutionEvent]:
184
+ return self._client.stream(
185
+ _PROCESS + "AttachExecution",
186
+ process_pb2.AttachExecutionRequest(
187
+ sandbox_id=self._sandbox_id,
188
+ execution_id=self.command_id,
189
+ stdout_offset=0,
190
+ stderr_offset=0,
191
+ ),
192
+ process_pb2.ExecutionEvent,
193
+ )
194
+
195
+ async def _collect_result(self, execution: process_pb2.Execution) -> CommandResult:
196
+ """Assemble the result of an exited execution. Output is re-read
197
+ from offset 0 — the daemon retains and replays it, so the result
198
+ is complete even when nobody consumed the live stream, UNLESS the
199
+ command outgrew the daemon's per-channel retention (8 MiB): chunk
200
+ offsets expose the dropped head, reported as ``truncated``."""
201
+ stdout: list[bytes] = []
202
+ stderr: list[bytes] = []
203
+ next_stdout = 0
204
+ next_stderr = 0
205
+ truncated = False
206
+ async with self._attach() as stream:
207
+ async for event in stream:
208
+ kind = event.WhichOneof("event")
209
+ if kind == "output":
210
+ chunk = event.output
211
+ is_stderr = chunk.channel == process_pb2.STDIO_CHANNEL_STDERR
212
+ # A chunk landing past the expected offset means
213
+ # retention already dropped bytes we asked for.
214
+ if chunk.offset > (next_stderr if is_stderr else next_stdout):
215
+ truncated = True
216
+ after = chunk.offset + len(chunk.data)
217
+ if is_stderr:
218
+ next_stderr = after
219
+ stderr.append(chunk.data)
220
+ else:
221
+ next_stdout = after
222
+ stdout.append(chunk.data)
223
+ elif kind == "exited":
224
+ break
225
+ return command_result_from_execution(
226
+ execution, _decode_output(stdout), _decode_output(stderr), truncated
227
+ )
228
+
229
+
230
+ class AsyncCommands:
231
+ """Run processes inside one sandbox."""
232
+
233
+ def __init__(self, client: AsyncConnectClient, sandbox_id: str) -> None:
234
+ self._client = client
235
+ self._sandbox_id = sandbox_id
236
+
237
+ @overload
238
+ async def run(
239
+ self,
240
+ cmd: str | Sequence[str],
241
+ *,
242
+ cwd: str | None = None,
243
+ env: Mapping[str, str] | None = None,
244
+ user: str | None = None,
245
+ timeout: float | None = None,
246
+ check: bool = False,
247
+ background: Literal[False] = False,
248
+ ) -> CommandResult: ...
249
+
250
+ @overload
251
+ async def run(
252
+ self,
253
+ cmd: str | Sequence[str],
254
+ *,
255
+ cwd: str | None = None,
256
+ env: Mapping[str, str] | None = None,
257
+ user: str | None = None,
258
+ timeout: float | None = None,
259
+ background: Literal[True],
260
+ ) -> AsyncCommandHandle: ...
261
+
262
+ async def run(
263
+ self,
264
+ cmd: str | Sequence[str],
265
+ *,
266
+ cwd: str | None = None,
267
+ env: Mapping[str, str] | None = None,
268
+ user: str | None = None,
269
+ timeout: float | None = None,
270
+ check: bool = False,
271
+ background: bool = False,
272
+ ) -> CommandResult | AsyncCommandHandle:
273
+ """Run a command. Foreground (default): returns the complete
274
+ :class:`CommandResult` once the process exits — non-zero exit is
275
+ data unless ``check=True`` (`subprocess.run` semantics).
276
+ Background (``background=True``): returns as soon as the process
277
+ is started, with a handle for streaming and waiting.
278
+
279
+ ``timeout`` kills the whole process group after that many
280
+ seconds; expiry surfaces as signal death in the result
281
+ (exit-as-data), not as a raised error."""
282
+ if background and check:
283
+ raise InvalidArgumentError(
284
+ "check=True applies to foreground runs; call "
285
+ ".wait_for_exit() and .expect() on the handle instead",
286
+ operation="commands.run",
287
+ )
288
+ handle = await self._start(cmd, cwd=cwd, env=env, user=user, timeout=timeout)
289
+ if background:
290
+ return handle
291
+ result = await handle.wait_for_exit()
292
+ return result.expect() if check else result
293
+
294
+ async def _start(
295
+ self,
296
+ cmd: str | Sequence[str],
297
+ *,
298
+ cwd: str | None,
299
+ env: Mapping[str, str] | None,
300
+ user: str | None,
301
+ timeout: float | None,
302
+ ) -> AsyncCommandHandle:
303
+ with wrap_errors("commands.run"):
304
+ # The execution id is minted client-side: a lost response
305
+ # leaves an addressable execution, and retries are idempotent
306
+ # by contract.
307
+ execution_id = str(uuid.uuid4())
308
+ execution = await self._client.unary(
309
+ _PROCESS + "StartExecution",
310
+ process_pb2.StartExecutionRequest(
311
+ sandbox_id=self._sandbox_id,
312
+ execution_id=execution_id,
313
+ cmd=_normalize_cmd(cmd),
314
+ env=dict(env) if env else {},
315
+ working_dir=cwd or "",
316
+ user=user or "",
317
+ timeout_seconds=0 if timeout is None else math.ceil(timeout),
318
+ stdin=False,
319
+ ),
320
+ process_pb2.Execution,
321
+ )
322
+ return AsyncCommandHandle(self._client, self._sandbox_id, execution.id)