Skip to content

[core][sandbox] Add an experimental HTTP API service for Ray Sandbox - #65633

Open
xyuzh wants to merge 8 commits into
ray-project:masterfrom
xyuzh:sandbox-http-api
Open

[core][sandbox] Add an experimental HTTP API service for Ray Sandbox#65633
xyuzh wants to merge 8 commits into
ray-project:masterfrom
xyuzh:sandbox-http-api

Conversation

@xyuzh

@xyuzh xyuzh commented Aug 20, 2026

Copy link
Copy Markdown
Member

Why are these changes needed?

Ray Sandbox (ray.experimental.sandbox) can currently only be driven from inside the cluster as Ray actors. This PR adds an experimental HTTP API service so sandboxes can be managed from outside the cluster with nothing but an HTTP client and a bearer token — for example as an Anyscale service, or from agent-evaluation frameworks (a Harbor ray-sandbox environment built on this API is submitted alongside: harbor-framework/harbor PR to follow).

This is the second step of the plan from #65570 (first: make the Python API run Docker-built images out of the box; second: make Ray Sandbox consumable as a standalone service).

Design

client ──HTTP/bearer──▶ FastAPI on Ray Serve (ray.experimental.sandbox.http, stateless replicas)
                          │ ray.get_actor(name, namespace)
                          ▼
                        SandboxHost — named, detached actor per sandbox
                          │ composes SandboxRuntime
                          ▼
                        gVisor (runsc) on the node
  • Named detached actors are the registry. Any replica resolves any sandbox by name; list = ray.util.list_named_actors. The service keeps no state, so replicas scale/restart freely.
  • Async submit + poll. Image pulls and commands outlive HTTP requests and LB limits, so POST /sandboxes and POST .../execs return 202 immediately; clients poll, optionally long-polling with wait_seconds (≤ 30 s per request). Boot progress is observable: pending → pulling → starting → running | error.
  • One TTL owner. The API's TTL reclaims the sandbox and its hosting actor (the core runtime TTL is deliberately disabled here); an abandoned sandbox never strands an actor reservation.
  • Everything sandbox-behavioral is the core SandboxConfig's ([core][sandbox] Make Ray Sandbox run Docker-built images out of the box #65570): capabilities (default DOCKER_DEFAULT_CAPABILITIES, sets written exactly), network modes (validated against VALID_NETWORK_MODES, so a new mode needs no API change), dns, shell (sandbox-level and per-exec override), and the workdir/readonly writability contract. The API patches nothing.
  • Auth: Authorization: Bearer <token> enforced when RAY_SANDBOX_API_TOKEN is set; an Anyscale service can leave it unset because the platform edge already requires the service bearer token. GET /health is always open.
  • Isolation from core: importing ray.experimental.sandbox.http requires ray[serve]; the base sandbox package never imports it.

Endpoints (/api/v1): create/list/get/delete sandboxes, start/poll execs, PUT/GET files, JSON error envelope (401/404/409/400/413/422/500). Full table in the new docs section.

E2E validation

Deployed as an Anyscale service (runsc-equipped cluster image) and as a local serve run; drove real gVisor sandboxes through create → exec → file transfer → delete, including a full Harbor evaluation run (oracle agent, reward 1.0) against the service URL + token.

Related issue number

Follow-up to #65570.

Checks

  • I've signed off every commit (DCO).
  • I've run pre-commit hooks (ruff, pydoclint, docstyle, semgrep, import order) on the changed files.
  • Unit tests: 54 tests run with no cluster and no runsc (fake runtime + fake resolver + FastAPI TestClient), including an OpenAPI contract snapshot that pins the v1 surface; test_http_integration.py is gated on the sandbox test env + runsc like the existing sandbox tests. BUILD.bazel uses the same sandbox_tests/team:core tags so the existing CI job picks the package up.
  • Release tests: N/A (experimental, off by default; nothing imports this package unless deployed).

@xyuzh
xyuzh requested review from a team as code owners August 20, 2026 22:37

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements an experimental REST API service for Ray Sandbox, featuring a FastAPI application, a detached SandboxHost actor for background execution, Pydantic schemas, and a comprehensive test suite. The review feedback provides valuable recommendations to improve robustness and performance: handling dead actors gracefully during idempotent creation, caching the runsc binary to prevent disk space leaks, querying sandbox descriptions concurrently using asyncio.gather, wrapping the TTL watchdog shutdown in a try-finally block to guarantee actor self-destruction, and catching SandboxError during file writes to return a client-friendly HTTP 400 instead of an HTTP 500.

Comment thread python/ray/experimental/sandbox/http/app.py Outdated
Comment thread python/ray/experimental/sandbox/http/host.py Outdated
Comment thread python/ray/experimental/sandbox/http/app.py
Comment thread python/ray/experimental/sandbox/http/host.py
Comment thread python/ray/experimental/sandbox/http/host.py
Comment thread python/ray/experimental/sandbox/http/app.py
Comment thread python/ray/experimental/sandbox/http/schemas.py
Comment thread python/ray/experimental/sandbox/http/app.py
@ray-gardener ray-gardener Bot added docs An issue or change related to documentation core Issues that should be addressed in Ray Core labels Aug 21, 2026
Comment thread python/ray/experimental/sandbox/http/app.py
Comment thread python/ray/experimental/sandbox/http/app.py
Comment thread python/ray/experimental/sandbox/http/app.py
Comment thread python/ray/experimental/sandbox/http/app.py Outdated
Comment thread python/ray/experimental/sandbox/http/app.py
@xyuzh
xyuzh force-pushed the sandbox-http-api branch 2 times, most recently from 3569f56 to da92929 Compare August 27, 2026 18:12
Comment thread python/ray/experimental/sandbox/http/app.py
Comment thread python/ray/experimental/sandbox/http/app.py
Comment thread python/ray/experimental/sandbox/http/host.py

@dstrodtman dstrodtman 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.

Docs-team review from Douglas Strodtman (Anyscale documentation team), style and grammar only. Claude Code assisted with the mechanics; I read every line and stand behind it.

The prose in the new HTTP API service section is in good shape after your last pass: soft-wrapped, no em dashes, the semicolons and the mid-sentence colon split into sentences, and the bullets read cleanly as a definition list. Approving on the docs side.

Two optional nits below, both stray semicolons in the endpoints table that the rest of the table already splits. Not blocking.

Scope note: this approval covers the prose in doc/source/ray-core/sandboxes.md. The Python and the API design belong to ray-project/ray-core, which still owns the code paths here.

Comment thread doc/source/ray-core/sandboxes.md
Comment thread doc/source/ray-core/sandboxes.md
os.chmod(tmp_path, 0o755)
os.replace(tmp_path, runsc_path)
if shared_bin not in os.environ.get("PATH", "").split(os.pathsep):
os.environ["PATH"] = f"{shared_bin}{os.pathsep}{os.environ.get('PATH', '')}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Predictable /tmp runsc is hijackable

Medium Severity

The shared runsc cache now lives at a predictable world-writable path. Another local user can pre-create /tmp/ray-sandbox-runsc/runsc; os.path.exists then skips the download and that binary is prepended to PATH. The previous mkdtemp location was not guessable. auto_install_runsc is opt-in, but when enabled this replaces the isolation runtime with an attacker-controlled executable.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d76d31e. Configure here.

# The previous actor died (node loss, OOM). Clear it and
# fall through to create a fresh sandbox under the same
# name, keeping the token idempotent across failures.
resolver.kill(existing)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Token retry kills live sandbox

Medium Severity

The new client-token recovery treats every sandbox_not_found from describe as a dead actor and kills it before recreating. _actor_call also maps transient ActorUnavailableError to that same code, so a brief node or GCS blip plus an idempotent create retry destroys a still-living sandbox and returns a fresh empty one.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d76d31e. Configure here.


## HTTP API service

Ray Sandbox ships an experimental REST API service so you can manage sandboxes from outside the Ray cluster with nothing but an HTTP client and a bearer token. The service is a FastAPI app on Ray Serve (`ray.experimental.sandbox.http`). Each sandbox is held by a named, detached actor, so the service itself is stateless and its replicas can scale or restart without losing sandboxes.

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.

I believe named detached actors are currently not as scalable as normal actors (e.g. each actor creation has to wait for a roundtrip to the GCS to make sure the names are unique), it would be great to check with @edoakes or @MengjinYan about that and what we should do (it it is indeed a problem)


## HTTP API service

Ray Sandbox ships an experimental REST API service so you can manage sandboxes from outside the Ray cluster with nothing but an HTTP client and a bearer token. The service is a FastAPI app on Ray Serve (`ray.experimental.sandbox.http`). Each sandbox is held by a named, detached actor, so the service itself is stateless and its replicas can scale or restart without losing sandboxes.

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.

Why make sandboxes detached actors instead of replicas? We can treat sandboxes as multiplex ids in serve to do multiplex aware routing, but we can certainly have each serve replica own multiple sandbox actors that can be suspended resumed etc. i think this will be a more scalable system.

xyuzh added a commit to xyuzh/harbor that referenced this pull request Aug 31, 2026
A new -e ray-sandbox environment that talks to a deployed
ray.experimental.sandbox.http service (ray-project/ray#65633) over plain
HTTPS with a bearer token — no ray dependency, no cluster connectivity,
zero new Harbor dependencies (httpx and tenacity are core deps):

    export RAY_SANDBOX_API_URL=https://<service-url>
    export RAY_SANDBOX_API_KEY=<service bearer token>
    harbor run -t <task> -e ray-sandbox

Sandboxes run the task's prebuilt image under gVisor on the service's
Ray cluster. Creation and execution are submit-and-poll so every HTTP
request stays short regardless of image pull or command duration; the
client retries narrowly (connection errors and 502/503/504 on idempotent
calls only, Modal-style) and creation is idempotent via client_token.
The task's network policy is authoritative: no-network maps to the
service's none mode and a wider --ek network= override is rejected at
construction; mode values themselves are validated by the service.
Exposes ray.experimental.sandbox over a versioned REST API (/api/v1)
served by Ray Serve, so sandboxes can be managed from outside the Ray
cluster with nothing but an HTTP client and a bearer token — e.g. as an
Anyscale service, or by agent-evaluation frameworks like Harbor.

Design:
- Each sandbox is a named, detached SandboxHost actor; the actors are
  the registry, so the Serve app is stateless and replicas can scale or
  restart without losing sandboxes.
- Creation and execution are async submit + poll (with optional
  long-poll wait_seconds <= 30s) because image pulls and agent commands
  outlive HTTP requests and load-balancer limits.
- The TTL reclaims both the sandbox and its hosting actor (the core
  runtime's TTL is deliberately disabled here so there is one owner).
- Capabilities, network modes, DNS, shell, and workdir semantics are the
  core SandboxConfig's (ray-project#65570); the API validates network against
  VALID_NETWORK_MODES and defaults capabilities to
  DOCKER_DEFAULT_CAPABILITIES, patching nothing.
- fastapi is only needed by this subpackage (ray[serve]); the base
  sandbox package never imports it.

Testing: 54 unit tests run with no cluster and no runsc (fake runtime +
fake actor resolver + FastAPI TestClient), including an OpenAPI contract
snapshot; a runsc-gated integration test covers the real path. Validated
end to end as a local 'serve run' and as an Anyscale service, driving
real gVisor sandboxes.

Signed-off-by: xyuzh <xinyzng@gmail.com>
POST /sandboxes now synthesizes its 202 response from the request
instead of awaiting describe() on the new actor: on a saturated cluster
the actor may be queued behind capacity for longer than a client (or
load balancer) read timeout, and the endpoint has everything it needs
to answer without touching the actor. Surfaced by a Harbor concurrency
test that oversubscribed a single node.

Signed-off-by: xyuzh <xinyzng@gmail.com>
A 16-way Terminal-Bench run against a cold cluster surfaced two
capacity bugs:

- The Serve deployment used the default max_ongoing_requests, but this
  API is long-poll based (requests deliberately hold a slot for up to
  ~30s), so a handful of concurrent clients saturated the replica and
  the platform load balancer answered 503 for everyone else. Raise it
  to 1000; the app is entirely async I/O.
- Calls to a SandboxHost whose detached actor exists but has not been
  *scheduled* yet (cluster autoscaling) block indefinitely. Bound every
  actor call by the request's own long-poll budget plus a configurable
  scheduling grace: describe paths report a synthesized 'pending'
  instead of hanging, and exec/file paths return 409 with a retry
  hint.

Signed-off-by: xyuzh <xinyzng@gmail.com>
An actor whose cpu/memory shape can never fit the cluster raises
ActorUnschedulableError from any call; the app let it escape as an
opaque 500. Terminal-Bench tasks declaring cpus=4/memory_mb=8192 on a
cluster of 4CPU-16GB workers hit this for every large task. Map it to
409 'unschedulable' carrying Ray's own message, so clients see exactly
which resource shape cannot be satisfied.

Signed-off-by: xyuzh <xinyzng@gmail.com>
Proxies in front of a deployed service cap request bodies (an Anyscale
ingress rejected a 4.8MB upload with 413 and killed an 11MB one
mid-body), so single-request file uploads have a hidden size ceiling.
PUT /files gains an append flag, plumbed through host, runtime, and
backend (cat >> instead of cat >), so clients can chunk arbitrarily
large uploads into proxy-sized pieces.

Signed-off-by: xyuzh <xinyzng@gmail.com>
Match the style established in ray-project#65627 for the networking section:
soft-wrapped prose, em-dash and semicolon clauses split into separate
sentences, and bold list leads. Also document the new append flag on
PUT /files and the 409 unschedulable error code.

Signed-off-by: xyuzh <xinyzng@gmail.com>
… shared runsc cache, concurrent listing

Three review findings:

- A dead detached actor made its client_token permanently return 404;
  the idempotent-create path now clears the dead actor and creates a
  fresh sandbox under the same name.
- The opt-in runsc download leaked a ~40MB temp dir per boot and
  raced concurrent boots; it now uses one shared cached path per node
  with an atomic rename.
- GET /sandboxes described actors sequentially; it now gathers
  concurrently.

Signed-off-by: xyuzh <xinyzng@gmail.com>
runsc exec takes numeric -user uid[:gid]; names are resolved against
the image's own /etc/passwd host-side. Plumbed through runtime, the
HTTP API (StartExecRequest.user), and the backend, with tests. Brings
exec to parity with container engines' exec --user and the Harbor
environment contract's user= parameter.

Signed-off-by: xyuzh <xinyzng@gmail.com>

@cursor cursor Bot 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.

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

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 2af9b07. Configure here.

request.ttl_seconds
if request.ttl_seconds is not None
else settings.max_ttl_seconds
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Default TTL rejected below one hour

Medium Severity

CreateSandboxRequest.ttl_seconds defaults to 3600, and the handler rejects any value above max_ttl_seconds. A server configured with a max below one hour therefore returns 400 for ordinary creates that omit ttl_seconds. Sending null instead applies the server max, so omitted and explicit-null TTLs disagree.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2af9b07. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Issues that should be addressed in Ray Core docs An issue or change related to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants