opensandbox-cli 1.1.2.dev20261020__tar.gz → 1.2.0__tar.gz
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.
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/PKG-INFO +15 -2
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/README.md +12 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/pyproject.toml +4 -1
- opensandbox_cli-1.2.0/src/opensandbox_cli/commands/terminal.py +404 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/main.py +2 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/utils.py +11 -5
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/.gitignore +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/LICENSE +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/__init__.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/__main__.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/client.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/__init__.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/command.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/config_cmd.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/credential_vault.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/devops.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/diagnostics.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/egress.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/file.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/sandbox.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/skills.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/snapshot.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/template.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/config.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/output.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/py.typed +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skill_registry.py +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-command-execution.md +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-credential-vault.md +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-file-operations.md +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-network-egress.md +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md +0 -0
- {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-sandbox-troubleshooting.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opensandbox-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: OpenSandbox CLI - Command-line interface for managing sandboxes
|
|
5
5
|
Project-URL: Homepage, https://open-sandbox.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/opensandbox-group/OpenSandbox
|
|
@@ -224,11 +224,12 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
224
224
|
Classifier: Typing :: Typed
|
|
225
225
|
Requires-Python: >=3.10
|
|
226
226
|
Requires-Dist: click<9.0,>=8.1.0
|
|
227
|
-
Requires-Dist: opensandbox<2.0.0,>=1.
|
|
227
|
+
Requires-Dist: opensandbox<2.0.0,>=1.2.0
|
|
228
228
|
Requires-Dist: pyyaml<7.0,>=6.0
|
|
229
229
|
Requires-Dist: rich<14.0,>=13.0.0
|
|
230
230
|
Requires-Dist: tomli>=2.0.0; python_version < '3.11'
|
|
231
231
|
Requires-Dist: tomlkit<1.0,>=0.13.0
|
|
232
|
+
Requires-Dist: websockets>=14.0
|
|
232
233
|
Description-Content-Type: text/markdown
|
|
233
234
|
|
|
234
235
|
# OpenSandbox CLI
|
|
@@ -527,6 +528,17 @@ osb command session run <sandbox-id> <session-id> -o raw -- sh -c 'echo $FOO'
|
|
|
527
528
|
osb command session delete <sandbox-id> <session-id> -o json
|
|
528
529
|
```
|
|
529
530
|
|
|
531
|
+
### Open an interactive terminal
|
|
532
|
+
|
|
533
|
+
`osb terminal` attaches your current terminal to a real PTY inside the sandbox (colors, `stty`, and resize all behave like a local shell). Exit the remote shell (or press Ctrl-D) to leave; the remote exit code becomes the command's exit code.
|
|
534
|
+
|
|
535
|
+
```bash
|
|
536
|
+
osb terminal <sandbox-id>
|
|
537
|
+
osb terminal <sandbox-id> -w /workspace
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Requires an interactive TTY, and is supported on Unix, macOS, and Linux. If the connection drops while the shell is still running, the session is kept server-side and its id is printed so it can be inspected or removed via the execd API; a clean exit tears the session down.
|
|
541
|
+
|
|
530
542
|
### Work with files
|
|
531
543
|
|
|
532
544
|
```bash
|
|
@@ -635,6 +647,7 @@ The main command groups are:
|
|
|
635
647
|
- `osb template`: golden-image template builds
|
|
636
648
|
- `osb snapshot`: snapshot management
|
|
637
649
|
- `osb command`: command execution and persistent sessions
|
|
650
|
+
- `osb terminal`: interactive PTY terminal into a sandbox
|
|
638
651
|
- `osb file`: file and directory operations
|
|
639
652
|
- `osb egress`: runtime egress policy
|
|
640
653
|
- `osb diagnostics`: stable diagnostics logs and events
|
|
@@ -294,6 +294,17 @@ osb command session run <sandbox-id> <session-id> -o raw -- sh -c 'echo $FOO'
|
|
|
294
294
|
osb command session delete <sandbox-id> <session-id> -o json
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
+
### Open an interactive terminal
|
|
298
|
+
|
|
299
|
+
`osb terminal` attaches your current terminal to a real PTY inside the sandbox (colors, `stty`, and resize all behave like a local shell). Exit the remote shell (or press Ctrl-D) to leave; the remote exit code becomes the command's exit code.
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
osb terminal <sandbox-id>
|
|
303
|
+
osb terminal <sandbox-id> -w /workspace
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Requires an interactive TTY, and is supported on Unix, macOS, and Linux. If the connection drops while the shell is still running, the session is kept server-side and its id is printed so it can be inspected or removed via the execd API; a clean exit tears the session down.
|
|
307
|
+
|
|
297
308
|
### Work with files
|
|
298
309
|
|
|
299
310
|
```bash
|
|
@@ -402,6 +413,7 @@ The main command groups are:
|
|
|
402
413
|
- `osb template`: golden-image template builds
|
|
403
414
|
- `osb snapshot`: snapshot management
|
|
404
415
|
- `osb command`: command execution and persistent sessions
|
|
416
|
+
- `osb terminal`: interactive PTY terminal into a sandbox
|
|
405
417
|
- `osb file`: file and directory operations
|
|
406
418
|
- `osb egress`: runtime egress policy
|
|
407
419
|
- `osb diagnostics`: stable diagnostics logs and events
|
|
@@ -44,12 +44,15 @@ classifiers = [
|
|
|
44
44
|
dependencies = [
|
|
45
45
|
# `command run --argv` passes a list to commands.run; the SDK accepts a
|
|
46
46
|
# list there from 0.1.17. Release the CLI only after that SDK is published.
|
|
47
|
-
"opensandbox>=1.
|
|
47
|
+
"opensandbox>=1.2.0,<2.0.0",
|
|
48
48
|
"click>=8.1.0,<9.0",
|
|
49
49
|
"rich>=13.0.0,<14.0",
|
|
50
50
|
"pyyaml>=6.0,<7.0",
|
|
51
51
|
"tomlkit>=0.13.0,<1.0",
|
|
52
52
|
"tomli>=2.0.0; python_version < '3.11'",
|
|
53
|
+
# `osb terminal` drives the execd PTY WebSocket; 14+ renamed extra_headers
|
|
54
|
+
# to additional_headers, which the terminal client uses.
|
|
55
|
+
"websockets>=14.0",
|
|
53
56
|
]
|
|
54
57
|
|
|
55
58
|
[project.urls]
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# Copyright 2026 The OpenSandbox Authors.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Interactive terminal into a sandbox over the execd PTY WebSocket.
|
|
16
|
+
|
|
17
|
+
Frame protocol is defined in components/execd/PTY.md.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import signal
|
|
26
|
+
import sys
|
|
27
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
28
|
+
from contextlib import contextmanager
|
|
29
|
+
from datetime import timedelta
|
|
30
|
+
from typing import IO, Protocol
|
|
31
|
+
|
|
32
|
+
import click
|
|
33
|
+
import httpx
|
|
34
|
+
import websockets
|
|
35
|
+
from opensandbox.constants import DEFAULT_EXECD_PORT
|
|
36
|
+
|
|
37
|
+
from opensandbox_cli.client import ClientContext
|
|
38
|
+
from opensandbox_cli.utils import handle_errors
|
|
39
|
+
|
|
40
|
+
# Binary WebSocket frame type bytes, mirroring execd pkg/web/model.
|
|
41
|
+
BIN_STDIN = 0x00
|
|
42
|
+
BIN_STDOUT = 0x01
|
|
43
|
+
BIN_STDERR = 0x02
|
|
44
|
+
BIN_REPLAY = 0x03
|
|
45
|
+
|
|
46
|
+
ReadFn = Callable[[], Awaitable[bytes]]
|
|
47
|
+
WriteFn = Callable[[bytes], None]
|
|
48
|
+
SizeFn = Callable[[], tuple[int, int]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class WSDuplex(Protocol):
|
|
52
|
+
"""Minimal WebSocket surface used by the relay loop."""
|
|
53
|
+
|
|
54
|
+
def send(self, message: bytes | str) -> Awaitable[None]: ...
|
|
55
|
+
|
|
56
|
+
def __aiter__(self) -> AsyncIterator[bytes | str]: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ws_scheme(protocol: str) -> str:
|
|
60
|
+
return "wss" if protocol == "https" else "ws"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _execd_base(endpoint: str) -> str:
|
|
64
|
+
"""Normalize an execd endpoint (``host:port`` or ``host/proxy/path``)."""
|
|
65
|
+
return endpoint.rstrip("/")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _create_pty_session(
|
|
69
|
+
base_url: str,
|
|
70
|
+
headers: dict[str, str],
|
|
71
|
+
*,
|
|
72
|
+
workdir: str | None,
|
|
73
|
+
timeout: timedelta,
|
|
74
|
+
) -> str:
|
|
75
|
+
body: dict[str, str] = {}
|
|
76
|
+
if workdir:
|
|
77
|
+
body["cwd"] = workdir
|
|
78
|
+
try:
|
|
79
|
+
response = httpx.post(
|
|
80
|
+
f"{base_url}/pty",
|
|
81
|
+
headers=headers,
|
|
82
|
+
json=body,
|
|
83
|
+
timeout=timeout.total_seconds(),
|
|
84
|
+
)
|
|
85
|
+
except httpx.HTTPError as exc:
|
|
86
|
+
raise click.ClickException(f"Could not reach the sandbox execd: {exc}") from exc
|
|
87
|
+
if response.status_code != 201:
|
|
88
|
+
detail = response.text.strip() or response.reason_phrase
|
|
89
|
+
raise click.ClickException(
|
|
90
|
+
f"Creating a PTY session failed with HTTP {response.status_code}: {detail}"
|
|
91
|
+
)
|
|
92
|
+
session_id = response.json().get("session_id")
|
|
93
|
+
if not session_id:
|
|
94
|
+
raise click.ClickException("The execd response did not include a session_id.")
|
|
95
|
+
return str(session_id)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _delete_pty_session(
|
|
99
|
+
base_url: str, headers: dict[str, str], session_id: str, *, timeout: timedelta
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Best-effort teardown of a finished PTY session; errors are ignored."""
|
|
102
|
+
try:
|
|
103
|
+
httpx.delete(
|
|
104
|
+
f"{base_url}/pty/{session_id}",
|
|
105
|
+
headers=headers,
|
|
106
|
+
timeout=timeout.total_seconds(),
|
|
107
|
+
)
|
|
108
|
+
except httpx.HTTPError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@contextmanager
|
|
113
|
+
def _raw_mode(fd: int) -> Iterator[None]:
|
|
114
|
+
import termios
|
|
115
|
+
import tty
|
|
116
|
+
|
|
117
|
+
old = termios.tcgetattr(fd)
|
|
118
|
+
tty.setraw(fd)
|
|
119
|
+
try:
|
|
120
|
+
yield
|
|
121
|
+
finally:
|
|
122
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _make_stdin_reader(fd: int) -> ReadFn:
|
|
126
|
+
"""Build a cancellable stdin read driven by event-loop readability.
|
|
127
|
+
|
|
128
|
+
A blocking ``os.read`` in the default executor cannot be cancelled: the
|
|
129
|
+
worker thread stays parked in ``read(2)`` and stalls event-loop shutdown
|
|
130
|
+
until a key arrives. A reader callback is cancelled with its task.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
async def stdin_read() -> bytes:
|
|
134
|
+
loop = asyncio.get_running_loop()
|
|
135
|
+
future: asyncio.Future[bytes] = loop.create_future()
|
|
136
|
+
|
|
137
|
+
def _on_readable() -> None:
|
|
138
|
+
if future.done():
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
future.set_result(os.read(fd, 4096))
|
|
142
|
+
except BlockingIOError:
|
|
143
|
+
return # Spurious wakeup; stay registered.
|
|
144
|
+
except OSError:
|
|
145
|
+
future.set_result(b"") # EOF or hang-up.
|
|
146
|
+
|
|
147
|
+
loop.add_reader(fd, _on_readable)
|
|
148
|
+
try:
|
|
149
|
+
return await future
|
|
150
|
+
finally:
|
|
151
|
+
loop.remove_reader(fd)
|
|
152
|
+
|
|
153
|
+
return stdin_read
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _posix_tty_io() -> tuple[ReadFn, WriteFn, SizeFn, int]:
|
|
157
|
+
"""Build TTY stdin/stdout/size hooks plus the stdin fd for raw mode."""
|
|
158
|
+
stdin_fd = sys.stdin.fileno()
|
|
159
|
+
stdout: IO[bytes] = sys.stdout.buffer
|
|
160
|
+
stdin_read = _make_stdin_reader(stdin_fd)
|
|
161
|
+
|
|
162
|
+
def stdout_write(data: bytes) -> None:
|
|
163
|
+
stdout.write(data)
|
|
164
|
+
stdout.flush()
|
|
165
|
+
|
|
166
|
+
def get_size() -> tuple[int, int]:
|
|
167
|
+
size = os.get_terminal_size(stdin_fd)
|
|
168
|
+
return size.columns, size.lines
|
|
169
|
+
|
|
170
|
+
return stdin_read, stdout_write, get_size, stdin_fd
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _consume_task_result(task: asyncio.Future) -> None:
|
|
174
|
+
"""Retrieve a fire-and-forget task's outcome to suppress task warnings."""
|
|
175
|
+
if not task.cancelled():
|
|
176
|
+
task.exception()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _pump_stdin(
|
|
180
|
+
ws: WSDuplex, send_lock: asyncio.Lock, stdin_read: ReadFn
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Forward local stdin to the PTY as binary frames until EOF or close."""
|
|
183
|
+
while True:
|
|
184
|
+
data = await stdin_read()
|
|
185
|
+
if not data:
|
|
186
|
+
return
|
|
187
|
+
try:
|
|
188
|
+
async with send_lock:
|
|
189
|
+
await ws.send(bytes([BIN_STDIN]) + data)
|
|
190
|
+
except websockets.ConnectionClosed:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def stream_pty(
|
|
195
|
+
ws: WSDuplex,
|
|
196
|
+
*,
|
|
197
|
+
stdin_read: ReadFn,
|
|
198
|
+
stdout_write: WriteFn,
|
|
199
|
+
error_write: WriteFn | None = None,
|
|
200
|
+
send_lock: asyncio.Lock | None = None,
|
|
201
|
+
) -> tuple[int | None, list[str]]:
|
|
202
|
+
"""Relay frames; the exit code is ``None`` without an ``exit`` frame."""
|
|
203
|
+
exit_code: int | None = None
|
|
204
|
+
errors: list[str] = []
|
|
205
|
+
|
|
206
|
+
async def read_ws() -> None:
|
|
207
|
+
nonlocal exit_code
|
|
208
|
+
try:
|
|
209
|
+
async for message in ws:
|
|
210
|
+
if isinstance(message, bytes) and message:
|
|
211
|
+
kind, payload = message[0], message[1:]
|
|
212
|
+
if kind == BIN_REPLAY:
|
|
213
|
+
if len(payload) <= 8:
|
|
214
|
+
continue
|
|
215
|
+
payload = payload[8:]
|
|
216
|
+
if payload and kind in (BIN_STDOUT, BIN_STDERR, BIN_REPLAY):
|
|
217
|
+
stdout_write(payload)
|
|
218
|
+
elif isinstance(message, str):
|
|
219
|
+
try:
|
|
220
|
+
frame = json.loads(message)
|
|
221
|
+
except json.JSONDecodeError:
|
|
222
|
+
continue
|
|
223
|
+
frame_type = frame.get("type")
|
|
224
|
+
if frame_type == "exit":
|
|
225
|
+
exit_code = int(frame.get("exit_code") or 0)
|
|
226
|
+
return
|
|
227
|
+
if frame_type == "error":
|
|
228
|
+
message = str(frame.get("error") or "unknown error")
|
|
229
|
+
errors.append(message)
|
|
230
|
+
if error_write is not None:
|
|
231
|
+
# The terminal is in raw mode: CRLF, not LF.
|
|
232
|
+
error_write(message.encode() + b"\r\n")
|
|
233
|
+
except websockets.ConnectionClosed:
|
|
234
|
+
# Abnormal close: keep the collected exit state and errors so the
|
|
235
|
+
# caller can surface them (e.g. execd's START_FAILED closes
|
|
236
|
+
# without a WebSocket close frame).
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
stdin_task = asyncio.create_task(
|
|
240
|
+
_pump_stdin(ws, send_lock or asyncio.Lock(), stdin_read)
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
# Give the stdin pump a head start before the read loop drains frames.
|
|
244
|
+
await asyncio.sleep(0)
|
|
245
|
+
# A local stdin EOF must not cut the output stream: reap the pump and
|
|
246
|
+
# keep reading until the server sends ``exit`` or closes.
|
|
247
|
+
await read_ws()
|
|
248
|
+
finally:
|
|
249
|
+
stdin_task.cancel()
|
|
250
|
+
await asyncio.gather(stdin_task, return_exceptions=True)
|
|
251
|
+
return exit_code, errors
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def _connect_and_stream(
|
|
255
|
+
ws_url: str,
|
|
256
|
+
headers: dict[str, str],
|
|
257
|
+
*,
|
|
258
|
+
stdin_read: ReadFn,
|
|
259
|
+
stdout_write: WriteFn,
|
|
260
|
+
get_size: SizeFn,
|
|
261
|
+
error_write: WriteFn | None = None,
|
|
262
|
+
) -> tuple[int | None, list[str]]:
|
|
263
|
+
"""Connect to the PTY WebSocket, drive the relay loop, handle resize."""
|
|
264
|
+
async with websockets.connect(ws_url, additional_headers=headers or None) as ws:
|
|
265
|
+
# websockets forbids concurrent sends: stdin frames and SIGWINCH
|
|
266
|
+
# resize frames share one lock, and fire-and-forget resize tasks get
|
|
267
|
+
# their results consumed so failures never surface as task warnings.
|
|
268
|
+
send_lock = asyncio.Lock()
|
|
269
|
+
|
|
270
|
+
async def send_frame(frame: str) -> None:
|
|
271
|
+
try:
|
|
272
|
+
async with send_lock:
|
|
273
|
+
await ws.send(frame)
|
|
274
|
+
except websockets.ConnectionClosed:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
async def send_resize() -> None:
|
|
278
|
+
try:
|
|
279
|
+
cols, rows = get_size()
|
|
280
|
+
except OSError:
|
|
281
|
+
return
|
|
282
|
+
await send_frame(json.dumps({"type": "resize", "cols": cols, "rows": rows}))
|
|
283
|
+
|
|
284
|
+
await send_resize()
|
|
285
|
+
loop = asyncio.get_running_loop()
|
|
286
|
+
has_winch = hasattr(signal, "SIGWINCH")
|
|
287
|
+
if has_winch:
|
|
288
|
+
try:
|
|
289
|
+
loop.add_signal_handler(
|
|
290
|
+
signal.SIGWINCH,
|
|
291
|
+
lambda: asyncio.ensure_future(send_resize()).add_done_callback(
|
|
292
|
+
_consume_task_result
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
except NotImplementedError: # pragma: no cover - platform guard
|
|
296
|
+
has_winch = False
|
|
297
|
+
try:
|
|
298
|
+
return await stream_pty(
|
|
299
|
+
ws,
|
|
300
|
+
stdin_read=stdin_read,
|
|
301
|
+
stdout_write=stdout_write,
|
|
302
|
+
error_write=error_write,
|
|
303
|
+
send_lock=send_lock,
|
|
304
|
+
)
|
|
305
|
+
finally:
|
|
306
|
+
if has_winch:
|
|
307
|
+
loop.remove_signal_handler(signal.SIGWINCH)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _require_posix_tty() -> None:
|
|
311
|
+
if os.name != "posix":
|
|
312
|
+
raise click.ClickException(
|
|
313
|
+
"osb terminal is only supported on Unix, macOS, and Linux."
|
|
314
|
+
)
|
|
315
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
316
|
+
raise click.ClickException("osb terminal requires an interactive TTY.")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@click.command(
|
|
320
|
+
"terminal",
|
|
321
|
+
help="Open an interactive terminal into a sandbox via the execd PTY.",
|
|
322
|
+
epilog=(
|
|
323
|
+
"The shell runs inside the sandbox; exit it (or press Ctrl-D) to leave. "
|
|
324
|
+
"The remote exit code becomes this command's exit code."
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
@click.argument("sandbox_id")
|
|
328
|
+
@click.option("-w", "--workdir", default=None, help="Initial working directory.")
|
|
329
|
+
@click.pass_obj
|
|
330
|
+
@handle_errors
|
|
331
|
+
def terminal(
|
|
332
|
+
obj: ClientContext,
|
|
333
|
+
sandbox_id: str,
|
|
334
|
+
workdir: str | None,
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Open an interactive terminal into a sandbox."""
|
|
337
|
+
_require_posix_tty()
|
|
338
|
+
|
|
339
|
+
sandbox = obj.connect_sandbox(sandbox_id)
|
|
340
|
+
try:
|
|
341
|
+
endpoint = sandbox.get_endpoint(DEFAULT_EXECD_PORT)
|
|
342
|
+
finally:
|
|
343
|
+
sandbox.close()
|
|
344
|
+
|
|
345
|
+
headers = endpoint.build_request_headers(obj.connection_config)
|
|
346
|
+
protocol = obj.connection_config.protocol
|
|
347
|
+
base = _execd_base(endpoint.endpoint)
|
|
348
|
+
http_base = f"{protocol}://{base}"
|
|
349
|
+
ws_url = f"{_ws_scheme(protocol)}://{base}"
|
|
350
|
+
|
|
351
|
+
timeout = timedelta(seconds=obj.resolved_config.get("request_timeout", 30))
|
|
352
|
+
session_id = _create_pty_session(
|
|
353
|
+
http_base, headers, workdir=workdir, timeout=timeout
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
stdin_read, stdout_write, get_size, stdin_fd = _posix_tty_io()
|
|
357
|
+
|
|
358
|
+
def report_error(data: bytes) -> None:
|
|
359
|
+
# CRLF line endings are added by the sender: the terminal is in raw
|
|
360
|
+
# mode, where a bare LF does not return the carriage.
|
|
361
|
+
sys.stderr.buffer.write(data)
|
|
362
|
+
sys.stderr.buffer.flush()
|
|
363
|
+
|
|
364
|
+
exit_code: int | None = None
|
|
365
|
+
errors: list[str] = []
|
|
366
|
+
teardown = False
|
|
367
|
+
try:
|
|
368
|
+
with _raw_mode(stdin_fd):
|
|
369
|
+
exit_code, errors = asyncio.run(
|
|
370
|
+
_connect_and_stream(
|
|
371
|
+
f"{ws_url}/pty/{session_id}/ws",
|
|
372
|
+
headers,
|
|
373
|
+
stdin_read=stdin_read,
|
|
374
|
+
stdout_write=stdout_write,
|
|
375
|
+
get_size=get_size,
|
|
376
|
+
error_write=report_error,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
teardown = exit_code is not None or bool(errors)
|
|
380
|
+
except websockets.InvalidStatus as exc:
|
|
381
|
+
raise click.ClickException(
|
|
382
|
+
f"The execd refused the terminal connection: {exc}"
|
|
383
|
+
) from exc
|
|
384
|
+
except (OSError, websockets.WebSocketException) as exc:
|
|
385
|
+
raise click.ClickException(f"Terminal connection failed: {exc}") from exc
|
|
386
|
+
finally:
|
|
387
|
+
# Clean exit tears the session down, as does a reported failure (e.g.
|
|
388
|
+
# START_FAILED: the shell never ran, so there is nothing to preserve).
|
|
389
|
+
# A lost connection on a live shell is the one case worth keeping.
|
|
390
|
+
if teardown:
|
|
391
|
+
_delete_pty_session(http_base, headers, session_id, timeout=timeout)
|
|
392
|
+
|
|
393
|
+
if exit_code is None:
|
|
394
|
+
if errors:
|
|
395
|
+
raise click.ClickException("Shell startup failed: " + "; ".join(errors))
|
|
396
|
+
sys.stderr.write(
|
|
397
|
+
"Warning: the terminal connection closed before the shell exited.\n"
|
|
398
|
+
f"The PTY session was kept on the sandbox: {session_id}\n"
|
|
399
|
+
f" status: GET {http_base}/pty/{session_id}\n"
|
|
400
|
+
f" remove: DELETE {http_base}/pty/{session_id}\n"
|
|
401
|
+
)
|
|
402
|
+
sys.stderr.flush()
|
|
403
|
+
raise click.ClickException("Terminal connection lost.")
|
|
404
|
+
sys.exit(exit_code)
|
|
@@ -35,6 +35,7 @@ from opensandbox_cli.commands.sandbox import sandbox_group
|
|
|
35
35
|
from opensandbox_cli.commands.skills import skills_group
|
|
36
36
|
from opensandbox_cli.commands.snapshot import snapshot_group
|
|
37
37
|
from opensandbox_cli.commands.template import template_group
|
|
38
|
+
from opensandbox_cli.commands.terminal import terminal
|
|
38
39
|
from opensandbox_cli.config import resolve_config
|
|
39
40
|
|
|
40
41
|
BANNER = r"""[bold cyan]
|
|
@@ -170,6 +171,7 @@ cli.add_command(sandbox_group)
|
|
|
170
171
|
cli.add_command(template_group)
|
|
171
172
|
cli.add_command(snapshot_group)
|
|
172
173
|
cli.add_command(command_group)
|
|
174
|
+
cli.add_command(terminal)
|
|
173
175
|
cli.add_command(file_group)
|
|
174
176
|
cli.add_command(egress_group)
|
|
175
177
|
cli.add_command(credential_vault_group)
|
|
@@ -43,7 +43,10 @@ def parse_duration(value: str) -> timedelta:
|
|
|
43
43
|
raise click.BadParameter("Duration cannot be empty")
|
|
44
44
|
|
|
45
45
|
if value.isdigit():
|
|
46
|
-
|
|
46
|
+
try:
|
|
47
|
+
return timedelta(seconds=int(value))
|
|
48
|
+
except (OverflowError, ValueError) as exc:
|
|
49
|
+
raise click.BadParameter(f"Duration '{value}' is out of range") from exc
|
|
47
50
|
|
|
48
51
|
m = _DURATION_RE.match(value)
|
|
49
52
|
if not m or not m.group(0):
|
|
@@ -51,10 +54,13 @@ def parse_duration(value: str) -> timedelta:
|
|
|
51
54
|
f"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s."
|
|
52
55
|
)
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
try:
|
|
58
|
+
hours = int(m.group("hours") or 0)
|
|
59
|
+
minutes = int(m.group("minutes") or 0)
|
|
60
|
+
seconds = int(m.group("seconds") or 0)
|
|
61
|
+
return timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
|
62
|
+
except (OverflowError, ValueError) as exc:
|
|
63
|
+
raise click.BadParameter(f"Duration '{value}' is out of range") from exc
|
|
58
64
|
|
|
59
65
|
|
|
60
66
|
def parse_nullable_duration(value: str) -> timedelta | None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/__init__.py
RENAMED
|
File without changes
|
{opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/command.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/devops.py
RENAMED
|
File without changes
|
|
File without changes
|
{opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/egress.py
RENAMED
|
File without changes
|
{opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/file.py
RENAMED
|
File without changes
|
{opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/sandbox.py
RENAMED
|
File without changes
|
{opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/skills.py
RENAMED
|
File without changes
|
{opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/snapshot.py
RENAMED
|
File without changes
|
{opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/template.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skill_registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|