Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1275,13 +1275,9 @@ enum SandboxCommands {
#[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs"])]
template: Option<String>,

/// Sandbox source: a community sandbox name (e.g., `ollama`), a rootfs
/// tar archive (`.tar`, `.tar.gz`, or `.tgz`), or a full container
/// image reference (e.g., `myregistry.com/img:tag`).
///
/// Community names are resolved to
/// `ghcr.io/nvidia/openshell-community/sandboxes/<name>:latest`
/// (override the prefix with `OPENSHELL_COMMUNITY_REGISTRY`).
/// Sandbox source: a full container image reference (e.g.,
/// `docker.io/library/alpine:3.22`, `myregistry.com/img:tag`) or a
/// rootfs tar archive (`.tar`, `.tar.gz`, or `.tgz`).
///
/// To use a local Dockerfile, build and tag it with the container
/// engine used by your local gateway, then pass the resulting image
Expand Down
7 changes: 2 additions & 5 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1234,11 +1234,8 @@ fn resolve_from(value: &str) -> Result<ResolvedSource> {
));
}

// Full image reference or community sandbox name — delegate to shared
// resolution in openshell-core.
Ok(ResolvedSource::Image(
openshell_core::image::resolve_community_image(value),
))
// Explicit OCI image reference — passed through to the gateway unchanged.
Ok(ResolvedSource::Image(value.to_string()))
}

#[allow(clippy::case_sensitive_file_extension_comparisons)] // already lowercased
Expand Down
122 changes: 8 additions & 114 deletions crates/openshell-core/src/image.rs
Original file line number Diff line number Diff line change
@@ -1,124 +1,18 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Shared image-name resolution for community sandbox images.
//! Default sandbox image.
//!
//! Both the CLI and TUI need to expand bare sandbox names (e.g. `"base"`) into
//! fully-qualified container image references. This module centralises that
//! logic so every client resolves names identically.
//! Provides the fallback image used by all compute drivers when a sandbox spec
//! does not specify one. User-supplied `--from` values are explicit OCI image
//! references passed through unchanged by the CLI and TUI.

/// Default registry prefix for community sandbox images.
///
/// Bare sandbox names are expanded to `{prefix}/{name}:latest`.
/// Override at runtime with the `OPENSHELL_COMMUNITY_REGISTRY` env var.
pub const DEFAULT_COMMUNITY_REGISTRY: &str = "ghcr.io/nvidia/openshell-community/sandboxes";

/// Return the default sandbox image reference (`{registry}/base:latest`).
/// Return the default sandbox image reference.
///
/// Used by all compute drivers as the fallback image when none is specified in
/// the sandbox spec.
/// the sandbox spec. Defaults to a generic, version-qualified official Alpine
/// image so a fresh install does not depend on the community image catalog.
#[must_use]
pub fn default_sandbox_image() -> String {
format!("{DEFAULT_COMMUNITY_REGISTRY}/base:latest")
}

/// Resolve a user-supplied image string into a fully-qualified reference.
///
/// Resolution rules (applied in order):
/// 1. If the value contains `/`, `:`, or `.` it is treated as a complete image
/// reference and returned as-is.
/// 2. Otherwise it is treated as a community sandbox name and expanded to
/// `{registry}/{value}:latest` where `{registry}` defaults to
/// [`DEFAULT_COMMUNITY_REGISTRY`] but can be overridden via the
/// `OPENSHELL_COMMUNITY_REGISTRY` environment variable.
///
/// This function only handles image-name resolution. Dockerfile detection is
/// the responsibility of the caller (e.g. the CLI's `resolve_from()`).
pub fn resolve_community_image(value: &str) -> String {
// Already a fully-qualified reference.
if value.contains('/') || value.contains(':') || value.contains('.') {
return value.to_string();
}

// Community sandbox shorthand → expand with registry prefix.
let prefix = std::env::var("OPENSHELL_COMMUNITY_REGISTRY")
.unwrap_or_else(|_| DEFAULT_COMMUNITY_REGISTRY.to_string());
let prefix = prefix.trim_end_matches('/');
format!("{prefix}/{value}:latest")
}

#[cfg(test)]
#[allow(unsafe_code)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};

fn env_lock() -> &'static Mutex<()> {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
ENV_LOCK.get_or_init(|| Mutex::new(()))
}

#[test]
fn bare_name_expands_to_community_registry() {
let _guard = env_lock().lock().unwrap();
let result = resolve_community_image("base");
assert_eq!(
result,
"ghcr.io/nvidia/openshell-community/sandboxes/base:latest"
);
}

#[test]
fn bare_name_with_env_override() {
let _guard = env_lock().lock().unwrap();
// Use a temp env override. Safety: test-only, and these env-var tests
// are not run concurrently with other tests reading the same var.
let key = "OPENSHELL_COMMUNITY_REGISTRY";
let prev = std::env::var(key).ok();
// SAFETY: single-threaded test context; no other thread reads this var.
unsafe { std::env::set_var(key, "my-registry.example.com/sandboxes") };
let result = resolve_community_image("python");
assert_eq!(result, "my-registry.example.com/sandboxes/python:latest");
// Restore.
match prev {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}

#[test]
fn full_reference_with_slash_passes_through() {
let _guard = env_lock().lock().unwrap();
let input = "ghcr.io/myorg/myimage:v1";
assert_eq!(resolve_community_image(input), input);
}

#[test]
fn reference_with_colon_passes_through() {
let _guard = env_lock().lock().unwrap();
let input = "myimage:latest";
assert_eq!(resolve_community_image(input), input);
}

#[test]
fn reference_with_dot_passes_through() {
let _guard = env_lock().lock().unwrap();
let input = "registry.example.com";
assert_eq!(resolve_community_image(input), input);
}

#[test]
fn trailing_slash_in_env_is_trimmed() {
let _guard = env_lock().lock().unwrap();
let key = "OPENSHELL_COMMUNITY_REGISTRY";
let prev = std::env::var(key).ok();
// SAFETY: single-threaded test context; no other thread reads this var.
unsafe { std::env::set_var(key, "my-registry.example.com/sandboxes/") };
let result = resolve_community_image("base");
assert_eq!(result, "my-registry.example.com/sandboxes/base:latest");
match prev {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
"docker.io/library/alpine:3.22".to_string()
}
12 changes: 12 additions & 0 deletions crates/openshell-core/src/sandbox_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,18 @@ pub const SANDBOX_UID: &str = "OPENSHELL_SANDBOX_UID";
/// supervisor drops privileges to a group other than the UID's primary group.
pub const SANDBOX_GID: &str = "OPENSHELL_SANDBOX_GID";

/// Default numeric UID assigned to a sandbox when the image declares no OCI
/// `USER` (e.g. a plain Alpine base).
///
/// Local container drivers (Docker, Podman) supply this in place of an empty
/// OCI declaration so the supervisor runs the sandbox as a synthesized non-root
/// account instead of rejecting the image, matching the numeric-identity
/// behavior of the Kubernetes and VM drivers.
pub const DEFAULT_SANDBOX_UID: u32 = 1000;

/// Default numeric GID paired with [`DEFAULT_SANDBOX_UID`].
pub const DEFAULT_SANDBOX_GID: u32 = 1000;

/// Raw OCI `Config.User` declaration from the immutable image selected by a
/// local container driver.
///
Expand Down
43 changes: 31 additions & 12 deletions crates/openshell-driver-docker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2896,18 +2896,37 @@ fn build_environment_for_oci_user(
// hostname could otherwise present a certificate for a name they control
// and intercept the sandbox JWT.
environment.remove(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME);
environment.insert(
openshell_core::sandbox_env::OCI_IMAGE_USER.to_string(),
oci_user.to_string(),
);
environment.insert(
openshell_core::sandbox_env::SANDBOX_UID.to_string(),
String::new(),
);
environment.insert(
openshell_core::sandbox_env::SANDBOX_GID.to_string(),
String::new(),
);
if oci_user.is_empty() {
// The image declares no OCI USER (e.g. a plain Alpine base). Assign a
// numeric non-root identity like the Kubernetes and VM drivers so the
// supervisor synthesizes the account instead of rejecting the image.
environment.insert(
openshell_core::sandbox_env::OCI_IMAGE_USER.to_string(),
String::new(),
);
environment.insert(
openshell_core::sandbox_env::SANDBOX_UID.to_string(),
openshell_core::sandbox_env::DEFAULT_SANDBOX_UID.to_string(),
);
environment.insert(
openshell_core::sandbox_env::SANDBOX_GID.to_string(),
openshell_core::sandbox_env::DEFAULT_SANDBOX_GID.to_string(),
);
} else {
// The image declares a USER; preserve the OCI resolution path.
environment.insert(
openshell_core::sandbox_env::OCI_IMAGE_USER.to_string(),
oci_user.to_string(),
);
environment.insert(
openshell_core::sandbox_env::SANDBOX_UID.to_string(),
String::new(),
);
environment.insert(
openshell_core::sandbox_env::SANDBOX_GID.to_string(),
String::new(),
);
}

// Gateway-minted sandbox JWT. Keep the raw bearer out of container
// metadata; the supervisor reads it from this driver-owned bind mount.
Expand Down
43 changes: 31 additions & 12 deletions crates/openshell-driver-podman/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,18 +578,37 @@ fn build_env(
// hostname could otherwise present a certificate for a name they control
// and intercept the sandbox JWT.
env.remove(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME);
env.insert(
openshell_core::sandbox_env::OCI_IMAGE_USER.into(),
oci_user.to_string(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_UID.into(),
String::new(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_GID.into(),
String::new(),
);
if oci_user.is_empty() {
// The image declares no OCI USER (e.g. a plain Alpine base). Assign a
// numeric non-root identity like the Kubernetes and VM drivers so the
// supervisor synthesizes the account instead of rejecting the image.
env.insert(
openshell_core::sandbox_env::OCI_IMAGE_USER.into(),
String::new(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_UID.into(),
openshell_core::sandbox_env::DEFAULT_SANDBOX_UID.to_string(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_GID.into(),
openshell_core::sandbox_env::DEFAULT_SANDBOX_GID.to_string(),
);
} else {
// The image declares a USER; preserve the OCI resolution path.
env.insert(
openshell_core::sandbox_env::OCI_IMAGE_USER.into(),
oci_user.to_string(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_UID.into(),
String::new(),
);
env.insert(
openshell_core::sandbox_env::SANDBOX_GID.into(),
String::new(),
);
}

// 4. Gateway-minted sandbox JWT. Keep the raw bearer out of container
// metadata; the supervisor reads it from a driver-owned bind mount.
Expand Down
1 change: 0 additions & 1 deletion crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1282,7 +1282,6 @@ pub fn restrictive_default_policy() -> SandboxPolicy {
"/lib".into(),
"/proc".into(),
"/dev/urandom".into(),
"/app".into(),
"/etc".into(),
"/var/log".into(),
],
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-supervisor-process/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ rustix = { workspace = true }

[target.'cfg(target_os = "linux")'.dependencies]
capctl = "0.2.4"
futures-util = { version = "0.3", default-features = false }
landlock = "0.4"
netlink-packet-route = "0.19"
rtnetlink = "0.14"
seccompiler = "0.5"
socket2 = { workspace = true }
tempfile = "3"
Expand Down
6 changes: 4 additions & 2 deletions crates/openshell-supervisor-process/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ impl DriverIdentity {
) -> Result<Self> {
// Resolved-identity drivers explicitly clear the OCI declaration so
// an image-baked or user-supplied value cannot select the OCI path.
// Preserve an empty declaration when no resolved pair is present:
// Docker and Podman use that state to reject images without USER.
// Preserve an empty declaration when no resolved pair is present so a
// bare OCI path still rejects a USER-less image; container drivers now
// pair an empty declaration with a numeric default for USER-less images,
// which selects the resolved path here instead of rejecting.
let oci_user = if oci_user.as_deref() == Some("") && (uid.is_some() || gid.is_some()) {
None
} else {
Expand Down
Loading
Loading