opensandbox-cli 1.1.2.dev2026102001__tar.gz → 1.2.1__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.dev2026102001 → opensandbox_cli-1.2.1}/.gitignore +4 -0
  2. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/PKG-INFO +23 -5
  3. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/README.md +20 -3
  4. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/pyproject.toml +6 -2
  5. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/command.py +6 -3
  6. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/config_cmd.py +4 -3
  7. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/egress.py +1 -1
  8. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/sandbox.py +12 -4
  9. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/template.py +4 -1
  10. opensandbox_cli-1.2.1/src/opensandbox_cli/commands/terminal.py +415 -0
  11. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/config.py +62 -9
  12. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/main.py +16 -3
  13. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/output.py +9 -7
  14. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md +5 -2
  15. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/utils.py +15 -12
  16. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/LICENSE +0 -0
  17. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/__init__.py +0 -0
  18. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/__main__.py +0 -0
  19. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/client.py +0 -0
  20. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/__init__.py +0 -0
  21. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/credential_vault.py +0 -0
  22. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/devops.py +0 -0
  23. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/diagnostics.py +0 -0
  24. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/file.py +0 -0
  25. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/skills.py +0 -0
  26. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/snapshot.py +0 -0
  27. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/py.typed +0 -0
  28. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skill_registry.py +0 -0
  29. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-command-execution.md +0 -0
  30. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-credential-vault.md +0 -0
  31. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-file-operations.md +0 -0
  32. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-network-egress.md +0 -0
  33. {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-sandbox-troubleshooting.md +0 -0
@@ -294,3 +294,7 @@ obj/
294
294
  # fast-sandbox pinned source checkout (materialized by
295
295
  # manifests/release/build-fast-sandbox.sh from manifests/third-party/fast-sandbox.commit)
296
296
  .fast-sandbox/
297
+
298
+ # Test-only localhost certificate pair for the egress OSEP-0025 resolver stub
299
+ # (fixtures/README.md documents regeneration; these certify nothing).
300
+ !components/egress/tests/resolver/fixtures/*.pem
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opensandbox-cli
3
- Version: 1.1.2.dev2026102001
3
+ Version: 1.2.1
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==1.1.2.dev20261020
227
+ Requires-Dist: opensandbox<2.0.0,>=1.2.1
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
@@ -435,7 +436,8 @@ osb sandbox create -f sandbox-request.json -o json
435
436
  Templates are golden-image builds; the build runs asynchronously, so poll `template get` until the status phase is `Succeeded`. Template management requires a Kubernetes-backed runtime.
436
437
 
437
438
  ```bash
438
- osb template create --image python:3.12 --publish s3://bucket/publish -o json
439
+ osb template create --image python:3.12 --publish s3://bucket/publish --format native -o json
440
+ osb template create --image python:3.12 --publish oci://registry.example.com/sandbox/templates --format overlaybd -o json
439
441
  osb template get <template-id> -o json
440
442
  osb template list -o json
441
443
  osb template delete <template-id> -o json
@@ -451,11 +453,14 @@ osb template create -f template-request.json -o json
451
453
  {
452
454
  "image": "python:3.12",
453
455
  "publish": "s3://bucket/publish",
456
+ "format": "native",
454
457
  "resourceLimits": { "cpu": "1", "memory": "512Mi" },
455
458
  "readiness": { "probe": "tcp://127.0.0.1:44772", "warmupSeconds": 60 }
456
459
  }
457
460
  ```
458
461
 
462
+ OCI targets must name a repository without a tag or digest. Use `overlaybd` (the default) for OCI and explicitly select `native` for S3. The builder returns an immutable OCI reference in `status.manifestRef`; `sandbox create --template` uses that reference automatically. See [OCI template setup](../docs/guides/oci-templates.md) for Registry Secrets and runtime prerequisites.
463
+
459
464
  ### Manage snapshots
460
465
 
461
466
  Snapshots capture a sandbox's state and can back new sandboxes.
@@ -511,7 +516,7 @@ osb command logs <sandbox-id> <execution-id> -o json
511
516
 
512
517
  Background start errors or a stream ending before startup confirmation make the CLI exit non-zero without success metadata. After a successful start, use `command status` and `command logs` to check the process's eventual result.
513
518
 
514
- By default the payload after `--` is joined into one shell command string, so pipelines, redirection, and `$VAR` expansion work as in a terminal. Add `--argv` to pass the arguments to the executable as a literal argv list (no shell) when values such as `$HOME`, quotes, embedded spaces, or empty strings must reach the process unchanged. `--argv` needs a sandbox image whose execd accepts argv requests:
519
+ By default each payload word after `--` is quoted as a single shell token and the tokens are joined into one shell command line that the sandbox's shell runs. Shell syntax such as pipelines or redirection must therefore ride inside an explicit word (`-- sh -c 'echo hi > /tmp/x'`); an unquoted `$VAR` is expanded by your own terminal before the CLI sees it, while a quoted one reaches the sandbox shell literally. Add `--argv` to pass the arguments to the executable as a literal argv list (no shell) when values such as `$HOME`, quotes, embedded spaces, or empty strings must reach the process unchanged. `--argv` needs a sandbox image whose execd accepts argv requests:
515
520
 
516
521
  ```bash
517
522
  osb command run <sandbox-id> -o raw --argv -- python3 -c "import sys; print(sys.argv[1:])" "a b" '$HOME' "x'y" ""
@@ -527,6 +532,17 @@ osb command session run <sandbox-id> <session-id> -o raw -- sh -c 'echo $FOO'
527
532
  osb command session delete <sandbox-id> <session-id> -o json
528
533
  ```
529
534
 
535
+ ### Open an interactive terminal
536
+
537
+ `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.
538
+
539
+ ```bash
540
+ osb terminal <sandbox-id>
541
+ osb terminal <sandbox-id> -w /workspace
542
+ ```
543
+
544
+ 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.
545
+
530
546
  ### Work with files
531
547
 
532
548
  ```bash
@@ -635,6 +651,7 @@ The main command groups are:
635
651
  - `osb template`: golden-image template builds
636
652
  - `osb snapshot`: snapshot management
637
653
  - `osb command`: command execution and persistent sessions
654
+ - `osb terminal`: interactive PTY terminal into a sandbox
638
655
  - `osb file`: file and directory operations
639
656
  - `osb egress`: runtime egress policy
640
657
  - `osb diagnostics`: stable diagnostics logs and events
@@ -721,7 +738,8 @@ api_key = "your-api-key"
721
738
  domain = "localhost:8080"
722
739
  protocol = "http"
723
740
  request_timeout = 30
724
- use_server_proxy = false
741
+ # use_server_proxy is deprecated (maintenance mode); prefer the ingress gateway for data-plane traffic.
742
+ # use_server_proxy = false
725
743
 
726
744
  [output]
727
745
  color = true
@@ -202,7 +202,8 @@ osb sandbox create -f sandbox-request.json -o json
202
202
  Templates are golden-image builds; the build runs asynchronously, so poll `template get` until the status phase is `Succeeded`. Template management requires a Kubernetes-backed runtime.
203
203
 
204
204
  ```bash
205
- osb template create --image python:3.12 --publish s3://bucket/publish -o json
205
+ osb template create --image python:3.12 --publish s3://bucket/publish --format native -o json
206
+ osb template create --image python:3.12 --publish oci://registry.example.com/sandbox/templates --format overlaybd -o json
206
207
  osb template get <template-id> -o json
207
208
  osb template list -o json
208
209
  osb template delete <template-id> -o json
@@ -218,11 +219,14 @@ osb template create -f template-request.json -o json
218
219
  {
219
220
  "image": "python:3.12",
220
221
  "publish": "s3://bucket/publish",
222
+ "format": "native",
221
223
  "resourceLimits": { "cpu": "1", "memory": "512Mi" },
222
224
  "readiness": { "probe": "tcp://127.0.0.1:44772", "warmupSeconds": 60 }
223
225
  }
224
226
  ```
225
227
 
228
+ OCI targets must name a repository without a tag or digest. Use `overlaybd` (the default) for OCI and explicitly select `native` for S3. The builder returns an immutable OCI reference in `status.manifestRef`; `sandbox create --template` uses that reference automatically. See [OCI template setup](../docs/guides/oci-templates.md) for Registry Secrets and runtime prerequisites.
229
+
226
230
  ### Manage snapshots
227
231
 
228
232
  Snapshots capture a sandbox's state and can back new sandboxes.
@@ -278,7 +282,7 @@ osb command logs <sandbox-id> <execution-id> -o json
278
282
 
279
283
  Background start errors or a stream ending before startup confirmation make the CLI exit non-zero without success metadata. After a successful start, use `command status` and `command logs` to check the process's eventual result.
280
284
 
281
- By default the payload after `--` is joined into one shell command string, so pipelines, redirection, and `$VAR` expansion work as in a terminal. Add `--argv` to pass the arguments to the executable as a literal argv list (no shell) when values such as `$HOME`, quotes, embedded spaces, or empty strings must reach the process unchanged. `--argv` needs a sandbox image whose execd accepts argv requests:
285
+ By default each payload word after `--` is quoted as a single shell token and the tokens are joined into one shell command line that the sandbox's shell runs. Shell syntax such as pipelines or redirection must therefore ride inside an explicit word (`-- sh -c 'echo hi > /tmp/x'`); an unquoted `$VAR` is expanded by your own terminal before the CLI sees it, while a quoted one reaches the sandbox shell literally. Add `--argv` to pass the arguments to the executable as a literal argv list (no shell) when values such as `$HOME`, quotes, embedded spaces, or empty strings must reach the process unchanged. `--argv` needs a sandbox image whose execd accepts argv requests:
282
286
 
283
287
  ```bash
284
288
  osb command run <sandbox-id> -o raw --argv -- python3 -c "import sys; print(sys.argv[1:])" "a b" '$HOME' "x'y" ""
@@ -294,6 +298,17 @@ osb command session run <sandbox-id> <session-id> -o raw -- sh -c 'echo $FOO'
294
298
  osb command session delete <sandbox-id> <session-id> -o json
295
299
  ```
296
300
 
301
+ ### Open an interactive terminal
302
+
303
+ `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.
304
+
305
+ ```bash
306
+ osb terminal <sandbox-id>
307
+ osb terminal <sandbox-id> -w /workspace
308
+ ```
309
+
310
+ 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.
311
+
297
312
  ### Work with files
298
313
 
299
314
  ```bash
@@ -402,6 +417,7 @@ The main command groups are:
402
417
  - `osb template`: golden-image template builds
403
418
  - `osb snapshot`: snapshot management
404
419
  - `osb command`: command execution and persistent sessions
420
+ - `osb terminal`: interactive PTY terminal into a sandbox
405
421
  - `osb file`: file and directory operations
406
422
  - `osb egress`: runtime egress policy
407
423
  - `osb diagnostics`: stable diagnostics logs and events
@@ -488,7 +504,8 @@ api_key = "your-api-key"
488
504
  domain = "localhost:8080"
489
505
  protocol = "http"
490
506
  request_timeout = 30
491
- use_server_proxy = false
507
+ # use_server_proxy is deprecated (maintenance mode); prefer the ingress gateway for data-plane traffic.
508
+ # use_server_proxy = false
492
509
 
493
510
  [output]
494
511
  color = true
@@ -42,13 +42,17 @@ classifiers = [
42
42
  "Typing :: Typed",
43
43
  ]
44
44
  dependencies = [
45
- # Keep this test CLI on the matching published SDK development build.
46
- "opensandbox==1.1.2.dev20261020",
45
+ # `command run --argv` passes a list to commands.run; the SDK accepts a
46
+ # list there from 0.1.17. Release the CLI only after that SDK is published.
47
+ "opensandbox>=1.2.1,<2.0.0",
47
48
  "click>=8.1.0,<9.0",
48
49
  "rich>=13.0.0,<14.0",
49
50
  "pyyaml>=6.0,<7.0",
50
51
  "tomlkit>=0.13.0,<1.0",
51
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",
52
56
  ]
53
57
 
54
58
  [project.urls]
@@ -147,9 +147,12 @@ def _handle_execution_error(
147
147
  help="Run a command in a sandbox. Use `--` before the sandbox command payload.",
148
148
  epilog=(
149
149
  "Separator rule: use `--` before the sandbox command payload.\n\n"
150
- "By default the payload is joined into one shell command string. Add "
151
- "`--argv` to pass the arguments to the executable as-is (no shell), so "
152
- "literal `$HOME`, quotes, and empty strings are preserved; this needs a "
150
+ "By default each payload word is quoted as a single shell token and "
151
+ "the tokens are joined into one shell command line, so shell syntax "
152
+ "such as redirects or pipes must ride inside an explicit word "
153
+ "(for example: -- sh -c 'echo hi > /tmp/x'). Add `--argv` to pass "
154
+ "the arguments to the executable as-is (no shell), so literal "
155
+ "`$HOME`, quotes, and empty strings are preserved; this needs a "
153
156
  "sandbox image whose execd accepts argv requests."
154
157
  ),
155
158
  )
@@ -26,7 +26,7 @@ from tomlkit.exceptions import ParseError
26
26
  from tomlkit.items import InlineTable, Table
27
27
 
28
28
  from opensandbox_cli.client import ClientContext
29
- from opensandbox_cli.config import init_config_file, resolve_config
29
+ from opensandbox_cli.config import STRING_KEYS, init_config_file, resolve_config
30
30
  from opensandbox_cli.utils import handle_errors, output_option, prepare_output
31
31
 
32
32
 
@@ -53,7 +53,8 @@ def config_init(obj: ClientContext, force: bool, output_format: str | None) -> N
53
53
  path = init_config_file(obj.config_path, force=force)
54
54
  output.success(f"Config file created: {path}")
55
55
  except FileExistsError as exc:
56
- output.warning(str(exc))
56
+ output.error(str(exc))
57
+ raise click.exceptions.Exit(1) from None
57
58
 
58
59
 
59
60
  _SENSITIVE_CONFIG_KEYS = {
@@ -166,7 +167,7 @@ def config_set(
166
167
  if not isinstance(table, (Table, InlineTable, OutOfOrderTableProxy)):
167
168
  raise click.ClickException(f"Cannot set {key}: '{section}' is not a TOML table.")
168
169
 
169
- table[field] = _parse_toml_value(value)
170
+ table[field] = value if key in STRING_KEYS else _parse_toml_value(value)
170
171
  path.write_text(tomlkit.dumps(document), encoding="utf-8")
171
172
 
172
173
  obj.output.success(f"Set {key} = {value}")
@@ -26,7 +26,7 @@ from opensandbox_cli.utils import handle_errors, output_option, prepare_output
26
26
  def _parse_rule(value: str) -> NetworkRule:
27
27
  """Parse ACTION=TARGET into a NetworkRule."""
28
28
  action, sep, target = value.partition("=")
29
- if not sep:
29
+ if not sep or not target.strip():
30
30
  raise click.BadParameter(
31
31
  f"Invalid rule '{value}'. Use ACTION=TARGET, for example allow=pypi.org."
32
32
  )
@@ -1015,12 +1015,20 @@ def _parse_metric_stream_line(line: str) -> tuple[SandboxMetrics | None, str | N
1015
1015
  if not payload:
1016
1016
  return None, None
1017
1017
 
1018
- decoded: Any = json.loads(payload)
1018
+ try:
1019
+ decoded: Any = json.loads(payload)
1020
+ except json.JSONDecodeError:
1021
+ return None, "Metrics stream error: invalid JSON"
1022
+
1019
1023
  if isinstance(decoded, dict) and decoded.get("error"):
1020
1024
  return None, f"Metrics stream error: {decoded['error']}"
1021
- if isinstance(decoded, dict) and "cpu_used_pct" in decoded:
1022
- return MetricsModelConverter.to_sandbox_metrics(Metrics.from_dict(decoded)), None
1023
- return SandboxMetrics.model_validate(decoded), None
1025
+
1026
+ try:
1027
+ if isinstance(decoded, dict) and "cpu_used_pct" in decoded:
1028
+ return MetricsModelConverter.to_sandbox_metrics(Metrics.from_dict(decoded)), None
1029
+ return SandboxMetrics.model_validate(decoded), None
1030
+ except (KeyError, TypeError, ValueError, ValidationError) as exc:
1031
+ return None, f"Metrics stream error: {exc}"
1024
1032
 
1025
1033
 
1026
1034
  def _describe_create_timeout(
@@ -55,7 +55,7 @@ def template_group(ctx: click.Context) -> None:
55
55
  "--publish",
56
56
  "-p",
57
57
  default=None,
58
- help="S3-compatible publish target for the built artifacts (e.g. s3://bucket/publish). Required unless --file is used.",
58
+ help="Publish target: s3://bucket/publish (native) or oci://registry/repository (overlaybd, no tag or digest). Required unless --file is used.",
59
59
  )
60
60
  @click.option(
61
61
  "--file",
@@ -233,6 +233,9 @@ def template_get(obj, template_id: str, output_format: str | None) -> None:
233
233
  status_val = d.get("status")
234
234
  if isinstance(status_val, dict):
235
235
  d["status"] = status_val.get("phase", str(status_val))
236
+ # model_dump uses the field name; accept the wire alias too.
237
+ manifest_ref = status_val.get("manifest_ref", status_val.get("manifestRef"))
238
+ d["manifest_ref"] = manifest_ref
236
239
  if status_val.get("message"):
237
240
  d["status_message"] = status_val["message"]
238
241
  readiness_val = d.get("readiness")
@@ -0,0 +1,415 @@
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
+ on_connected: Callable[[], None] | None = None,
263
+ ) -> tuple[int | None, list[str]]:
264
+ """Connect to the PTY WebSocket, drive the relay loop, handle resize."""
265
+ async with websockets.connect(ws_url, additional_headers=headers or None) as ws:
266
+ if on_connected is not None:
267
+ on_connected()
268
+ # websockets forbids concurrent sends: stdin frames and SIGWINCH
269
+ # resize frames share one lock, and fire-and-forget resize tasks get
270
+ # their results consumed so failures never surface as task warnings.
271
+ send_lock = asyncio.Lock()
272
+
273
+ async def send_frame(frame: str) -> None:
274
+ try:
275
+ async with send_lock:
276
+ await ws.send(frame)
277
+ except websockets.ConnectionClosed:
278
+ pass
279
+
280
+ async def send_resize() -> None:
281
+ try:
282
+ cols, rows = get_size()
283
+ except OSError:
284
+ return
285
+ await send_frame(json.dumps({"type": "resize", "cols": cols, "rows": rows}))
286
+
287
+ await send_resize()
288
+ loop = asyncio.get_running_loop()
289
+ has_winch = hasattr(signal, "SIGWINCH")
290
+ if has_winch:
291
+ try:
292
+ loop.add_signal_handler(
293
+ signal.SIGWINCH,
294
+ lambda: asyncio.ensure_future(send_resize()).add_done_callback(
295
+ _consume_task_result
296
+ ),
297
+ )
298
+ except NotImplementedError: # pragma: no cover - platform guard
299
+ has_winch = False
300
+ try:
301
+ return await stream_pty(
302
+ ws,
303
+ stdin_read=stdin_read,
304
+ stdout_write=stdout_write,
305
+ error_write=error_write,
306
+ send_lock=send_lock,
307
+ )
308
+ finally:
309
+ if has_winch:
310
+ loop.remove_signal_handler(signal.SIGWINCH)
311
+
312
+
313
+ def _require_posix_tty() -> None:
314
+ if os.name != "posix":
315
+ raise click.ClickException(
316
+ "osb terminal is only supported on Unix, macOS, and Linux."
317
+ )
318
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
319
+ raise click.ClickException("osb terminal requires an interactive TTY.")
320
+
321
+
322
+ @click.command(
323
+ "terminal",
324
+ help="Open an interactive terminal into a sandbox via the execd PTY.",
325
+ epilog=(
326
+ "The shell runs inside the sandbox; exit it (or press Ctrl-D) to leave. "
327
+ "The remote exit code becomes this command's exit code."
328
+ ),
329
+ )
330
+ @click.argument("sandbox_id")
331
+ @click.option("-w", "--workdir", default=None, help="Initial working directory.")
332
+ @click.pass_obj
333
+ @handle_errors
334
+ def terminal(
335
+ obj: ClientContext,
336
+ sandbox_id: str,
337
+ workdir: str | None,
338
+ ) -> None:
339
+ """Open an interactive terminal into a sandbox."""
340
+ _require_posix_tty()
341
+
342
+ sandbox = obj.connect_sandbox(sandbox_id)
343
+ try:
344
+ endpoint = sandbox.get_endpoint(DEFAULT_EXECD_PORT)
345
+ finally:
346
+ sandbox.close()
347
+
348
+ headers = endpoint.build_request_headers(obj.connection_config)
349
+ protocol = obj.connection_config.protocol
350
+ base = _execd_base(endpoint.endpoint)
351
+ http_base = f"{protocol}://{base}"
352
+ ws_url = f"{_ws_scheme(protocol)}://{base}"
353
+
354
+ timeout = timedelta(seconds=obj.resolved_config.get("request_timeout", 30))
355
+ session_id = _create_pty_session(
356
+ http_base, headers, workdir=workdir, timeout=timeout
357
+ )
358
+
359
+ stdin_read, stdout_write, get_size, stdin_fd = _posix_tty_io()
360
+
361
+ def report_error(data: bytes) -> None:
362
+ # CRLF line endings are added by the sender: the terminal is in raw
363
+ # mode, where a bare LF does not return the carriage.
364
+ sys.stderr.buffer.write(data)
365
+ sys.stderr.buffer.flush()
366
+
367
+ exit_code: int | None = None
368
+ errors: list[str] = []
369
+ teardown = False
370
+ connected = False
371
+
372
+ def mark_connected() -> None:
373
+ nonlocal connected
374
+ connected = True
375
+
376
+ try:
377
+ with _raw_mode(stdin_fd):
378
+ exit_code, errors = asyncio.run(
379
+ _connect_and_stream(
380
+ f"{ws_url}/pty/{session_id}/ws",
381
+ headers,
382
+ stdin_read=stdin_read,
383
+ stdout_write=stdout_write,
384
+ get_size=get_size,
385
+ error_write=report_error,
386
+ on_connected=mark_connected,
387
+ )
388
+ )
389
+ teardown = exit_code is not None or bool(errors)
390
+ except websockets.InvalidStatus as exc:
391
+ raise click.ClickException(
392
+ f"The execd refused the terminal connection: {exc}"
393
+ ) from exc
394
+ except (OSError, websockets.WebSocketException) as exc:
395
+ raise click.ClickException(f"Terminal connection failed: {exc}") from exc
396
+ finally:
397
+ # Clean exit tears the session down, as does a reported failure (e.g.
398
+ # START_FAILED: the shell never ran, so there is nothing to preserve)
399
+ # or a WebSocket that never opened (the shell starts on the first
400
+ # one). A lost connection on a live shell is the one case worth keeping.
401
+ if teardown or not connected:
402
+ _delete_pty_session(http_base, headers, session_id, timeout=timeout)
403
+
404
+ if exit_code is None:
405
+ if errors:
406
+ raise click.ClickException("Shell startup failed: " + "; ".join(errors))
407
+ sys.stderr.write(
408
+ "Warning: the terminal connection closed before the shell exited.\n"
409
+ f"The PTY session was kept on the sandbox: {session_id}\n"
410
+ f" status: GET {http_base}/pty/{session_id}\n"
411
+ f" remove: DELETE {http_base}/pty/{session_id}\n"
412
+ )
413
+ sys.stderr.flush()
414
+ raise click.ClickException("Terminal connection lost.")
415
+ sys.exit(exit_code)
@@ -28,6 +28,8 @@ import sys
28
28
  from pathlib import Path
29
29
  from typing import Any
30
30
 
31
+ import click
32
+
31
33
  if sys.version_info >= (3, 11):
32
34
  import tomllib
33
35
  else:
@@ -49,6 +51,7 @@ DEFAULT_CONFIG_TEMPLATE = """\
49
51
  # domain = "localhost:8080"
50
52
  # protocol = "http"
51
53
  # request_timeout = 30
54
+ # use_server_proxy is deprecated (maintenance mode); prefer the ingress gateway for data-plane traffic.
52
55
  # use_server_proxy = false
53
56
 
54
57
  [output]
@@ -64,14 +67,38 @@ def load_config_file(config_path: Path | None = None) -> dict[str, Any]:
64
67
  """Load and parse the TOML config file.
65
68
 
66
69
  Returns an empty dict if the file doesn't exist or tomllib is unavailable.
70
+ Raises ``click.ClickException`` if the file cannot be read or parsed.
67
71
  """
68
72
  path = config_path or DEFAULT_CONFIG_PATH
69
- if not path.exists():
70
- return {}
71
73
  if tomllib is None:
72
74
  return {}
73
- with open(path, "rb") as f:
74
- return tomllib.load(f)
75
+ # Open directly instead of checking path.exists() first: exists() raises
76
+ # PermissionError for a path under a directory that cannot be searched.
77
+ try:
78
+ with open(path, "rb") as f:
79
+ return tomllib.load(f)
80
+ except (FileNotFoundError, NotADirectoryError):
81
+ return {}
82
+ except OSError as exc:
83
+ raise click.ClickException(f"Cannot read config file {path}: {exc}") from exc
84
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
85
+ raise click.ClickException(
86
+ f"Config file is not valid TOML ({path}): {exc}"
87
+ ) from exc
88
+
89
+
90
+ # Config-file keys resolve_config reads as text. `config set` stores their
91
+ # values verbatim: inferring a TOML number would rewrite an API key such as
92
+ # "007123" as 7123.
93
+ STRING_KEYS = frozenset(
94
+ {
95
+ "connection.api_key",
96
+ "connection.domain",
97
+ "connection.protocol",
98
+ "defaults.image",
99
+ "defaults.timeout",
100
+ }
101
+ )
75
102
 
76
103
 
77
104
  def resolve_config(
@@ -92,10 +119,15 @@ def resolve_config(
92
119
  declared type.
93
120
 
94
121
  Keys returned:
95
- - api_key, domain, protocol, request_timeout (int seconds), use_server_proxy (bool)
122
+ - api_key, domain, protocol, request_timeout (int seconds), use_server_proxy (bool; deprecated)
96
123
  - default_image, default_timeout (str like "10m")
97
124
  """
98
125
  file_cfg = load_config_file(config_path)
126
+ for section in ("connection", "output", "defaults"):
127
+ if section in file_cfg and not isinstance(file_cfg[section], dict):
128
+ raise click.ClickException(
129
+ f"Config section '{section}' must be a TOML table (use [{section}])."
130
+ )
99
131
  conn = file_cfg.get("connection", {})
100
132
  output_cfg = file_cfg.get("output", {})
101
133
  defaults = file_cfg.get("defaults", {})
@@ -111,10 +143,11 @@ def resolve_config(
111
143
  or os.getenv("OPEN_SANDBOX_PROTOCOL")
112
144
  or _str_or_none(conn.get("protocol"))
113
145
  or "http",
114
- "request_timeout": cli_timeout
115
- or _int_or_none(os.getenv("OPEN_SANDBOX_REQUEST_TIMEOUT"))
116
- or _int_or_none(conn.get("request_timeout"))
117
- or 30,
146
+ "request_timeout": _request_timeout_seconds(
147
+ cli_timeout,
148
+ _int_or_none(os.getenv("OPEN_SANDBOX_REQUEST_TIMEOUT")),
149
+ _int_or_none(conn.get("request_timeout")),
150
+ ),
118
151
  "use_server_proxy": _coalesce(
119
152
  cli_use_server_proxy,
120
153
  _bool_or_none(os.getenv("OPEN_SANDBOX_USE_SERVER_PROXY")),
@@ -139,6 +172,26 @@ def init_config_file(config_path: Path | None = None, *, force: bool = False) ->
139
172
  return path
140
173
 
141
174
 
175
+ def _request_timeout_seconds(*candidates: int | None) -> int:
176
+ """Return the first explicit request timeout, in seconds.
177
+
178
+ ``None`` means that source did not set a timeout. Zero and negative values
179
+ are explicit and invalid: they must not fall through to a later source or
180
+ the 30 second default. ``--request-timeout 0`` used to be ignored because
181
+ ``0`` is falsy.
182
+ """
183
+ for value in candidates:
184
+ if value is None:
185
+ continue
186
+ if value <= 0:
187
+ raise click.ClickException(
188
+ "Request timeout must be a positive number of seconds, "
189
+ f"got {value}."
190
+ )
191
+ return value
192
+ return 30
193
+
194
+
142
195
  def _int_or_none(value: Any) -> int | None:
143
196
  """Coerce a configured value to ``int``, or ``None`` when it is not numeric."""
144
197
  if value is None or isinstance(value, bool):
@@ -35,7 +35,8 @@ 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.config import resolve_config
38
+ from opensandbox_cli.commands.terminal import terminal
39
+ from opensandbox_cli.config import _request_timeout_seconds, resolve_config
39
40
 
40
41
  BANNER = r"""[bold cyan]
41
42
  ____ _____ _ _
@@ -103,7 +104,10 @@ class BannerGroup(click.Group):
103
104
  @click.option(
104
105
  "--use-server-proxy/--no-use-server-proxy",
105
106
  default=None,
106
- help="Route execd and endpoint traffic through the sandbox server proxy.",
107
+ help=(
108
+ "DEPRECATED: the server proxy is in maintenance mode (security/bugfix only); "
109
+ "route data-plane traffic through the ingress gateway instead."
110
+ ),
107
111
  )
108
112
  @click.option("--config", "config_path", type=click.Path(exists=False, path_type=Path), default=None, help="Config file path.")
109
113
  @click.option("-v", "--verbose", is_flag=True, default=False, help="Enable verbose/debug output.")
@@ -140,7 +144,7 @@ def cli(
140
144
  "api_key": api_key,
141
145
  "domain": domain,
142
146
  "protocol": protocol or "http",
143
- "request_timeout": request_timeout or 30,
147
+ "request_timeout": _request_timeout_seconds(request_timeout),
144
148
  "use_server_proxy": use_server_proxy if use_server_proxy is not None else False,
145
149
  "color": True,
146
150
  "default_image": None,
@@ -157,6 +161,14 @@ def cli(
157
161
  )
158
162
  resolved["color"] = not no_color and resolved.get("color", True)
159
163
 
164
+ if ctx.invoked_subcommand != "config" and resolved.get("use_server_proxy"):
165
+ click.echo(
166
+ "Warning: connection.use_server_proxy is deprecated: the server proxy "
167
+ "data path is in maintenance mode (security and bug fixes only); route "
168
+ "data-plane traffic through the ingress gateway instead.",
169
+ err=True,
170
+ )
171
+
160
172
  effective_config_path = config_path or Path.home() / ".opensandbox" / "config.toml"
161
173
  ctx.obj = ClientContext(
162
174
  resolved_config=resolved,
@@ -170,6 +182,7 @@ cli.add_command(sandbox_group)
170
182
  cli.add_command(template_group)
171
183
  cli.add_command(snapshot_group)
172
184
  cli.add_command(command_group)
185
+ cli.add_command(terminal)
173
186
  cli.add_command(file_group)
174
187
  cli.add_command(egress_group)
175
188
  cli.add_command(credential_vault_group)
@@ -32,6 +32,7 @@ except ImportError: # pragma: no cover
32
32
  from pydantic import BaseModel
33
33
  from rich import box
34
34
  from rich.console import Console
35
+ from rich.markup import escape
35
36
  from rich.panel import Panel
36
37
  from rich.status import Status
37
38
  from rich.table import Table
@@ -116,7 +117,7 @@ class OutputFormatter:
116
117
  return
117
118
  if self.color:
118
119
  console = self._err_console if stderr else self.console
119
- console.print(f" [bold {style}]{icon} {msg}[/]")
120
+ console.print(f" [bold {style}]{icon} {escape(msg)}[/]")
120
121
  else:
121
122
  click.echo(f"{plain}: {msg}", err=stderr)
122
123
 
@@ -143,7 +144,7 @@ class OutputFormatter:
143
144
  self._err_console.print(f" [bold red]{title}[/]")
144
145
  self._err_console.print(f" [dim]{'─' * (len(title) + 2)}[/]")
145
146
  for line in msg.splitlines():
146
- self._err_console.print(f" {line}")
147
+ self._err_console.print(f" {escape(line)}")
147
148
  self._err_console.print()
148
149
  else:
149
150
  click.echo(f"ERROR [{title}]: {msg}", err=True)
@@ -152,7 +153,7 @@ class OutputFormatter:
152
153
  def spinner(self, msg: str) -> Generator[Status, None, None]:
153
154
  """Context manager that shows a spinner while work is in progress."""
154
155
  if self.color and self.fmt == "table":
155
- with self._err_console.status(f"[bold cyan]⏳ {msg}[/]", spinner="dots") as status:
156
+ with self._err_console.status(f"[bold cyan]⏳ {escape(msg)}[/]", spinner="dots") as status:
156
157
  yield status
157
158
  else:
158
159
  yield None # type: ignore[arg-type]
@@ -161,7 +162,7 @@ class OutputFormatter:
161
162
  """Print content inside a styled panel."""
162
163
  if self.color:
163
164
  self.console.print(Panel(
164
- content,
165
+ Text(content),
165
166
  title=title,
166
167
  title_align="left",
167
168
  border_style=style,
@@ -187,7 +188,7 @@ class OutputFormatter:
187
188
  self.console.print(f" [bold green]✓ {title}[/]")
188
189
  self.console.print(f" [dim]{'─' * (len(title) + 2)}[/]")
189
190
  for k, v in data.items():
190
- self.console.print(f" [bold]{k}:[/] [cyan]{v}[/]")
191
+ self.console.print(f" [bold]{escape(str(k))}:[/] [cyan]{escape(str(v))}[/]")
191
192
  self.console.print()
192
193
  else:
193
194
  click.echo(f"--- {title} ---")
@@ -316,10 +317,11 @@ class OutputFormatter:
316
317
  for row in rows:
317
318
  cells: list[Text | str] = []
318
319
  for col in columns:
319
- val = str(row.get(col, "-"))
320
+ raw = row.get(col)
321
+ val = "-" if raw is None else str(raw)
320
322
  if col in _STATUS_COLUMNS:
321
323
  cells.append(_style_value(col, val))
322
324
  else:
323
- cells.append(val)
325
+ cells.append(Text(val))
324
326
  table.add_row(*cells)
325
327
  self.console.print(table)
@@ -140,7 +140,8 @@ If the user does not specify an image, recommend one that matches the runtime th
140
140
  Templates are golden-image builds. The build is asynchronous: `template create` returns a template in `Pending`; poll `template get` until the phase is `Succeeded` (or `Failed`).
141
141
 
142
142
  ```bash
143
- osb template create --image python:3.12 --publish s3://bucket/publish -o json
143
+ osb template create --image python:3.12 --publish s3://bucket/publish --format native -o json
144
+ osb template create --image python:3.12 --publish oci://registry.example.com/sandbox/templates --format overlaybd -o json
144
145
  osb template create -f template-request.json -o json
145
146
  osb template get <template-id> -o json
146
147
  osb template list -o json
@@ -149,6 +150,8 @@ osb template delete <template-id> -o json
149
150
 
150
151
  Useful `template create` options: `--resource cpu=1 memory=512Mi disk=2Gi`, `--entrypoint` (repeat per argv item), `--env KEY=VALUE`, `--metadata KEY=VALUE`, `--format native|overlaybd`, `--readiness-probe tcp://127.0.0.1:44772`, `--warmup-seconds 60`. `-f <request-file>` reads the full request as public `CreateTemplateRequest` JSON (camelCase: `image`, `publish`, `resourceLimits`, `entrypoint`, `env`, `metadata`, `format`, `readiness.probe`, `readiness.warmupSeconds`), mutually exclusive with those flags.
151
152
 
153
+ OCI publication requires a repository-only target without a tag or digest, `overlaybd` format, and a server-configured `.dockerconfigjson` Secret (`kubernetes.template_oci_publish_secret`). Sandbox creation also requires an OCI-enabled Fast Sandbox pool and the OverlayBD node daemon. S3 publication uses `native`. Wait for `Succeeded` before creating a sandbox; the server uses the immutable OCI manifest reference for OCI templates.
154
+
152
155
  Create a sandbox from a succeeded template:
153
156
 
154
157
  ```bash
@@ -320,7 +323,7 @@ osb sandbox health <sandbox-id> -o json
320
323
  Build a template, wait for it, and create from it:
321
324
 
322
325
  ```bash
323
- osb template create --image python:3.12 --publish s3://bucket/publish -o json
326
+ osb template create --image python:3.12 --publish s3://bucket/publish --format native -o json
324
327
  osb template get <template-id> -o json
325
328
  osb sandbox create --template <template-id> --timeout 30m -o json
326
329
  osb sandbox health <sandbox-id> -o json
@@ -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:
@@ -80,12 +86,9 @@ class DurationType(click.ParamType):
80
86
  return value
81
87
  try:
82
88
  return parse_duration(value)
83
- except click.BadParameter:
84
- self.fail(
85
- f"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s.",
86
- param,
87
- ctx,
88
- )
89
+ except click.BadParameter as exc:
90
+ # Keep the specific reason (empty, malformed, out of range).
91
+ self.fail(exc.format_message(), param, ctx)
89
92
 
90
93
 
91
94
  DURATION = DurationType()
@@ -199,7 +202,7 @@ def handle_errors(fn): # type: ignore[no-untyped-def]
199
202
  output.error_panel(str(exc), title="Sandbox Error")
200
203
  else:
201
204
  output.error_panel(
202
- f"{str(exc)}\n\n[dim]Type: {type(exc).__qualname__}[/]",
205
+ f"{exc}\n\nType: {type(exc).__qualname__}",
203
206
  title=type(exc).__name__,
204
207
  )
205
208
  else: