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,142 @@
1
+ """Iterable reader over a command container's captured output — sync counterpart.
2
+
3
+ `SyncLogStream` is what `Sandbox.stdout` and `Sandbox.stderr` return, giving a
4
+ command container the same ``.stdout`` / ``.stderr`` shape a streamed exec already
5
+ has via `sync_exec_stream.SyncExecStream`.
6
+
7
+ It is a line-for-line port of `log_stream.LogStream`: same polling of
8
+ ``GET containers/{id}/logs?since=<ts>``, same watermark handling, same
9
+ positional dedupe of untimestamped lines, same drain-after-terminal rule. The
10
+ async version's comments explain WHY each of those exists and are not repeated
11
+ here — read `log_stream` for the reasoning, and change the two together.
12
+
13
+ The dedupe in particular is load-bearing rather than tidy: the server filters on
14
+ ``since``, so a line it never timestamped cannot be filtered out and returns on
15
+ every poll. Counting them positionally is what stops `read()` growing without
16
+ bound, and unlike matching on text it keeps a genuinely repeated log line.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from collections.abc import Iterator
23
+ from typing import TYPE_CHECKING, Self
24
+
25
+ from snowflake.sandbox._runtime._protocol import _RESULT_SENTINEL
26
+ from snowflake.sandbox.log_stream import (
27
+ _DRAIN_AFTER_TERMINAL_S,
28
+ _POLL_INTERVAL_S,
29
+ )
30
+ from snowflake.sandbox.types import TERMINAL_STATUSES, StreamName
31
+
32
+ if TYPE_CHECKING: # pragma: no cover - typing only
33
+ from snowflake.sandbox.sync_client import Sandbox
34
+
35
+ __all__ = ["SyncLogStream"]
36
+
37
+
38
+ class SyncLogStream(Iterator[str]):
39
+ """Iterator over one channel of a command container's output.
40
+
41
+ Yields line text with the trailing newline stripped, oldest first. Iteration
42
+ ends once the container is in a terminal state and no further lines arrive, so
43
+ ``for line in sb.stdout`` over a finished container terminates rather than
44
+ hanging.
45
+
46
+ Only meaningful for command containers (created with ``command=``); an
47
+ ``exec``-only sandbox produces no managed-process output and the stream ends
48
+ immediately.
49
+ """
50
+
51
+ def __init__(self, sandbox: Sandbox, channel: StreamName, *, since_ts_ms: int = 0) -> None:
52
+ self._sandbox = sandbox
53
+ self._channel = channel
54
+ self._since_ts_ms = since_ts_ms
55
+ self._pending: list[str] = []
56
+ self._done = False
57
+ # Set once the managed process's exit marker is seen in the log stream.
58
+ self._process_done = False
59
+ self._terminal_since: float | None = None
60
+ self._untimestamped_seen = 0
61
+
62
+ def __iter__(self) -> Self:
63
+ return self
64
+
65
+ def __next__(self) -> str:
66
+ while True:
67
+ if self._pending:
68
+ return self._pending.pop(0)
69
+ if self._done:
70
+ raise StopIteration
71
+ self._fill()
72
+
73
+ def _fill(self) -> None:
74
+ """Fetch any lines newer than the last one seen, or decide we are done."""
75
+ lines = self._sandbox._fetch_log_lines(since_ts_ms=self._since_ts_ms)
76
+
77
+ # Snapshot the skip threshold once; advancing it mid-loop would drop later
78
+ # lines in the batch that share a `ts` (same millisecond). See log_stream.
79
+ start_ts = self._since_ts_ms
80
+ next_ts = self._since_ts_ms
81
+ got = False
82
+ untimestamped = 0
83
+ for ln in lines:
84
+ text = str(ln.get("text") or "")
85
+ # End-of-stream marker the injected runner prints on stdout when the
86
+ # managed process exits; checked before the watermark/dedup logic and on
87
+ # every channel, and suppressed from output. See log_stream._fill for the
88
+ # full reasoning (a command container stays `ready` after its process
89
+ # ends, so the refresh() fallback below would otherwise poll forever).
90
+ if _RESULT_SENTINEL in text:
91
+ self._process_done = True
92
+ continue
93
+ ts = int(ln.get("ts") or 0)
94
+ if ts:
95
+ if ts < start_ts:
96
+ continue
97
+ next_ts = max(next_ts, ts + 1)
98
+ else:
99
+ untimestamped += 1
100
+ if untimestamped <= self._untimestamped_seen:
101
+ continue
102
+ self._untimestamped_seen = untimestamped
103
+ if ln.get("stream") != self._channel:
104
+ continue
105
+ self._pending.append(text)
106
+ got = True
107
+
108
+ # Advance the watermark once, after the whole batch (see log_stream).
109
+ self._since_ts_ms = next_ts
110
+
111
+ if got:
112
+ self._terminal_since = None
113
+ return
114
+
115
+ # Managed process has exited (its result marker was seen) and everything
116
+ # newer than the watermark is drained -- stop now, rather than waiting for
117
+ # the container lifecycle to reach a terminal status (it never will: a
118
+ # command container stays `ready` after its process exits).
119
+ if self._process_done:
120
+ self._done = True
121
+ return
122
+
123
+ # A live read, not the cached status: `Sandbox.status` is set at create
124
+ # time and nothing on the container's lifecycle mutates it, so trusting it
125
+ # would poll a finished container forever.
126
+ if self._sandbox.refresh() in TERMINAL_STATUSES:
127
+ now = time.monotonic()
128
+ if self._terminal_since is None:
129
+ self._terminal_since = now
130
+ elif now - self._terminal_since >= _DRAIN_AFTER_TERMINAL_S:
131
+ self._done = True
132
+ return
133
+
134
+ time.sleep(_POLL_INTERVAL_S)
135
+
136
+ def read(self) -> str:
137
+ """Drain the stream and return it as one newline-joined string.
138
+
139
+ Blocks until the stream ends, so only use it on a container that will
140
+ finish.
141
+ """
142
+ return "\n".join(list(self))
@@ -0,0 +1,413 @@
1
+ """Synchronous interactive shell sessions on a sandbox.
2
+
3
+ The blocking counterpart to `shell.Shell`. `SyncShell` is returned by
4
+ `Sandbox.shell()`; `AsyncSandbox.shell()` returns the async `Shell`.
5
+
6
+ There is no separate sync *transport* problem to solve here, contrary to how the
7
+ half-duplex hop reads at first: a shell is not one duplex socket but an SSE
8
+ response carrying output (`stream_sse`, a streaming read) paired with POSTs
9
+ carrying input (`request`). `_sync_transport.SyncTransport` already does both, so
10
+ the sync shell rides the exact same two-request protocol as the async one -- see
11
+ the `shell` module docstring for why that protocol exists and why it is a stopgap.
12
+
13
+ Everything that is not I/O -- id validation, the request path, the resume cursor,
14
+ base64 framing, exit/gap interpretation -- is inherited from `_ShellCore`, so the
15
+ two shells cannot encode the wire differently. What differs is only the shape of
16
+ the I/O:
17
+
18
+ * `output()` is a blocking generator over `SyncTransport.stream_sse`, with the
19
+ same resume-across-a-cut loop as the async side but `time.sleep` for its
20
+ backoff instead of `await asyncio.sleep`.
21
+ * `attach()` cannot lean on an event loop's reader/signal registration, so it runs
22
+ a **worker thread** that pumps keystrokes and applies resizes. All blocking work
23
+ (both the input POSTs and the resize POSTs) happens on that thread; the SIGWINCH
24
+ handler and the output loop stay on the main thread, and the handler does no I/O
25
+ -- it records the new size and wakes the worker. The worker blocks in
26
+ `select()` on stdin plus a wake pipe rather than a bare `os.read`, which a
27
+ finished session could not interrupt: a leaked reader thread would sit blocked
28
+ on the real terminal and steal the next keystroke after `attach()` returned.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import os
35
+ import select
36
+ import signal
37
+ import sys
38
+ import threading
39
+ import time
40
+ from collections.abc import Callable, Iterator
41
+ from typing import TYPE_CHECKING, Any
42
+
43
+ from snowflake.sandbox._ansi import _ClipboardStripper
44
+ from snowflake.sandbox.exceptions import SandboxError, SandboxTransportError
45
+ from snowflake.sandbox.shell import (
46
+ _EVENT_CONTROL,
47
+ _EVENT_GAP,
48
+ _EVENT_OUT,
49
+ _RECONNECT_DELAYS,
50
+ ShellClosed,
51
+ _ShellCore,
52
+ )
53
+
54
+ if TYPE_CHECKING:
55
+ from snowflake.sandbox._sync_transport import SyncTransport
56
+
57
+ __all__ = ["SyncShell", "open_shell_sync"]
58
+
59
+ # How long `attach()` waits for its worker thread to unwind on teardown. The
60
+ # thread is blocked in `select()` and wakes the instant the stop byte is written,
61
+ # so this is only a backstop against a wedged fd, not a normal-path delay.
62
+ _WORKER_JOIN_TIMEOUT_S = 2.0
63
+
64
+
65
+ class SyncShell(_ShellCore):
66
+ """One interactive shell session on a sandbox (blocking API).
67
+
68
+ Obtained from `Sandbox.shell()`. Not constructed directly.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ transport: SyncTransport,
74
+ sandbox_id: str,
75
+ session_id: str,
76
+ *,
77
+ rows: int,
78
+ cols: int,
79
+ ) -> None:
80
+ super().__init__(sandbox_id, session_id, rows=rows, cols=cols)
81
+ self._t = transport
82
+
83
+ # --- input ----------------------------------------------------------
84
+
85
+ def send(self, data: bytes | str) -> None:
86
+ """Write to the shell's stdin.
87
+
88
+ Bytes, not text, because the interesting input is control characters:
89
+ Ctrl-C is 0x03, and a transport that treated keystrokes as text would
90
+ make the terminal unable to interrupt anything. A `str` is encoded UTF-8
91
+ for convenience.
92
+ """
93
+ if self.closed:
94
+ raise ShellClosed(f"shell session {self._session_id} has ended")
95
+ body = self._input_body(data)
96
+ if body is None:
97
+ return
98
+ self._t.request("POST", self._path("/input"), json_body=body)
99
+
100
+ def resize(self, rows: int, cols: int) -> None:
101
+ """Tell the pty its new window size, so curses apps re-lay-out."""
102
+ if self.closed:
103
+ return
104
+ self._rows, self._cols = rows, cols
105
+ self._t.request("POST", self._path("/input"), json_body=self._resize_body(rows, cols))
106
+
107
+ # --- output ---------------------------------------------------------
108
+
109
+ def output(self, *, on_gap: Callable[[int], None] | None = None) -> Iterator[bytes]:
110
+ """Yield pty output as it arrives, across gateway stream cuts.
111
+
112
+ Ends when the shell process exits (`exit_code` is then set) or `close()`
113
+ is called. A cut stream is re-opened from the last frame id seen; only a
114
+ cut we cannot recover from raises `SandboxTransportError`.
115
+ """
116
+ attempt = 0
117
+ while not self.closed:
118
+ delivered = False
119
+ try:
120
+ for chunk in self._read_once(on_gap):
121
+ delivered = True
122
+ attempt = 0 # progress resets the budget
123
+ yield chunk
124
+ if self.closed:
125
+ return
126
+ # A clean end with no exit frame is the ordinary 900s case: the
127
+ # gateway closed a response whose shell is still alive. Re-open
128
+ # from the cursor.
129
+ if not delivered and attempt >= len(_RECONNECT_DELAYS):
130
+ # ...but a stream that keeps closing having sent nothing is
131
+ # not that. Without this the loop reconnects forever and the
132
+ # caller sees a terminal that is simply silent.
133
+ raise SandboxTransportError(
134
+ f"shell session {self._session_id}: stream closed without output "
135
+ f"after {attempt} attempts"
136
+ )
137
+ except SandboxTransportError:
138
+ if self.closed:
139
+ return
140
+ if attempt >= len(_RECONNECT_DELAYS):
141
+ raise
142
+ if self.closed:
143
+ return
144
+ delay = _RECONNECT_DELAYS[min(attempt, len(_RECONNECT_DELAYS) - 1)]
145
+ attempt += 1
146
+ time.sleep(delay)
147
+
148
+ def _read_once(self, on_gap: Callable[[int], None] | None) -> Iterator[bytes]:
149
+ """One SSE connection's worth of frames."""
150
+ for evt in self._t.stream_sse("GET", self._stream_path()):
151
+ if evt.event == _EVENT_OUT:
152
+ # Decode BEFORE advancing the cursor (see `_decode_out_frame`): a
153
+ # malformed frame becomes a retryable transport error with the
154
+ # cursor left put, so the retry re-fetches it rather than skipping
155
+ # past lost output.
156
+ chunk = self._decode_out_frame(evt)
157
+ self._advance(evt)
158
+ yield chunk
159
+ continue
160
+ self._advance(evt)
161
+ if evt.event == _EVENT_GAP:
162
+ self.gaps += 1
163
+ if on_gap is not None:
164
+ on_gap(self._lost_at(evt))
165
+ elif evt.event == _EVENT_CONTROL:
166
+ if self._apply_control(evt):
167
+ return
168
+
169
+ # --- lifecycle ------------------------------------------------------
170
+
171
+ def close(self) -> None:
172
+ """End the session and reap the shell. Idempotent."""
173
+ if self._closed:
174
+ return
175
+ self._closed = True
176
+ with contextlib.suppress(Exception):
177
+ # A close that fails is not worth raising over: the sandbox reaps
178
+ # idle sessions anyway, and the caller is on its way out.
179
+ self._t.request("DELETE", self._path())
180
+
181
+ def __enter__(self) -> SyncShell:
182
+ return self
183
+
184
+ def __exit__(self, *exc: object) -> None:
185
+ self.close()
186
+
187
+ # --- interactive attach ---------------------------------------------
188
+
189
+ def attach(
190
+ self,
191
+ *,
192
+ stdin: int | None = None,
193
+ stdout: int | None = None,
194
+ sanitize: bool = True,
195
+ ) -> int | None:
196
+ """Wire the local terminal to this shell until it exits. Returns exit code.
197
+
198
+ **Trust boundary, worth stating plainly:** this hands the sandbox's raw
199
+ output to your *local* terminal emulator, which interprets it. A sandbox
200
+ runs untrusted and often agent-generated code, so treat attaching the way
201
+ you would treat `ssh`-ing into a machine you do not control -- the remote
202
+ end can emit control sequences, not just text.
203
+
204
+ `sanitize` (default on) removes OSC 52, the sequence that writes the local
205
+ clipboard: no display purpose, and a sandbox quietly replacing what you
206
+ are about to paste is a real consequence. Everything else passes through,
207
+ deliberately -- see `_ansi._ClipboardStripper` for what is not filtered
208
+ and why, including one residual risk that cannot be filtered without
209
+ breaking legitimate terminal programs (a sandbox can emit a capability
210
+ query, and the local terminal's *reply* lands as typed input at the next
211
+ prompt). Pass `sanitize=False` for byte-exact passthrough.
212
+
213
+ `output()` is always raw: it returns bytes to a program, and silently
214
+ altering them there would be the surprising choice.
215
+
216
+ Three things have to happen for this to feel like a terminal:
217
+
218
+ * **Raw mode** on the local tty, so keystrokes go to the *remote* shell
219
+ instead of being line-buffered and interpreted locally.
220
+ * **SIGWINCH** forwarded as a resize, so a window change re-lays-out the
221
+ remote curses app. Installed on the main thread only; a `SyncShell`
222
+ driven from a worker thread simply loses live resize (the handler cannot
223
+ be registered off the main thread), which is advisory, not fatal -- the
224
+ async `attach()` treats the analogous failure the same way.
225
+ * The initial size sent up front, since the session was created with a
226
+ default that is probably not this terminal's.
227
+
228
+ A worker thread reads keystrokes and applies resizes so every blocking
229
+ request happens off the main thread; it is torn down cleanly on exit and
230
+ is not left blocked on the terminal after this returns. Requires a real tty
231
+ on stdin; raises otherwise rather than silently degrading.
232
+ """
233
+ import termios
234
+ import tty
235
+
236
+ in_fd = sys.stdin.fileno() if stdin is None else stdin
237
+ out_fd = sys.stdout.fileno() if stdout is None else stdout
238
+ if not os.isatty(in_fd):
239
+ raise SandboxError("attach() needs a tty on stdin; use send()/output() instead")
240
+
241
+ saved = termios.tcgetattr(in_fd)
242
+
243
+ def _note(resumed_at: int) -> None:
244
+ del resumed_at
245
+ os.write(out_fd, b"\r\n[output was dropped while reconnecting]\r\n")
246
+
247
+ # The worker thread + wake pipe + resize mailbox are one unit -- the sync
248
+ # stand-in for the event loop's reader/signal registration the async attach
249
+ # gets for free. See `_ShellInputForwarder`.
250
+ forwarder = _ShellInputForwarder(self, in_fd, out_fd)
251
+ prev_winch: Any = None
252
+ winch_installed = False
253
+ stripper = _ClipboardStripper() if sanitize else None
254
+ # Acquire inside the try, exactly as the async attach does: a failure
255
+ # part-way through setup must still restore the terminal and stop the worker.
256
+ # setraw lands first, so a raise on any later line is the tightest window,
257
+ # and it must not escape over a terminal left in raw mode or a live thread.
258
+ try:
259
+ tty.setraw(in_fd)
260
+ forwarder.start()
261
+ if hasattr(signal, "SIGWINCH"):
262
+ # signal.signal raises ValueError off the main thread (and the
263
+ # attribute is absent on Windows). Resize-on-winch is advisory, so
264
+ # this is survivable -- the async side suppresses the analogous
265
+ # error rather than failing the attach.
266
+ with contextlib.suppress(ValueError, OSError):
267
+ prev_winch = signal.signal(signal.SIGWINCH, forwarder.on_winch)
268
+ winch_installed = True
269
+
270
+ if os.isatty(out_fd):
271
+ size = os.get_terminal_size(out_fd)
272
+ self.resize(size.lines, size.columns)
273
+
274
+ try:
275
+ for chunk in self.output(on_gap=_note):
276
+ os.write(out_fd, stripper.feed(chunk) if stripper else chunk)
277
+ finally:
278
+ if stripper is not None:
279
+ # Anything held back mid-sequence still belongs on screen.
280
+ tail = stripper.flush()
281
+ if tail:
282
+ os.write(out_fd, tail)
283
+ finally:
284
+ if winch_installed:
285
+ with contextlib.suppress(ValueError, OSError, TypeError):
286
+ signal.signal(signal.SIGWINCH, prev_winch)
287
+ forwarder.stop_and_join()
288
+ termios.tcsetattr(in_fd, termios.TCSADRAIN, saved)
289
+ return self._exit_code
290
+
291
+ def _resize_quietly(self, rows: int, cols: int) -> None:
292
+ with contextlib.suppress(Exception):
293
+ self.resize(rows, cols)
294
+
295
+
296
+ class _ShellInputForwarder:
297
+ """Pumps local stdin to the remote shell, off the main thread.
298
+
299
+ The sync stand-in for what the async `attach()` gets from the event loop --
300
+ a reader on stdin plus a SIGWINCH handler. A worker thread blocks in
301
+ ``select()`` on stdin and a wake pipe rather than a bare ``os.read``: writing
302
+ one byte to the pipe (on resize or on teardown) breaks the block at once, so
303
+ the thread never sits uninterruptibly on the real terminal -- the failure
304
+ mode that would leak a thread eating the next keystroke after `attach()`
305
+ returns.
306
+
307
+ ``on_winch`` is the SIGWINCH handler the caller installs. It runs on the main
308
+ thread between bytecode instructions and does NO I/O -- a blocking POST from
309
+ inside a signal handler could reenter the httpx client -- so it records the
310
+ new size in a locked mailbox and wakes the worker, which sends the resize.
311
+ """
312
+
313
+ def __init__(self, shell: SyncShell, in_fd: int, out_fd: int) -> None:
314
+ self._shell = shell
315
+ self._in_fd = in_fd
316
+ self._out_fd = out_fd
317
+ self._stop = threading.Event()
318
+ self._wake_r, self._wake_w = os.pipe()
319
+ # Single-slot mailbox for the latest window size, written by on_winch (main
320
+ # thread) and drained by the worker. A lock because the handler can fire
321
+ # while the worker is mid-read.
322
+ self._pending: list[tuple[int, int]] = []
323
+ self._pending_lock = threading.Lock()
324
+ self._worker = threading.Thread(target=self._pump, name="sync-shell-input", daemon=True)
325
+
326
+ def start(self) -> None:
327
+ self._worker.start()
328
+
329
+ def on_winch(self, signum: int, frame: object) -> None:
330
+ try:
331
+ size = os.get_terminal_size(self._out_fd)
332
+ except OSError:
333
+ return
334
+ with self._pending_lock:
335
+ self._pending.append((size.lines, size.columns))
336
+ with contextlib.suppress(OSError):
337
+ os.write(self._wake_w, b"\0")
338
+
339
+ def _pump(self) -> None:
340
+ while not self._stop.is_set():
341
+ try:
342
+ readable, _, _ = select.select([self._in_fd, self._wake_r], [], [])
343
+ except OSError:
344
+ return
345
+ if self._stop.is_set():
346
+ return
347
+ if self._wake_r in readable:
348
+ with contextlib.suppress(OSError):
349
+ os.read(self._wake_r, 4096)
350
+ with self._pending_lock:
351
+ size = self._pending.pop() if self._pending else None
352
+ self._pending.clear()
353
+ if size is not None:
354
+ self._shell._resize_quietly(*size)
355
+ if self._in_fd in readable:
356
+ try:
357
+ data = os.read(self._in_fd, 4096)
358
+ except OSError:
359
+ data = b""
360
+ if not data: # EOF on stdin
361
+ return
362
+ try:
363
+ self._shell.send(data)
364
+ except ShellClosed:
365
+ return
366
+ except SandboxTransportError:
367
+ # A dropped keystroke is better than tearing down a live
368
+ # terminal; the output stream reports the real end.
369
+ continue
370
+
371
+ def stop_and_join(self) -> None:
372
+ """Stop the worker and reclaim the wake pipe. Safe if start() never ran."""
373
+ self._stop.set()
374
+ with contextlib.suppress(OSError):
375
+ os.write(self._wake_w, b"\0") # break the worker out of select()
376
+ if self._worker.is_alive():
377
+ self._worker.join(timeout=_WORKER_JOIN_TIMEOUT_S)
378
+ with contextlib.suppress(OSError):
379
+ os.close(self._wake_r)
380
+ with contextlib.suppress(OSError):
381
+ os.close(self._wake_w)
382
+
383
+
384
+ def open_shell_sync(
385
+ transport: SyncTransport,
386
+ sandbox_id: str,
387
+ *,
388
+ rows: int = 24,
389
+ cols: int = 80,
390
+ cwd: str | None = None,
391
+ ) -> SyncShell:
392
+ """Start a shell session on a sandbox and return it.
393
+
394
+ The pty is running by the time this returns, so output produced before the
395
+ caller starts reading -- the shell's greeting and first prompt -- is already
396
+ in the sandbox's backlog and arrives on the first read rather than being
397
+ lost to the gap between the two requests.
398
+ """
399
+ body: dict[str, object] = {"rows": rows, "cols": cols}
400
+ if cwd is not None:
401
+ body["cwd"] = cwd
402
+ resp = transport.request("POST", f"containers/{sandbox_id}/shell/sessions", json_body=body)
403
+ payload = resp.json() if resp.content else {}
404
+ session_id = payload.get("session_id") if isinstance(payload, dict) else None
405
+ if not session_id:
406
+ raise SandboxError(f"shell: sandbox returned no session_id (body: {payload!r})")
407
+ # Defence in depth. The id goes straight into the path of every follow-up
408
+ # request, all of which carry the caller's bearer token, so a value with path
409
+ # characters in it could point those requests somewhere else. The endpoint is
410
+ # authenticated and trusted, so this is not attacker-reachable today -- it
411
+ # bounds the blast radius of a buggy or compromised gateway for the price of
412
+ # one check. SyncShell.__init__ validates both ids (see _ShellCore._checked_id).
413
+ return SyncShell(transport, sandbox_id, str(session_id), rows=rows, cols=cols)