opensandbox-cli 1.2.0__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.2.0 → opensandbox_cli-1.2.1}/.gitignore +4 -0
  2. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/PKG-INFO +10 -5
  3. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/README.md +8 -3
  4. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/pyproject.toml +1 -1
  5. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/command.py +6 -3
  6. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/config_cmd.py +4 -3
  7. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/egress.py +1 -1
  8. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/sandbox.py +12 -4
  9. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/template.py +4 -1
  10. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/terminal.py +14 -3
  11. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/config.py +62 -9
  12. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/main.py +14 -3
  13. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/output.py +9 -7
  14. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md +5 -2
  15. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/utils.py +4 -7
  16. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/LICENSE +0 -0
  17. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/__init__.py +0 -0
  18. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/__main__.py +0 -0
  19. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/client.py +0 -0
  20. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/__init__.py +0 -0
  21. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/credential_vault.py +0 -0
  22. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/devops.py +0 -0
  23. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/diagnostics.py +0 -0
  24. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/file.py +0 -0
  25. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/skills.py +0 -0
  26. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/snapshot.py +0 -0
  27. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/py.typed +0 -0
  28. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skill_registry.py +0 -0
  29. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-command-execution.md +0 -0
  30. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-credential-vault.md +0 -0
  31. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-file-operations.md +0 -0
  32. {opensandbox_cli-1.2.0 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-network-egress.md +0 -0
  33. {opensandbox_cli-1.2.0 → 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.2.0
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,7 +224,7 @@ 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.2.0
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'
@@ -436,7 +436,8 @@ osb sandbox create -f sandbox-request.json -o json
436
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.
437
437
 
438
438
  ```bash
439
- 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
440
441
  osb template get <template-id> -o json
441
442
  osb template list -o json
442
443
  osb template delete <template-id> -o json
@@ -452,11 +453,14 @@ osb template create -f template-request.json -o json
452
453
  {
453
454
  "image": "python:3.12",
454
455
  "publish": "s3://bucket/publish",
456
+ "format": "native",
455
457
  "resourceLimits": { "cpu": "1", "memory": "512Mi" },
456
458
  "readiness": { "probe": "tcp://127.0.0.1:44772", "warmupSeconds": 60 }
457
459
  }
458
460
  ```
459
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
+
460
464
  ### Manage snapshots
461
465
 
462
466
  Snapshots capture a sandbox's state and can back new sandboxes.
@@ -512,7 +516,7 @@ osb command logs <sandbox-id> <execution-id> -o json
512
516
 
513
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.
514
518
 
515
- 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:
516
520
 
517
521
  ```bash
518
522
  osb command run <sandbox-id> -o raw --argv -- python3 -c "import sys; print(sys.argv[1:])" "a b" '$HOME' "x'y" ""
@@ -734,7 +738,8 @@ api_key = "your-api-key"
734
738
  domain = "localhost:8080"
735
739
  protocol = "http"
736
740
  request_timeout = 30
737
- 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
738
743
 
739
744
  [output]
740
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" ""
@@ -500,7 +504,8 @@ api_key = "your-api-key"
500
504
  domain = "localhost:8080"
501
505
  protocol = "http"
502
506
  request_timeout = 30
503
- 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
504
509
 
505
510
  [output]
506
511
  color = true
@@ -44,7 +44,7 @@ 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.2.0,<2.0.0",
47
+ "opensandbox>=1.2.1,<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",
@@ -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")
@@ -259,9 +259,12 @@ async def _connect_and_stream(
259
259
  stdout_write: WriteFn,
260
260
  get_size: SizeFn,
261
261
  error_write: WriteFn | None = None,
262
+ on_connected: Callable[[], None] | None = None,
262
263
  ) -> tuple[int | None, list[str]]:
263
264
  """Connect to the PTY WebSocket, drive the relay loop, handle resize."""
264
265
  async with websockets.connect(ws_url, additional_headers=headers or None) as ws:
266
+ if on_connected is not None:
267
+ on_connected()
265
268
  # websockets forbids concurrent sends: stdin frames and SIGWINCH
266
269
  # resize frames share one lock, and fire-and-forget resize tasks get
267
270
  # their results consumed so failures never surface as task warnings.
@@ -364,6 +367,12 @@ def terminal(
364
367
  exit_code: int | None = None
365
368
  errors: list[str] = []
366
369
  teardown = False
370
+ connected = False
371
+
372
+ def mark_connected() -> None:
373
+ nonlocal connected
374
+ connected = True
375
+
367
376
  try:
368
377
  with _raw_mode(stdin_fd):
369
378
  exit_code, errors = asyncio.run(
@@ -374,6 +383,7 @@ def terminal(
374
383
  stdout_write=stdout_write,
375
384
  get_size=get_size,
376
385
  error_write=report_error,
386
+ on_connected=mark_connected,
377
387
  )
378
388
  )
379
389
  teardown = exit_code is not None or bool(errors)
@@ -385,9 +395,10 @@ def terminal(
385
395
  raise click.ClickException(f"Terminal connection failed: {exc}") from exc
386
396
  finally:
387
397
  # 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:
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:
391
402
  _delete_pty_session(http_base, headers, session_id, timeout=timeout)
392
403
 
393
404
  if exit_code is None:
@@ -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):
@@ -36,7 +36,7 @@ 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
38
  from opensandbox_cli.commands.terminal import terminal
39
- from opensandbox_cli.config import resolve_config
39
+ from opensandbox_cli.config import _request_timeout_seconds, resolve_config
40
40
 
41
41
  BANNER = r"""[bold cyan]
42
42
  ____ _____ _ _
@@ -104,7 +104,10 @@ class BannerGroup(click.Group):
104
104
  @click.option(
105
105
  "--use-server-proxy/--no-use-server-proxy",
106
106
  default=None,
107
- 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
+ ),
108
111
  )
109
112
  @click.option("--config", "config_path", type=click.Path(exists=False, path_type=Path), default=None, help="Config file path.")
110
113
  @click.option("-v", "--verbose", is_flag=True, default=False, help="Enable verbose/debug output.")
@@ -141,7 +144,7 @@ def cli(
141
144
  "api_key": api_key,
142
145
  "domain": domain,
143
146
  "protocol": protocol or "http",
144
- "request_timeout": request_timeout or 30,
147
+ "request_timeout": _request_timeout_seconds(request_timeout),
145
148
  "use_server_proxy": use_server_proxy if use_server_proxy is not None else False,
146
149
  "color": True,
147
150
  "default_image": None,
@@ -158,6 +161,14 @@ def cli(
158
161
  )
159
162
  resolved["color"] = not no_color and resolved.get("color", True)
160
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
+
161
172
  effective_config_path = config_path or Path.home() / ".opensandbox" / "config.toml"
162
173
  ctx.obj = ClientContext(
163
174
  resolved_config=resolved,
@@ -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
@@ -86,12 +86,9 @@ class DurationType(click.ParamType):
86
86
  return value
87
87
  try:
88
88
  return parse_duration(value)
89
- except click.BadParameter:
90
- self.fail(
91
- f"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s.",
92
- param,
93
- ctx,
94
- )
89
+ except click.BadParameter as exc:
90
+ # Keep the specific reason (empty, malformed, out of range).
91
+ self.fail(exc.format_message(), param, ctx)
95
92
 
96
93
 
97
94
  DURATION = DurationType()
@@ -205,7 +202,7 @@ def handle_errors(fn): # type: ignore[no-untyped-def]
205
202
  output.error_panel(str(exc), title="Sandbox Error")
206
203
  else:
207
204
  output.error_panel(
208
- f"{str(exc)}\n\n[dim]Type: {type(exc).__qualname__}[/]",
205
+ f"{exc}\n\nType: {type(exc).__qualname__}",
209
206
  title=type(exc).__name__,
210
207
  )
211
208
  else:
File without changes