Skip to content

feat(providers): add NovitaSandboxProvider for Novita AI sandboxes - #1191

Merged
burtenshaw merged 12 commits into
huggingface:mainfrom
CeerDecy:feature/novita-sandbox
Sep 18, 2026
Merged

burtenshaw merged 12 commits into
huggingface:mainfrom
CeerDecy:feature/novita-sandbox

Conversation

@CeerDecy

@CeerDecy CeerDecy commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Runs an OpenEnv server in a Novita sandbox and returns an https base_url that EnvClient connects to over wss://, matching the ContainerProvider contract.

start_container accepts either source form: a registry image reference (resolved and cached by the SDK), or a "template:" reference from image_from_dockerfile, which builds a Novita template from a local Dockerfile.

Novita's template parser rejects multi-stage build definitions, which is the layout every in-repo environment uses, so image_from_dockerfile rewrites the Dockerfile before handing it over: BuildKit --mount flags are stripped, ARG/--platform in FROM lines are resolved, and a two-stage build whose stages share one base image is replayed as a single stage (COPY --from=builder becomes an in-place cp). A Dockerfile that does not fit those rules raises with the registry route as the alternative.

Two properties the provider pins that the image does not:

  • Runs as root. Novita's parser rewrites USER to a non-root "user" when the Dockerfile declares none, which cannot write the root-owned /app the server installs into -- observed as "Permission denied" on /app/resources and the task's git work tree. Providers that do not rewrite USER keep the image default and never hit this.
  • Pins the template start command to a keepalive, leaving port 8000 free for the server the provider launches itself. That launch writes a PID file, so wait_for_ready reports a crashed server immediately instead of waiting out the timeout.

Secure by default: enforces https/wss transport and withholds captured sandbox output from raised errors unless surface_server_logs=True.

The SDK is an optional extra (openenv[novita]) imported lazily; the provider talks to it through a private adapter, so the behavior tests inject a duck-typed fake and need no account, and a separate fake novita_sandbox module pins the real SDK surface.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • New environment
  • Refactoring

Alignment Checklist

Before submitting, verify:

  • I have read .claude/docs/PRINCIPLES.md and this PR aligns with our principles
  • I have checked .claude/docs/INVARIANTS.md and no invariants are violated
  • I have run /pre-submit-pr (or bash .claude/hooks/lint.sh and tests) and addressed all issues

There are many lint warnings that this PR did not introduce.

RFC Status

  • Not required (bug fix, docs, minor refactoring)
  • RFC exists: #___
  • RFC needed (will create before merge)

Test Plan

Unit tests: 87 in tests/test_core/test_novita_provider.py, no network or credentials needed.

PYTHONPATH=src:envs uv run pytest tests/test_core/test_novita_provider.py

All SDK calls are faked, so the suite runs anywhere. It covers: source resolution (registry image vs image_from_dockerfile), port and lifecycle guards, command discovery and background launch, /health polling with early crash detection, https/secret-hygiene enforcement, the Dockerfile rewrite transforms (buildkit strips, ARG resolution, multi-stage flattening), and the real SDK's method names and kwargs.

Examples:

  • novita_echo_env.py — fastest check that the provider works end to end; echo is the smallest env (no task data, no
    external services, no model calls).
  • novita_tbench2_simple.py — boots a TB2 task in the sandbox and runs commands through it.
  • novita_tbench2_e2e_eval.py — full loop: an LLM agent works a real TB2 task, then the environment's own verifier scores
    it. Verified end to end against a live Novita sandbox (this is the one that exercised Template.build(), the flattened
    multi-stage image, and the WebSocket session).

Claude Code Review

Flags Raised and Resolved

Seven flags came up across the review; none survived.

Five were withdrawn after verification — four of them because your end-to-end run had already proven what I claimed was
unverified:

  • RFC 002 invariant 2 (WebSocket conformance): the e2e run drives reset/step/evaluate over wss://…/ws
    (env_client.py:379-382, :558), which is exactly what the invariant demands.
  • Template.build() and the flattened .venv: both proven by that same run reaching a reward.
  • S3 egress default: the parameter is deleted. Daytona sets no network policy when calling its SDK, so OpenEnv now treats
    both providers identically.
  • Build logs unredacted (S4): on_build_logs defaults to None and forwards only on explicit opt-in — item-for-item the
    same pattern as Daytona's on_snapshot_create_logs, which is precisely S4's "withheld by default."

One it turned out I had no business raising:

  • Examples lack test coverage: CI never executes examples/, and where examples are covered (pelican, thinkingbox,
    imported_environment) the tests check the contract they depend on, not the script. The three daytona_* examples are
    equally uncovered. Convention, not deviation.

And one you had already adjudicated:

  • root is hardcoded: deliberate. Novita's parser rewrites USER to a non-root account when a Dockerfile declares none, and
    OpenEnv images install into a root-owned /app, so every in-process command fails. The fix restores what a plain docker
    run of the same image gives. Going least-privilege instead would mean injecting chown into the Dockerfile-transformation
    code and maintaining it — a cost you weighed and declined. The reasoning is recorded in the code comment at
    novita_provider.py:404-415, so the decision is closed.

Summary

  • 0 mechanical issues to fix
  • 0 alignment points for human review
  • 0 RFC conflicts to discuss

Note

Medium Risk
New optional cloud provider touches remote sandbox lifecycle, Dockerfile rewriting, and transport/security invariants; behavior is heavily unit-tested but live Novita/API-key paths are integration-dependent.

Overview
Adds Novita AI as a cloud runtime via new NovitaSandboxProvider, installable as pip install openenv[novita]. The provider implements ContainerProvider: it creates a Novita sandbox, launches the OpenEnv server on port 8000 (keepalive template + background start with PID-based crash detection), and returns an https base_url for EnvClient.

image_from_dockerfile rewrites in-repo multi-stage Dockerfiles for Novita’s template parser (strip BuildKit cache mounts, resolve FROM ARG/platform, flatten two-stage same-base builds); unsupported layouts fail with guidance to use a registry image instead.

Security defaults align with other cloud providers: enforce HTTPS/WSS, redact/withhold sandbox logs unless surface_server_logs=True, single active sandbox per provider instance, and cleanup on failed start.

Docs list Novita in getting-started, runtime-providers, and core API autodoc. Examples: echo smoke test, minimal TB2 sandbox run, and full TB2 agent + verifier e2e eval. Tests: large test_novita_provider.py suite with injected fake adapter (no live Novita account).

Reviewed by Cursor Bugbot for commit 3cb2b7e. Bugbot is set up for automated code reviews on this repo. Configure here.

CeerDecy and others added 4 commits September 17, 2026 10:30
Runs an OpenEnv server in a Novita sandbox and returns an https base_url
that EnvClient connects to over wss://, matching the ContainerProvider
contract.

start_container accepts either source form: a registry image reference
(resolved and cached by the SDK), or a "template:<path>" reference from
image_from_dockerfile, which builds a Novita template from a local
Dockerfile.

Novita's template parser rejects multi-stage build definitions, which is
the layout every in-repo environment uses, so image_from_dockerfile
rewrites the Dockerfile before handing it over: BuildKit --mount flags
are stripped, ARG/--platform in FROM lines are resolved, and a two-stage
build whose stages share one base image is replayed as a single stage
(COPY --from=builder becomes an in-place cp). A Dockerfile that does not
fit those rules raises with the registry route as the alternative.

Two properties the provider pins that the image does not:

- Runs as root. Novita's parser rewrites USER to a non-root "user" when
  the Dockerfile declares none, which cannot write the root-owned /app
  the server installs into -- observed as "Permission denied" on
  /app/resources and the task's git work tree. Providers that do not
  rewrite USER (Daytona) keep the image default and never hit this.
- Pins the template start command to a keepalive, leaving port 8000 free
  for the server the provider launches itself. That launch writes a PID
  file, so wait_for_ready reports a crashed server immediately instead of
  waiting out the timeout.

Secure by default: enforces https/wss transport and withholds captured
sandbox output from raised errors unless surface_server_logs=True.

The SDK is an optional extra (openenv[novita]) imported lazily; the
provider talks to it through a private adapter, so the behavior tests
inject a duck-typed fake and need no account, and a separate fake
novita_sandbox module pins the real SDK surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…der-independent

Two defects found in alignment review.

The Dockerfile registry was snapshotted into an instance attribute in
__init__, so a provider constructed before image_from_dockerfile() saw an
empty registry and failed with a misleading "call
image_from_dockerfile() first" error. The registry is class-level, so
reading it through self works via the MRO -- which is what
ModalProvider and DaytonaProvider already do. Drop the copy.

Rejecting unknown start_container kwargs made the entire AutoEnv path
unusable: AutoEnv.from_env() forwards wait_timeout (default 30.0)
unconditionally through from_docker_image -> _bootstrap_container. The
value is inert -- _bootstrap_container calls wait_for_ready(base_url)
without a timeout, so no provider ever sees it. Accept and drop it,
keeping the typo guard for genuinely unknown options.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove `allow_internet_access` from NovitaSandboxProvider. DaytonaProvider
sets no network policy when calling its SDK, so OpenEnv does not configure
egress for either provider -- the sandbox takes the vendor default. Passing
`True` explicitly was redundant (it is the SDK default) and implied OpenEnv
tunes network posture when it does not.

Also wrap an over-long line in examples/novita_tbench2_simple.py, the one
formatting failure introduced by the Novita provider commits. The remaining
lint.sh failures are pre-existing and unrelated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@CeerDecy
CeerDecy marked this pull request as ready for review September 17, 2026 07:47
@burtenshaw

Copy link
Copy Markdown
Collaborator

cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale comment

Release-manager review at head 69e910ff (Ben asked for cursor review).

Keep out of the 0.5.0 cut (#1190). Optional openenv[novita] surface, ~1.1k LOC provider + Dockerfile rewrite engine, and open RFC/security questions — not release cargo. 0.5.0 is already held for Harbor/run_batch auth and the version call.

What looks good

  • Matches the ContainerProvider shape used by Daytona/Modal/ACA (https base URL → EnvClient wss://, lazy optional SDK, private adapter, surface_server_logs default-off).
  • No server/ imports — client/runtime boundary is clean.
  • Focused suite is strong: 87 tests passed locally (PYTHONPATH=src:envs pytest tests/test_core/test_novita_provider.py); required CI lint/package/docs/locks jobs are green (tests were still finishing when I started).
  • Secret hygiene direction is right (env redaction, withheld sandbox output by default).

Tier 1 — please fix before merge

  1. Unquoted paths in Dockerfile flatten (novita_provider.py ~221): COPY --from becomes RUN mkdir -p $(dirname {dst}) && cp -a {src} {dst} without shell quoting. Docker COPY does not expand shell metacharacters; the rewritten RUN does. Quote with shlex.quote or reject unsafe paths.
  2. Host URL construction (~899–900): always prefixes https://. If get_host ever returns a scheme-bearing string you get https://https://…, which still passes _require_secure_url (startswith("https://")) and can point at the wrong endpoint. Require a bare host (reject embedded ://).
  3. Narrow COPY --from regex (~55): only matches --from as the first flag with exactly two path tokens. Forms like COPY --chown=… --from=… fall through and can leave --from= in a “single-stage” template. Fail closed on unmatched COPY --from.
  4. Misleading test (test_novita_provider.py ~404): test_plaintext_host_rejected currently asserts https prepending succeeds — it does not reject plaintext. Add a real rejection case for scheme-bearing hosts.

Tier 2 — alignment (needs a human call, not silent merge)

  • RFC required. This adds a public core provider and Dockerfile-rewrite semantics. The module itself says the RFC 002 “Cloud Sandbox Providers” amendment is proposed / unratified, but the PR checklist says “RFC not required.” That checkbox is wrong — open or update the RFC before treating this as supported public surface. Suggested: @Darktex + RFC 002 authors.
  • Forced root via builder.set_user("root"): documented Novita-parser workaround, but it overrides a Dockerfile that already declares a non-root USER. Prefer “set root only when Novita would inject default user,” or accept this as an explicit security trade-off in the RFC.
  • Network posture left to SDK default (parity with Daytona). Still weaker than INVARIANTS “network access must be explicitly configured” for a new public provider — call it out in the RFC/docs.
  • Auth model vs Daytona/Modal: Daytona defaults to signed preview; Modal treats the tunnel URL as bearer. Novita returns an account-scoped public https host with no OpenEnv-level auth story for /ws — document whether the platform enforces that.

Verdict

Request changes on the Tier 1 items + RFC checkbox. Not blocking Thursday’s delayed 0.5.0 either way — this stays outside the candidate until those land and someone signs the RFC/security trade-offs.

Open in Web View Automation 

Sent by Cursor Automation: Release

Comment thread src/openenv/core/containers/runtime/novita_provider.py Outdated
Comment thread src/openenv/core/containers/runtime/novita_provider.py Outdated
Comment thread src/openenv/core/containers/runtime/novita_provider.py
Comment thread src/openenv/core/containers/runtime/novita_provider.py Outdated
connects to over ``wss://``.

Note: the ``RFC 002 security invariant S<n>`` references in this module point to
the **proposed** "Cloud Sandbox Providers" amendment to RFC 002, which is pending

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tier 2 / RFC: This module already states the Cloud Sandbox Providers amendment is proposed/unratified, but the PR checklist marks “RFC not required.” Adding a public core provider + Dockerfile-rewrite engine needs that RFC (or an update to it) before this is treated as supported surface — please fix the checkbox and link the RFC.

@CeerDecy CeerDecy Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For the Dockerfile rewrite, I’d like to provide some background. image_from_dockerfile creates a Novita Template, similar to Daytona’s Snapshot flow. Since Novita does not currently support multi-stage Dockerfiles, I added a rewrite step to adapt them before building the Template.
I’d also like to confirm whether this change, together with the new Novita provider, requires a separate RFC.
@Darktex @thegovind

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Corrected release-manager review at exact head 69e910ff, after testing the real SDK rather than only the fake adapter.

Automated checks

  • All substantive exact-head CI checks pass.
  • 87/87 focused provider tests pass.
  • Focused usort/Ruff format/check pass.
  • The fake tests do not model the real failures below.

Tier 1 — fixes required

  1. The declared novita-sandbox>=2.1.0 floor is incompatible. In 2.1.0, Sandbox.create has no image/build parameters; the advertised registry path passes them through **opts and raises TypeError: ConnectionConfig.__init__() got an unexpected keyword argument 'image'. Require at least 2.1.1 and add a minimum-version contract test.
  2. Registry images still fail on 2.1.1 before network access. wait_for_timeout() returns a ReadyCmd, but SDK oci_fingerprint() JSON-serializes the build mapping first, producing TypeError: Object of type ReadyCmd is not JSON serializable. Pass ready_cmd.get_cmd().
  3. Dockerfile builds discard explicit credentials and region. The adapter configures self._novita, then bypasses it with static Template.build(...); the real SDK creates ConnectionConfig(**{}). Use self._novita.template.build(...) so its namespace merges api_key/domain.
  4. The Dockerfile rewrite is not fail-closed or semantics-preserving as documented: it strips secret/SSH/bind mounts along with cache mounts; overlapping ARG names corrupt FROM $BASE_IMAGE; unsupported COPY --from forms survive after their stage is removed; generated shell paths are unquoted; and replaying the builder stage retains builder-only packages/files/secrets in the runtime filesystem. Narrow the supported grammar, reject everything else, and add real transform regressions—or remove the local-Dockerfile route in favor of prebuilt registry images.
  5. novita_tbench2_simple.py starts readiness polling before its cleanup try; a timeout leaks the sandbox until its hard lifetime expires.

Security/alignment gates

  • The real SDK defaults omitted allow_internet_access to True. That is unrestricted egress for untrusted code, not neutral omission, and conflicts with the repository invariant that network access be explicitly configured plus proposed RFC 002 S3. Make the policy explicit/configurable and default-deny unless the RFC owners approve a different posture.
  • The examples log the returned public URL even though proposed RFC 002 S2 treats it as a bearer capability that must never be logged.
  • wait_for_ready() checks only HTTP /health while RFC 002 explicitly requires a WebSocket conformance check before claiming provider support.
  • Always forcing root overrides an explicit non-root Dockerfile USER; this needs an accepted RFC/security decision.
  • The Cloud Sandbox Providers amendment is still proposed/unratified. Obtain RFC-author/security review before exposing this as supported public core API.

Correction to my earlier review: the nested-scheme host scenario is defensive hardening, not a demonstrated 2.1.1 mismatch—the real SDK’s get_host() contract returns a bare host. The mislabeled plaintext-host test is therefore not itself a merge blocker. The fail-closed COPY/path concerns remain valid correctness issues.

Verdict

Keep CHANGES_REQUESTED and keep this out of the 0.5.0 candidate. Re-review after real-SDK regressions, an explicit ingress/egress/log policy, a fail-closed build route, and RFC-owner sign-off.

Open in Web View Automation 

Sent by Cursor Automation: Release

Comment thread pyproject.toml Outdated
Comment thread src/openenv/core/containers/runtime/novita_provider.py Outdated
Comment thread src/openenv/core/containers/runtime/novita_provider.py Outdated
Comment thread src/openenv/core/containers/runtime/novita_provider.py Outdated
Comment thread examples/novita_echo_env.py
@cursor
cursor Bot requested review from Darktex and thegovind September 17, 2026 09:24
Quote flattened COPY paths before embedding them in shell RUN commands. Reject scheme-bearing sandbox
    hosts before adding the HTTPS prefix, with regression tests for both cases.
Quote flattened COPY paths before embedding them in shell RUN commands. Reject scheme-bearing sandbox hosts before adding the HTTPS prefix, and preserve explicit Dockerfile USER directives.
Fail closed when Dockerfile COPY --from options cannot be flattened, including reordered flags and multiple sources. Add regression coverage for each unsupported form.
Require novita-sandbox 2.1.1 and serialize readiness commands for OCI image fingerprints. Build Dockerfile templates through the configured Novita namespace to retain credentials and region settings, with regression coverage.
Strip only explicit cache mounts during Dockerfile rewriting. Reject secret, ssh, bind, missing-type, and otherwise unsupported mounts instead of silently changing build semantics.
Run OCI image server commands through the SDK background API as root, use the same user for liveness probes, and tolerate the PID file startup race. Document explicit OCI startup commands, preserve template users, and avoid logging bearer URLs.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread src/openenv/core/containers/runtime/novita_provider.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@CeerDecy on your RFC question (Dockerfile rewrite + new Novita provider):

Verdict: RFC REQUIRED for the provider addition; the Dockerfile rewrite needs to be in that RFC (or a linked amendment), not a silent implementation detail.

Why (from rfc-check + RFC 002):

  • New public surface under src/openenv/core/ (NovitaSandboxProvider) is a new provider abstraction — that is explicitly RFC-required territory.
  • RFC 002 already covers provider contracts (S3 egress / least privilege, readiness including WebSocket conformance, secret hygiene). Shipping Novita without an amendment leaves those gates unratified for this path (egress default, root user, health-only readiness, logging of URLs/credentials).
  • A Dockerfile rewrite that flattens multi-stage builds, strips BuildKit mounts, and rewrites ARG/COPY --from is a new semantics-changing pattern, not a private helper. It needs documented invariants: fail-closed vs best-effort, which grammar is supported, and what is rejected.

Suggested path: open or extend an RFC 002 provider amendment (owners @Darktex / @thegovind) that covers:

  1. Novita as a first-class provider and its readiness/egress/user/logging contract.
  2. Whether local Dockerfile → Template builds are in scope at all; if yes, the rewrite must be fail-closed and semantics-preserving (or only prebuilt registry images).

Until that lands, please keep treating the Tier-1 SDK bugs from the earlier CHANGES_REQUESTED review as merge blockers independently of the RFC — the RFC does not waive those.

View PR

Open in Web View Automation 

Sent by Cursor Automation: Release

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3cb2b7e. Configure here.

print("Command output:")
print(result.observation.output)
finally:
provider.stop_container()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sandbox leaked on readiness failure

Medium Severity

wait_for_ready runs after start_container but outside the try/finally that calls stop_container. A crash or timeout during readiness never releases the sandbox, so it keeps running until Novita's hard lifetime expires. The other Novita examples in this PR put readiness inside that try.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3cb2b7e. Configure here.

@bot-ci-comment

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@burtenshaw
burtenshaw merged commit a4798f5 into huggingface:main Sep 18, 2026
11 checks passed
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.

2 participants