Skip to content

[core][sandbox] Support multi-uid user namespaces for network="public" sandboxes - #65823

Open
xyuzh wants to merge 12 commits into
ray-project:masterfrom
xyuzh:sandbox-multi-uid
Open

[core][sandbox] Support multi-uid user namespaces for network="public" sandboxes#65823
xyuzh wants to merge 12 commits into
ray-project:masterfrom
xyuzh:sandbox-multi-uid

Conversation

@xyuzh

@xyuzh xyuzh commented Sep 1, 2026

Copy link
Copy Markdown
Member

Description

Rootless-style sandboxes map the whole container to a single host uid: every file reads as root, chown to any other user fails, and ownership baked into image layers flattens away. Workloads that spread ownership across users break — terminal-bench's mailman task fails postfix's ownership integrity check (postsuper: fatal: scan_dir_push: open directory defer: Permission denied) because the image ships /var/spool/postfix/* owned by uid 101 with 0700/1730 modes, which single-uid extraction flattens to root.

This PR gives network="public" sandboxes a multi-uid user namespace, the rootless-Podman model:

  • Holder mapping: the namespace holder now starts unmapped, and container root maps onto the worker's ids plus container 1..count onto the node's /etc/subuid//etc/subgid subordinate range before pasta and runsc join. Probe-based detection (detect_idmap()) verifies a mapping can actually be written at boot — via the setuid newuidmap/newgidmap helpers, or via privileged direct /proc/<pid>/uid_map writes where a build pipeline stripped the setuid bits (shadow's helpers refuse cross-user targets, so sudo-ing them is not an option) — and degrades to the previous single-uid namespace with a warn-once when neither works (e.g. no_new_privs, or the pod itself running under a sandboxed runtime such as gVisor/GKE Sandbox whose kernel only accepts self-maps). RAY_SANDBOX_SINGLE_UID=1 forces the fallback.
  • Ownership-preserving image cache: extraction records each layer's non-root owners (whiteout-aware) into an .ownership.json sidecar; the cached tar restores true uid/gid onto its members while the shared extracted rootfs stays worker-owned (single-uid sandboxes are unaffected); multi-uid sandboxes mount a per-node <image>.idmap rootfs variant extracted from that tar inside an ephemeral mapped user namespace — chown-before-chmod so setuid/setgid bits survive, out-of-range ids skipped with a warning. The .extracted marker is versioned (ownership-v2) so legacy caches re-pull once.
  • Traversing another user's 0700 directory as root additionally needs CAP_DAC_OVERRIDE — present in Docker's default capability set, absent from the bare runsc spec default; documented in troubleshooting.

Node prerequisites (documented): uidmap package + subordinate id ranges for the worker user; runc-backed pods (an outer gVisor sandbox cannot write child uid maps).

Stacked on #65633 and #65820 — review the top commit only until those merge.

Related issues

Related to #65633, #65820.

Additional information

Tested with TEST_SANDBOX=1 gated tests in a privileged dev container as a non-root user, on both mapping paths (setuid helpers intact, and stripped bits + sudo map-file writes): image-baked ownership (uid=101 0700 spool dir, 02710 setgid dir built into a fixture image) stats correctly inside the sandbox and root traverses it; runtime chown to arbitrary uids materializes host-side at the subordinate ids on workdir binds; a mailman-shaped mini (adduser + chown -R + setgid-directory group inheritance) passes; single-uid network="none" sandboxes sharing the same image cache are unaffected; the netns isolation matrix from #65820 passes unchanged under multi-uid.

@xyuzh
xyuzh requested review from a team and andrewsykim as code owners September 1, 2026 00:54

@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 introduces an experimental REST API service for Ray Sandbox, allowing users to manage sandboxes externally via a FastAPI application on Ray Serve. It also adds support for private network namespaces bridged by pasta (passt) and multi-uid user-namespace mapping to preserve file ownership within sandboxes. The review feedback highlights two critical issues: a missing json import in image_utils.py that will cause a runtime NameError, and a potential unhandled OSError in idmap.py if subprocess.Popen fails outside of the try block.

Comment on lines +9 to +10
import subprocess
import sys

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.

high

The json module is used in _write_ownership_sidecar (via json.dump) and ensure_idmapped_rootfs (via json.dumps), but it is not imported in this file. This will raise a NameError at runtime when these functions are executed. Please import json at the top of the file.

Suggested change
import subprocess
import sys
import json
import subprocess
import sys

Comment on lines +183 to +197
holder = subprocess.Popen(
["unshare", "--user", "sleep", "5"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
wait_for_userns(holder.pid)
map_ids_into(holder.pid, candidate)
return sudo_mapfile
except (OSError, RuntimeError):
pass
finally:
holder.kill()
holder.communicate()

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.

high

In _probe_sudo_mapfile, subprocess.Popen is called outside of the try block. If unshare is missing or fails to execute (raising an OSError), the exception will propagate unhandled and crash the detection process instead of gracefully falling back to a single-uid namespace. Moving the Popen call inside the try block and checking if holder is not None in the finally block ensures robust error handling.

Suggested change
holder = subprocess.Popen(
["unshare", "--user", "sleep", "5"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
wait_for_userns(holder.pid)
map_ids_into(holder.pid, candidate)
return sudo_mapfile
except (OSError, RuntimeError):
pass
finally:
holder.kill()
holder.communicate()
holder = None
try:
holder = subprocess.Popen(
["unshare", "--user", "sleep", "5"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
wait_for_userns(holder.pid)
map_ids_into(holder.pid, candidate)
return sudo_mapfile
except (OSError, RuntimeError):
pass
finally:
if holder is not None:
holder.kill()
holder.communicate()

Comment thread python/ray/experimental/sandbox/backend/gvisor.py
Comment thread python/ray/experimental/sandbox/_internal/image_utils.py
@ray-gardener ray-gardener Bot added the core Issues that should be addressed in Ray Core label Sep 1, 2026
xyuzh added 11 commits August 31, 2026 23:52
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>
… via pasta

Each public-mode sandbox now runs inside its own network namespace
bridged by pasta (passt) user-mode networking: internet egress works,
but ports and loopback are private — a bind on 0.0.0.0 cannot collide
with the worker pod or other sandboxes, and nothing in the sandbox is
reachable from outside it. This fulfills public's documented contract
(egress without the host's network identity) and unblocks workloads
that bind fixed ports, e.g. QEMU hostfwd tasks in Terminal-Bench.

runsc still runs with --network=host; host is simply the private
namespace. The mount namespace stays shared so runsc control sockets
keep working. RAY_SANDBOX_PUBLIC_HOST_NETNS=1 on workers restores the
previous shared-namespace behavior without a deploy; the
auto_install_pasta server setting (default off) downloads a static
pasta build on nodes that lack the passt package.

Signed-off-by: xyuzh <xinyzng@gmail.com>
…ns via pasta

Each public-mode sandbox gets a private user+network namespace pair,
bridged by pasta (passt) user-mode networking in the rootless-Podman
topology: a holder process pins the namespaces, pasta attaches from the
pod side (so its uplink is the pod's real interface) and daemonizes,
and runsc enters via nsenter as mapped root. Internet egress works, but
ports and loopback are private — a bind on 0.0.0.0 cannot collide with
the worker pod or other sandboxes, and nothing in the sandbox is
reachable from outside it. This fulfills public's documented contract
(egress without the host's network identity) and unblocks workloads
that bind fixed ports, e.g. QEMU hostfwd tasks in Terminal-Bench.

runsc still runs with --network=host — "host" is simply the private
namespace — and without --rootless: it is already root in the holder's
user namespace, and a nested user namespace would put the gofer's
/proc/<pid>/root magic links out of reach. Mount and pid namespaces
stay shared, so the bundle and the runsc control sockets keep working
for pod-side state/exec/kill/delete. The host boot path creates
/dev/net/tun (absent in fresh Kubernetes pods) via passwordless sudo.

RAY_SANDBOX_PUBLIC_HOST_NETNS=1 on workers restores the previous
shared-namespace behavior without a deploy; the auto_install_pasta
server setting (default off) downloads a static pasta build on nodes
without the passt package.

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

Rootless-style sandboxes previously mapped the whole container to one
host uid: every file read as root, chown to any other user failed with
EINVAL, and ownership baked into image layers (postfix's 0700 uid-101
spool, mailman's uid-38 data) flattened away — failing workloads that
spread ownership across users.

network="public" sandboxes now map subordinate id ranges into their user
namespace, the rootless-Podman model: the holder starts unmapped and the
setuid newuidmap/newgidmap helpers write "0 <worker-id> 1" plus
"1 <subbase> <count>" before pasta and runsc join as mapped root.
detect_idmap() degrades to the previous single-uid mapping (warn-once)
when the uidmap helpers, /etc/subuid ranges, or no_new_privs make the
mapping impossible; RAY_SANDBOX_SINGLE_UID=1 forces that fallback.

Image-baked ownership survives through a reworked cache: extraction
records each layer's non-root owners (whiteout-aware) into an
.ownership.json sidecar, the cached tar restores true uid/gid onto its
members (the shared extracted rootfs stays worker-owned for single-uid
sandboxes), and multi-uid sandboxes mount a per-node "<image>.idmap"
rootfs variant extracted from that tar inside an ephemeral mapped user
namespace — chown-before-chmod so setuid/setgid bits survive, ids
outside the range skipped with a warning. The .extracted marker is
versioned (ownership-v2), so legacy caches re-pull once.

boot() best-effort-appends missing subordinate id entries via sudo
(uidmap package install is warned about, not attempted). Traversing
another user's 0700 directory as root additionally needs
CAP_DAC_OVERRIDE, which Docker's default capability set carries and the
bare runsc-spec default does not; documented in troubleshooting.

Signed-off-by: xyuzh <xinyzng@gmail.com>
@xyuzh
xyuzh force-pushed the sandbox-multi-uid branch from d633a27 to f2f0c1b Compare September 1, 2026 07:00
euid, egid = os.geteuid(), os.getegid()
name = _user_name()
uid_range = parse_subid_file("/etc/subuid", name, euid)
gid_range = parse_subid_file("/etc/subgid", name, egid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Subgid lookup uses gid not uid

Medium Severity

/etc/subgid is keyed by user name or numeric uid, same as /etc/subuid, but detect_idmap looks it up with egid. When the file is uid-keyed and the worker's uid and gid differ, the range is missed and multi-uid silently falls back to a single-uid namespace. _ensure_idmap uses the same gid key when deciding whether a subgid line already exists.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f2f0c1b. Configure here.

…t the sandbox

The netns tests boot a network="public" sandbox, whose non-rootless runsc
drops the sandbox process to nobody and opens a nested user namespace. Some
sandboxed CI hosts allow a single unprivileged user namespace (enough for the
rootless tests) yet forbid that nested open, so the tests failed with
"Couldn't open user namespace ...: Permission denied". Gate ensure_pasta on a
ground-truth probe that boots and tears down a throwaway busybox sandbox, and
skip when the host cannot.

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 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit d74d0a1. Configure here.

except SandboxError:
return False
backend.delete_sandbox(sandbox_id)
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Probe skips tests on any failure

Medium Severity

_public_netns_supported treats every SandboxError from create_sandbox as an unsupported host and ensure_pasta then skips the whole netns suite, always blaming a nested user namespace. Image-pull failures, pasta/tun errors, idmap extract problems, timeouts, and regressions in the pasta boot path all become silent skips, so a broken network="public" create no longer fails CI.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d74d0a1. 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant