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.
8.4 KiB
Python SDK
Overview
The Python SDK at python/openshell/ provides programmatic access to an OpenShell
gateway from notebooks, scripts, and tests. It is a peer to the openshell CLI:
both speak the same gRPC service, share the same mTLS bootstrap layout under
~/.config/openshell/gateways/<cluster>/, and target the same set of user-facing
RPCs from proto/openshell.proto. The CLI is the right tool for one-off
interactive work; the SDK is the right tool when the work is parallel,
data-driven, or needs to live in code that future readers can step through.
When to reach for the SDK
- Driving multiple sandboxes in parallel from a single Python process — for
example, the multi-agent flow in
examples/multi-agent-notepad/demo.sh. - Embedding sandbox lifecycle into evaluation harnesses, integration tests, or notebook-based investigations.
- Stitching sandbox results into other Python code (data frames, plotting,
report generation) without shelling out to
openshelland parsing text.
The CLI remains the right tool for ad-hoc operator work, image build / push, gateway bootstrap, draft-policy review, and anything that benefits from a TTY.
Design principle: mirror the CLI, not the raw protos
crates/openshell-cli/src/run.rs (sandbox create) is roughly 500 lines of
orchestration on top of a handful of RPCs. The work it does — gateway endpoint
resolution, mTLS cert loading, polling for Provisioning -> Ready, file
upload, friendly error mapping, multi-step sequencing — is exactly what every
SDK consumer would otherwise rewrite by hand. A proto-only SDK pushes that
500 lines into every notebook that wants to do anything more interesting than
Health().
The SDK therefore mirrors CLI verbs (sandbox create, provider create,
policy ..., file upload, exec) at its public surface, and uses the protos as
its wire format. The public methods are designed so that a reader who knows
openshell sandbox create --from base --provider X --policy y.yaml --upload src:dst can predict the SDK call.
Public surface
The package exposes a small set of types that wrap the generated proto stubs.
| Type | Purpose |
|---|---|
SandboxClient |
Top-level gRPC client. Created from an explicit endpoint or via from_active_cluster() for mTLS auto-discovery. |
Sandbox |
Context-managed sandbox lifecycle: create, wait for ready, exec, delete on exit. |
SandboxSession |
Bound pair of (client, sandbox) for repeated exec calls without re-resolving names. |
ProviderClient |
Provider CRUD wrappers (create, get, list, delete). |
InferenceRouteClient |
Cluster-level inference config (Set/GetClusterInference). |
policy_from_yaml(path) |
YAML loader that returns a SandboxPolicy proto. |
ExecResult, ExecChunk, SandboxRef, ProviderRef, TlsConfig |
Plain dataclasses for return values and configuration. |
SandboxError |
Domain-specific runtime error surfaced from gRPC Status codes the SDK can interpret. |
Generated stubs live under python/openshell/_proto/ and are an implementation
detail. Power users who need a field the SDK does not yet expose can still
construct openshell_pb2.SandboxSpec directly and pass it via
SandboxClient.create(spec=...).
Sketch — reproducing the demo flow
from openshell import SandboxClient, policy_from_yaml
client = SandboxClient.from_active_cluster()
# Provider records that hold credentials by env-var name.
client.providers.create(
name="codex-oauth",
provider_type="generic",
credentials_from_env=["CODEX_AUTH_ACCESS_TOKEN", "CODEX_AUTH_REFRESH_TOKEN"],
)
# Load and bind the same YAML the CLI accepts.
policy = policy_from_yaml(Path("policy.yaml"))
# Friendlier sandbox creation; raw spec= still supported for advanced cases.
sandbox = client.create(
name="agent-1",
image="base",
providers=["codex-oauth"],
policy=policy,
)
client.wait_ready(sandbox.name)
# Upload payloads and run an entrypoint command, mirroring CLI's --upload + -- bash ...
client.upload(sandbox.id, src="./payload", dst="/sandbox")
result = client.exec(sandbox.id, ["bash", "/sandbox/payload/run.sh", "worker", "1"])
client.delete(sandbox.name)
client.providers.delete("codex-oauth")
Authentication
SandboxClient.from_active_cluster() reads
~/.config/openshell/gateways/<cluster>/metadata.json for the gateway endpoint
and mtls/{ca.crt,tls.crt,tls.key} for client certificates. Cluster name comes
from OPENSHELL_GATEWAY if set, otherwise from
~/.config/openshell/active_gateway. This matches the CLI exactly.
XDG_CONFIG_HOME is honored when set. Plain SandboxClient(endpoint, tls=...)
works for explicit setups (CI, multiple gateways in one process).
OIDC / Edge bearer-token auth (the CLI's interceptor path in
crates/openshell-cli/src/tls.rs) is not in the SDK today. mTLS only.
Adding bearer auth is straightforward when needed: a gRPC interceptor that
injects an authorization: Bearer <token> header, mirroring the Rust
EdgeAuthInterceptor.
File upload
SandboxClient.upload(sandbox_id, src, dst) packages the source path as a
gzipped tar archive, then runs mkdir -p <dst> && tar xzf - inside the
sandbox via ExecSandbox with the tarball streamed as stdin. This matches the
behavior of the CLI's --upload flag for the demo's payload sizes without
introducing an SSH client dependency. The CLI uses the gateway's HTTP-CONNECT
SSH tunnel from CreateSshSession; we deliberately do not port that — the
tar-pipe is simpler, has no extra dependencies, and is sufficient for files up
to a few megabytes (the typical notebook scenario).
If a future use case requires preserving permissions or pushing larger payloads
efficiently, switch the implementation behind upload() to a streaming SSH
session over the gateway tunnel without changing the public signature.
Codegen pipeline
Stubs are generated by the python:proto task in tasks/python.toml:
mise run python:proto
Which runs grpc_tools.protoc against proto/*.proto, writes outputs to
python/openshell/_proto/, and post-processes the generated files to use
package-relative imports (the regex rewrite in tasks/python.toml). The stubs
are checked in so end users do not need protoc installed to import the
package.
There is no buf today. The current setup works; if the SDK gains additional
target languages (Node/TS) the recommendation is to adopt buf at that point —
it cleanly replaces the regex import rewrite with proper plugin config and adds
buf lint / buf breaking for free.
Out of scope
These are deliberate omissions. They are CLI-thick concerns that don't fit a client library:
- Image build and push.
openshell sandbox create --from Dockerfilebuilds locally and pushes into the gateway's registry. Not an RPC; not in the SDK. Build images viamise run docker:buildordocker buildahead of time and pass an image reference. - Gateway bootstrap.
openshell gateway startprovisions a local gateway along with mTLS material. Out of scope; the SDK assumes a running gateway. - Draft-policy review. The interactive
policy advisorflow lives in the CLI and TUI. - Port-forward helpers. The CLI's
forwardsubcommand binds local ports to sandbox services through SSH tunnels. The SDK could expose this later if notebooks need it. RelayStream/ConnectSupervisor. Internal gateway↔supervisor contracts. They are not part of the client surface and never should be.
Testing
python/openshell/sandbox_test.py shows the test pattern: instantiate a
client with object.__new__(SandboxClient) to bypass the gRPC channel, then
inject a fake stub object that records the request. New SDK surface should
follow the same pattern — a _FakeStub per test, assertions on the request
shape.
Tests run via pytest python/openshell/. Lint and typecheck:
mise run python:lint
mise run python:typecheck
Future work
- Switch readiness polling to
WatchSandboxserver-streaming. TodaySandboxClient.wait_readypollsGetSandboxonce a second. The proto already supports streaming snapshots; same blocking signature, fewer RPCs, richer events available for verbose modes. - Node / TypeScript SDK. If a Node consumer surfaces, design the Node API
to mirror the same CLI verbs the Python SDK does. At that point adopt
buffor the codegen pipeline. - OIDC bearer auth interceptor. Mirror the Rust
EdgeAuthInterceptorfor gateways behind Cloudflare Access or similar SSO.