Files
OpenShell/python/openshell/sandbox.py
T
Max Dubrinsky e07b4a2a7c feat(python-sdk): add provider/policy/upload helpers and demo notebook
Extend python/openshell/ with the verbs needed to express the
multi-agent-notepad flow entirely in Python:

- ProviderClient (create/get/list/delete); credentials_from_env
  resolves env var names to mirror the CLI's --credential flag.
- policy_from_yaml() loads the CLI's YAML format into a SandboxPolicy
  proto. Lazy-imports PyYAML, added to runtime deps.
- SandboxClient.upload() gzip-tars the source and pipes the tarball
  over ExecSandbox(stdin=...) to 'tar xzf -'. No paramiko dependency.
- SandboxClient.create() gains friendly kwargs (name, image, providers,
  policy, environment, gpu, labels) alongside the existing spec= path.

architecture/python-sdk.md documents the design principle (mirror
CLI verbs, not raw protos), the public surface, and what's out of
scope (image build, gateway bootstrap, draft policy, port-forward,
RelayStream).

examples/multi-agent-notepad/notebook.ipynb is a Python equivalent of
demo.sh: env validation, policy render, provider creation, parallel
worker sandboxes via ThreadPoolExecutor, synthesis sandbox, cleanup.

13 new unit tests; all 23 sandbox tests pass; ruff/ty/markdownlint clean.
2026-05-11 20:50:34 -04:00

811 lines
25 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import base64
import io
import json
import os
import pathlib
import sys
import tarfile
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
from urllib.parse import urlparse
import grpc
from google.protobuf import json_format
from ._proto import (
datamodel_pb2,
inference_pb2,
inference_pb2_grpc,
openshell_pb2,
openshell_pb2_grpc,
sandbox_pb2,
)
if TYPE_CHECKING:
import builtins
from collections.abc import Callable, Iterator, Mapping, Sequence
@dataclass(frozen=True)
class TlsConfig:
ca_path: pathlib.Path
cert_path: pathlib.Path
key_path: pathlib.Path
@dataclass(frozen=True)
class SandboxRef:
id: str
name: str
phase: int
@dataclass(frozen=True)
class ProviderRef:
id: str
name: str
type: str
@dataclass(frozen=True)
class ExecChunk:
stream: str
data: bytes
@dataclass(frozen=True)
class ExecResult:
exit_code: int
stdout: str
stderr: str
class SandboxError(RuntimeError):
pass
class SandboxSession:
def __init__(self, client: SandboxClient, sandbox: SandboxRef) -> None:
self._client = client
self.sandbox = sandbox
@property
def id(self) -> str:
return self.sandbox.id
def exec(
self,
command: Sequence[str],
*,
stream_output: bool = False,
workdir: str | None = None,
env: Mapping[str, str] | None = None,
stdin: bytes | None = None,
timeout_seconds: int | None = None,
) -> ExecResult:
return self._client.exec(
self.sandbox.id,
command,
stream_output=stream_output,
workdir=workdir,
env=env,
stdin=stdin,
timeout_seconds=timeout_seconds,
)
def exec_python(
self,
function: Callable[..., object],
*,
args: Sequence[object] = (),
kwargs: Mapping[str, object] | None = None,
stream_output: bool = False,
workdir: str | None = None,
env: Mapping[str, str] | None = None,
timeout_seconds: int | None = None,
) -> ExecResult:
return self._client.exec_python(
self.sandbox.id,
function,
args=args,
kwargs=kwargs,
stream_output=stream_output,
workdir=workdir,
env=env,
timeout_seconds=timeout_seconds,
)
def delete(self) -> bool:
return self._client.delete(self.sandbox.name)
class SandboxClient:
"""gRPC client for sandbox CRUD and command execution."""
def __init__(
self,
endpoint: str,
*,
tls: TlsConfig | None = None,
timeout: float = 30.0,
cluster_name: str | None = None,
) -> None:
self._endpoint = endpoint
self._timeout = timeout
self._cluster_name = cluster_name
if tls is None:
self._channel = grpc.insecure_channel(endpoint)
else:
credentials = grpc.ssl_channel_credentials(
root_certificates=tls.ca_path.read_bytes(),
private_key=tls.key_path.read_bytes(),
certificate_chain=tls.cert_path.read_bytes(),
)
self._channel = grpc.secure_channel(endpoint, credentials)
self._stub = openshell_pb2_grpc.OpenShellStub(self._channel)
self._providers: ProviderClient | None = None
@property
def providers(self) -> ProviderClient:
if self._providers is None:
self._providers = ProviderClient(self._stub, timeout=self._timeout)
return self._providers
@classmethod
def from_active_cluster(
cls,
*,
cluster: str | None = None,
timeout: float = 30.0,
) -> SandboxClient:
cluster_name = cluster or _resolve_active_cluster()
metadata_path = (
_xdg_config_home()
/ "openshell"
/ "gateways"
/ cluster_name
/ "metadata.json"
)
metadata = json.loads(metadata_path.read_text())
parsed = urlparse(metadata["gateway_endpoint"])
host = parsed.hostname or "127.0.0.1"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
endpoint = f"{host}:{port}"
if parsed.scheme == "https":
mtls_dir = (
_xdg_config_home() / "openshell" / "gateways" / cluster_name / "mtls"
)
tls = TlsConfig(
ca_path=mtls_dir / "ca.crt",
cert_path=mtls_dir / "tls.crt",
key_path=mtls_dir / "tls.key",
)
return cls(endpoint, tls=tls, timeout=timeout, cluster_name=cluster_name)
return cls(endpoint, timeout=timeout, cluster_name=cluster_name)
def close(self) -> None:
self._channel.close()
def __enter__(self) -> SandboxClient:
return self
def __exit__(self, *args: object) -> None:
self.close()
def health(self) -> openshell_pb2.HealthResponse:
return self._stub.Health(openshell_pb2.HealthRequest(), timeout=self._timeout)
def create(
self,
*,
spec: openshell_pb2.SandboxSpec | None = None,
name: str | None = None,
image: str | None = None,
providers: Sequence[str] | None = None,
policy: sandbox_pb2.SandboxPolicy | None = None,
environment: Mapping[str, str] | None = None,
gpu: bool = False,
labels: Mapping[str, str] | None = None,
) -> SandboxRef:
kwargs_used = (
image is not None
or providers is not None
or policy is not None
or environment is not None
or gpu
)
if spec is not None and kwargs_used:
raise SandboxError(
"create() accepts either spec= or the convenience kwargs "
"(image, providers, policy, environment, gpu), not both"
)
if spec is not None:
request_spec = spec
elif kwargs_used:
request_spec = openshell_pb2.SandboxSpec(
environment=dict(environment or {}),
providers=list(providers or []),
gpu=gpu,
)
if image is not None:
request_spec.template.image = image
if policy is not None:
request_spec.policy.CopyFrom(policy)
else:
request_spec = _default_spec()
request = openshell_pb2.CreateSandboxRequest(
spec=request_spec,
name=name or "",
labels=dict(labels or {}),
)
response = self._stub.CreateSandbox(request, timeout=self._timeout)
sandbox_ref = _sandbox_ref(response.sandbox)
if sandbox_ref.id == "":
raise SandboxError("CreateSandbox returned empty sandbox id")
return sandbox_ref
def create_session(
self,
*,
spec: openshell_pb2.SandboxSpec | None = None,
) -> SandboxSession:
return SandboxSession(self, self.create(spec=spec))
def get(self, sandbox_name: str) -> SandboxRef:
response = self._stub.GetSandbox(
openshell_pb2.GetSandboxRequest(name=sandbox_name),
timeout=self._timeout,
)
return _sandbox_ref(response.sandbox)
def get_session(self, sandbox_name: str) -> SandboxSession:
return SandboxSession(self, self.get(sandbox_name))
def list(self, *, limit: int = 100, offset: int = 0) -> builtins.list[SandboxRef]:
response = self._stub.ListSandboxes(
openshell_pb2.ListSandboxesRequest(limit=limit, offset=offset),
timeout=self._timeout,
)
return [_sandbox_ref(item) for item in response.sandboxes]
def list_ids(self, *, limit: int = 100, offset: int = 0) -> builtins.list[str]:
return [item.id for item in self.list(limit=limit, offset=offset)]
def delete(self, sandbox_name: str) -> bool:
response = self._stub.DeleteSandbox(
openshell_pb2.DeleteSandboxRequest(name=sandbox_name),
timeout=self._timeout,
)
return bool(response.deleted)
def wait_deleted(self, sandbox_name: str, *, timeout_seconds: float = 60.0) -> None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
try:
self.get(sandbox_name)
except grpc.RpcError as exc:
if (
isinstance(exc, grpc.Call)
and exc.code() == grpc.StatusCode.NOT_FOUND
):
return
raise
time.sleep(1)
raise SandboxError(f"sandbox {sandbox_name} was not deleted within timeout")
def wait_ready(
self, sandbox_name: str, *, timeout_seconds: float = 300.0
) -> SandboxRef:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
sandbox = self.get(sandbox_name)
if sandbox.phase == openshell_pb2.SANDBOX_PHASE_READY:
return sandbox
if sandbox.phase == openshell_pb2.SANDBOX_PHASE_ERROR:
raise SandboxError(f"sandbox {sandbox_name} entered error phase")
time.sleep(1)
raise SandboxError(f"sandbox {sandbox_name} was not ready within timeout")
def upload(
self,
sandbox_id: str,
src: str | pathlib.Path,
dst: str,
*,
timeout_seconds: int | None = None,
) -> None:
src_path = pathlib.Path(src)
if not src_path.exists():
raise SandboxError(f"upload source does not exist: {src_path}")
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
tar.add(str(src_path), arcname=src_path.name)
tarball = buffer.getvalue()
# Stream the tarball over ExecSandbox stdin instead of using the
# gateway SSH tunnel; sufficient for notebook-sized payloads and
# avoids an SSH client dependency.
quoted_dst = "'" + dst.replace("'", "'\\''") + "'"
result = self.exec(
sandbox_id,
["sh", "-c", f"mkdir -p {quoted_dst} && tar xzf - -C {quoted_dst}"],
stdin=tarball,
timeout_seconds=timeout_seconds,
)
if result.exit_code != 0:
raise SandboxError(
f"upload to {dst} failed (exit {result.exit_code}): "
f"{result.stderr.strip() or result.stdout.strip()}"
)
def exec_stream(
self,
sandbox_id: str,
command: Sequence[str],
*,
workdir: str | None = None,
env: Mapping[str, str] | None = None,
stdin: bytes | None = None,
timeout_seconds: int | None = None,
) -> Iterator[ExecChunk | ExecResult]:
if not command:
raise SandboxError("command must not be empty")
request = openshell_pb2.ExecSandboxRequest(
sandbox_id=sandbox_id,
command=list(command),
workdir=workdir or "",
environment=dict(env or {}),
timeout_seconds=timeout_seconds or 0,
stdin=stdin or b"",
)
# Use whichever is larger: the default client timeout or the command
# timeout plus headroom for SSH setup / teardown overhead.
grpc_deadline = self._timeout
if timeout_seconds and timeout_seconds + 10 > grpc_deadline:
grpc_deadline = timeout_seconds + 10
stream = self._stub.ExecSandbox(request, timeout=grpc_deadline)
stdout_parts: list[bytes] = []
stderr_parts: list[bytes] = []
exit_code: int | None = None
for event in stream:
payload = event.WhichOneof("payload")
if payload == "stdout":
data = bytes(event.stdout.data)
stdout_parts.append(data)
yield ExecChunk(stream="stdout", data=data)
elif payload == "stderr":
data = bytes(event.stderr.data)
stderr_parts.append(data)
yield ExecChunk(stream="stderr", data=data)
elif payload == "exit":
exit_code = int(event.exit.exit_code)
if exit_code is None:
raise SandboxError("ExecSandbox stream ended without an exit event")
yield ExecResult(
exit_code=exit_code,
stdout=b"".join(stdout_parts).decode("utf-8", errors="replace"),
stderr=b"".join(stderr_parts).decode("utf-8", errors="replace"),
)
def exec(
self,
sandbox_id: str,
command: Sequence[str],
*,
stream_output: bool = False,
workdir: str | None = None,
env: Mapping[str, str] | None = None,
stdin: bytes | None = None,
timeout_seconds: int | None = None,
) -> ExecResult:
result: ExecResult | None = None
for item in self.exec_stream(
sandbox_id,
command,
workdir=workdir,
env=env,
stdin=stdin,
timeout_seconds=timeout_seconds,
):
if stream_output and isinstance(item, ExecChunk):
if item.stream == "stdout":
sys.stdout.buffer.write(item.data)
sys.stdout.flush()
else:
sys.stderr.buffer.write(item.data)
sys.stderr.flush()
if isinstance(item, ExecResult):
result = item
if result is None:
raise SandboxError("ExecSandbox did not return a result")
return result
def exec_python(
self,
sandbox_id: str,
function: Callable[..., object],
*,
args: Sequence[object] = (),
kwargs: Mapping[str, object] | None = None,
stream_output: bool = False,
workdir: str | None = None,
env: Mapping[str, str] | None = None,
timeout_seconds: int | None = None,
) -> ExecResult:
exec_env = dict(env or {})
exec_env["OPENSHELL_PYFUNC_B64"] = _serialize_python_callable(
function,
args=args,
kwargs=kwargs,
)
return self.exec(
sandbox_id,
[_SANDBOX_PYTHON_BIN, "-c", _PYTHON_CLOUDPICKLE_BOOTSTRAP],
stream_output=stream_output,
workdir=workdir,
env=exec_env,
timeout_seconds=timeout_seconds,
)
class ProviderClient:
"""gRPC client for provider CRUD."""
def __init__(
self,
stub: openshell_pb2_grpc.OpenShellStub,
*,
timeout: float = 30.0,
) -> None:
self._stub = stub
self._timeout = timeout
def create(
self,
*,
name: str,
provider_type: str,
credentials: Mapping[str, str] | None = None,
credentials_from_env: Sequence[str] | None = None,
config: Mapping[str, str] | None = None,
labels: Mapping[str, str] | None = None,
) -> ProviderRef:
creds: dict[str, str] = dict(credentials or {})
for env_name in credentials_from_env or ():
value = os.environ.get(env_name)
if value is None:
raise SandboxError(
f"environment variable {env_name!r} is not set; "
f"cannot resolve credential for provider {name!r}"
)
creds[env_name] = value
provider = datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(
name=name,
labels=dict(labels or {}),
),
type=provider_type,
credentials=creds,
config=dict(config or {}),
)
response = self._stub.CreateProvider(
openshell_pb2.CreateProviderRequest(provider=provider),
timeout=self._timeout,
)
return _provider_ref(response.provider)
def get(self, name: str) -> ProviderRef:
response = self._stub.GetProvider(
openshell_pb2.GetProviderRequest(name=name),
timeout=self._timeout,
)
return _provider_ref(response.provider)
def list(
self,
*,
limit: int = 100,
offset: int = 0,
) -> builtins.list[ProviderRef]:
response = self._stub.ListProviders(
openshell_pb2.ListProvidersRequest(limit=limit, offset=offset),
timeout=self._timeout,
)
return [_provider_ref(item) for item in response.providers]
def delete(self, name: str) -> bool:
response = self._stub.DeleteProvider(
openshell_pb2.DeleteProviderRequest(name=name),
timeout=self._timeout,
)
return bool(response.deleted)
@dataclass(frozen=True)
class ClusterInferenceConfig:
provider_name: str
model_id: str
version: int
class InferenceRouteClient:
"""gRPC client for cluster-level inference configuration."""
def __init__(self, channel: grpc.Channel, *, timeout: float = 30.0) -> None:
self._stub = inference_pb2_grpc.InferenceStub(channel)
self._timeout = timeout
@classmethod
def from_sandbox_client(cls, client: SandboxClient) -> InferenceRouteClient:
return cls(client._channel, timeout=client._timeout)
def set_cluster(
self,
*,
provider_name: str,
model_id: str,
no_verify: bool = False,
) -> ClusterInferenceConfig:
response = self._stub.SetClusterInference(
inference_pb2.SetClusterInferenceRequest(
provider_name=provider_name,
model_id=model_id,
no_verify=no_verify,
),
timeout=self._timeout,
)
return ClusterInferenceConfig(
provider_name=response.provider_name,
model_id=response.model_id,
version=response.version,
)
def get_cluster(self) -> ClusterInferenceConfig:
response = self._stub.GetClusterInference(
inference_pb2.GetClusterInferenceRequest(),
timeout=self._timeout,
)
return ClusterInferenceConfig(
provider_name=response.provider_name,
model_id=response.model_id,
version=response.version,
)
class Sandbox:
"""Context-managed sandbox session bound to one sandbox id."""
def __init__(
self,
*,
cluster: str | None = None,
sandbox: str | SandboxRef | None = None,
delete_on_exit: bool = True,
spec: openshell_pb2.SandboxSpec | None = None,
timeout: float = 30.0,
ready_timeout_seconds: float = 120.0,
) -> None:
self._cluster = cluster
self._sandbox_input = sandbox
self._delete_on_exit = delete_on_exit
self._spec = spec
self._timeout = timeout
self._ready_timeout_seconds = ready_timeout_seconds
self._client: SandboxClient | None = None
self._session: SandboxSession | None = None
@property
def id(self) -> str:
if self._session is None:
raise SandboxError("sandbox context has not been entered")
return self._session.id
@property
def sandbox(self) -> SandboxRef:
if self._session is None:
raise SandboxError("sandbox context has not been entered")
return self._session.sandbox
def __enter__(self) -> Sandbox:
client = SandboxClient.from_active_cluster(
cluster=self._cluster,
timeout=self._timeout,
)
self._client = client
if self._sandbox_input is None:
self._session = client.create_session(spec=self._spec)
elif isinstance(self._sandbox_input, SandboxRef):
self._session = SandboxSession(client, self._sandbox_input)
else:
self._session = client.get_session(self._sandbox_input)
ready = client.wait_ready(
self._session.sandbox.name,
timeout_seconds=self._ready_timeout_seconds,
)
self._session = SandboxSession(client, ready)
return self
def __exit__(self, *args: object) -> None:
try:
if (
self._delete_on_exit
and self._session is not None
and self._client is not None
):
try:
deleted = self._session.delete()
if deleted:
self._client.wait_deleted(self._session.sandbox.name)
except grpc.RpcError as exc:
if (
not isinstance(exc, grpc.Call)
or exc.code() != grpc.StatusCode.NOT_FOUND
):
raise
finally:
if self._client is not None:
self._client.close()
self._session = None
self._client = None
def exec(
self,
command: Sequence[str],
*,
stream_output: bool = False,
workdir: str | None = None,
env: Mapping[str, str] | None = None,
stdin: bytes | None = None,
timeout_seconds: int | None = None,
) -> ExecResult:
if self._session is None:
raise SandboxError("sandbox context has not been entered")
return self._session.exec(
command,
stream_output=stream_output,
workdir=workdir,
env=env,
stdin=stdin,
timeout_seconds=timeout_seconds,
)
def exec_python(
self,
function: Callable[..., object],
*,
args: Sequence[object] = (),
kwargs: Mapping[str, object] | None = None,
stream_output: bool = False,
workdir: str | None = None,
env: Mapping[str, str] | None = None,
timeout_seconds: int | None = None,
) -> ExecResult:
if self._session is None:
raise SandboxError("sandbox context has not been entered")
return self._session.exec_python(
function,
args=args,
kwargs=kwargs,
stream_output=stream_output,
workdir=workdir,
env=env,
timeout_seconds=timeout_seconds,
)
def policy_from_yaml(
source: str | pathlib.Path,
) -> sandbox_pb2.SandboxPolicy:
try:
import yaml
except ImportError as exc:
raise SandboxError(
"policy_from_yaml requires PyYAML; install with `pip install pyyaml`"
) from exc
if isinstance(source, pathlib.Path):
text = source.read_text()
elif "\n" not in source and pathlib.Path(source).exists():
text = pathlib.Path(source).read_text()
else:
text = source
parsed = yaml.safe_load(text)
if not isinstance(parsed, dict):
raise SandboxError("policy YAML must parse to a mapping at the top level")
# CLI YAML uses `filesystem_policy`; the proto field is `filesystem`.
if "filesystem_policy" in parsed and "filesystem" not in parsed:
parsed["filesystem"] = parsed.pop("filesystem_policy")
policy = sandbox_pb2.SandboxPolicy()
json_format.ParseDict(parsed, policy, ignore_unknown_fields=False)
return policy
_PYTHON_CLOUDPICKLE_BOOTSTRAP = (
"import base64,cloudpickle,os;"
"payload=base64.b64decode(os.environ['OPENSHELL_PYFUNC_B64']);"
"func,args,kwargs=cloudpickle.loads(payload);"
"result=func(*args,**kwargs);"
"print(result) if result is not None else None"
)
_SANDBOX_PYTHON_BIN = "python"
def _serialize_python_callable(
function: Callable[..., object],
*,
args: Sequence[object],
kwargs: Mapping[str, object] | None,
) -> str:
try:
import cloudpickle
except ImportError as exc: # pragma: no cover - import error path
raise SandboxError("cloudpickle is required for exec_python") from exc
payload = cloudpickle.dumps((function, tuple(args), dict(kwargs or {})))
return base64.b64encode(payload).decode("ascii")
def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef:
return SandboxRef(
id=sandbox.metadata.id if sandbox.metadata else "",
name=sandbox.metadata.name if sandbox.metadata else "",
phase=sandbox.phase,
)
def _provider_ref(provider: datamodel_pb2.Provider) -> ProviderRef:
return ProviderRef(
id=provider.metadata.id if provider.metadata else "",
name=provider.metadata.name if provider.metadata else "",
type=provider.type,
)
def _default_spec() -> openshell_pb2.SandboxSpec:
# Omit the policy field so the sandbox container discovers its policy
# from /etc/openshell/policy.yaml (baked into the image at build time).
# This avoids duplicating policy defaults between the SDK and the
# container image and ensures sandboxes get the full dev-sandbox-policy
# (including network_policies) out of the box.
return openshell_pb2.SandboxSpec()
def _xdg_config_home() -> pathlib.Path:
configured = os.environ.get("XDG_CONFIG_HOME")
if configured:
return pathlib.Path(configured)
return pathlib.Path.home() / ".config"
def _resolve_active_cluster() -> str:
env_gateway = os.environ.get("OPENSHELL_GATEWAY")
if env_gateway:
return env_gateway
active_file = _xdg_config_home() / "openshell" / "active_gateway"
value = active_file.read_text().strip()
if value == "":
raise SandboxError("no active gateway configured")
return value