Skip to content

Add hand-written baseten.sandbox surface - #22

Closed
marius-baseten wants to merge 18 commits into
mainfrom
marius/sandbox-surface
Closed

marius-baseten wants to merge 18 commits into
mainfrom
marius/sandbox-surface

Conversation

@marius-baseten

@marius-baseten marius-baseten commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

🚀 What

💻 How

  • SandboxClient / AsyncSandboxClient: create, get, get_info, sandbox_from_url, list, delete, library_images
  • Sandbox / AsyncSandbox: fs (text read/write) and process (exec, list, logs); api reaches the generated execution client for anything unwrapped
  • API key exchanged at /v1/token for a cached, refreshed token used on both planes; token_provider overrides
  • Generated clients reachable as api / sandbox_api on the client

🔬 Testing

  • Unit: a routing transport serving both planes, covering token minting and revocation, retries, pagination, and wire shapes
  • Parity: every translated generated type is pinned, so a new server field fails until the surface forwards it or lists it under an explicit exception
  • E2E (env-gated): lifecycle and exec against a live environment

Important review notes and deviations (running list of decisions)

  1. API shape
    1. Matches the JS and Go SDKs' behavior with Python conventions: keyword-only options, sync and async twins, Optional meaning "leave unset" on wire types. The Go and JS SDK PRs are the source of what to build; this list tracks the same decisions here
    2. create/get return a Sandbox handle (name, URL); get_info/list return data only. create also carries info, the record as of creation
    3. No public Sandbox constructor: sandbox_from_url builds one without a call, so a sandbox always shares its client's authentication, connections, and retry budgets, and closes with it
    4. get_info takes show_secrets; the server masks values for callers without the workspace administrator role even then
    5. create sends only what the caller set; the server owns defaults and conflicts (create_if_not_exists returns the existing live sandbox or recreates a dying one). create fails loudly when the served record carries no URL, which the served spec says is always present
    6. SandboxInfo has no state and no display_name: the served spec drops both. create takes create_if_not_exists as a flag, not a tri-state
    7. list is a lazy generator; a repeated server cursor errors rather than paging forever
    8. Env values default to secret=True, matching the server's default; secret=False opts out and unmasks the value in responses. The previous draft's False default would have unmasked everything
    9. Execution-plane timestamps parse an explicit layout list (ISO 8601, RFC 1123) and fail clearly on anything else
    10. library_images drops nothing client-side: the served endpoint already excludes hidden and coming-soon entries. Creation options are flattened to creation_extra_args and creation_volumes, documented as suggestions since create cannot pass volumes or extra args yet
    11. Not implemented yet: update, the image client (push, wait, logs, tags, cleanup), process breadth (exec_stream, wait, stop, kill, stdin), filesystem breadth (bytes, multipart, list, mkdir, find, grep, copy, write_tree, watch), drives, codegen, system, network. api and sandbox_api reach them
  2. Errors and retries
    1. SandboxApiError on both planes, SandboxGatewayError for edge 502/503/504 on the execution API
    2. Control-plane calls are not retried. Execution-plane calls retry idempotent ones only, with the same budgets as Go and JS: reads 5, gateway 2, uploads 3, settable via SandboxRetryOptions
    3. A revoked token (401 plus x-blaxel-error-code: TOKEN_REVOKED) is re-minted and the request resent; later resends wait 1s past the revocation's rounded-up cutoff second, since a token minted right after the revoking event can still fall before it. Past two resends the rejection is returned
    4. One token exchange is bounded by 30s, so a stalled mint is not what every caller waits on
  3. Transport
    1. httpx, HTTP/2 when the optional h2 package is installed (http2=None auto-detects). The default timeout bounds the connect time only: exec with wait_for_completion holds the response open until the command exits, so a response-header timeout would fail every long command

@CLAassistant

CLAassistant commented Sep 30, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Authentication exhaustion, gateway retries, error conversion, resource cleanup, and forward-compatible statuses have unresolved correctness issues.

Review effort: Balanced
Findings: 13 Medium severity

Open (13)
What changed in this PR

Adds a hand-written synchronous and asynchronous sandbox SDK over the generated control- and execution-plane clients.

Changes:

  • Adds sandbox lifecycle, authentication, retries, errors, and curated models.
  • Implements filesystem read/write and process execution.
  • Adds API documentation and routing-based tests.
File Description
baseten/​sandbox/​__init__.py Exports the public sandbox API.
baseten/​sandbox/​_auth.py Implements token caching and revocation handling.
baseten/​sandbox/​_client.py Adds sync and async control-plane clients.
baseten/​sandbox/​_errors.py Adds curated API error types.
baseten/​sandbox/​_info.py Defines curated sandbox records.
baseten/​sandbox/​_retry.py Adds transient retry policies.
baseten/​sandbox/​_sandbox.py Implements execution-plane wrappers.
scripts/​docgen/​conf.py Configures sandbox API documentation.
scripts/​docgen/​index.rst Adds sandbox documentation navigation.
tests/​sandbox/​test_surface.py Tests the new public surface.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread baseten/sandbox/_auth.py Outdated
Comment thread baseten/sandbox/_auth.py Outdated
Comment thread baseten/sandbox/_client.py Outdated
Comment thread baseten/sandbox/_client.py Outdated
Comment thread baseten/sandbox/_client.py
Comment thread baseten/sandbox/_info.py
Comment thread baseten/sandbox/_sandbox.py Outdated
Comment thread baseten/sandbox/_sandbox.py Outdated
Comment thread baseten/sandbox/_sandbox.py Outdated
Comment thread baseten/sandbox/_sandbox.py Outdated
@marius-baseten
marius-baseten force-pushed the marius/sandbox-surface branch 3 times, most recently from 7ce0dd3 to 3bb5cea Compare September 30, 2026 02:25

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am holding off reviewing this because I think it's a bit premature. basetenlabs/baseten-js#23 I think needs to get further along first, and then can be used as inspiration for this PR.

@marius-baseten
marius-baseten force-pushed the marius/sandbox-surface branch 2 times, most recently from 8bdfb49 to 2b58f8c Compare September 30, 2026 17:16
@marius-baseten
marius-baseten requested a balanced review from Copilot September 30, 2026 19:34

Copilot AI left a comment

Copy link
Copy Markdown

Comment thread baseten/sandbox/_client.py
Comment thread baseten/sandbox/_client.py
Comment thread baseten/sandbox/_retry.py
Comment thread baseten/sandbox/_sandbox.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The advertised HTTP/2 installation path is unavailable, and list handling has input and pagination correctness issues.

Review effort: Balanced
Findings: None

Resolved since last review (17)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Add the missing http2 package extra or document installing h2

baseten/​sandbox/​_auth.py:50

The installation command in this error does not work: pyproject.toml defines no http2 optional dependency, so pip install baseten[http2] will not install h2. Add the advertised package extra (or direct users to install h2) so http2=True has an actionable failure path.

Medium severity Reject bare status strings in synchronous API

baseten/​sandbox/​_client.py:327

str satisfies Sequence[str], so the public annotation accepts statuses="DEPLOYED"; the comprehension then sends one status per character and the API rejects the request. Reject a bare string before constructing the query (and keep accepting actual sequences).

This issue also appears in the following locations of the same file:

  • line 349
  • line 598
  • line 626

@marius-baseten
marius-baseten force-pushed the marius/sandbox-surface branch 7 times, most recently from 57e8dcc to 7ed6e86 Compare October 2, 2026 17:50
Base automatically changed from marius/sandbox-sdk to main October 5, 2026 17:44
marius-baseten and others added 5 commits October 5, 2026 10:53
Sync and async SandboxClient for the control plane: create, get, get_info,
list (cursor-paginated), delete. Sandbox/AsyncSandbox for the execution
plane with fs (read with retry, write) and process (exec) subsystems;
drives, codegen, system, and network raise NotImplementedError until
wrapped.

API keys are exchanged for a sandbox token at POST /v1/token, cached until
shortly before expiry, and replaced on 401 TOKEN_REVOKED by re-sending
the request. One shared transport pools connections across both planes;
HTTP/2 is best-effort via the optional h2 package. Records convert to
curated types (SandboxInfo, SandboxProcessInfo) with open string
statuses. Creation sends only the fields the caller set, so server-side
defaults apply.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The surface exposes no generated types: methods take keyword arguments of
primitives and curated dataclasses, and the generated models are built
internally. The parity tests fail when an upstream spec field appears,
until the surface forwards it or lists it as deliberately not translated.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The sandbox management API can be served on a different host than the
token exchange (today: api.blaxel.dev serves /v1/sandboxes while
api.staging.baseten.co mints the tokens, until staging.b10.co is live),
so the client accepts a separate base URL for the sandboxes calls.

The e2e suite mirrors the JS suite's environment contract
(BASETEN_E2E_TEST_API_KEY, BASETEN_E2E_TEST_DOMAIN,
BASETEN_E2E_TEST_SANDBOXES_DOMAIN) and skips without them. Verified live
against staging + blaxel dev: create (server applied its image and
memory defaults), exec with waitForCompletion, fs write/read, delete.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…rchive

Exercises the raw execution client against a live sandbox: stdin piping
with delete_process_stdin and the text/plain logs stream, multipart
initiate/part/complete followed by an fs read-back, and a dry-run archive
export (the 200 ExportResult branch; the 202 branch needs a presigned
upload URL, which stays unit-tested).

The stdin test is xfail: the server sends logs: null on a still-running
process while the spec declares logs required, so pydantic rejects the
response. Reported for a server-side fix.

Co-Authored-By: Claude Code <noreply@anthropic.com>
marius-baseten and others added 3 commits October 5, 2026 10:53
library_images() on both clients lists the platform's starter images
over the regenerated typed client, dropping hidden and coming-soon
entries to match the console's create form. Named after the
library_models consumer catalog, not Blaxel's hub.

Co-Authored-By: Claude Code <noreply@anthropic.com>
library_images() on both clients lists the platform's starter images
over the served GET /v1/sandboxes/library_images, which drops hidden
and coming-soon entries server-side. Named after the library_models
consumer vocabulary, not Blaxel's hub.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ains create_if_not_exists

The curated SandboxInfo loses state and display_name with the spec
forward-patch; create() drops display_name and gains the
create_if_not_exists idempotent form the CLI already exposes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@marius-baseten
marius-baseten force-pushed the marius/sandbox-surface branch from 7ed6e86 to 7502c7f Compare October 5, 2026 17:54
- sandbox_from_url on both clients; the standalone Sandbox constructors
  are gone, so a sandbox always shares its client's authentication,
  connections, and retry budgets.
- get_info takes show_secrets.
- A revoked token's later resends wait out the revocation's rounded-up
  cutoff second; one token exchange is bounded by 30s.
- create fails loudly when the served record carries no URL.
- Env values default to secret, matching the server's default; the old
  False default would have unmasked values.
- LibraryImage flattens the catalog's creation options, matching Go.

Co-Authored-By: Claude Code <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Async token minting can remain unbounded, and several advertised retry budgets are not applied.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity · 1 Low severity

Open (4)

Comment thread baseten/sandbox/_client.py Outdated
Comment thread baseten/sandbox/_sandbox.py
Comment thread baseten/sandbox/_sandbox.py Outdated
Comment thread baseten/sandbox/__init__.py
- The async token exchange carries the same 30s bound as sync.
- Process list and logs retry on the read and gateway budgets, as
  idempotent execution-plane reads.
- A same-content file write is idempotent, so fs write retries on the
  upload budgets.
- LibraryImageVolume exports with the other curated library types.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@marius-baseten
marius-baseten requested a balanced review from Copilot October 5, 2026 20:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread baseten/sandbox/_auth.py Outdated
Comment thread baseten/sandbox/_errors.py Outdated
Comment thread baseten/sandbox/_info.py
Comment thread baseten/sandbox/_sandbox.py Outdated
Comment thread baseten/sandbox/_sandbox.py
- The async auth transport buffers with aread, since the request body
  can be an async stream.
- SandboxApiError keeps an error-only body's error as its code and
  description; the gate checked the wrong field.
- Sandbox drops its context-manager methods: a sandbox closes with its
  client, so the protocol advertised cleanup it does not own.
- Async fs read and write retry paths covered by tests, mirroring the
  sync pair.

Co-Authored-By: Claude Code <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Authentication eagerly buffers potentially large uploads, and live tests race sandbox deployment readiness.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (7)
Previously missed (7)

In code that hasn't changed since last review

Medium severity Advertised http2 extra does not install the h2 dependency

baseten/​sandbox/​_auth.py:59

The suggested installation command cannot work because this package defines no http2 optional dependency in pyproject.toml; pip will not install h2 for baseten[http2]. Point users to an installation command that actually provides h2, or add the advertised extra.

Medium severity Second-resolution sandbox names collide across concurrent runs

tests/​sandbox/​test_sandbox_e2e.py:36

Rounding wall-clock time to one second does not produce a unique sandbox name. Concurrent E2E runs can create the same resource, and each test's unconditional cleanup may then delete another run's sandbox. Use a UUID or equivalent random suffix, as the existing E2E suite does in tests/test_e2e.py:144.

Medium severity Test assumes deployment completes before execution calls

tests/​sandbox/​test_sandbox_e2e.py:54

The served create contract explicitly allows deployment to continue after the response (scripts/apigen/specs/management.json:10632), so create() may return DEPLOYING. This strict assertion and the immediate execution calls make the live test flaky; poll get_info() with a bounded timeout until DEPLOYED before using the execution API.

Medium severity Test does not tolerate transitional DEPLOYING status

tests/​sandbox/​test_sandbox_e2e.py:98

This test also assumes the first follow-up read must already report DEPLOYED, although the create endpoint is allowed to return while deployment continues. Poll to readiness, or assert a contract that permits the transitional status, so a valid DEPLOYING response does not fail the E2E suite.

Medium severity Process starts before sandbox deployment is ready

tests/​sandbox/​test_sandbox_e2e.py:142

The process API is called immediately after creation even though creation may return before deployment is ready. A gateway failure here is especially misleading because this test is marked xfail and will be accepted as the expected server bug without exercising stdin/log behavior. Wait for DEPLOYED before starting the process.

Medium severity Multipart execution starts before sandbox deployment completes

tests/​sandbox/​test_sandbox_e2e.py:177

Multipart initiation is attempted immediately after create(), but the create response may still be DEPLOYING under the served API contract. Wait for the sandbox to reach DEPLOYED before calling its execution API to avoid intermittent gateway failures.

Medium severity Execution write races sandbox deployment readiness

tests/​sandbox/​test_sandbox_e2e.py:217

This execution-plane write can race sandbox deployment because create() is allowed to return while status is still DEPLOYING. Poll with a bounded timeout until DEPLOYED before writing/exporting so the E2E test does not intermittently fail at the gateway.

Comment thread baseten/sandbox/_auth.py Outdated
marius-baseten and others added 2 commits October 5, 2026 14:28
Matching the Go rule: only a body that can be replayed is ever sent
again. An in-memory body re-sends on a revoked token; one still backed
by a stream, such as a raw multipart upload, is sent once, since
replaying it would mean materializing it. The eager read/aread that
buffered every body is gone.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Public handles remain directly constructible in an invalid state, readiness guidance is incomplete, and HTTP/2 installation instructions reference a nonexistent extra.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (1)
Previously missed (4)

In code that hasn't changed since last review

Medium severity Documented baseten[http2] extra is missing

baseten/​sandbox/​_auth.py:59

The documented baseten[http2] extra does not exist in pyproject.toml, so following this recovery instruction does not install h2 and http2=True continues to fail. Point users to the actual package (or add the advertised extra).

Low severity Document that sandbox creation may return before deployment completes

baseten/​sandbox/​_client.py:280

The served create contract says deployment may continue after the response and only DEPLOYED sandboxes accept execution calls (scripts/apigen/specs/management.json:10632, baseten/sandbox/_info.py:10). The current docstring omits this, making the natural client.create(...).process.exec(...) flow race deployment. Document polling get_info() (the handle's info is only the creation snapshot), or make create wait.

Low severity Document async sandbox creation readiness and polling

baseten/​sandbox/​_client.py:586

The async create call has the same readiness contract: the response may still be DEPLOYING, but this docstring does not warn callers before they await an execution operation. Document polling get_info() (because AsyncSandbox.info is a snapshot), or make creation wait consistently.

Low severity Add parity checks for all nested translated model types

tests/​sandbox/​test_parity.py:151

This only pins the top-level library-image model. The converter also translates SandboxPort, SandboxLibraryImageCreationOptions, and SandboxLibraryImageVolume, so new fields on any of those nested generated types would still be silently dropped despite the parity suite's stated guarantee. Add field-set parity assertions for each nested translated type (and SandboxEnv, which is translated in create/info paths).

Comment thread baseten/sandbox/_sandbox.py
With no __init__, Sandbox() built an uninitialized object that failed
later on access; construction now names the client entry points. _shared
bypasses it with __new__.

Co-Authored-By: Claude Code <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Revoked-token caching, request serialization, and readiness handling leave important sandbox workflows unreliable.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)
Resolved since last review (1)
Previously missed (5)

In code that hasn't changed since last review

Medium severity Synchronous create returns handles before deployment is ready

baseten/​sandbox/​_client.py:302

The generated control-plane contract says creation may return while status is still DEPLOYING, with the URL assigned before readiness, but this immediately hands out an execution handle. Since process.exec() is deliberately not retried, the common create-then-exec flow can fail against an allowed successful create response. Poll to DEPLOYED before returning, or expose a documented readiness/wait operation so callers can safely use the handle.

This issue also appears on line 605 of the same file.

Medium severity Status filters incorrectly expand bare strings

baseten/​sandbox/​_client.py:350

A plain string satisfies Sequence[str], so client.list(statuses="DEPLOYED") is accepted by type checkers but this implementation emits one status per character. Reject string input explicitly (or use an input type that excludes strings) rather than sending a malformed filter.

This issue also appears on line 651 of the same file.

Medium severity Synchronous exec serializes omitted options as null

baseten/​sandbox/​_sandbox.py:397

All optional process fields are passed explicitly even when None. The generated serializer preserves explicitly set nulls, so exec("cmd") sends workingDir, env, name, timeout, and waitForCompletion as null instead of leaving them unset, contrary to this PR's stated wire contract and potentially overriding server defaults. Build the request from only the arguments the caller supplied, as _sandbox_create_request already does.

This issue also appears on line 473 of the same file.

Medium severity CI omits the sandbox domain secret

tests/​sandbox/​test_sandbox_e2e.py:27

These new E2E tests cannot run in the repository CI configuration: .github/workflows/ci.yml:41-47 injects the API key and management domain but never BASETEN_E2E_TEST_SANDBOXES_DOMAIN, so this condition always skips the entire sandbox suite even on main. Wire the sandbox-domain secret into the workflow so the claimed live coverage executes.

Low severity Recovery instructions reference an undefined http2 extra

baseten/​sandbox/​_auth.py:59

This recovery instruction names a package extra that the project does not define, so pip install baseten[http2] will not install h2. Point users to an installation command that actually provides the imported package.

Comment thread baseten/sandbox/_auth.py
Comment thread baseten/sandbox/_sandbox.py
A stream-backed body that meets a revocation is still sent only once,
but its token now leaves the cache, so the next request mints instead
of reusing the known-bad one; the exhausted replayable attempts do too.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@marius-baseten
marius-baseten deleted the marius/sandbox-surface branch October 7, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants