snowflake-sandbox-python 0.2.1a1__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.
- snowflake/cli_sandbox/__init__.py +13 -0
- snowflake/cli_sandbox/_adapter.py +170 -0
- snowflake/cli_sandbox/_common.py +77 -0
- snowflake/cli_sandbox/_egress_flags.py +121 -0
- snowflake/cli_sandbox/_get_command.py +109 -0
- snowflake/cli_sandbox/_run_command.py +1091 -0
- snowflake/cli_sandbox/_shell_command.py +666 -0
- snowflake/cli_sandbox/_upload_plan.py +187 -0
- snowflake/cli_sandbox/commands.py +556 -0
- snowflake/cli_sandbox/plugin_spec.py +28 -0
- snowflake/cli_sandbox/py.typed +0 -0
- snowflake/sandbox/__init__.py +317 -0
- snowflake/sandbox/__main__.py +225 -0
- snowflake/sandbox/_ansi.py +206 -0
- snowflake/sandbox/_args.py +208 -0
- snowflake/sandbox/_assemble.py +256 -0
- snowflake/sandbox/_bundle.py +240 -0
- snowflake/sandbox/_connection_resolve.py +328 -0
- snowflake/sandbox/_deploy_spec.py +56 -0
- snowflake/sandbox/_diagnostics.py +501 -0
- snowflake/sandbox/_env.py +143 -0
- snowflake/sandbox/_files_mixin.py +280 -0
- snowflake/sandbox/_fs_ops.py +304 -0
- snowflake/sandbox/_globs.py +176 -0
- snowflake/sandbox/_hosts.py +110 -0
- snowflake/sandbox/_mcp_discovery.py +288 -0
- snowflake/sandbox/_mcp_status.py +183 -0
- snowflake/sandbox/_retry.py +94 -0
- snowflake/sandbox/_runtime/__init__.py +42 -0
- snowflake/sandbox/_runtime/_fs_helper.py +93 -0
- snowflake/sandbox/_runtime/_job_runner.py +111 -0
- snowflake/sandbox/_runtime/_protocol.py +53 -0
- snowflake/sandbox/_runtime/_shims.py +267 -0
- snowflake/sandbox/_sandbox_state.py +303 -0
- snowflake/sandbox/_session_registry.py +222 -0
- snowflake/sandbox/_sse.py +160 -0
- snowflake/sandbox/_stage.py +270 -0
- snowflake/sandbox/_sync_files_mixin.py +272 -0
- snowflake/sandbox/_sync_fs_ops.py +185 -0
- snowflake/sandbox/_sync_transport.py +737 -0
- snowflake/sandbox/_sync_watch.py +99 -0
- snowflake/sandbox/_transport.py +1366 -0
- snowflake/sandbox/_transport_errors.py +270 -0
- snowflake/sandbox/_upload_plan.py +497 -0
- snowflake/sandbox/_version.py +37 -0
- snowflake/sandbox/_watch.py +164 -0
- snowflake/sandbox/_wire.py +348 -0
- snowflake/sandbox/app.py +256 -0
- snowflake/sandbox/client.py +2356 -0
- snowflake/sandbox/config.py +1133 -0
- snowflake/sandbox/connect.py +288 -0
- snowflake/sandbox/deploy.py +499 -0
- snowflake/sandbox/egress.py +388 -0
- snowflake/sandbox/exceptions.py +253 -0
- snowflake/sandbox/exec_stream.py +264 -0
- snowflake/sandbox/files.py +547 -0
- snowflake/sandbox/function.py +567 -0
- snowflake/sandbox/image.py +46 -0
- snowflake/sandbox/jobs.py +649 -0
- snowflake/sandbox/lifecycle.py +67 -0
- snowflake/sandbox/log_stream.py +219 -0
- snowflake/sandbox/mcp.py +480 -0
- snowflake/sandbox/mount.py +161 -0
- snowflake/sandbox/py.typed +0 -0
- snowflake/sandbox/secret.py +244 -0
- snowflake/sandbox/session_app.py +244 -0
- snowflake/sandbox/shell.py +556 -0
- snowflake/sandbox/sync_client.py +2245 -0
- snowflake/sandbox/sync_exec_stream.py +238 -0
- snowflake/sandbox/sync_files.py +377 -0
- snowflake/sandbox/sync_log_stream.py +142 -0
- snowflake/sandbox/sync_shell.py +413 -0
- snowflake/sandbox/types.py +193 -0
- snowflake/sandbox/warm_session.py +700 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Interactive shell sessions on a sandbox.
|
|
2
|
+
|
|
3
|
+
A terminal is duplex and the REST path in front of the sandbox is not: the
|
|
4
|
+
gateway proxies with a synchronous send, so it never upgrades to a WebSocket.
|
|
5
|
+
The sandbox therefore exposes one pty across two ordinary requests -- an SSE
|
|
6
|
+
response carrying output, POSTs carrying input -- and this module drives that
|
|
7
|
+
pair.
|
|
8
|
+
|
|
9
|
+
**This transport is a deliberate stopgap and is meant to be deleted.** It exists
|
|
10
|
+
only because the hop in front of the sandbox is half-duplex -- not because
|
|
11
|
+
splitting a terminal across two requests is the right shape for one. When a
|
|
12
|
+
genuine end-to-end duplex stream reaches the sandbox from an external client (a
|
|
13
|
+
bidirectional session on the data plane rather than a request/response gateway),
|
|
14
|
+
this module and the two server-side halves behind it should be removed rather than
|
|
15
|
+
maintained alongside the real thing.
|
|
16
|
+
|
|
17
|
+
The *public surface* is the part worth preserving across that change: `shell()`,
|
|
18
|
+
`send`, `output`, `resize` and `attach` describe a terminal, not a transport, so a
|
|
19
|
+
duplex replacement can keep them and drop everything below -- the cursor
|
|
20
|
+
bookkeeping, the reconnect loop, and the base64 framing all exist to work around
|
|
21
|
+
the half-duplex hop and have no reason to outlive it.
|
|
22
|
+
|
|
23
|
+
`Shell` is the programmatic surface (send bytes, read output, resize). `attach()`
|
|
24
|
+
is the thing a CLI wants: it puts the local terminal in raw mode, forwards
|
|
25
|
+
keystrokes, and forwards window-size changes, so `python -m ...` becomes an
|
|
26
|
+
actual terminal into the sandbox.
|
|
27
|
+
|
|
28
|
+
**Resumption is not optional here.** The gateway bounds a streaming response at
|
|
29
|
+
its exec-stream timeout (900s), so any shell used for real work will have its
|
|
30
|
+
output stream cut while the shell itself is still alive and healthy. Every frame
|
|
31
|
+
carries an id; on a cut we re-open from the last id seen, which the sandbox
|
|
32
|
+
answers exactly (no replay, no loss) from a bounded backlog. When the backlog has
|
|
33
|
+
already evicted our cursor the sandbox says so with a `gap` frame rather than
|
|
34
|
+
resuming mid-escape-sequence, which would render as a corrupt screen.
|
|
35
|
+
|
|
36
|
+
**Sync and async share one protocol.** Everything that is *not* I/O -- id
|
|
37
|
+
validation, the request path, the resume cursor, base64 framing in both
|
|
38
|
+
directions, and exit/gap frame interpretation -- lives on `_ShellCore`. The async
|
|
39
|
+
`Shell` here and the blocking `SyncShell` in `sync_shell` each add only the
|
|
40
|
+
transport calls (awaited on one side, blocking on the other), so the wire
|
|
41
|
+
contract cannot drift between them.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import asyncio
|
|
47
|
+
import base64
|
|
48
|
+
import binascii
|
|
49
|
+
import contextlib
|
|
50
|
+
import os
|
|
51
|
+
import re
|
|
52
|
+
import signal
|
|
53
|
+
import sys
|
|
54
|
+
from collections.abc import AsyncIterator, Callable
|
|
55
|
+
from typing import TYPE_CHECKING
|
|
56
|
+
|
|
57
|
+
from snowflake.sandbox._ansi import _ClipboardStripper
|
|
58
|
+
from snowflake.sandbox.exceptions import SandboxError, SandboxTransportError
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from snowflake.sandbox._transport import SSEEvent, Transport
|
|
62
|
+
|
|
63
|
+
__all__ = ["Shell", "ShellClosed", "open_shell"]
|
|
64
|
+
|
|
65
|
+
# Frames the sandbox sends. `out` is base64 pty bytes, `control` is JSON in the
|
|
66
|
+
# same shape the WebSocket transport uses, `gap` reports evicted output.
|
|
67
|
+
_EVENT_OUT = "out"
|
|
68
|
+
_EVENT_CONTROL = "control"
|
|
69
|
+
_EVENT_GAP = "gap"
|
|
70
|
+
|
|
71
|
+
# Reconnect pacing after the stream is cut. Deliberately short: the common cause
|
|
72
|
+
# is the gateway's timeout on a *healthy* session, so the user is sitting at a
|
|
73
|
+
# live prompt waiting for output to resume.
|
|
74
|
+
_RECONNECT_DELAYS = (0.1, 0.25, 0.5, 1.0, 2.0)
|
|
75
|
+
|
|
76
|
+
# Shape an id must have before it is interpolated into a request path. Applied to
|
|
77
|
+
# BOTH the session id and the sandbox id: they come from the same place (a server
|
|
78
|
+
# response, in the sandbox id's case often the picker's `list_sandboxes()`) and go
|
|
79
|
+
# to the same place (the path of a token-bearing request), so validating one and
|
|
80
|
+
# not the other was inconsistent rather than considered.
|
|
81
|
+
_ID_RE = re.compile(r"[A-Za-z0-9_-]{1,64}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ShellClosed(SandboxError):
|
|
85
|
+
"""Raised when input is sent to a shell whose process has exited."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _ShellCore:
|
|
89
|
+
"""State and pure protocol logic shared by `Shell` (async) and `SyncShell`.
|
|
90
|
+
|
|
91
|
+
Everything here is transport-agnostic: id validation, the request path, the
|
|
92
|
+
resume cursor, base64 framing in both directions, and exit/gap frame
|
|
93
|
+
interpretation. Neither shell may re-implement any of it -- the two halves
|
|
94
|
+
speak the same wire protocol, and copying a piece back into one of them is the
|
|
95
|
+
drift `SyncShell` exists to avoid. The subclasses add only the actual requests
|
|
96
|
+
(awaited vs blocking) on top of these helpers.
|
|
97
|
+
|
|
98
|
+
Not constructed directly.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
sandbox_id: str,
|
|
104
|
+
session_id: str,
|
|
105
|
+
*,
|
|
106
|
+
rows: int,
|
|
107
|
+
cols: int,
|
|
108
|
+
) -> None:
|
|
109
|
+
# Both ids are validated here, in one place, because both are
|
|
110
|
+
# server-supplied and both are interpolated into the path of a request
|
|
111
|
+
# carrying the caller's token.
|
|
112
|
+
self._sandbox_id = self._checked_id(sandbox_id, "sandbox_id")
|
|
113
|
+
self._session_id = self._checked_id(session_id, "session_id")
|
|
114
|
+
self._rows = rows
|
|
115
|
+
self._cols = cols
|
|
116
|
+
self._cursor = 0
|
|
117
|
+
self._exit_code: int | None = None
|
|
118
|
+
# Separate from `_exit_code`: the shell has ended is a different fact
|
|
119
|
+
# from we know its exit code. Deriving one from the other meant a shell
|
|
120
|
+
# that exited with a code we could not read looked like a live session,
|
|
121
|
+
# and the output loop reconnected to it forever.
|
|
122
|
+
self._ended = False
|
|
123
|
+
self._closed = False
|
|
124
|
+
self.gaps = 0
|
|
125
|
+
"""How many times output was lost to backlog eviction (see module doc)."""
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def session_id(self) -> str:
|
|
129
|
+
"""The sandbox-assigned id of this shell session."""
|
|
130
|
+
return self._session_id
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def exit_code(self) -> int | None:
|
|
134
|
+
"""The shell's exit code once it has exited, else None."""
|
|
135
|
+
return self._exit_code
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def closed(self) -> bool:
|
|
139
|
+
"""True once the shell has ended or the session has been closed."""
|
|
140
|
+
return self._closed or self._ended
|
|
141
|
+
|
|
142
|
+
def _path(self, tail: str = "") -> str:
|
|
143
|
+
return f"containers/{self._sandbox_id}/shell/sessions/{self._session_id}{tail}"
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _checked_id(value: str, kind: str) -> str:
|
|
147
|
+
"""Reject an id that could steer a token-bearing request off its path."""
|
|
148
|
+
if not _ID_RE.fullmatch(str(value)):
|
|
149
|
+
raise SandboxError(
|
|
150
|
+
f"shell: unusable {kind} {value!r} (expected an opaque token of "
|
|
151
|
+
"letters, digits, '-' or '_')"
|
|
152
|
+
)
|
|
153
|
+
return str(value)
|
|
154
|
+
|
|
155
|
+
# --- framing: shared so the two shells cannot encode the wire differently ---
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _input_body(data: bytes | str) -> dict[str, str] | None:
|
|
159
|
+
"""Base64-frame a stdin write for a POST `/input`, or None for an empty one.
|
|
160
|
+
|
|
161
|
+
Bytes, not text, because the interesting input is control characters:
|
|
162
|
+
Ctrl-C is 0x03, and a transport that treated keystrokes as text would
|
|
163
|
+
make the terminal unable to interrupt anything. A `str` is encoded UTF-8
|
|
164
|
+
for convenience. An empty write has no body -- the caller skips the POST.
|
|
165
|
+
"""
|
|
166
|
+
if isinstance(data, str):
|
|
167
|
+
data = data.encode("utf-8")
|
|
168
|
+
if not data:
|
|
169
|
+
return None
|
|
170
|
+
return {"data": base64.b64encode(data).decode("ascii")}
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _resize_body(rows: int, cols: int) -> dict[str, dict[str, int]]:
|
|
174
|
+
"""The POST `/input` body that tells the pty its new window size."""
|
|
175
|
+
return {"resize": {"rows": rows, "cols": cols}}
|
|
176
|
+
|
|
177
|
+
def _stream_path(self) -> str:
|
|
178
|
+
"""The output-stream path, carrying the resume cursor as a query param.
|
|
179
|
+
|
|
180
|
+
The cursor travels as a query parameter, not `Last-Event-ID`: the Snowflake
|
|
181
|
+
passthrough forwards only an allowlist of request headers (X-Request-Id
|
|
182
|
+
and Accept) and would drop the header, while query strings are forwarded
|
|
183
|
+
verbatim. The sandbox accepts either.
|
|
184
|
+
"""
|
|
185
|
+
path = self._path("/stream")
|
|
186
|
+
if self._cursor:
|
|
187
|
+
path += f"?last_event_id={self._cursor}"
|
|
188
|
+
return path
|
|
189
|
+
|
|
190
|
+
def _decode_out_frame(self, evt: SSEEvent) -> bytes:
|
|
191
|
+
"""Base64-decode one `out` frame, mapping a bad frame to a transport error.
|
|
192
|
+
|
|
193
|
+
Decode is the caller's first step, BEFORE advancing the cursor: a decode
|
|
194
|
+
failure raised straight out of the read loop (as a bare `binascii.Error`)
|
|
195
|
+
killed an otherwise-healthy session -- the exact failure resumption exists
|
|
196
|
+
to prevent. Converting it to the error type the reconnect loop handles, and
|
|
197
|
+
leaving the cursor put, makes the retry re-fetch the frame rather than skip
|
|
198
|
+
past it; if it is permanently corrupt the retry budget ends the session with
|
|
199
|
+
a clear error instead of losing output silently.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
return base64.b64decode(evt.data, validate=True)
|
|
203
|
+
except (ValueError, binascii.Error) as exc:
|
|
204
|
+
raise SandboxTransportError(
|
|
205
|
+
f"shell session {self._session_id}: undecodable output frame"
|
|
206
|
+
f"{f' (id {evt.id})' if evt.id else ''}: {exc}"
|
|
207
|
+
) from exc
|
|
208
|
+
|
|
209
|
+
def _advance(self, evt: SSEEvent) -> None:
|
|
210
|
+
"""Move the resume cursor forward. Never backwards -- a frame with no id
|
|
211
|
+
(or an out-of-order one) must not cause a replay of output already seen."""
|
|
212
|
+
if not evt.id:
|
|
213
|
+
return
|
|
214
|
+
try:
|
|
215
|
+
self._cursor = max(self._cursor, int(evt.id))
|
|
216
|
+
except ValueError: # pragma: no cover - defensive
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
def _apply_control(self, evt: SSEEvent) -> bool:
|
|
220
|
+
"""Handle one control frame. Returns True when the session is over."""
|
|
221
|
+
try:
|
|
222
|
+
msg = evt.json()
|
|
223
|
+
except (ValueError, TypeError): # pragma: no cover - defensive
|
|
224
|
+
return False
|
|
225
|
+
if isinstance(msg, dict) and msg.get("type") == "exit":
|
|
226
|
+
raw = msg.get("code")
|
|
227
|
+
# A code we cannot read (null, or a type we did not expect) still
|
|
228
|
+
# means the shell is gone -- record the end, leave the code unknown.
|
|
229
|
+
self._exit_code = raw if isinstance(raw, int) else None
|
|
230
|
+
self._ended = True
|
|
231
|
+
return True
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _lost_at(evt: SSEEvent) -> int:
|
|
236
|
+
try:
|
|
237
|
+
payload = evt.json()
|
|
238
|
+
return int(payload.get("resumed_at", 0)) if isinstance(payload, dict) else 0
|
|
239
|
+
except (ValueError, TypeError): # pragma: no cover - defensive
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Shell(_ShellCore):
|
|
244
|
+
"""One interactive shell session on a sandbox.
|
|
245
|
+
|
|
246
|
+
Obtained from `AsyncSandbox.shell()`. Not constructed directly.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def __init__(
|
|
250
|
+
self,
|
|
251
|
+
transport: Transport,
|
|
252
|
+
sandbox_id: str,
|
|
253
|
+
session_id: str,
|
|
254
|
+
*,
|
|
255
|
+
rows: int,
|
|
256
|
+
cols: int,
|
|
257
|
+
) -> None:
|
|
258
|
+
super().__init__(sandbox_id, session_id, rows=rows, cols=cols)
|
|
259
|
+
self._t = transport
|
|
260
|
+
|
|
261
|
+
# --- input ----------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
async def send(self, data: bytes | str) -> None:
|
|
264
|
+
"""Write to the shell's stdin.
|
|
265
|
+
|
|
266
|
+
Bytes, not text, because the interesting input is control characters:
|
|
267
|
+
Ctrl-C is 0x03, and a transport that treated keystrokes as text would
|
|
268
|
+
make the terminal unable to interrupt anything. A `str` is encoded UTF-8
|
|
269
|
+
for convenience.
|
|
270
|
+
"""
|
|
271
|
+
if self.closed:
|
|
272
|
+
raise ShellClosed(f"shell session {self._session_id} has ended")
|
|
273
|
+
body = self._input_body(data)
|
|
274
|
+
if body is None:
|
|
275
|
+
return
|
|
276
|
+
await self._t.request("POST", self._path("/input"), json_body=body)
|
|
277
|
+
|
|
278
|
+
async def resize(self, rows: int, cols: int) -> None:
|
|
279
|
+
"""Tell the pty its new window size, so curses apps re-lay-out."""
|
|
280
|
+
if self.closed:
|
|
281
|
+
return
|
|
282
|
+
self._rows, self._cols = rows, cols
|
|
283
|
+
await self._t.request("POST", self._path("/input"), json_body=self._resize_body(rows, cols))
|
|
284
|
+
|
|
285
|
+
# --- output ---------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
async def output(self, *, on_gap: Callable[[int], None] | None = None) -> AsyncIterator[bytes]:
|
|
288
|
+
"""Yield pty output as it arrives, across gateway stream cuts.
|
|
289
|
+
|
|
290
|
+
Ends when the shell process exits (`exit_code` is then set) or `close()`
|
|
291
|
+
is called. A cut stream is re-opened from the last frame id seen; only a
|
|
292
|
+
cut we cannot recover from raises `SandboxTransportError`.
|
|
293
|
+
"""
|
|
294
|
+
attempt = 0
|
|
295
|
+
while not self.closed:
|
|
296
|
+
delivered = False
|
|
297
|
+
try:
|
|
298
|
+
async for chunk in self._read_once(on_gap):
|
|
299
|
+
delivered = True
|
|
300
|
+
attempt = 0 # progress resets the budget
|
|
301
|
+
yield chunk
|
|
302
|
+
if self.closed:
|
|
303
|
+
return
|
|
304
|
+
# A clean end with no exit frame is the ordinary 900s case: the
|
|
305
|
+
# gateway closed a response whose shell is still alive. Re-open
|
|
306
|
+
# from the cursor.
|
|
307
|
+
if not delivered and attempt >= len(_RECONNECT_DELAYS):
|
|
308
|
+
# ...but a stream that keeps closing having sent nothing is
|
|
309
|
+
# not that. Without this the loop reconnects forever and the
|
|
310
|
+
# caller sees a terminal that is simply silent.
|
|
311
|
+
raise SandboxTransportError(
|
|
312
|
+
f"shell session {self._session_id}: stream closed without output "
|
|
313
|
+
f"after {attempt} attempts"
|
|
314
|
+
)
|
|
315
|
+
except SandboxTransportError:
|
|
316
|
+
if self.closed:
|
|
317
|
+
return
|
|
318
|
+
if attempt >= len(_RECONNECT_DELAYS):
|
|
319
|
+
raise
|
|
320
|
+
if self.closed:
|
|
321
|
+
return
|
|
322
|
+
delay = _RECONNECT_DELAYS[min(attempt, len(_RECONNECT_DELAYS) - 1)]
|
|
323
|
+
attempt += 1
|
|
324
|
+
await asyncio.sleep(delay)
|
|
325
|
+
|
|
326
|
+
async def _read_once(self, on_gap: Callable[[int], None] | None) -> AsyncIterator[bytes]:
|
|
327
|
+
"""One SSE connection's worth of frames."""
|
|
328
|
+
async for evt in self._t.stream_sse("GET", self._stream_path()):
|
|
329
|
+
if evt.event == _EVENT_OUT:
|
|
330
|
+
# Decode BEFORE advancing the cursor (see `_decode_out_frame`): a
|
|
331
|
+
# malformed frame becomes a retryable transport error with the
|
|
332
|
+
# cursor left put, so the retry re-fetches it rather than skipping
|
|
333
|
+
# past lost output.
|
|
334
|
+
chunk = self._decode_out_frame(evt)
|
|
335
|
+
self._advance(evt)
|
|
336
|
+
yield chunk
|
|
337
|
+
continue
|
|
338
|
+
self._advance(evt)
|
|
339
|
+
if evt.event == _EVENT_GAP:
|
|
340
|
+
self.gaps += 1
|
|
341
|
+
if on_gap is not None:
|
|
342
|
+
on_gap(self._lost_at(evt))
|
|
343
|
+
elif evt.event == _EVENT_CONTROL:
|
|
344
|
+
if self._apply_control(evt):
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
# --- lifecycle ------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
async def close(self) -> None:
|
|
350
|
+
"""End the session and reap the shell. Idempotent."""
|
|
351
|
+
if self._closed:
|
|
352
|
+
return
|
|
353
|
+
self._closed = True
|
|
354
|
+
with contextlib.suppress(Exception):
|
|
355
|
+
# A close that fails is not worth raising over: the sandbox reaps
|
|
356
|
+
# idle sessions anyway, and the caller is on its way out.
|
|
357
|
+
await self._t.request("DELETE", self._path())
|
|
358
|
+
|
|
359
|
+
async def __aenter__(self) -> Shell:
|
|
360
|
+
return self
|
|
361
|
+
|
|
362
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
363
|
+
await self.close()
|
|
364
|
+
|
|
365
|
+
# --- interactive attach ---------------------------------------------
|
|
366
|
+
|
|
367
|
+
async def attach(
|
|
368
|
+
self,
|
|
369
|
+
*,
|
|
370
|
+
stdin: int | None = None,
|
|
371
|
+
stdout: int | None = None,
|
|
372
|
+
sanitize: bool = True,
|
|
373
|
+
) -> int | None:
|
|
374
|
+
"""Wire the local terminal to this shell until it exits. Returns exit code.
|
|
375
|
+
|
|
376
|
+
**Trust boundary, worth stating plainly:** this hands the sandbox's raw
|
|
377
|
+
output to your *local* terminal emulator, which interprets it. A sandbox
|
|
378
|
+
runs untrusted and often agent-generated code, so treat attaching the way
|
|
379
|
+
you would treat `ssh`-ing into a machine you do not control -- the remote
|
|
380
|
+
end can emit control sequences, not just text.
|
|
381
|
+
|
|
382
|
+
`sanitize` (default on) removes OSC 52, the sequence that writes the local
|
|
383
|
+
clipboard: no display purpose, and a sandbox quietly replacing what you
|
|
384
|
+
are about to paste is a real consequence. Everything else passes through,
|
|
385
|
+
deliberately -- see `_ansi._ClipboardStripper` for what is not filtered
|
|
386
|
+
and why, including one residual risk that cannot be filtered without
|
|
387
|
+
breaking legitimate terminal programs (a sandbox can emit a capability
|
|
388
|
+
query, and the local terminal's *reply* lands as typed input at the next
|
|
389
|
+
prompt). Pass `sanitize=False` for byte-exact passthrough.
|
|
390
|
+
|
|
391
|
+
`output()` is always raw: it returns bytes to a program, and silently
|
|
392
|
+
altering them there would be the surprising choice.
|
|
393
|
+
|
|
394
|
+
Three things have to happen for this to feel like a terminal, and each
|
|
395
|
+
one is a thing users notice by its absence:
|
|
396
|
+
|
|
397
|
+
* **Raw mode** on the local tty, so keystrokes go to the *remote* shell
|
|
398
|
+
instead of being line-buffered and interpreted locally -- without it
|
|
399
|
+
Ctrl-C kills the client and nothing reaches the sandbox.
|
|
400
|
+
* **SIGWINCH** forwarded as a resize, so a window change re-lays-out the
|
|
401
|
+
remote curses app rather than leaving it drawing at the old size.
|
|
402
|
+
* The initial size sent up front, since the session was created with a
|
|
403
|
+
default that is probably not this terminal's.
|
|
404
|
+
|
|
405
|
+
Requires a real tty on both ends; raises otherwise rather than silently
|
|
406
|
+
degrading to something that looks broken.
|
|
407
|
+
"""
|
|
408
|
+
import termios
|
|
409
|
+
import tty
|
|
410
|
+
|
|
411
|
+
in_fd = sys.stdin.fileno() if stdin is None else stdin
|
|
412
|
+
out_fd = sys.stdout.fileno() if stdout is None else stdout
|
|
413
|
+
if not os.isatty(in_fd):
|
|
414
|
+
raise SandboxError("attach() needs a tty on stdin; use send()/output() instead")
|
|
415
|
+
|
|
416
|
+
loop = asyncio.get_running_loop()
|
|
417
|
+
saved = termios.tcgetattr(in_fd)
|
|
418
|
+
keys: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
419
|
+
# Strong refs to in-flight resize tasks: asyncio holds only a weak ref to a
|
|
420
|
+
# bare create_task(), so a fire-and-forget resize can be garbage-collected
|
|
421
|
+
# mid-run. Hold it until it finishes, then drop it.
|
|
422
|
+
winch_tasks: set[asyncio.Task[None]] = set()
|
|
423
|
+
|
|
424
|
+
def _readable() -> None:
|
|
425
|
+
try:
|
|
426
|
+
data = os.read(in_fd, 4096)
|
|
427
|
+
except OSError:
|
|
428
|
+
data = b""
|
|
429
|
+
keys.put_nowait(data or None)
|
|
430
|
+
|
|
431
|
+
def _on_winch() -> None:
|
|
432
|
+
size = os.get_terminal_size(out_fd)
|
|
433
|
+
# Fire-and-forget: a resize is advisory, and awaiting it here would
|
|
434
|
+
# block the signal handler.
|
|
435
|
+
task = loop.create_task(self._resize_quietly(size.lines, size.columns))
|
|
436
|
+
winch_tasks.add(task)
|
|
437
|
+
task.add_done_callback(winch_tasks.discard)
|
|
438
|
+
|
|
439
|
+
def _note(resumed_at: int) -> None:
|
|
440
|
+
del resumed_at
|
|
441
|
+
os.write(out_fd, b"\r\n[output was dropped while reconnecting]\r\n")
|
|
442
|
+
|
|
443
|
+
# Acquire *inside* the try, so a failure part-way through setup still runs
|
|
444
|
+
# the matched release below. `add_signal_handler` is the realistic tripwire
|
|
445
|
+
# (RuntimeError off the main thread), but leaving a terminal in raw mode is
|
|
446
|
+
# bad enough that nothing here should sit outside the guard. Each release
|
|
447
|
+
# is a no-op when its acquire never happened -- `remove_reader` returns
|
|
448
|
+
# False for an unregistered fd, `remove_signal_handler` for an uninstalled
|
|
449
|
+
# signal, and `tcsetattr` re-applies the attributes we just read -- so one
|
|
450
|
+
# try/finally covering both setup and use is enough, and keeps acquire and
|
|
451
|
+
# release paired in a single readable order.
|
|
452
|
+
try:
|
|
453
|
+
tty.setraw(in_fd)
|
|
454
|
+
loop.add_reader(in_fd, _readable)
|
|
455
|
+
with contextlib.suppress(NotImplementedError, ValueError):
|
|
456
|
+
loop.add_signal_handler(signal.SIGWINCH, _on_winch)
|
|
457
|
+
|
|
458
|
+
if os.isatty(out_fd):
|
|
459
|
+
size = os.get_terminal_size(out_fd)
|
|
460
|
+
await self.resize(size.lines, size.columns)
|
|
461
|
+
|
|
462
|
+
pump = loop.create_task(self._pump_keys(keys))
|
|
463
|
+
stripper = _ClipboardStripper() if sanitize else None
|
|
464
|
+
try:
|
|
465
|
+
async for chunk in self.output(on_gap=_note):
|
|
466
|
+
os.write(out_fd, stripper.feed(chunk) if stripper else chunk)
|
|
467
|
+
finally:
|
|
468
|
+
if stripper is not None:
|
|
469
|
+
# Anything held back mid-sequence still belongs on screen.
|
|
470
|
+
tail = stripper.flush()
|
|
471
|
+
if tail:
|
|
472
|
+
os.write(out_fd, tail)
|
|
473
|
+
pump.cancel()
|
|
474
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
475
|
+
await pump
|
|
476
|
+
finally:
|
|
477
|
+
with contextlib.suppress(NotImplementedError, ValueError):
|
|
478
|
+
loop.remove_signal_handler(signal.SIGWINCH)
|
|
479
|
+
loop.remove_reader(in_fd)
|
|
480
|
+
termios.tcsetattr(in_fd, termios.TCSADRAIN, saved)
|
|
481
|
+
return self._exit_code
|
|
482
|
+
|
|
483
|
+
async def _pump_keys(self, keys: asyncio.Queue[bytes | None]) -> None:
|
|
484
|
+
while True:
|
|
485
|
+
data = await keys.get()
|
|
486
|
+
if data is None:
|
|
487
|
+
return
|
|
488
|
+
# Coalesce every keystroke already queued behind this one into a single
|
|
489
|
+
# POST. Each POST /input is a full round-trip (~150ms+ against a remote
|
|
490
|
+
# deployment) and `_readable` keeps enqueuing while one is in flight, so
|
|
491
|
+
# sending them one-at-a-time makes input latency grow with typing speed --
|
|
492
|
+
# a burst of N bytes costs N sequential round-trips. Draining the
|
|
493
|
+
# already-ready items adds no latency to a lone keystroke (nothing else is
|
|
494
|
+
# queued) and collapses a burst to one request. `None` is the stdin-EOF
|
|
495
|
+
# sentinel: send what we have, then stop.
|
|
496
|
+
chunks = [data]
|
|
497
|
+
eof = False
|
|
498
|
+
while True:
|
|
499
|
+
try:
|
|
500
|
+
nxt = keys.get_nowait()
|
|
501
|
+
except asyncio.QueueEmpty:
|
|
502
|
+
break
|
|
503
|
+
if nxt is None:
|
|
504
|
+
eof = True
|
|
505
|
+
break
|
|
506
|
+
chunks.append(nxt)
|
|
507
|
+
try:
|
|
508
|
+
await self.send(b"".join(chunks))
|
|
509
|
+
except ShellClosed:
|
|
510
|
+
return
|
|
511
|
+
except SandboxTransportError:
|
|
512
|
+
# A dropped keystroke is better than tearing down a live
|
|
513
|
+
# terminal; the output stream reports the real end of session.
|
|
514
|
+
pass
|
|
515
|
+
if eof:
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
async def _resize_quietly(self, rows: int, cols: int) -> None:
|
|
519
|
+
with contextlib.suppress(Exception):
|
|
520
|
+
await self.resize(rows, cols)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
async def open_shell(
|
|
524
|
+
transport: Transport,
|
|
525
|
+
sandbox_id: str,
|
|
526
|
+
*,
|
|
527
|
+
rows: int = 24,
|
|
528
|
+
cols: int = 80,
|
|
529
|
+
cwd: str | None = None,
|
|
530
|
+
) -> Shell:
|
|
531
|
+
"""Start a shell session on a sandbox and return it.
|
|
532
|
+
|
|
533
|
+
The pty is running by the time this returns, so output produced before the
|
|
534
|
+
caller starts reading -- the shell's greeting and first prompt -- is already
|
|
535
|
+
in the sandbox's backlog and arrives on the first read rather than being
|
|
536
|
+
lost to the gap between the two requests.
|
|
537
|
+
"""
|
|
538
|
+
body: dict[str, object] = {"rows": rows, "cols": cols}
|
|
539
|
+
if cwd is not None:
|
|
540
|
+
body["cwd"] = cwd
|
|
541
|
+
resp = await transport.request(
|
|
542
|
+
"POST", f"containers/{sandbox_id}/shell/sessions", json_body=body
|
|
543
|
+
)
|
|
544
|
+
payload = resp.json() if resp.content else {}
|
|
545
|
+
session_id = payload.get("session_id") if isinstance(payload, dict) else None
|
|
546
|
+
if not session_id:
|
|
547
|
+
raise SandboxError(f"shell: sandbox returned no session_id (body: {payload!r})")
|
|
548
|
+
# Defence in depth. The id goes straight into the path of every follow-up
|
|
549
|
+
# request, all of which carry the caller's bearer token, so a value with path
|
|
550
|
+
# characters in it could point those requests somewhere else. The endpoint is
|
|
551
|
+
# authenticated and trusted, so this is not attacker-reachable today -- it
|
|
552
|
+
# bounds the blast radius of a buggy or compromised gateway for the price of
|
|
553
|
+
# one check. The charset is wider than the ids the sandbox actually mints
|
|
554
|
+
# (hex) so a future id format does not need a client release.
|
|
555
|
+
# Shell.__init__ validates both ids (see _ShellCore._checked_id).
|
|
556
|
+
return Shell(transport, sandbox_id, str(session_id), rows=rows, cols=cols)
|