From d88b1768b0313280333f921e61699735ba130e0b Mon Sep 17 00:00:00 2001 From: Akram Date: Thu, 10 Sep 2026 23:54:37 +0400 Subject: [PATCH 1/7] feat(sandbox): default to official Alpine sandbox image default_sandbox_image() now returns docker.io/library/alpine:3.22, a generic version-qualified official image, so a fresh install no longer depends on the community sandbox image catalog. All compute drivers (docker, podman, kubernetes, vm) inherit this fallback. Part of #3116. Signed-off-by: Akram Signed-off-by: Akram --- crates/openshell-core/src/image.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/openshell-core/src/image.rs b/crates/openshell-core/src/image.rs index e804afd60f..e1b242cbfa 100644 --- a/crates/openshell-core/src/image.rs +++ b/crates/openshell-core/src/image.rs @@ -13,13 +13,19 @@ /// 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`). +/// Default sandbox base image reference. +/// +/// A generic, version-qualified official Alpine image so a fresh install does +/// not depend on the community image catalog. +pub const DEFAULT_SANDBOX_BASE_IMAGE: &str = "docker.io/library/alpine:3.22"; + +/// Return the default sandbox image reference. /// /// Used by all compute drivers as the fallback image when none is specified in /// the sandbox spec. #[must_use] pub fn default_sandbox_image() -> String { - format!("{DEFAULT_COMMUNITY_REGISTRY}/base:latest") + DEFAULT_SANDBOX_BASE_IMAGE.to_string() } /// Resolve a user-supplied image string into a fully-qualified reference. From 487cdaebd62ea105f851a23cd5979a63dc12efc9 Mon Sep 17 00:00:00 2001 From: Akram Date: Fri, 11 Sep 2026 15:57:23 +0400 Subject: [PATCH 2/7] feat(deploy): default deployment configs to the official Alpine sandbox image Update the shared gateway default_image, Helm chart values, the standalone Kubernetes manifest, and the dev gateway task scripts to use docker.io/library/alpine:3.22 instead of the community base image, consistent with default_sandbox_image(). GPU e2e image-build base is left unchanged (CUDA needs a glibc base). Part of #3116. Signed-off-by: Akram Signed-off-by: Akram --- deploy/docker/gateway.toml | 2 +- deploy/helm/openshell/values.yaml | 2 +- deploy/kube/manifests/openshell-helmchart.yaml | 2 +- tasks/scripts/gateway-docker.sh | 2 +- tasks/scripts/gateway-podman.sh | 2 +- tasks/scripts/gateway.sh | 2 +- tasks/scripts/helm-k3s-local.sh | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/deploy/docker/gateway.toml b/deploy/docker/gateway.toml index 1372bfc3fe..463bbd585b 100644 --- a/deploy/docker/gateway.toml +++ b/deploy/docker/gateway.toml @@ -35,7 +35,7 @@ disable_tls = true [openshell.drivers.docker] # Default image pulled for `openshell sandbox create` without --from. -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "docker.io/library/alpine:3.22" # Sandbox runtime image from which the openshell-sandbox binary is extracted. sandbox_runtime_image = "ghcr.io/nvidia/openshell/sandbox:latest" # Image containing the external supervisor process. diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 67d5587ec9..6fedb52862 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -214,7 +214,7 @@ server: # `uri` key, e.g. postgresql://user:pass@host:5432/dbname. externalDbSecret: "" # -- Default sandbox image used when requests do not specify one. - sandboxImage: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" + sandboxImage: "docker.io/library/alpine:3.22" # -- Pull policy for sandbox pods. Leave unset to use the Kubernetes image # default (Always for :latest, IfNotPresent otherwise). Prefer always, # if_not_present, or never; the chart also accepts legacy Kubernetes spellings diff --git a/deploy/kube/manifests/openshell-helmchart.yaml b/deploy/kube/manifests/openshell-helmchart.yaml index 3ca6e3b902..8fd83c2796 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -29,7 +29,7 @@ spec: tag: latest pullPolicy: __IMAGE_PULL_POLICY__ server: - sandboxImage: ghcr.io/nvidia/openshell-community/sandboxes/base:latest + sandboxImage: docker.io/library/alpine:3.22 sandboxImagePullPolicy: __SANDBOX_IMAGE_PULL_POLICY__ supervisorImage: ghcr.io/nvidia/openshell/supervisor:latest dbUrl: __DB_URL__ diff --git a/tasks/scripts/gateway-docker.sh b/tasks/scripts/gateway-docker.sh index a98ff3ea2b..9490d631d3 100644 --- a/tasks/scripts/gateway-docker.sh +++ b/tasks/scripts/gateway-docker.sh @@ -34,7 +34,7 @@ PORT="${OPENSHELL_SERVER_PORT:-18080}" GATEWAY_NAME="${OPENSHELL_DOCKER_GATEWAY_NAME:-docker-dev}" STATE_DIR="${OPENSHELL_DOCKER_GATEWAY_STATE_DIR:-${ROOT}/.cache/gateway-docker}" SANDBOX_NAMESPACE="${OPENSHELL_SANDBOX_NAMESPACE:-docker-dev}" -SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-docker.io/library/alpine:3.22}" SUPERVISOR_IMAGE="${OPENSHELL_SUPERVISOR_IMAGE:-openshell/supervisor:dev}" SANDBOX_RUNTIME_IMAGE="${OPENSHELL_SANDBOX_RUNTIME_IMAGE:-openshell/sandbox:dev}" SANDBOX_IMAGE_PULL_POLICY="$(normalize_image_pull_policy "${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}")" diff --git a/tasks/scripts/gateway-podman.sh b/tasks/scripts/gateway-podman.sh index 745dcd542f..5d7c718f51 100644 --- a/tasks/scripts/gateway-podman.sh +++ b/tasks/scripts/gateway-podman.sh @@ -31,7 +31,7 @@ PORT="${OPENSHELL_SERVER_PORT:-18080}" GATEWAY_NAME="${OPENSHELL_PODMAN_GATEWAY_NAME:-podman-dev}" STATE_DIR="${OPENSHELL_PODMAN_GATEWAY_STATE_DIR:-${OPENSHELL_GATEWAY_STATE_DIR:-${ROOT}/.cache/gateway-podman}}" SANDBOX_NAMESPACE="${OPENSHELL_SANDBOX_NAMESPACE:-podman-dev}" -SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-docker.io/library/alpine:3.22}" SANDBOX_IMAGE_PULL_POLICY="$(normalize_image_pull_policy "${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}")" GRPC_ENDPOINT="${OPENSHELL_GRPC_ENDPOINT:-}" LOG_LEVEL="${OPENSHELL_LOG_LEVEL:-info}" diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index 34bc143fb6..edacadb904 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -207,7 +207,7 @@ PORT="${OPENSHELL_SERVER_PORT:-8080}" GATEWAY_NAME="${OPENSHELL_GATEWAY_NAME:-${DRIVER}-dev}" STATE_DIR="${OPENSHELL_GATEWAY_STATE_DIR:-${ROOT}/.cache/gateway-${DRIVER}}" SANDBOX_NAMESPACE="${OPENSHELL_SANDBOX_NAMESPACE:-${DRIVER}-dev}" -SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-docker.io/library/alpine:3.22}" SANDBOX_IMAGE_PULL_POLICY="$(normalize_image_pull_policy "${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}")" GRPC_ENDPOINT="${OPENSHELL_GRPC_ENDPOINT:-}" LOG_LEVEL="${OPENSHELL_LOG_LEVEL:-info}" diff --git a/tasks/scripts/helm-k3s-local.sh b/tasks/scripts/helm-k3s-local.sh index dc8adb9bdf..f93dbc3fb6 100755 --- a/tasks/scripts/helm-k3s-local.sh +++ b/tasks/scripts/helm-k3s-local.sh @@ -29,7 +29,7 @@ K3D_CLUSTER_NAME_MAX=32 HOST_LB_PORT="${HELM_K3S_LB_HOST_PORT:-8080}" # Preload the default community sandbox image so the first sandbox create does # not pay the full registry pull cost inside the cluster. -DEFAULT_SANDBOX_PRELOAD_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +DEFAULT_SANDBOX_PRELOAD_IMAGE="docker.io/library/alpine:3.22" PRELOAD_SANDBOX_IMAGE="${HELM_K3S_PRELOAD_SANDBOX_IMAGE-${DEFAULT_SANDBOX_PRELOAD_IMAGE}}" # Upstream agent-sandbox release pinned for both CRDs/controller and extensions. From 26e3e5f07d1a92ab1153e26543e08b52cd2b4ebf Mon Sep 17 00:00:00 2001 From: Akram Date: Wed, 16 Sep 2026 19:47:32 +0400 Subject: [PATCH 3/7] feat(driver): default to numeric non-root identity for USER-less images With the default sandbox image now Alpine, images that declare no OCI USER must start instead of being rejected. When the image declares no USER and the policy requests none, the Podman and Docker drivers now supply a numeric non-root identity (DEFAULT_SANDBOX_UID/GID = 1000) instead of rejecting, matching the numeric-identity behavior of the Kubernetes and VM drivers. The supervisor's resolved-identity path runs the sandbox as a synthesized non-root account without the account existing in the image. Images that declare a USER keep the OCI resolution path unchanged. Part of #3116. Signed-off-by: Akram --- crates/openshell-core/src/sandbox_env.rs | 12 ++++++ crates/openshell-driver-docker/src/lib.rs | 15 +++++-- .../openshell-driver-podman/src/container.rs | 43 +++++++++++++------ 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index c1c91822b6..d298c5e99b 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -243,6 +243,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. /// diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index bdc1b27a64..ee65a04db0 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -630,9 +630,18 @@ fn resolve_docker_identity_from_accounts( requested_user }; if user_selector.is_empty() { - return Err(Status::failed_precondition( - "the pinned image defaults to root; configure a non-root process.run_as_user", - )); + // The image declares no USER (e.g. a plain Alpine base) and the policy + // requested none. Synthesize a numeric non-root identity instead of + // rejecting, matching the Podman driver's USER-less default and the + // numeric-identity behavior of the Kubernetes and VM drivers. + return ResolvedWorkloadIdentity::new( + openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + Vec::new(), + "default".to_string(), + image.id.clone(), + ) + .map_err(|error| Status::failed_precondition(error.to_string())); } let (uid, passwd_entry) = resolve_numeric_or_named_user(user_selector, &passwd)?; let username = passwd_entry.map(|entry| entry.name.as_str()); diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 572f741ac2..7b53a81c41 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -593,18 +593,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. From d2da8bbc04b06aa0660c93d741a556600f63cd35 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Sat, 19 Sep 2026 11:27:54 +0200 Subject: [PATCH 4/7] test(conformance): use Alpine workload image Signed-off-by: Evan Lezar --- .../src/scenarios/sandbox_lifecycle.rs | 2 - .../src/scenarios/smoke.rs | 10 +--- crates/openshell-policy/src/lib.rs | 5 ++ crates/openshell-sandbox/src/boundary_exec.rs | 3 +- .../src/sandbox/linux/landlock.rs | 21 ++++++- .../openshell-supervisor-process/src/ssh.rs | 60 ++++++++++++++----- .../templates/gateway-docker.toml.j2 | 1 - .../templates/gateway-podman.toml.j2 | 1 - 8 files changed, 72 insertions(+), 31 deletions(-) diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs index a06de97a43..61a26558ff 100644 --- a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs +++ b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs @@ -141,8 +141,6 @@ async fn create_running_sandbox( "create", "--name", sandbox_name, - "--from", - "base", "--detach", "--no-tty", "--", diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index 7da9ef2210..e9ba070dac 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -55,15 +55,7 @@ async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { .step("create") .description("sandbox creation succeeds") .with_timeout(CREATE_TIMEOUT) - .run(&[ - "sandbox", - "create", - "--name", - &sandbox_name, - "--from", - "base", - "--detach", - ]) + .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) .await .map_err(|error| error.to_string())?; create.require_success()?; diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 5443883ac9..cefd4283be 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -988,6 +988,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { filesystem: Some(FilesystemPolicy { include_workdir: true, read_only: vec![ + "/bin".into(), "/usr".into(), "/lib".into(), "/proc".into(), @@ -2128,6 +2129,10 @@ network_policies: let policy = restrictive_default_policy(); let fs = policy.filesystem.expect("must have filesystem policy"); assert!(fs.include_workdir); + assert!( + fs.read_only.iter().any(|p| p == "/bin"), + "read_only should contain /bin" + ); assert!( fs.read_only.iter().any(|p| p == "/usr"), "read_only should contain /usr" diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index 57d9778a6f..a01e430de3 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -69,12 +69,13 @@ impl LocalBoundaryExec { let (session_user, session_home) = crate::process::session_user_and_home(&self.policy, effective_workdir); let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()); + let shell = openshell_core::shell::detect_login_shell(); command .env_clear() .env(openshell_core::sandbox_env::SANDBOX, "1") .env("HOME", session_home) .env("USER", session_user) - .env("SHELL", "/bin/bash") + .env("SHELL", shell) .env("PATH", path) .env("TERM", if spec.pty { "xterm-256color" } else { "dumb" }); for (key, value) in &self.user_environment { diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index c18b633e75..bd0f3520ea 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -302,7 +302,6 @@ fn prepare_with_path_open_mode( let result: Result = (|| { let access_all = AccessFs::from_all(abi); - let access_read = AccessFs::from_read(abi); let mut ruleset = Ruleset::default(); ruleset = ruleset @@ -315,7 +314,8 @@ fn prepare_with_path_open_mode( for path in &read_only { if let Some(path_fd) = try_open_path(path, compatibility, path_open_mode)? { - let allowed_access = access_for_path_fd(&path_fd, access_read, abi)?; + let allowed_access = + access_for_path_fd(&path_fd, read_only_access(path, abi), abi)?; debug!(path = %path.display(), "Landlock allow read-only"); ruleset = ruleset .add_rule(PathBeneath::new(path_fd, allowed_access)) @@ -434,6 +434,17 @@ pub fn enforce(prepared: PreparedRuleset) -> Result<()> { Ok(()) } +/// The baseline permits execution only from Alpine's `/bin`. Other read-only +/// paths, including `/proc` and `/etc`, remain non-executable. +fn read_only_access(path: &Path, abi: ABI) -> BitFlags { + let access = AccessFs::from_read(abi); + if path == Path::new("/bin") { + access | AccessFs::Execute + } else { + access + } +} + /// Tailor a rule's access mask to the inode referenced by its already-open FD. /// /// Landlock directory-only rights such as `ReadDir` are invalid for regular @@ -727,6 +738,12 @@ mod tests { ); } + #[test] + fn only_bin_read_only_access_includes_execute() { + assert!(read_only_access(Path::new("/bin"), ABI::V3).contains(AccessFs::Execute)); + assert!(!read_only_access(Path::new("/usr"), ABI::V3).contains(AccessFs::Execute)); + } + #[test] fn access_for_path_fd_limits_regular_file_access() { let file = tempfile::NamedTempFile::new().unwrap(); diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index c93f361e24..5d3a56e2aa 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -892,21 +892,11 @@ impl SshHandler { let no_login_shell = state.no_login_shell; let pty = state.pty_request.take(); let pty_requested = pty.is_some(); - let (program, args) = command.map_or_else( - || { - if pty_requested { - ("/bin/bash".to_string(), vec!["-i".to_string()]) - } else { - ("/bin/bash".to_string(), vec![]) - } - }, - |command| { - ( - "/bin/bash".to_string(), - vec![login_shell_flag(no_login_shell).to_string(), command], - ) - }, - ); + // The supervisor shares the workload filesystem, so select a shell + // that actually exists in the image. Alpine and other minimal images + // provide `/bin/sh` but not `/bin/bash`. + let shell = openshell_core::shell::detect_login_shell(); + let (program, args) = shell_command(shell, command, pty_requested, no_login_shell); let env = pty .as_ref() .map(|request| vec![("TERM".to_string(), request.term.clone())]) @@ -1084,6 +1074,25 @@ const fn login_shell_flag(no_login_shell: bool) -> &'static str { if no_login_shell { "-c" } else { "-lc" } } +fn shell_command( + shell: String, + command: Option, + pty_requested: bool, + no_login_shell: bool, +) -> (String, Vec) { + let args = command.map_or_else( + || { + if pty_requested { + vec!["-i".to_string()] + } else { + Vec::new() + } + }, + |command| vec![login_shell_flag(no_login_shell).to_string(), command], + ); + (shell, args) +} + #[allow(dead_code)] #[derive(Clone)] struct PtyRequest { @@ -1400,6 +1409,27 @@ mod tests { main_session.end_terminal_attachment(); } + #[test] + fn shell_command_uses_the_resolved_image_shell() { + let (program, args) = shell_command( + "/bin/sh".to_string(), + Some("printf ready".to_string()), + false, + false, + ); + + assert_eq!(program, "/bin/sh"); + assert_eq!(args, ["-lc", "printf ready"]); + } + + #[test] + fn interactive_shell_uses_the_resolved_image_shell() { + let (program, args) = shell_command("/bin/sh".to_string(), None, true, false); + + assert_eq!(program, "/bin/sh"); + assert_eq!(args, ["-i"]); + } + #[cfg(unix)] fn file_mode(path: &Path) -> u32 { use std::os::unix::fs::PermissionsExt; diff --git a/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 b/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 index 320d2979c7..e1992c60e6 100644 --- a/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 +++ b/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 @@ -4,7 +4,6 @@ {% block driver %} [openshell.drivers.docker] socket_path = "{{ openshell_runtime_socket }}" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" image_pull_policy = "if_not_present" sandbox_label = "tmachine" grpc_endpoint = "http://127.0.0.1:17670" diff --git a/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 b/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 index 61658c1af4..08f9ca4b17 100644 --- a/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 +++ b/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 @@ -4,7 +4,6 @@ {% block driver %} [openshell.drivers.podman] socket_path = "{{ openshell_runtime_socket }}" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" image_pull_policy = "if_not_present" sandbox_runtime_image = "docker.io/openshell/sandbox:tmachine" supervisor_image = "docker.io/openshell/supervisor:tmachine" From 75a18616cd9ab02328c89df86a8073c1d06c2e73 Mon Sep 17 00:00:00 2001 From: Akram Date: Fri, 11 Sep 2026 10:26:56 +0400 Subject: [PATCH 5/7] refactor(policy): drop community image /app path from default policy The restrictive default policy granted read-only access to /app, a directory that only existed in the community base image. A generic Alpine default has no /app, so remove it. Landlock best-effort already ignores absent paths; this just stops advertising a community-specific layout in the default. Part of #3116. Signed-off-by: Akram Signed-off-by: Akram --- crates/openshell-policy/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index cefd4283be..b231458181 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -993,7 +993,6 @@ pub fn restrictive_default_policy() -> SandboxPolicy { "/lib".into(), "/proc".into(), "/dev/urandom".into(), - "/app".into(), "/etc".into(), "/var/log".into(), ], From dd617491dd2dc08c2b9f3efca3b6a542e7095eae Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 21 Sep 2026 12:04:33 +0200 Subject: [PATCH 6/7] docs(config): document Alpine default images Signed-off-by: Evan Lezar --- deploy/helm/openshell/README.md | 2 +- docs/reference/gateway-config.mdx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 003475df96..3da8a4e9c9 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -289,7 +289,7 @@ discovery endpoint or its TLS CA. | server.policyValidationFailureMode | string | `"fail_closed"` | Posture when a candidate sandbox policy fails validation. `fail_closed` deactivates the previous policy; `retain_last_valid` keeps it active. | | server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into gateway and sandbox pods for dynamic provider token grants. | | server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into gateway and sandbox pods. | -| server.sandboxImage | string | `"ghcr.io/nvidia/openshell-community/sandboxes/base:latest"` | Default sandbox image used when requests do not specify one. | +| server.sandboxImage | string | `"docker.io/library/alpine:3.22"` | Default sandbox image used when requests do not specify one. | | server.sandboxImagePullPolicy | string | `nil` | Pull policy for sandbox pods. Leave unset to use the Kubernetes image default (Always for :latest, IfNotPresent otherwise). Prefer always, if_not_present, or never; the chart also accepts legacy Kubernetes spellings Always, IfNotPresent, and Never. | | server.sandboxImagePullSecrets | list | `[]` | Image pull secrets attached to sandbox pods. Referenced Secrets must exist in the sandbox namespace. | | server.sandboxJwt.gatewayId | string | `""` | Stable gateway identity embedded in iss/aud of every minted token. Defaults to the release name so HA replicas share identity. | diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ead6f89f11..6e7672d0bc 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -246,7 +246,7 @@ phases = ["validate"] namespace = "openshell" # Required in raw TOML; Helm derives this from the gateway Service. grpc_endpoint = "https://openshell-gateway.openshell.svc:8080" -default_image = "ghcr.io/nvidia/openshell/sandbox:latest" +default_image = "docker.io/library/alpine:3.22" # Defaults to the gateway version; override to pin a specific build. # supervisor_image = "ghcr.io/nvidia/openshell/supervisor:" client_tls_secret_name = "openshell-client-tls" @@ -549,7 +549,7 @@ workspace_mode = "shared" # gateway_id = "openshell" namespace = "agents" service_account_name = "openshell-sandbox" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "docker.io/library/alpine:3.22" image_pull_policy = "if_not_present" image_pull_secrets = ["regcred"] # Defaults to the gateway version; override to pin a specific build. @@ -699,7 +699,7 @@ guest_tls_key = "/etc/openshell/certs/client-key.pem" [openshell.drivers.docker] socket_path = "/var/run/docker.sock" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "docker.io/library/alpine:3.22" # Canonical values: always | if_not_present | never. `newer` is Podman-only. image_pull_policy = "if_not_present" # Value assigned to the openshell.sandbox_namespace label on sandbox containers. @@ -759,7 +759,7 @@ network_name = "openshell" # asks the podman CLI where its socket is, and fails to start if neither finds # one. Set this to pin a specific Podman machine instead. socket_path = "/run/user/1000/podman/podman.sock" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "docker.io/library/alpine:3.22" image_pull_policy = "if_not_present" # always | if_not_present | never | newer # Optional override. When omitted, Linux uses gateway loopback and Podman # Machine uses host.containers.internal. From 3b0b9620a313e3ce54a3bc5f9ced0b8c699d8e49 Mon Sep 17 00:00:00 2001 From: Akram Date: Wed, 16 Sep 2026 19:38:06 +0400 Subject: [PATCH 7/7] refactor(cli)!: remove community image resolution Remove DEFAULT_COMMUNITY_REGISTRY, resolve_community_image, and the OPENSHELL_COMMUNITY_REGISTRY override. Bare --from values are no longer expanded into the OpenShell Community registry; the CLI and TUI now pass explicit OCI image references through to the gateway unchanged. The openshell-core image module is reduced to default_sandbox_image(). BREAKING CHANGE: community sandbox shorthand names and OPENSHELL_COMMUNITY_REGISTRY are no longer supported; pass a full OCI image reference to --from. Part of #3116. Signed-off-by: Akram --- crates/openshell-cli/src/main.rs | 10 +-- crates/openshell-cli/src/run.rs | 7 +- crates/openshell-core/src/image.rs | 115 +---------------------------- crates/openshell-tui/src/lib.rs | 3 +- 4 files changed, 10 insertions(+), 125 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 9e1beded85..a6b45aefb3 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1278,13 +1278,9 @@ enum SandboxCommands { #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs"])] template: Option, - /// 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/:latest` - /// (override the prefix with `OPENSHELL_COMMUNITY_REGISTRY`). + /// Sandbox source: a full container image reference (e.g., + /// `ghcr.io/owner/image:tag`, `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 diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index a00f6b9dfe..fa39d51f0a 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1313,11 +1313,8 @@ fn resolve_from(value: &str) -> Result { )); } - // 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 diff --git a/crates/openshell-core/src/image.rs b/crates/openshell-core/src/image.rs index e1b242cbfa..8027322828 100644 --- a/crates/openshell-core/src/image.rs +++ b/crates/openshell-core/src/image.rs @@ -1,17 +1,11 @@ // 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. - -/// 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"; +//! 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 sandbox base image reference. /// @@ -27,104 +21,3 @@ pub const DEFAULT_SANDBOX_BASE_IMAGE: &str = "docker.io/library/alpine:3.22"; pub fn default_sandbox_image() -> String { DEFAULT_SANDBOX_BASE_IMAGE.to_string() } - -/// 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> = 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) }, - } - } -} diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index ef361b8b15..03209e969a 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1424,9 +1424,8 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let has_custom_image = !image.is_empty(); let template = if has_custom_image { - let resolved = openshell_core::image::resolve_community_image(&image); Some(openshell_core::proto::SandboxTemplate { - image: resolved, + image, ..Default::default() }) } else {