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,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)
|