mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-16 05:20:22 +08:00
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.
549 lines
16 KiB
Python
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)
|