Files
OpenShell/python/openshell/sandbox_test.py
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

549 lines
16 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 io
import json
import tarfile
from typing import TYPE_CHECKING, Any, cast
import pytest
from openshell._proto import datamodel_pb2, openshell_pb2, sandbox_pb2
from openshell.sandbox import (
_PYTHON_CLOUDPICKLE_BOOTSTRAP,
_SANDBOX_PYTHON_BIN,
InferenceRouteClient,
ProviderClient,
SandboxClient,
SandboxError,
policy_from_yaml,
)
if TYPE_CHECKING:
from pathlib import Path
class _FakeStub:
def __init__(self) -> None:
self.request: openshell_pb2.ExecSandboxRequest | None = None
self.create_sandbox_request: openshell_pb2.CreateSandboxRequest | None = None
self.exec_exit_code: int = 0
self.exec_stdout: bytes = b""
self.exec_stderr: bytes = b""
def ExecSandbox(
self,
request: openshell_pb2.ExecSandboxRequest,
timeout: float | None = None,
):
self.request = request
_ = timeout
if self.exec_stdout:
yield openshell_pb2.ExecSandboxEvent(
stdout=openshell_pb2.ExecSandboxStdout(data=self.exec_stdout)
)
if self.exec_stderr:
yield openshell_pb2.ExecSandboxEvent(
stderr=openshell_pb2.ExecSandboxStderr(data=self.exec_stderr)
)
yield openshell_pb2.ExecSandboxEvent(
exit=openshell_pb2.ExecSandboxExit(exit_code=self.exec_exit_code)
)
def CreateSandbox(
self,
request: openshell_pb2.CreateSandboxRequest,
timeout: float | None = None,
):
self.create_sandbox_request = request
_ = timeout
sandbox = openshell_pb2.Sandbox(
metadata=datamodel_pb2.ObjectMeta(
id="sandbox-id-1",
name=request.name or "generated-name",
),
phase=openshell_pb2.SANDBOX_PHASE_PROVISIONING,
)
return openshell_pb2.SandboxResponse(sandbox=sandbox)
class _FakeProviderStub:
def __init__(self) -> None:
self.create_request: openshell_pb2.CreateProviderRequest | None = None
self.get_request: openshell_pb2.GetProviderRequest | None = None
self.list_request: openshell_pb2.ListProvidersRequest | None = None
self.delete_request: openshell_pb2.DeleteProviderRequest | None = None
self.delete_response_flag: bool = True
self.providers_to_return: list[datamodel_pb2.Provider] = []
def CreateProvider(
self,
request: openshell_pb2.CreateProviderRequest,
timeout: float | None = None,
):
self.create_request = request
_ = timeout
echo = datamodel_pb2.Provider()
echo.CopyFrom(request.provider)
echo.metadata.id = "provider-id-1"
return openshell_pb2.ProviderResponse(provider=echo)
def GetProvider(
self,
request: openshell_pb2.GetProviderRequest,
timeout: float | None = None,
):
self.get_request = request
_ = timeout
provider = datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(id="get-id", name=request.name),
type="generic",
)
return openshell_pb2.ProviderResponse(provider=provider)
def ListProviders(
self,
request: openshell_pb2.ListProvidersRequest,
timeout: float | None = None,
):
self.list_request = request
_ = timeout
return openshell_pb2.ListProvidersResponse(
providers=self.providers_to_return,
)
def DeleteProvider(
self,
request: openshell_pb2.DeleteProviderRequest,
timeout: float | None = None,
):
self.delete_request = request
_ = timeout
return openshell_pb2.DeleteProviderResponse(deleted=self.delete_response_flag)
class _FakeInferenceStub:
def __init__(self) -> None:
self.request = None
def SetClusterInference(self, request: Any, timeout: float | None = None) -> Any:
self.request = request
_ = timeout
class _Response:
provider_name = request.provider_name
model_id = request.model_id
version = 1
return _Response()
def _client_with_fake_stub(stub: _FakeStub) -> SandboxClient:
client = cast("SandboxClient", object.__new__(SandboxClient))
client._timeout = 30.0
client._stub = cast("Any", stub)
client._providers = None
return client
def _provider_client_with_fake_stub(stub: _FakeProviderStub) -> ProviderClient:
client = cast("ProviderClient", object.__new__(ProviderClient))
client._timeout = 30.0
client._stub = cast("Any", stub)
return client
def test_exec_sends_stdin_payload() -> None:
stub = _FakeStub()
client = _client_with_fake_stub(stub)
result = client.exec("sandbox-1", ["python", "-c", "print('ok')"], stdin=b"payload")
assert result.exit_code == 0
assert stub.request is not None
assert stub.request.stdin == b"payload"
def test_exec_python_serializes_callable_payload() -> None:
stub = _FakeStub()
client = _client_with_fake_stub(stub)
def add(a: int, b: int) -> int:
return a + b
result = client.exec_python("sandbox-1", add, args=(2, 3))
assert result.exit_code == 0
assert stub.request is not None
assert stub.request.command == [
_SANDBOX_PYTHON_BIN,
"-c",
_PYTHON_CLOUDPICKLE_BOOTSTRAP,
]
assert stub.request.environment["OPENSHELL_PYFUNC_B64"]
assert stub.request.stdin == b""
def test_from_active_cluster_reads_gateway_metadata_layout(
tmp_path: Path,
monkeypatch: Any,
) -> None:
gateway_name = "test-gateway"
gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
mtls_dir = gateway_dir / "mtls"
mtls_dir.mkdir(parents=True)
(tmp_path / "openshell" / "active_gateway").write_text(gateway_name)
(gateway_dir / "metadata.json").write_text(
json.dumps({"gateway_endpoint": "https://127.0.0.1:8443"})
)
(mtls_dir / "ca.crt").write_text("ca")
(mtls_dir / "tls.crt").write_text("cert")
(mtls_dir / "tls.key").write_text("key")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("OPENSHELL_GATEWAY", raising=False)
client = SandboxClient.from_active_cluster()
try:
assert client._cluster_name == gateway_name
finally:
client.close()
def test_from_active_cluster_prefers_openshell_gateway_env(
tmp_path: Path,
monkeypatch: Any,
) -> None:
gateway_name = "env-gateway"
gateway_dir = tmp_path / "openshell" / "gateways" / gateway_name
mtls_dir = gateway_dir / "mtls"
mtls_dir.mkdir(parents=True)
(gateway_dir / "metadata.json").write_text(
json.dumps({"gateway_endpoint": "https://127.0.0.1:8443"})
)
(mtls_dir / "ca.crt").write_text("ca")
(mtls_dir / "tls.crt").write_text("cert")
(mtls_dir / "tls.key").write_text("key")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("OPENSHELL_GATEWAY", gateway_name)
client = SandboxClient.from_active_cluster()
try:
assert client._cluster_name == gateway_name
finally:
client.close()
def test_inference_set_cluster_forwards_no_verify_flag() -> None:
stub = _FakeInferenceStub()
client = cast("InferenceRouteClient", object.__new__(InferenceRouteClient))
client._timeout = 30.0
client._stub = cast("Any", stub)
client.set_cluster(
provider_name="openai-dev",
model_id="gpt-4.1",
no_verify=True,
)
assert stub.request is not None
assert stub.request.no_verify is True
def test_provider_create_resolves_credentials_from_env(monkeypatch: Any) -> None:
monkeypatch.setenv("CODEX_AUTH_ACCESS_TOKEN", "access-secret")
monkeypatch.setenv("CODEX_AUTH_REFRESH_TOKEN", "refresh-secret")
stub = _FakeProviderStub()
client = _provider_client_with_fake_stub(stub)
ref = client.create(
name="codex-oauth",
provider_type="generic",
credentials_from_env=[
"CODEX_AUTH_ACCESS_TOKEN",
"CODEX_AUTH_REFRESH_TOKEN",
],
)
assert stub.create_request is not None
provider = stub.create_request.provider
assert provider.metadata.name == "codex-oauth"
assert provider.type == "generic"
assert dict(provider.credentials) == {
"CODEX_AUTH_ACCESS_TOKEN": "access-secret",
"CODEX_AUTH_REFRESH_TOKEN": "refresh-secret",
}
assert ref.id == "provider-id-1"
assert ref.name == "codex-oauth"
assert ref.type == "generic"
def test_provider_create_passes_explicit_credentials(monkeypatch: Any) -> None:
monkeypatch.setenv("DEMO_GITHUB_TOKEN", "from-env")
stub = _FakeProviderStub()
client = _provider_client_with_fake_stub(stub)
client.create(
name="github-memory",
provider_type="generic",
credentials={"DEMO_GITHUB_TOKEN": "explicit-override"},
config={"endpoint": "api.github.com"},
)
assert stub.create_request is not None
provider = stub.create_request.provider
assert dict(provider.credentials) == {"DEMO_GITHUB_TOKEN": "explicit-override"}
assert dict(provider.config) == {"endpoint": "api.github.com"}
def test_provider_create_missing_env_raises(monkeypatch: Any) -> None:
monkeypatch.delenv("MISSING_TOKEN", raising=False)
stub = _FakeProviderStub()
client = _provider_client_with_fake_stub(stub)
with pytest.raises(SandboxError, match="MISSING_TOKEN"):
client.create(
name="x",
provider_type="generic",
credentials_from_env=["MISSING_TOKEN"],
)
assert stub.create_request is None
def test_provider_delete_returns_response_flag() -> None:
stub = _FakeProviderStub()
stub.delete_response_flag = False
client = _provider_client_with_fake_stub(stub)
deleted = client.delete("github-memory")
assert deleted is False
assert stub.delete_request is not None
assert stub.delete_request.name == "github-memory"
def test_provider_list_returns_refs() -> None:
stub = _FakeProviderStub()
stub.providers_to_return = [
datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(id="p1", name="alpha"),
type="generic",
),
datamodel_pb2.Provider(
metadata=datamodel_pb2.ObjectMeta(id="p2", name="beta"),
type="claude",
),
]
client = _provider_client_with_fake_stub(stub)
refs = client.list(limit=50)
assert stub.list_request is not None
assert stub.list_request.limit == 50
assert [(r.id, r.name, r.type) for r in refs] == [
("p1", "alpha", "generic"),
("p2", "beta", "claude"),
]
def test_policy_from_yaml_loads_demo_template() -> None:
yaml_text = """
version: 1
filesystem_policy:
include_workdir: true
read_only: [/usr, /lib]
read_write: [/sandbox, /tmp]
landlock:
compatibility: best_effort
process:
run_as_user: sandbox
run_as_group: sandbox
network_policies:
github_memory:
name: github-memory
endpoints:
- host: api.github.com
port: 443
protocol: rest
enforcement: enforce
rules:
- allow:
method: GET
path: "/repos/owner/repo/contents/runs/run-id"
"""
policy = policy_from_yaml(yaml_text)
assert policy.version == 1
assert policy.filesystem.include_workdir is True
assert list(policy.filesystem.read_only) == ["/usr", "/lib"]
assert list(policy.filesystem.read_write) == ["/sandbox", "/tmp"]
assert policy.landlock.compatibility == "best_effort"
assert policy.process.run_as_user == "sandbox"
rule = policy.network_policies["github_memory"]
assert rule.name == "github-memory"
assert len(rule.endpoints) == 1
endpoint = rule.endpoints[0]
assert endpoint.host == "api.github.com"
assert endpoint.port == 443
assert endpoint.protocol == "rest"
assert len(endpoint.rules) == 1
assert endpoint.rules[0].allow.method == "GET"
assert endpoint.rules[0].allow.path == "/repos/owner/repo/contents/runs/run-id"
def test_policy_from_yaml_reads_path(tmp_path: Path) -> None:
policy_file = tmp_path / "policy.yaml"
policy_file.write_text("version: 2\nfilesystem_policy:\n include_workdir: false\n")
policy = policy_from_yaml(policy_file)
assert policy.version == 2
assert policy.filesystem.include_workdir is False
def test_policy_from_yaml_rejects_non_mapping() -> None:
with pytest.raises(SandboxError, match="mapping"):
policy_from_yaml("- just-a-list\n- item-2\n")
def test_upload_packages_tarball_and_pipes_to_exec(tmp_path: Path) -> None:
src = tmp_path / "payload"
src.mkdir()
(src / "runner.sh").write_text("#!/bin/sh\necho hello\n")
(src / "prompts").mkdir()
(src / "prompts" / "worker.md").write_text("# worker\n")
stub = _FakeStub()
client = _client_with_fake_stub(stub)
client.upload("sandbox-1", src, "/sandbox")
assert stub.request is not None
assert stub.request.sandbox_id == "sandbox-1"
assert stub.request.command == [
"sh",
"-c",
"mkdir -p '/sandbox' && tar xzf - -C '/sandbox'",
]
assert stub.request.stdin
with tarfile.open(fileobj=io.BytesIO(stub.request.stdin), mode="r:gz") as tar:
names = sorted(tar.getnames())
assert "payload" in names
assert "payload/runner.sh" in names
assert "payload/prompts/worker.md" in names
def test_upload_missing_source_raises(tmp_path: Path) -> None:
stub = _FakeStub()
client = _client_with_fake_stub(stub)
with pytest.raises(SandboxError, match="upload source does not exist"):
client.upload("sandbox-1", tmp_path / "nope", "/sandbox")
assert stub.request is None
def test_upload_failure_surfaces_stderr(tmp_path: Path) -> None:
src = tmp_path / "payload"
src.mkdir()
(src / "f.txt").write_text("ok")
stub = _FakeStub()
stub.exec_exit_code = 2
stub.exec_stderr = b"tar: cannot write: read-only filesystem"
client = _client_with_fake_stub(stub)
with pytest.raises(SandboxError, match="read-only filesystem"):
client.upload("sandbox-1", src, "/sandbox")
def test_upload_quotes_destination_with_single_quote(tmp_path: Path) -> None:
src = tmp_path / "f.txt"
src.write_text("ok")
stub = _FakeStub()
client = _client_with_fake_stub(stub)
client.upload("sandbox-1", src, "/sand'box")
assert stub.request is not None
assert stub.request.command[2] == (
"mkdir -p '/sand'\\''box' && tar xzf - -C '/sand'\\''box'"
)
def test_create_with_kwargs_constructs_spec() -> None:
stub = _FakeStub()
client = _client_with_fake_stub(stub)
policy = sandbox_pb2.SandboxPolicy(version=3)
policy.filesystem.include_workdir = True
ref = client.create(
name="agent-1",
image="base",
providers=["codex-oauth", "github-memory"],
policy=policy,
environment={"AGENT_INDEX": "1"},
labels={"run-id": "20260506-001"},
)
assert stub.create_sandbox_request is not None
request = stub.create_sandbox_request
assert request.name == "agent-1"
assert dict(request.labels) == {"run-id": "20260506-001"}
assert request.spec.template.image == "base"
assert list(request.spec.providers) == ["codex-oauth", "github-memory"]
assert dict(request.spec.environment) == {"AGENT_INDEX": "1"}
assert request.spec.policy.version == 3
assert request.spec.policy.filesystem.include_workdir is True
assert ref.id == "sandbox-id-1"
assert ref.name == "agent-1"
def test_create_default_uses_empty_spec_with_name() -> None:
stub = _FakeStub()
client = _client_with_fake_stub(stub)
client.create(name="just-a-name")
assert stub.create_sandbox_request is not None
request = stub.create_sandbox_request
assert request.name == "just-a-name"
assert request.spec.template.image == ""
assert list(request.spec.providers) == []
def test_create_rejects_spec_and_kwargs_together() -> None:
stub = _FakeStub()
client = _client_with_fake_stub(stub)
spec = openshell_pb2.SandboxSpec(
template=openshell_pb2.SandboxTemplate(image="explicit"),
)
with pytest.raises(SandboxError, match="spec="):
client.create(spec=spec, image="conflicting")
assert stub.create_sandbox_request is None
def test_sandbox_client_providers_property_caches() -> None:
stub = _FakeStub()
client = _client_with_fake_stub(stub)
first = client.providers
second = client.providers
assert first is second
assert isinstance(first, ProviderClient)