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.
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/.gitignore +4 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/PKG-INFO +23 -5
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/README.md +20 -3
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/pyproject.toml +6 -2
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/command.py +6 -3
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/config_cmd.py +4 -3
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/egress.py +1 -1
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/sandbox.py +12 -4
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/template.py +4 -1
- opensandbox_cli-1.2.1/src/opensandbox_cli/commands/terminal.py +415 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/config.py +62 -9
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/main.py +16 -3
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/output.py +9 -7
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md +5 -2
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/utils.py +15 -12
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/LICENSE +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/__init__.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/__main__.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/client.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/__init__.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/credential_vault.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/devops.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/diagnostics.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/file.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/skills.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/snapshot.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/py.typed +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skill_registry.py +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-command-execution.md +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-credential-vault.md +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-file-operations.md +0 -0
- {opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skills/opensandbox-network-egress.md +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
46
|
-
|
|
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
|
|
151
|
-
"
|
|
152
|
-
"
|
|
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.
|
|
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}")
|
{opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/egress.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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="
|
|
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
|
-
|
|
74
|
-
|
|
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":
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
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=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
try:
|
|
47
|
+
return timedelta(seconds=int(value))
|
|
48
|
+
except (OverflowError, ValueError) as exc:
|
|
49
|
+
raise click.BadParameter(f"Duration '{value}' is out of range") from exc
|
|
47
50
|
|
|
48
51
|
m = _DURATION_RE.match(value)
|
|
49
52
|
if not m or not m.group(0):
|
|
@@ -51,10 +54,13 @@ def parse_duration(value: str) -> timedelta:
|
|
|
51
54
|
f"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s."
|
|
52
55
|
)
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
try:
|
|
58
|
+
hours = int(m.group("hours") or 0)
|
|
59
|
+
minutes = int(m.group("minutes") or 0)
|
|
60
|
+
seconds = int(m.group("seconds") or 0)
|
|
61
|
+
return timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
|
62
|
+
except (OverflowError, ValueError) as exc:
|
|
63
|
+
raise click.BadParameter(f"Duration '{value}' is out of range") from exc
|
|
58
64
|
|
|
59
65
|
|
|
60
66
|
def parse_nullable_duration(value: str) -> timedelta | None:
|
|
@@ -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
|
-
|
|
85
|
-
|
|
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"{
|
|
205
|
+
f"{exc}\n\nType: {type(exc).__qualname__}",
|
|
203
206
|
title=type(exc).__name__,
|
|
204
207
|
)
|
|
205
208
|
else:
|
|
File without changes
|
{opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/__init__.py
RENAMED
|
File without changes
|
{opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/devops.py
RENAMED
|
File without changes
|
|
File without changes
|
{opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/file.py
RENAMED
|
File without changes
|
{opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/commands/skills.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opensandbox_cli-1.1.2.dev2026102001 → opensandbox_cli-1.2.1}/src/opensandbox_cli/skill_registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|