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.
Files changed (80) hide show
  1. snowflake/cli_sandbox/__init__.py +13 -0
  2. snowflake/cli_sandbox/_adapter.py +170 -0
  3. snowflake/cli_sandbox/_common.py +77 -0
  4. snowflake/cli_sandbox/_egress_flags.py +121 -0
  5. snowflake/cli_sandbox/_get_command.py +109 -0
  6. snowflake/cli_sandbox/_run_command.py +1091 -0
  7. snowflake/cli_sandbox/_shell_command.py +666 -0
  8. snowflake/cli_sandbox/_upload_plan.py +187 -0
  9. snowflake/cli_sandbox/commands.py +556 -0
  10. snowflake/cli_sandbox/plugin_spec.py +28 -0
  11. snowflake/cli_sandbox/py.typed +0 -0
  12. snowflake/sandbox/__init__.py +317 -0
  13. snowflake/sandbox/__main__.py +225 -0
  14. snowflake/sandbox/_ansi.py +206 -0
  15. snowflake/sandbox/_args.py +208 -0
  16. snowflake/sandbox/_assemble.py +256 -0
  17. snowflake/sandbox/_bundle.py +240 -0
  18. snowflake/sandbox/_connection_resolve.py +328 -0
  19. snowflake/sandbox/_deploy_spec.py +56 -0
  20. snowflake/sandbox/_diagnostics.py +501 -0
  21. snowflake/sandbox/_env.py +143 -0
  22. snowflake/sandbox/_files_mixin.py +280 -0
  23. snowflake/sandbox/_fs_ops.py +304 -0
  24. snowflake/sandbox/_globs.py +176 -0
  25. snowflake/sandbox/_hosts.py +110 -0
  26. snowflake/sandbox/_mcp_discovery.py +288 -0
  27. snowflake/sandbox/_mcp_status.py +183 -0
  28. snowflake/sandbox/_retry.py +94 -0
  29. snowflake/sandbox/_runtime/__init__.py +42 -0
  30. snowflake/sandbox/_runtime/_fs_helper.py +93 -0
  31. snowflake/sandbox/_runtime/_job_runner.py +111 -0
  32. snowflake/sandbox/_runtime/_protocol.py +53 -0
  33. snowflake/sandbox/_runtime/_shims.py +267 -0
  34. snowflake/sandbox/_sandbox_state.py +303 -0
  35. snowflake/sandbox/_session_registry.py +222 -0
  36. snowflake/sandbox/_sse.py +160 -0
  37. snowflake/sandbox/_stage.py +270 -0
  38. snowflake/sandbox/_sync_files_mixin.py +272 -0
  39. snowflake/sandbox/_sync_fs_ops.py +185 -0
  40. snowflake/sandbox/_sync_transport.py +737 -0
  41. snowflake/sandbox/_sync_watch.py +99 -0
  42. snowflake/sandbox/_transport.py +1366 -0
  43. snowflake/sandbox/_transport_errors.py +270 -0
  44. snowflake/sandbox/_upload_plan.py +497 -0
  45. snowflake/sandbox/_version.py +37 -0
  46. snowflake/sandbox/_watch.py +164 -0
  47. snowflake/sandbox/_wire.py +348 -0
  48. snowflake/sandbox/app.py +256 -0
  49. snowflake/sandbox/client.py +2356 -0
  50. snowflake/sandbox/config.py +1133 -0
  51. snowflake/sandbox/connect.py +288 -0
  52. snowflake/sandbox/deploy.py +499 -0
  53. snowflake/sandbox/egress.py +388 -0
  54. snowflake/sandbox/exceptions.py +253 -0
  55. snowflake/sandbox/exec_stream.py +264 -0
  56. snowflake/sandbox/files.py +547 -0
  57. snowflake/sandbox/function.py +567 -0
  58. snowflake/sandbox/image.py +46 -0
  59. snowflake/sandbox/jobs.py +649 -0
  60. snowflake/sandbox/lifecycle.py +67 -0
  61. snowflake/sandbox/log_stream.py +219 -0
  62. snowflake/sandbox/mcp.py +480 -0
  63. snowflake/sandbox/mount.py +161 -0
  64. snowflake/sandbox/py.typed +0 -0
  65. snowflake/sandbox/secret.py +244 -0
  66. snowflake/sandbox/session_app.py +244 -0
  67. snowflake/sandbox/shell.py +556 -0
  68. snowflake/sandbox/sync_client.py +2245 -0
  69. snowflake/sandbox/sync_exec_stream.py +238 -0
  70. snowflake/sandbox/sync_files.py +377 -0
  71. snowflake/sandbox/sync_log_stream.py +142 -0
  72. snowflake/sandbox/sync_shell.py +413 -0
  73. snowflake/sandbox/types.py +193 -0
  74. snowflake/sandbox/warm_session.py +700 -0
  75. snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
  76. snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
  77. snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
  78. snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
  79. snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
  80. 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)