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.
Files changed (33) hide show
  1. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/PKG-INFO +15 -2
  2. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/README.md +12 -0
  3. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/pyproject.toml +4 -1
  4. opensandbox_cli-1.2.0/src/opensandbox_cli/commands/terminal.py +404 -0
  5. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/main.py +2 -0
  6. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/utils.py +11 -5
  7. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/.gitignore +0 -0
  8. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/LICENSE +0 -0
  9. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/__init__.py +0 -0
  10. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/__main__.py +0 -0
  11. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/client.py +0 -0
  12. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/__init__.py +0 -0
  13. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/command.py +0 -0
  14. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/config_cmd.py +0 -0
  15. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/credential_vault.py +0 -0
  16. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/devops.py +0 -0
  17. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/diagnostics.py +0 -0
  18. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/egress.py +0 -0
  19. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/file.py +0 -0
  20. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/sandbox.py +0 -0
  21. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/skills.py +0 -0
  22. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/snapshot.py +0 -0
  23. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/commands/template.py +0 -0
  24. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/config.py +0 -0
  25. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/output.py +0 -0
  26. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/py.typed +0 -0
  27. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skill_registry.py +0 -0
  28. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-command-execution.md +0 -0
  29. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-credential-vault.md +0 -0
  30. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-file-operations.md +0 -0
  31. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-network-egress.md +0 -0
  32. {opensandbox_cli-1.1.2.dev20261020 → opensandbox_cli-1.2.0}/src/opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md +0 -0
  33. {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.1.2.dev20261020
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.1.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.1.1,<2.0.0",
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
- return timedelta(seconds=int(value))
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
- hours = int(m.group("hours") or 0)
55
- minutes = int(m.group("minutes") or 0)
56
- seconds = int(m.group("seconds") or 0)
57
- return timedelta(hours=hours, minutes=minutes, seconds=seconds)
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: