Files
OpenShell/architecture/python-sdk.md
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

191 lines
8.4 KiB
Markdown

# 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 `openshell` and 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
```python
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`:
```sh
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 Dockerfile` builds
locally and pushes into the gateway's registry. Not an RPC; not in the SDK.
Build images via `mise run docker:build` or `docker build` ahead of time and
pass an image reference.
- **Gateway bootstrap.** `openshell gateway start` provisions a local gateway
along with mTLS material. Out of scope; the SDK assumes a running gateway.
- **Draft-policy review.** The interactive `policy advisor` flow lives in the
CLI and TUI.
- **Port-forward helpers.** The CLI's `forward` subcommand 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:
```sh
mise run python:lint
mise run python:typecheck
```
## Future work
- **Switch readiness polling to `WatchSandbox` server-streaming.** Today
`SandboxClient.wait_ready` polls `GetSandbox` once 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 `buf`
for the codegen pipeline.
- **OIDC bearer auth interceptor.** Mirror the Rust `EdgeAuthInterceptor` for
gateways behind Cloudflare Access or similar SSO.