From ae425f04be97506439297ea3883f8573918c0684 Mon Sep 17 00:00:00 2001 From: Akram Date: Thu, 10 Sep 2026 23:54:37 +0400 Subject: [PATCH 01/17] 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 641866ebfa43f871485440dc842430582d6681f3 Mon Sep 17 00:00:00 2001 From: Akram Date: Fri, 11 Sep 2026 15:57:23 +0400 Subject: [PATCH 02/17] 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 b943160a9d58df0da40aa430535b628d7e65b5b5 Mon Sep 17 00:00:00 2001 From: Akram Date: Wed, 16 Sep 2026 19:47:32 +0400 Subject: [PATCH 03/17] 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 Signed-off-by: Evan Lezar --- crates/openshell-core/src/sandbox_env.rs | 12 ++++++ crates/openshell-driver-docker/src/lib.rs | 15 +++++-- .../openshell-driver-podman/src/container.rs | 43 +++++++++++++------ .../openshell-driver-podman/src/isolation.rs | 35 ++++++++++++++- 4 files changed, 89 insertions(+), 16 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. diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index b616fa6474..92877958a9 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -87,6 +87,19 @@ pub fn resolve_identity( } else { requested_group }; + if user.is_empty() && group.is_empty() { + // The image declares no OCI USER (e.g. a plain Alpine base) and the + // policy requested no identity. Synthesize a numeric non-root identity + // instead of rejecting the image, matching Docker, Kubernetes, and VM. + return ResolvedWorkloadIdentity::new( + openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + Vec::new(), + "default".into(), + image_id.into(), + ) + .map_err(invalid); + } let account = accounts .iter() .find(|(name, uid, _)| *name == user || user.parse::().ok() == Some(*uid)); @@ -361,10 +374,30 @@ mod tests { assert_eq!(identity.supplementary_gids, vec![2000]); assert_eq!(identity.resource_digest, "sha256:pinned"); assert!(resolve_identity(&sandbox, "sha256:pinned", "root", passwd, groups).is_err()); - assert!(resolve_identity(&sandbox, "sha256:pinned", "", passwd, groups).is_err()); assert!(resolve_identity(&sandbox, "sha256:pinned", "2000", passwd, groups).is_err()); } + #[test] + fn identity_uses_numeric_default_for_userless_image() { + let identity = resolve_identity( + &DriverSandbox::default(), + "sha256:pinned", + "", + b"root:x:0:0:root:/root:/bin/sh\n", + b"root:x:0:\n", + ) + .unwrap(); + + assert_eq!( + (identity.uid, identity.gid), + ( + openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + ) + ); + assert_eq!(identity.source, "default"); + } + fn files(bytes: &[u8]) -> BTreeMap> { tar::Archive::new(bytes) .entries() From 2b6bd9c2f6fb55494a5d4e052494c190d0c18fc0 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Sat, 19 Sep 2026 11:27:54 +0200 Subject: [PATCH 04/17] 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 +- .../openshell-supervisor-process/src/ssh.rs | 60 ++++++++++++++----- .../templates/gateway-docker.toml.j2 | 1 - .../templates/gateway-podman.toml.j2 | 1 - 7 files changed, 53 insertions(+), 29 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-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 723b8186b926c79fd9c6544363c94d441680a9a3 Mon Sep 17 00:00:00 2001 From: Akram Date: Fri, 11 Sep 2026 10:26:56 +0400 Subject: [PATCH 05/17] 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 628c6413322145e8a7f1fe46223f62823af2ee5c Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 21 Sep 2026 12:04:33 +0200 Subject: [PATCH 06/17] 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 d614b5637e..19356c7aad 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 39014b863fc088305c865aa8504339fc35dc64ae Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 21 Sep 2026 15:15:36 +0200 Subject: [PATCH 07/17] fix(podman): report early sandbox termination Signed-off-by: Evan Lezar --- crates/openshell-driver-podman/src/client.rs | 26 ++++++++ crates/openshell-driver-podman/src/watcher.rs | 66 ++++++++++++++++++- .../openshell-sandbox/src/boundary_server.rs | 37 ++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/crates/openshell-driver-podman/src/client.rs b/crates/openshell-driver-podman/src/client.rs index 4455551018..8bf332d296 100644 --- a/crates/openshell-driver-podman/src/client.rs +++ b/crates/openshell-driver-podman/src/client.rs @@ -115,6 +115,10 @@ pub struct ContainerState { pub started_at: Option, #[serde(default)] pub finished_at: Option, + /// A driver-local diagnostic derived from a narrowly allow-listed + /// container-log marker. It is never deserialized from Podman. + #[serde(skip)] + pub startup_diagnostic: Option, } #[derive(Debug, Clone, serde::Deserialize)] @@ -643,6 +647,28 @@ impl PodmanClient { .await } + /// Read a bounded tail of a container's combined output. + /// + /// Callers must treat this as sensitive workload output. The Podman + /// watcher uses it only to recognize fixed, driver-owned startup markers; + /// it never forwards the raw output to the gateway. + pub async fn container_logs(&self, name: &str) -> Result { + validate_name(name)?; + let (status, bytes) = self + .request( + hyper::Method::GET, + &format!("/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=200"), + None, + API_TIMEOUT, + ) + .await?; + if status.is_success() { + Ok(bytes) + } else { + Err(error_from_response(status.as_u16(), &bytes)) + } + } + /// List containers matching label filters (e.g. `&["openshell.managed=true"]`). pub async fn list_containers( &self, diff --git a/crates/openshell-driver-podman/src/watcher.rs b/crates/openshell-driver-podman/src/watcher.rs index 56ab14706a..ff0d0b12e4 100644 --- a/crates/openshell-driver-podman/src/watcher.rs +++ b/crates/openshell-driver-podman/src/watcher.rs @@ -396,10 +396,30 @@ pub async fn inspect_workload( } Err(error) => return Err(error), } + } else if matches!(workload.state.status.as_str(), "exited" | "stopped") { + workload.state.startup_diagnostic = client + .container_logs(&workload.id) + .await + .ok() + .and_then(|logs| boundary_startup_termination_marker(&logs)); } Ok(workload) } +/// Extract only fixed, OpenShell-owned startup diagnostics from container +/// output. Workload and supervisor output may contain secrets, so it must not +/// be propagated to driver conditions or tracing. +fn boundary_startup_termination_marker(logs: &[u8]) -> Option { + const SIGTERM_MARKER: &str = "sandbox boundary received SIGTERM before supervisor confirmation"; + const SIGINT_MARKER: &str = "sandbox boundary received SIGINT before supervisor confirmation"; + + let logs = String::from_utf8_lossy(logs); + [SIGTERM_MARKER, SIGINT_MARKER] + .into_iter() + .find(|marker| logs.contains(marker)) + .map(str::to_string) +} + /// Construct a `DriverSandbox` from common fields. /// /// Centralises the boilerplate that every event/inspect/list path shares: @@ -528,7 +548,7 @@ fn condition_from_state(state: &ContainerState) -> DriverCondition { // exiting on its own — the signature of a machine/daemon restart // killing running containers. Those are recoverable at gateway // startup; ordinary application exits (0, non-zero, faults) are not. - let (reason, msg) = if state.oom_killed { + let (reason, mut msg) = if state.oom_killed { ( "OOMKilled", "Container was killed by the OOM killer".to_string(), @@ -552,6 +572,10 @@ fn condition_from_state(state: &ContainerState) -> DriverCondition { format!("Container exited with code {}", state.exit_code), ) }; + if let Some(diagnostic) = &state.startup_diagnostic { + msg.push_str(": "); + msg.push_str(diagnostic); + } ("False", reason, msg) } other => ( @@ -668,6 +692,7 @@ mod tests { health: None, started_at: Some("2026-08-12T16:38:58Z".to_string()), finished_at: Some("2026-08-12T16:39:13Z".to_string()), + startup_diagnostic: None, }; assert!(fences.matches_previous_exit( @@ -716,6 +741,7 @@ mod tests { }), started_at: Some("2026-04-14T10:00:00Z".to_string()), finished_at: None, + startup_diagnostic: None, }; let cond = condition_from_state(&state); assert_eq!(cond.r#type, "Ready"); @@ -734,6 +760,7 @@ mod tests { health: None, started_at: Some("2026-04-14T10:00:00Z".to_string()), finished_at: None, + startup_diagnostic: None, }; let cond = condition_from_state(&state); assert_eq!(cond.r#type, "Ready"); @@ -755,6 +782,7 @@ mod tests { }), started_at: Some("2026-04-14T10:00:00Z".to_string()), finished_at: None, + startup_diagnostic: None, }; let condition = condition_from_state(&state); assert_eq!(condition.r#type, "Ready"); @@ -772,6 +800,7 @@ mod tests { health: None, started_at: None, finished_at: Some("2026-04-14T11:00:00Z".to_string()), + startup_diagnostic: None, }; let cond = condition_from_state(&state); assert_eq!(cond.status, "False"); @@ -789,6 +818,7 @@ mod tests { health: None, started_at: None, finished_at: Some("2026-04-14T12:00:00Z".to_string()), + startup_diagnostic: None, }; let cond = condition_from_state(&state); assert_eq!(cond.status, "False"); @@ -796,6 +826,38 @@ mod tests { assert!(cond.message.contains("code 1")); } + #[test] + fn condition_includes_allow_listed_boundary_startup_diagnostic() { + let state = ContainerState { + status: "exited".to_string(), + running: false, + exit_code: 1, + oom_killed: false, + health: None, + started_at: None, + finished_at: Some("2026-04-14T12:00:00Z".to_string()), + startup_diagnostic: boundary_startup_termination_marker( + b"untrusted workload output\nsandbox boundary received SIGTERM before supervisor confirmation\n", + ), + }; + + let condition = condition_from_state(&state); + + assert_eq!(condition.reason, CONDITION_EXITED); + assert_eq!( + condition.message, + "Container exited with code 1: sandbox boundary received SIGTERM before supervisor confirmation" + ); + } + + #[test] + fn boundary_startup_diagnostic_does_not_forward_unrecognized_logs() { + assert_eq!( + boundary_startup_termination_marker(b"token=not-for-the-driver"), + None + ); + } + #[test] fn condition_workspace_validation_exit_is_reported_explicitly() { let state = ContainerState { @@ -806,6 +868,7 @@ mod tests { health: None, started_at: None, finished_at: Some("2026-04-14T12:00:00Z".to_string()), + startup_diagnostic: None, }; let cond = condition_from_state(&state); @@ -829,6 +892,7 @@ mod tests { health: None, started_at: None, finished_at: Some("2026-04-14T12:30:00Z".to_string()), + startup_diagnostic: None, }; let cond = condition_from_state(&state); assert_eq!(cond.status, "False"); diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 5452ea6efa..33937c79f5 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -20,7 +20,7 @@ mod linux { use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _, PermissionsExt as _}; use std::path::Path; use std::pin::Pin; - use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::task::{Context, Poll}; use std::time::Duration; @@ -178,8 +178,10 @@ mod linux { .map(|_| ControlConnectionSlot(active.clone())) } static BOUNDARY_TERMINATION_REQUESTED: AtomicBool = AtomicBool::new(false); + static BOUNDARY_TERMINATION_SIGNAL: AtomicI32 = AtomicI32::new(0); - extern "C" fn request_boundary_termination(_signal: libc::c_int) { + extern "C" fn request_boundary_termination(signal: libc::c_int) { + BOUNDARY_TERMINATION_SIGNAL.store(signal, Ordering::Release); BOUNDARY_TERMINATION_REQUESTED.store(true, Ordering::Release); } @@ -271,6 +273,7 @@ mod linux { fn install_boundary_signal_handlers() -> Result<(), String> { BOUNDARY_TERMINATION_REQUESTED.store(false, Ordering::Release); + BOUNDARY_TERMINATION_SIGNAL.store(0, Ordering::Release); let action = nix::sys::signal::SigAction::new( nix::sys::signal::SigHandler::Handler(request_boundary_termination), nix::sys::signal::SaFlags::empty(), @@ -484,7 +487,22 @@ mod linux { tracing::info!(?config, "Boundary control listener ready"); loop { if BOUNDARY_TERMINATION_REQUESTED.load(Ordering::Acquire) { + let signal = BOUNDARY_TERMINATION_SIGNAL.load(Ordering::Acquire); + let before_supervisor_confirmation = !matches!( + *lock(&runtime.supervisor_connection), + SupervisorConnectionState::Connected(_) + ); runtime.shutdown(); + if before_supervisor_confirmation { + return Err(format!( + "sandbox boundary received {} before supervisor confirmation", + boundary_termination_signal_name(signal) + )); + } + tracing::info!( + signal = boundary_termination_signal_name(signal), + "Sandbox boundary received termination signal" + ); return Ok(()); } match listener.accept() { @@ -522,6 +540,14 @@ mod linux { } } + fn boundary_termination_signal_name(signal: i32) -> &'static str { + match signal { + libc::SIGTERM => "SIGTERM", + libc::SIGINT => "SIGINT", + _ => "unknown signal", + } + } + async fn serve_control_connection( stream: ControlStream, runtime: Arc, @@ -3780,6 +3806,13 @@ mod linux { ); } + #[test] + fn boundary_termination_signal_name_is_explicit() { + assert_eq!(boundary_termination_signal_name(libc::SIGTERM), "SIGTERM"); + assert_eq!(boundary_termination_signal_name(libc::SIGINT), "SIGINT"); + assert_eq!(boundary_termination_signal_name(0), "unknown signal"); + } + #[test] fn supplementary_group_measurement_rejects_unexpected_groups_by_default() { assert!(!supplementary_groups_match(&[44, 992], &[], false)); From 38fa191331a00c232e0acc2fdb26f5f3b7d4017a Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 21 Sep 2026 21:38:26 +0200 Subject: [PATCH 08/17] fix(podman): initialize rootless workspace ownership Signed-off-by: Evan Lezar --- .../openshell-driver-podman/src/container.rs | 76 +++++++++++++++---- crates/openshell-driver-podman/src/driver.rs | 1 + crates/openshell-sandbox/src/main.rs | 28 ++++++- 3 files changed, 87 insertions(+), 18 deletions(-) diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 7b53a81c41..97ce583854 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -1402,6 +1402,8 @@ pub struct IsolationSpecInput<'a> { pub supervisor_bin: Option<&'a Path>, pub tls_secrets: Option<&'a [String; 3]>, pub identity: &'a openshell_isolation_interface::contract::ResolvedWorkloadIdentity, + /// Whether this workload is created by a rootless Podman service. + pub rootless: bool, } pub struct IsolationSpecs { @@ -1439,19 +1441,44 @@ pub fn build_isolation_specs( .iter() .filter_map(|entry| entry.split_once('=').map(|(key, _)| key.to_string())) .collect(); - workload.command = vec![ - "--bootstrap".into(), - crate::isolation::BOOTSTRAP_PATH.into(), - ]; - workload.user.clone_from(&user); - workload.groups = input - .identity - .supplementary_gids - .iter() - .map(ToString::to_string) - .collect(); - workload.cap_drop = vec!["ALL".into()]; - workload.cap_add.clear(); + if input.rootless { + // Podman's archive endpoint writes named-volume contents with the + // rootless gateway user's host ownership. In a remapped user namespace, + // that is container root rather than the resolved workload UID. Start + // the trusted runtime as namespace root only long enough to chown the + // empty workspace and irreversibly drop to the resolved identity before + // it reads bootstrap material or accepts a control connection. + workload.command = vec![ + "launch-capability-free".into(), + input.identity.uid.to_string(), + input.identity.gid.to_string(), + crate::isolation::BOOTSTRAP_PATH.into(), + driver_mounts::DEFAULT_WORKSPACE_ROOT.into(), + ]; + workload.user = "0:0".into(); + workload.groups.clear(); + workload.cap_drop = vec!["ALL".into()]; + workload.cap_add = vec![ + "CHOWN".into(), + "SETGID".into(), + "SETUID".into(), + "SETPCAP".into(), + ]; + } else { + workload.command = vec![ + "--bootstrap".into(), + crate::isolation::BOOTSTRAP_PATH.into(), + ]; + workload.user.clone_from(&user); + workload.groups = input + .identity + .supplementary_gids + .iter() + .map(ToString::to_string) + .collect(); + workload.cap_drop = vec!["ALL".into()]; + workload.cap_add.clear(); + } workload.apparmor_profile = input .config .app_armor_profile @@ -1740,16 +1767,33 @@ mod tests { supervisor_bin: None, tls_secrets: None, identity: &identity, + rootless: true, }) .unwrap(); for spec in [&specs.workload, &specs.supervisor] { - assert_eq!(spec.user, "1000:1001"); - assert_eq!(spec.groups, vec!["2000"]); assert_eq!(spec.cap_drop, vec!["ALL"]); - assert!(spec.cap_add.is_empty()); assert!(spec.seccomp_profile_path.is_empty()); assert!(spec.no_new_privileges); } + assert_eq!(specs.workload.user, "0:0"); + assert!(specs.workload.groups.is_empty()); + assert_eq!( + specs.workload.cap_add, + vec!["CHOWN", "SETGID", "SETUID", "SETPCAP"] + ); + assert_eq!( + specs.workload.command, + vec![ + "launch-capability-free", + "1000", + "1001", + crate::isolation::BOOTSTRAP_PATH, + driver_mounts::DEFAULT_WORKSPACE_ROOT, + ] + ); + assert_eq!(specs.supervisor.user, "1000:1001"); + assert_eq!(specs.supervisor.groups, vec!["2000"]); + assert!(specs.supervisor.cap_add.is_empty()); assert_eq!(specs.workload.netns.nsmode, "none"); assert_eq!( specs diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index bacc58769c..00f6d205ce 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -938,6 +938,7 @@ impl PodmanComputeDriver { supervisor_bin: supervisor_bin_path.as_deref(), tls_secrets: tls_secret_names.as_ref(), identity: &identity, + rootless: self.rootless, }); let specs = match specs { Ok(spec) => spec, diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 24fa65ab16..b31a8757a8 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -1518,13 +1518,37 @@ fn launch_capability_probe(_args: &[String]) -> Result<()> { fn launch_capability_free(args: &[String]) -> Result<()> { use miette::{Context as _, IntoDiagnostic as _}; - let [uid, gid, bootstrap] = args else { + let [uid, gid, bootstrap, workspace @ ..] = args else { return Err(miette::miette!( - "usage: openshell-sandbox {CAPABILITY_FREE_LAUNCH_SUBCOMMAND} " + "usage: openshell-sandbox {CAPABILITY_FREE_LAUNCH_SUBCOMMAND} [WORKSPACE]" )); }; + if workspace.len() > 1 { + return Err(miette::miette!( + "usage: openshell-sandbox {CAPABILITY_FREE_LAUNCH_SUBCOMMAND} [WORKSPACE]" + )); + } let uid = uid.parse::().into_diagnostic().wrap_err("parse UID")?; let gid = gid.parse::().into_diagnostic().wrap_err("parse GID")?; + if let Some(workspace) = workspace.first() { + let workspace = Path::new(workspace); + let metadata = std::fs::symlink_metadata(workspace) + .into_diagnostic() + .wrap_err_with(|| format!("read workspace metadata {}", workspace.display()))?; + if !metadata.file_type().is_dir() { + return Err(miette::miette!( + "workspace {} must be a real directory", + workspace.display() + )); + } + nix::unistd::chown( + workspace, + Some(nix::unistd::Uid::from_raw(uid)), + Some(nix::unistd::Gid::from_raw(gid)), + ) + .into_diagnostic() + .wrap_err_with(|| format!("set workspace ownership {}:{gid}", workspace.display()))?; + } enter_capability_free_identity(uid, gid)?; let log_level = std::env::var(openshell_core::sandbox_env::LOG_LEVEL) .unwrap_or_else(|_| "warn".to_string()); From b8f5b46f07a374cfa080f062e6a9f474034a2cd6 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 21 Sep 2026 16:28:54 -0700 Subject: [PATCH 09/17] fix(sandbox): qualify NVIDIA Ubuntu default Signed-off-by: Drew Newberry --- .github/actions/setup-e2e-kind/action.yml | 13 +++ .github/actions/setup-e2e-podman/action.yml | 2 +- crates/openshell-core/src/image.rs | 6 +- crates/openshell-core/src/shell.rs | 18 ++-- crates/openshell-gateway/src/vm.rs | 7 +- .../src/contract.rs | 13 +++ .../tests/backend_conformance.rs | 2 + .../src/boundary_protocol.rs | 40 +++++++- .../openshell-sandbox-backend/src/runtime.rs | 1 + crates/openshell-sandbox/src/boundary_exec.rs | 93 ++++++++++++++++++- .../openshell-sandbox/src/boundary_server.rs | 4 + crates/openshell-sandbox/src/process.rs | 4 +- .../openshell-supervisor-process/src/ssh.rs | 58 ++---------- deploy/docker/gateway.toml | 2 +- deploy/helm/openshell/README.md | 2 +- deploy/helm/openshell/values.yaml | 2 +- .../kube/manifests/openshell-helmchart.yaml | 2 +- docs/reference/gateway-config.mdx | 8 +- e2e/rust/tests/host_gateway_alias.rs | 49 +++++++--- tasks/scripts/gateway-docker.sh | 2 +- tasks/scripts/gateway-podman.sh | 2 +- tasks/scripts/gateway.sh | 2 +- tasks/scripts/helm-k3s-local.sh | 4 +- 23 files changed, 237 insertions(+), 99 deletions(-) diff --git a/.github/actions/setup-e2e-kind/action.yml b/.github/actions/setup-e2e-kind/action.yml index 8351895664..e617a27717 100644 --- a/.github/actions/setup-e2e-kind/action.yml +++ b/.github/actions/setup-e2e-kind/action.yml @@ -80,3 +80,16 @@ runs: docker image save --platform linux/amd64 --output "$archive" "$image" kind load image-archive "$archive" --name "$CLUSTER_NAME" done + + - name: Preload default sandbox image into kind + shell: bash + env: + CLUSTER_NAME: ${{ inputs.cluster-name }} + SANDBOX_IMAGE: nvcr.io/nvidia/base/ubuntu:24.04 + run: | + set -euo pipefail + archive="${RUNNER_TEMP:-/tmp}/openshell-default-sandbox-linux-amd64.tar" + docker pull --platform linux/amd64 "$SANDBOX_IMAGE" + docker image inspect "$SANDBOX_IMAGE" --format 'default sandbox image: {{join .RepoDigests ", "}}' + docker image save --platform linux/amd64 --output "$archive" "$SANDBOX_IMAGE" + kind load image-archive "$archive" --name "$CLUSTER_NAME" diff --git a/.github/actions/setup-e2e-podman/action.yml b/.github/actions/setup-e2e-podman/action.yml index 6309383315..3e6f481f4f 100644 --- a/.github/actions/setup-e2e-podman/action.yml +++ b/.github/actions/setup-e2e-podman/action.yml @@ -122,5 +122,5 @@ runs: podman run --rm \ --cap-add=SETPCAP \ --volume "$probe:/openshell-capbset-probe:ro" \ - docker.io/library/alpine:3.22 \ + nvcr.io/nvidia/base/ubuntu:24.04 \ /openshell-capbset-probe diff --git a/crates/openshell-core/src/image.rs b/crates/openshell-core/src/image.rs index e1b242cbfa..a3d314b3e8 100644 --- a/crates/openshell-core/src/image.rs +++ b/crates/openshell-core/src/image.rs @@ -15,9 +15,9 @@ pub const DEFAULT_COMMUNITY_REGISTRY: &str = "ghcr.io/nvidia/openshell-community /// 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"; +/// A version-qualified NVIDIA Ubuntu Noble image so a fresh install does not +/// depend on the community image catalog or Docker Hub availability. +pub const DEFAULT_SANDBOX_BASE_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; /// Return the default sandbox image reference. /// diff --git a/crates/openshell-core/src/shell.rs b/crates/openshell-core/src/shell.rs index d24a7bced2..16d0b4de09 100644 --- a/crates/openshell-core/src/shell.rs +++ b/crates/openshell-core/src/shell.rs @@ -10,8 +10,8 @@ //! with an opaque `No such file or directory`. //! //! These helpers resolve a shell that actually exists in the current root -//! filesystem. They must run inside the sandbox (i.e. in the supervisor), not -//! on the gateway, because the answer depends on the sandbox image's contents. +//! filesystem. They must run inside the workload boundary, not in the external +//! supervisor or gateway, because the answer depends on the workload image. /// Preferred interactive shell when the image provides it. pub const BASH: &str = "/bin/bash"; @@ -62,13 +62,19 @@ pub fn is_executable(path: &str) -> bool { /// that footgun. #[must_use] pub fn detect_login_shell() -> String { + find_login_shell().unwrap_or_else(|| POSIX_SH.to_string()) +} + +/// Resolve an executable shell in the current root filesystem. +/// +/// Unlike [`detect_login_shell`], this reports absence explicitly so boundary +/// exec can return a useful error for shell-free images. +#[must_use] +pub fn find_login_shell() -> Option { SHELL_CANDIDATES .iter() .find(|candidate| is_executable(candidate)) - .map_or_else( - || POSIX_SH.to_string(), - |candidate| (*candidate).to_string(), - ) + .map(|candidate| (*candidate).to_string()) } #[cfg(test)] diff --git a/crates/openshell-gateway/src/vm.rs b/crates/openshell-gateway/src/vm.rs index 413d5a579b..a706b6364e 100644 --- a/crates/openshell-gateway/src/vm.rs +++ b/crates/openshell-gateway/src/vm.rs @@ -58,7 +58,6 @@ use tower::service_fn; const DRIVER_BIN_NAME: &str = "openshell-driver-vm"; const COMPUTE_DRIVER_SOCKET_RUN_DIR: &str = "run"; const COMPUTE_DRIVER_SOCKET_NAME: &str = "compute-driver.sock"; -const DEFAULT_VM_SANDBOX_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; /// Configuration for launching and talking to the VM compute driver. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -234,7 +233,7 @@ impl Default for VmComputeConfig { Self { state_dir: Self::default_state_dir(), driver_dir: None, - default_image: DEFAULT_VM_SANDBOX_IMAGE.to_string(), + default_image: openshell_core::image::default_sandbox_image(), grpc_endpoint: String::new(), bootstrap_image: String::new(), krun_log_level: Self::default_krun_log_level(), @@ -760,7 +759,7 @@ async fn connect_compute_driver(socket_path: &Path) -> Result { #[cfg(all(test, unix))] mod tests { use super::{ - DEFAULT_VM_SANDBOX_IMAGE, VmComputeConfig, append_otlp_args, append_vm_identity_args, + VmComputeConfig, append_otlp_args, append_vm_identity_args, append_vm_proxy_and_spiffe_args, append_vm_rootfs_tar_args, compute_driver_guest_tls_paths, compute_driver_socket_path, current_euid, prepare_compute_driver_socket_path, prepare_vm_state_dir, resolve_compute_driver_bin, resolve_driver_search_dirs, @@ -777,7 +776,7 @@ mod tests { fn vm_uses_nvidia_ubuntu_default_image() { assert_eq!( VmComputeConfig::default().default_image, - DEFAULT_VM_SANDBOX_IMAGE + openshell_core::image::DEFAULT_SANDBOX_BASE_IMAGE ); } diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 8e68d48c77..0d699fb453 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -745,6 +745,9 @@ pub struct ExecSpec { pub program: String, /// Program arguments. pub args: Vec, + /// Workload-local shell request. When present, the boundary resolves the + /// concrete shell and ignores `program` and `args`. + pub shell: Option, /// Extra environment over the boundary's base. pub env: Vec<(String, String)>, /// Working directory, if any. @@ -753,6 +756,16 @@ pub struct ExecSpec { pub pty: bool, } +/// A shell invocation whose executable must be resolved inside the workload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShellSpec { + /// Command passed to the shell. `None` starts an interactive shell when a + /// PTY is requested and a plain shell otherwise. + pub command: Option, + /// Whether command execution should load the shell's login environment. + pub login: bool, +} + /// In-boundary process entry, consumed by the SSH server and supervisor session. /// /// Like `start_agent`, every exec ensures the applicable launch-time controls diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 73559b203f..dd987ec24a 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -709,6 +709,7 @@ async fn exec_session_owns_its_process_and_streams() { .exec(ExecSpec { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "true".to_string()], + shell: None, env: vec![], workdir: None, pty: false, @@ -730,6 +731,7 @@ async fn pty_exec_merges_output_and_supports_resize() { .exec(ExecSpec { program: "/bin/sh".to_string(), args: vec![], + shell: None, env: vec![], workdir: None, pty: true, diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 05542fb2bc..3d0b8db251 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -24,7 +24,7 @@ use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ BackendDescriptor, BackendError, BinaryIdentity, BoundaryExitStatus, BoundarySignal, - DriverFenceEvidence, ExecSpec, ResolveError, SandboxConfirmEvidence, + DriverFenceEvidence, ExecSpec, ResolveError, SandboxConfirmEvidence, ShellSpec, }; use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; use serde::de::DeserializeOwned; @@ -865,16 +865,43 @@ impl BinaryIdentityWire { pub struct ExecSpecWire { pub program: String, pub args: Vec, + #[serde(default)] + pub shell: Option, pub env: Vec<(String, String)>, pub workdir: Option, pub pty: bool, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShellSpecWire { + pub command: Option, + pub login: bool, +} + +impl From for ShellSpecWire { + fn from(spec: ShellSpec) -> Self { + Self { + command: spec.command, + login: spec.login, + } + } +} + +impl From for ShellSpec { + fn from(spec: ShellSpecWire) -> Self { + Self { + command: spec.command, + login: spec.login, + } + } +} + impl From for ExecSpecWire { fn from(spec: ExecSpec) -> Self { Self { program: spec.program, args: spec.args, + shell: spec.shell.map(ShellSpecWire::from), env: spec.env, workdir: spec.workdir, pty: spec.pty, @@ -887,6 +914,7 @@ impl From for ExecSpec { Self { program: spec.program, args: spec.args, + shell: spec.shell.map(ShellSpec::from), env: spec.env, workdir: spec.workdir, pty: spec.pty, @@ -1395,4 +1423,14 @@ mod tests { let decoded: SandboxTransport = serde_json::from_slice(&encoded).expect("decode transport"); assert_eq!(decoded, transport); } + + #[test] + fn exec_wire_accepts_legacy_direct_exec_without_shell_intent() { + let wire: ExecSpecWire = serde_json::from_str( + r#"{"program":"/bin/true","args":[],"env":[],"workdir":null,"pty":false}"#, + ) + .expect("decode legacy exec spec"); + + assert_eq!(wire.shell, None); + } } diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 1b56d6df9d..c4dd460125 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -2749,6 +2749,7 @@ mod tests { ExecSpec { program: "/bin/true".to_string(), args: Vec::new(), + shell: None, env: Vec::new(), workdir: None, pty: false, diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index a01e430de3..275dd79d76 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -60,24 +60,51 @@ impl LocalBoundaryExec { } fn command(&self, spec: &ExecSpec) -> Result { - if spec.program.is_empty() { + let (program, args) = if let Some(shell_spec) = &spec.shell { + let shell = openshell_core::shell::find_login_shell().ok_or_else(|| { + BackendError::Process( + "sandbox image does not provide an executable shell at /bin/bash, /usr/bin/bash, or /bin/sh" + .to_string(), + ) + })?; + let args = shell_spec.command.as_ref().map_or_else( + || { + if spec.pty { + vec!["-i".to_string()] + } else { + Vec::new() + } + }, + |command| { + vec![ + if shell_spec.login { "-lc" } else { "-c" }.to_string(), + command.clone(), + ] + }, + ); + (shell, args) + } else { + (spec.program.clone(), spec.args.clone()) + }; + if program.is_empty() { return Err(BackendError::Process("exec program is empty".to_string())); } - let mut command = Command::new(&spec.program); - command.args(&spec.args); + let mut command = Command::new(&program); + command.args(&args); let effective_workdir = spec.workdir.as_deref().or(self.base_workdir.as_deref()); 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", shell) .env("PATH", path) .env("TERM", if spec.pty { "xterm-256color" } else { "dumb" }); + if let Some(shell) = openshell_core::shell::find_login_shell() { + command.env("SHELL", shell); + } for (key, value) in &self.user_environment { if !key.starts_with("OPENSHELL_") { command.env(key, value); @@ -536,6 +563,55 @@ mod tests { ) } + #[test] + fn shell_requests_are_resolved_by_the_workload_executor() { + let executor = executor(); + let command = executor + .command(&ExecSpec { + program: "/supervisor/does/not/share/this/root".to_string(), + args: vec!["ignored".to_string()], + shell: Some(openshell_isolation_interface::contract::ShellSpec { + command: Some("printf ready".to_string()), + login: true, + }), + env: vec![], + workdir: None, + pty: false, + }) + .expect("resolve workload shell"); + + assert_eq!( + command.get_program(), + std::ffi::OsStr::new(&openshell_core::shell::detect_login_shell()) + ); + assert_eq!( + command.get_args().collect::>(), + [ + std::ffi::OsStr::new("-lc"), + std::ffi::OsStr::new("printf ready") + ] + ); + } + + #[test] + fn interactive_shell_request_adds_interactive_flag() { + let command = executor() + .command(&ExecSpec { + program: String::new(), + args: Vec::new(), + shell: Some(openshell_isolation_interface::contract::ShellSpec { + command: None, + login: true, + }), + env: vec![], + workdir: None, + pty: true, + }) + .expect("resolve interactive workload shell"); + + assert_eq!(command.get_args().collect::>(), ["-i"]); + } + #[tokio::test] async fn non_pty_exec_preserves_stdin_stdout_and_stderr() { let mut session = executor() @@ -546,6 +622,7 @@ mod tests { "read line; printf 'out:%s' \"$line\"; printf 'err:%s' \"$line\" >&2" .to_string(), ], + shell: None, env: vec![], workdir: None, pty: false, @@ -585,6 +662,7 @@ mod tests { .exec(ExecSpec { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "exit 0".to_string()], + shell: None, env: vec![], workdir: None, pty: false, @@ -601,6 +679,7 @@ mod tests { .exec(ExecSpec { program: "/definitely/missing/openshell-exec".to_string(), args: vec![], + shell: None, env: vec![], workdir: None, pty: false, @@ -620,6 +699,7 @@ mod tests { .exec(ExecSpec { program: "/bin/sleep".to_string(), args: vec!["30".to_string()], + shell: None, env: vec![], workdir: None, pty: false, @@ -651,6 +731,7 @@ mod tests { executor.spawn_piped(&ExecSpec { program: "/bin/sleep".to_string(), args: vec!["30".to_string()], + shell: None, env: vec![], workdir: None, pty: false, @@ -682,6 +763,7 @@ mod tests { .exec(ExecSpec { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "exit 0".to_string()], + shell: None, env: vec![], workdir: None, pty: false, @@ -701,6 +783,7 @@ mod tests { .exec(ExecSpec { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "exit 7".to_string()], + shell: None, env: vec![], workdir: None, pty: true, diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 33937c79f5..8053e06b33 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -4871,6 +4871,7 @@ mod linux { let exec_spec = ExecSpecWire { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "printf '%s' \"$REPLAY_TEST\"".to_string()], + shell: None, env: Vec::new(), workdir: None, pty: false, @@ -5151,6 +5152,7 @@ mod linux { let sleep_spec = ExecSpecWire { program: "/bin/sleep".to_string(), args: vec!["30".to_string()], + shell: None, env: Vec::new(), workdir: None, pty: false, @@ -5218,6 +5220,7 @@ mod linux { let spec = ExecSpecWire { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), format!("exit {exit_code}")], + shell: None, env: Vec::new(), workdir: None, pty: false, @@ -5255,6 +5258,7 @@ mod linux { "if [ -z \"${ROTATED_TOKEN+x}\" ]; then printf revoked; else printf 'unexpected:%s' \"$ROTATED_TOKEN\"; fi" .to_string(), ], + shell: None, env: Vec::new(), workdir: None, pty: false, diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 56d4f244ad..7b61d30030 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -249,8 +249,8 @@ fn apply_canonical_process_environment( ) { cmd.envs(user_environment); let (session_user, session_home) = session_user_and_home(policy, workspace.home()); - // Resolve a shell present in the sandbox image (minimal images such as - // Alpine ship only `/bin/sh`, not bash). Runs in the supervisor. + // Resolve a shell present in the workload image. This code runs inside the + // workload boundary, where the image filesystem is visible. let shell = openshell_core::shell::detect_login_shell(); for (key, value) in [ diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 5d3a56e2aa..572ab47dee 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -729,6 +729,7 @@ impl russh::server::Handler for SshHandler { openshell_isolation_interface::contract::ExecSpec { program: "/usr/lib/openssh/sftp-server".to_string(), args: vec![], + shell: None, env: vec![], workdir: None, pty: false, @@ -892,11 +893,6 @@ impl SshHandler { let no_login_shell = state.no_login_shell; let pty = state.pty_request.take(); let pty_requested = pty.is_some(); - // 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())]) @@ -905,8 +901,12 @@ impl SshHandler { channel, handle, openshell_isolation_interface::contract::ExecSpec { - program, - args, + program: String::new(), + args: Vec::new(), + shell: Some(openshell_isolation_interface::contract::ShellSpec { + command, + login: !no_login_shell, + }), env, workdir: None, pty: pty_requested, @@ -1070,29 +1070,6 @@ async fn send_main_output(handle: &Handle, channel: ChannelId, event: MainOutput } } -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 { @@ -1409,27 +1386,6 @@ 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/deploy/docker/gateway.toml b/deploy/docker/gateway.toml index 463bbd585b..c0cbda4ef6 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 = "docker.io/library/alpine:3.22" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" # 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/README.md b/deploy/helm/openshell/README.md index 3da8a4e9c9..c32c6179c1 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 | `"docker.io/library/alpine:3.22"` | Default sandbox image used when requests do not specify one. | +| server.sandboxImage | string | `"nvcr.io/nvidia/base/ubuntu:24.04"` | 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/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 6fedb52862..f395b7fa58 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: "docker.io/library/alpine:3.22" + sandboxImage: "nvcr.io/nvidia/base/ubuntu:24.04" # -- 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 8fd83c2796..f2c7540333 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: docker.io/library/alpine:3.22 + sandboxImage: nvcr.io/nvidia/base/ubuntu:24.04 sandboxImagePullPolicy: __SANDBOX_IMAGE_PULL_POLICY__ supervisorImage: ghcr.io/nvidia/openshell/supervisor:latest dbUrl: __DB_URL__ diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 19356c7aad..6d17628452 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 = "docker.io/library/alpine:3.22" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" # 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 = "docker.io/library/alpine:3.22" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" 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 = "docker.io/library/alpine:3.22" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" # 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 = "docker.io/library/alpine:3.22" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" 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. diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs index bc26068afc..e4ae811472 100644 --- a/e2e/rust/tests/host_gateway_alias.rs +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -174,7 +174,7 @@ endpoints: protocol: rest access: full enforcement: enforce -binaries: [/usr/bin/curl] +binaries: [/usr/bin/bash] "# ); file.write_all(profile.as_bytes()) @@ -191,7 +191,7 @@ fn write_binding_policy(port: u16) -> Result { filesystem_policy: include_workdir: true - read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_only: [/bin, /usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] read_write: [/sandbox, /tmp, /dev/null] landlock: @@ -218,7 +218,7 @@ network_policies: access: full enforcement: enforce binaries: - - path: /usr/bin/curl + - path: /usr/bin/bash "# ); file.write_all(policy.as_bytes()) @@ -263,6 +263,7 @@ filesystem_policy: include_workdir: true read_only: - /usr + - /bin - /lib - /proc - /dev/urandom @@ -293,7 +294,7 @@ network_policies: - "192.168.0.0/16" - "fc00::/7" binaries: - - path: /usr/bin/curl + - path: /usr/bin/bash "# ); file.write_all(policy.as_bytes()) @@ -315,16 +316,17 @@ async fn sandbox_reaches_host_openshell_internal_via_host_gateway_alias() { .expect("temp policy path should be utf-8") .to_string(); + let command = format!( + r#"exec 3<>/dev/tcp/host.openshell.internal/{0}; printf 'GET / HTTP/1.1\r\nHost: host.openshell.internal:{0}\r\nConnection: close\r\n\r\n' >&3; while IFS= read -r line <&3 || [[ -n $line ]]; do printf '%s\n' "$line"; done"#, + server.port + ); let guard = SandboxGuard::create(&[ "--policy", &policy_path, "--", - "curl", - "--silent", - "--show-error", - "--max-time", - "15", - &format!("http://host.openshell.internal:{}/", server.port), + "/usr/bin/bash", + "-c", + &command, ]) .await .expect("sandbox create with host.openshell.internal echo request"); @@ -400,8 +402,29 @@ async fn static_provider_credentials_are_bound_to_profile_endpoints() { .expect("create endpoint-bound provider B"); let command = format!( - r#"allowed=$(curl --silent --show-error --max-time 15 -H "Authorization: Bearer $BOUND_TOKEN_A" http://host.openshell.internal:{}/allowed/check); host_denied=$(curl --silent --show-error --max-time 15 -o /tmp/host-denied-body -w "%{{http_code}}" -H "Authorization: Bearer $BOUND_TOKEN_A" http://host.docker.internal:{}/allowed/check); path_denied=$(curl --silent --show-error --max-time 15 -o /tmp/path-denied-body -w "%{{http_code}}" -H "Authorization: Bearer $BOUND_TOKEN_A" http://host.openshell.internal:{}/other/check); printf 'ALLOWED=%s HOST_DENIED=%s PATH_DENIED=%s\n' "$allowed" "$host_denied" "$path_denied""#, - server.port, server.port, server.port + r#" +http_request() {{ + local host="$1" path="$2" status_line line + HTTP_STATUS= HTTP_BODY= + exec 3<>"/dev/tcp/$host/{0}" || return 1 + printf 'GET %s HTTP/1.1\r\nHost: %s:{0}\r\nAuthorization: Bearer %s\r\nConnection: close\r\n\r\n' "$path" "$host" "$BOUND_TOKEN_A" >&3 + IFS= read -r status_line <&3 || return 1 + status_line="${{status_line%$'\r'}}" + HTTP_STATUS="${{status_line#* }}" + HTTP_STATUS="${{HTTP_STATUS%% *}}" + while IFS= read -r line <&3; do + line="${{line%$'\r'}}" + [[ -z "$line" ]] && break + done + while IFS= read -r line <&3 || [[ -n "$line" ]]; do HTTP_BODY+="$line"; done + exec 3>&- 3<&- +}} +http_request host.openshell.internal /allowed/check; allowed="$HTTP_BODY" +http_request host.docker.internal /allowed/check; host_denied="$HTTP_STATUS" +http_request host.openshell.internal /other/check; path_denied="$HTTP_STATUS" +printf 'ALLOWED=%s HOST_DENIED=%s PATH_DENIED=%s\n' "$allowed" "$host_denied" "$path_denied" +"#, + server.port ); let mut guard = SandboxGuard::create(&[ "--policy", @@ -412,7 +435,7 @@ async fn static_provider_credentials_are_bound_to_profile_endpoints() { BINDING_PROVIDER_B_NAME, "--no-auto-providers", "--", - "sh", + "/usr/bin/bash", "-c", &command, ]) diff --git a/tasks/scripts/gateway-docker.sh b/tasks/scripts/gateway-docker.sh index 9490d631d3..b85c9a6638 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:-docker.io/library/alpine:3.22}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" 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 5d7c718f51..d2b5397fb0 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:-docker.io/library/alpine:3.22}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" 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 edacadb904..8df3dace97 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:-docker.io/library/alpine:3.22}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" 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 f93dbc3fb6..e24893e7d5 100755 --- a/tasks/scripts/helm-k3s-local.sh +++ b/tasks/scripts/helm-k3s-local.sh @@ -27,9 +27,9 @@ K3D_CLUSTER_NAME_MAX=32 # Host port forwarded to port 80 via the k3d load balancer. # Used by Envoy Gateway's LoadBalancer service (values-gateway.yaml). HOST_LB_PORT="${HELM_K3S_LB_HOST_PORT:-8080}" -# Preload the default community sandbox image so the first sandbox create does +# Preload the default sandbox image so the first sandbox create does # not pay the full registry pull cost inside the cluster. -DEFAULT_SANDBOX_PRELOAD_IMAGE="docker.io/library/alpine:3.22" +DEFAULT_SANDBOX_PRELOAD_IMAGE="nvcr.io/nvidia/base/ubuntu:24.04" PRELOAD_SANDBOX_IMAGE="${HELM_K3S_PRELOAD_SANDBOX_IMAGE-${DEFAULT_SANDBOX_PRELOAD_IMAGE}}" # Upstream agent-sandbox release pinned for both CRDs/controller and extensions. From bf9fba676fa41b8f770b6e220a2426deeb28e4bb Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 21 Sep 2026 17:02:12 -0700 Subject: [PATCH 10/17] fix(podman): initialize rootful default workspace Signed-off-by: Drew Newberry --- .../openshell-driver-podman/src/container.rs | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 97ce583854..49de68b7fe 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -1441,13 +1441,13 @@ pub fn build_isolation_specs( .iter() .filter_map(|entry| entry.split_once('=').map(|(key, _)| key.to_string())) .collect(); - if input.rootless { - // Podman's archive endpoint writes named-volume contents with the - // rootless gateway user's host ownership. In a remapped user namespace, - // that is container root rather than the resolved workload UID. Start - // the trusted runtime as namespace root only long enough to chown the - // empty workspace and irreversibly drop to the resolved identity before - // it reads bootstrap material or accepts a control connection. + if input.rootless || input.identity.source == "default" { + // Podman's archive endpoint leaves named-volume contents owned by + // container root for rootless services and for a rootful USER-less + // image's newly-created workspace. Start the trusted runtime as root + // only long enough to chown the workspace, then irreversibly drop to + // the resolved workload identity before reading bootstrap material or + // accepting a control connection. workload.command = vec![ "launch-capability-free".into(), input.identity.uid.to_string(), @@ -1808,6 +1808,42 @@ mod tests { Some("openshell-sandbox") ); assert_eq!(specs.supervisor.apparmor_profile, None); + + let default_identity = + openshell_isolation_interface::contract::ResolvedWorkloadIdentity::new( + 1000, + 1000, + Vec::new(), + "default".into(), + "sha256:image".into(), + ) + .unwrap(); + let rootful_specs = build_isolation_specs(IsolationSpecInput { + sandbox: &sandbox, + config: &config, + token_secret: Some("jwt"), + gpu_devices: None, + requested_image: "image:latest", + image_id: "sha256:image", + image_user: "", + image_env: &env, + supervisor_bin: None, + tls_secrets: None, + identity: &default_identity, + rootless: false, + }) + .unwrap(); + assert_eq!(rootful_specs.workload.user, "0:0"); + assert_eq!( + rootful_specs.workload.command, + vec![ + "launch-capability-free", + "1000", + "1000", + crate::isolation::BOOTSTRAP_PATH, + driver_mounts::DEFAULT_WORKSPACE_ROOT, + ] + ); let workload_json = serde_json::to_string(&specs.workload).unwrap(); assert!(workload_json.contains("\"apparmor_profile\":\"openshell-sandbox\"")); assert_eq!(specs.supervisor.healthconfig.test, vec!["NONE"]); From 6a77bd23fa21309e353fd1db738788c7b6ec4dc1 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 21 Sep 2026 20:31:55 -0700 Subject: [PATCH 11/17] feat(sftp): add native sandbox adapter Signed-off-by: Drew Newberry --- Cargo.lock | 279 +++++---- .../src/contract.rs | 8 + .../tests/backend_conformance.rs | 2 + .../src/boundary_protocol.rs | 38 ++ .../openshell-sandbox-backend/src/runtime.rs | 1 + crates/openshell-sandbox/Cargo.toml | 2 + crates/openshell-sandbox/src/boundary_exec.rs | 234 ++++++++ .../openshell-sandbox/src/boundary_server.rs | 4 + crates/openshell-sandbox/src/lib.rs | 2 + crates/openshell-sandbox/src/sftp.rs | 553 ++++++++++++++++++ .../openshell-supervisor-process/src/ssh.rs | 14 +- 11 files changed, 1021 insertions(+), 116 deletions(-) create mode 100644 crates/openshell-sandbox/src/sftp.rs diff --git a/Cargo.lock b/Cargo.lock index a979d700cf..fe72444b6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,7 +155,7 @@ checksum = "947e21ff51879f8a40d7519dfe619268de2afba4042a8a43878276de3cb910f0" dependencies = [ "memchr", "rowan", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -691,7 +691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ "fastrand", - "gloo-timers", + "gloo-timers 0.3.0", "tokio", ] @@ -772,9 +772,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -851,7 +851,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tower-service", @@ -894,9 +894,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytes-utils" @@ -997,9 +997,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1293,7 +1293,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "crossterm_winapi", "libc", "mio 0.8.11", @@ -1309,7 +1309,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "crossterm_winapi", "mio 1.2.0", "parking_lot", @@ -1486,6 +1486,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -2271,6 +2285,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "group" version = "0.13.0" @@ -2416,7 +2442,7 @@ dependencies = [ "once_cell", "rand 0.10.2", "ring", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "url", @@ -2880,7 +2906,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "inotify-sys", "libc", ] @@ -3014,7 +3040,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -3072,13 +3098,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3102,7 +3127,7 @@ dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3115,7 +3140,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3216,7 +3241,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "libc", ] @@ -3262,7 +3287,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tower", @@ -3285,7 +3310,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3324,7 +3349,7 @@ dependencies = [ "pin-project", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -3338,7 +3363,7 @@ checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088" dependencies = [ "enumflags2", "libc", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3390,7 +3415,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "libc", "plain", "redox_syscall 0.7.4", @@ -3436,9 +3461,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" @@ -3526,7 +3551,7 @@ dependencies = [ "metrics", "metrics-util", "quanta", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -3669,7 +3694,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3681,7 +3706,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3703,7 +3728,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "fsevent-sys", "inotify", "kqueue", @@ -3721,7 +3746,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -3876,7 +3901,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "unicase", @@ -3896,7 +3921,7 @@ dependencies = [ "serde_json", "strum 0.27.2", "strum_macros 0.27.2", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4001,7 +4026,7 @@ dependencies = [ "tar", "temp-env", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -4062,7 +4087,7 @@ dependencies = [ "sha2 0.10.9", "tar", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tonic", @@ -4160,7 +4185,7 @@ dependencies = [ "serde", "serde_json", "temp-env", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "toml", @@ -4204,7 +4229,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tonic", @@ -4238,7 +4263,7 @@ dependencies = [ "serde_json", "tar", "temp-env", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tonic", @@ -4332,7 +4357,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tonic", @@ -4378,7 +4403,7 @@ dependencies = [ "prost-types", "serde_json", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tonic", "tracing", @@ -4421,7 +4446,7 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tonic", "tower-http", @@ -4506,7 +4531,7 @@ dependencies = [ "prost-types", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", ] @@ -4534,6 +4559,7 @@ dependencies = [ "openshell-sandbox-backend", "rand 0.10.2", "rcgen", + "russh-sftp", "rustix 1.1.4", "rustls", "rustls-pemfile", @@ -4549,6 +4575,7 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -4570,7 +4597,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -4597,7 +4624,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -4681,7 +4708,7 @@ dependencies = [ "sqlx", "sqlx-core", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -4819,7 +4846,7 @@ dependencies = [ "spiffe", "temp-env", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -4889,7 +4916,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -4903,7 +4930,7 @@ dependencies = [ "once_cell", "shell-escape", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", ] @@ -4923,7 +4950,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -4938,7 +4965,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tonic", "tonic-types", @@ -4970,7 +4997,7 @@ dependencies = [ "percent-encoding", "portable-atomic", "rand 0.9.4", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", ] @@ -5635,7 +5662,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "memchr", "unicase", ] @@ -5678,7 +5705,7 @@ dependencies = [ "rustc-hash 2.1.2", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -5701,7 +5728,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -5842,7 +5869,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cassowary", "compact_str", "crossterm 0.27.0", @@ -5862,7 +5889,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -5885,7 +5912,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -5894,7 +5921,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -5949,7 +5976,7 @@ dependencies = [ "serde_json", "serde_yaml", "spin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -6127,7 +6154,7 @@ checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e" dependencies = [ "aes", "aws-lc-rs", - "bitflags 2.11.1", + "bitflags 2.13.2", "block-padding", "byteorder", "bytes", @@ -6184,7 +6211,7 @@ dependencies = [ "ssh-encoding", "ssh-key", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "typenum", "universal-hash", @@ -6203,6 +6230,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "russh-sftp" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093197e526668d92bba562e2bbbe98d1af9831bf080b619c736316ca1fa35101" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "chrono", + "dashmap", + "gloo-timers 0.4.0", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + [[package]] name = "russh-util" version = "0.52.0" @@ -6257,7 +6304,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6270,7 +6317,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6497,7 +6544,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -6522,9 +6569,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -6540,24 +6587,34 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -6824,7 +6881,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] @@ -6876,7 +6933,7 @@ dependencies = [ "prost-types", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "tokio-util", @@ -6965,7 +7022,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -7006,7 +7063,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.117", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "url", ] @@ -7017,7 +7074,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "byteorder", "bytes", "crc", @@ -7033,7 +7090,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -7045,7 +7102,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", - "bitflags 2.11.1", + "bitflags 2.13.2", "byteorder", "crc", "dotenvy", @@ -7067,7 +7124,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "whoami", ] @@ -7091,7 +7148,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", ] @@ -7336,7 +7393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -7405,11 +7462,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -7425,13 +7482,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -7579,13 +7636,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", + "libc", "pin-project-lite", "slab", "tokio", @@ -7739,7 +7798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "base64", - "bitflags 2.11.1", + "bitflags 2.13.2", "bytes", "futures-util", "http 1.4.0", @@ -7769,7 +7828,7 @@ dependencies = [ "base64", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -7798,7 +7857,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -7902,7 +7961,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1 0.10.6", - "thiserror 2.0.18", + "thiserror 2.0.20", "utf-8", ] @@ -7919,7 +7978,7 @@ dependencies = [ "log", "rand 0.9.4", "sha1 0.10.6", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -8159,9 +8218,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -8172,9 +8231,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -8182,9 +8241,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8192,22 +8251,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -8253,7 +8312,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "hashbrown 0.15.5", "indexmap", "semver", @@ -8261,9 +8320,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -8837,7 +8896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.2", "indexmap", "log", "serde", diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index b3d6287ad1..cc559f5a75 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -728,6 +728,9 @@ pub struct ExecSpec { /// Workload-local shell request. When present, the boundary resolves the /// concrete shell and ignores `program` and `args`. pub shell: Option, + /// Trusted helper implemented by the sandbox runtime. This intent is set + /// only by supervisor-owned protocol adapters, never by public exec APIs. + pub runtime_helper: Option, /// Extra environment over the boundary's base. pub env: Vec<(String, String)>, /// Working directory, if any. @@ -736,6 +739,11 @@ pub struct ExecSpec { pub pty: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeHelper { + Sftp, +} + /// A shell invocation whose executable must be resolved inside the workload. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ShellSpec { diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index c6bca4af7f..09166e01e1 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -703,6 +703,7 @@ async fn exec_session_owns_its_process_and_streams() { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "true".to_string()], shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -725,6 +726,7 @@ async fn pty_exec_merges_output_and_supports_resize() { program: "/bin/sh".to_string(), args: vec![], shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: true, diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index c291cac042..2c56fd1ed1 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -1008,11 +1008,19 @@ pub struct ExecSpecWire { pub args: Vec, #[serde(default)] pub shell: Option, + #[serde(default)] + pub runtime_helper: Option, pub env: Vec<(String, String)>, pub workdir: Option, pub pty: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeHelperWire { + Sftp, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ShellSpecWire { pub command: Option, @@ -1043,6 +1051,11 @@ impl From for ExecSpecWire { program: spec.program, args: spec.args, shell: spec.shell.map(ShellSpecWire::from), + runtime_helper: spec.runtime_helper.map(|helper| match helper { + openshell_isolation_interface::contract::RuntimeHelper::Sftp => { + RuntimeHelperWire::Sftp + } + }), env: spec.env, workdir: spec.workdir, pty: spec.pty, @@ -1056,6 +1069,11 @@ impl From for ExecSpec { program: spec.program, args: spec.args, shell: spec.shell.map(ShellSpec::from), + runtime_helper: spec.runtime_helper.map(|helper| match helper { + RuntimeHelperWire::Sftp => { + openshell_isolation_interface::contract::RuntimeHelper::Sftp + } + }), env: spec.env, workdir: spec.workdir, pty: spec.pty, @@ -1628,5 +1646,25 @@ mod tests { .expect("decode legacy exec spec"); assert_eq!(wire.shell, None); + assert_eq!(wire.runtime_helper, None); + } + + #[test] + fn exec_wire_preserves_trusted_runtime_helper_intent() { + let spec = ExecSpec { + program: String::new(), + args: Vec::new(), + shell: None, + runtime_helper: Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp), + env: Vec::new(), + workdir: None, + pty: false, + }; + + let decoded = ExecSpec::from(ExecSpecWire::from(spec)); + assert_eq!( + decoded.runtime_helper, + Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp) + ); } } diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index f67007999d..06891d8aa8 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -2903,6 +2903,7 @@ mod tests { program: "/bin/true".to_string(), args: Vec::new(), shell: None, + runtime_helper: None, env: Vec::new(), workdir: None, pty: false, diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 1283dd063b..da8ddd45aa 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -69,6 +69,8 @@ tracing-subscriber = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" rustix = { workspace = true } +russh-sftp = "3.0" +uuid = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] capctl = "0.2.4" diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index 275dd79d76..45c8545501 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -4,7 +4,10 @@ //! Workload-side implementation of RFC 0012 sandbox exec. use std::collections::HashMap; +use std::io::Write as _; +use std::net::Shutdown; use std::os::fd::{AsRawFd, OwnedFd}; +use std::os::unix::net::UnixStream as StdUnixStream; use std::process::{Child, Command, Stdio}; use std::sync::Arc; @@ -59,6 +62,61 @@ impl LocalBoundaryExec { } } + #[cfg(target_os = "linux")] + fn runtime_helper(&self, spec: ExecSpec) -> Result { + self.runtime.ensure_active()?; + if spec.pty + || spec.runtime_helper + != Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp) + { + return Err(BackendError::Process( + "unsupported sandbox runtime helper request".to_string(), + )); + } + let root = spec + .workdir + .as_deref() + .or(self.base_workdir.as_deref()) + .map(std::path::PathBuf::from) + .ok_or_else(|| { + BackendError::Process("SFTP requires a workload directory".to_string()) + })?; + + let (stdin_client, stdin_helper) = helper_socket_pair()?; + let (stdout_client, stdout_helper) = helper_socket_pair()?; + let (stderr_client, mut stderr_helper) = helper_socket_pair()?; + let cancel_sockets = vec![ + stdin_helper.try_clone().map_err(|error| { + BackendError::Process(format!("clone runtime helper input: {error}")) + })?, + stdout_helper.try_clone().map_err(|error| { + BackendError::Process(format!("clone runtime helper output: {error}")) + })?, + ]; + let stdin = async_socket(stdin_client)?.into_split().1; + let stdout = async_socket(stdout_client)?.into_split().0; + let stderr = async_socket(stderr_client)?.into_split().0; + let input = async_socket(stdin_helper)?; + let output = async_socket(stdout_helper)?; + let worker = tokio::spawn(async move { + crate::sftp::serve(tokio::io::join(input, output), root) + .await + .map_err(|error| { + let message = format!("{error:?}"); + let _ = writeln!(stderr_helper, "{message}"); + message + }) + }); + let process = RuntimeHelperProcess::new(worker, cancel_sockets); + Ok(ExecSession { + process, + stdin: Some(Box::new(stdin)), + stdout: Box::new(stdout), + stderr: Some(Box::new(stderr)), + terminal: None, + }) + } + fn command(&self, spec: &ExecSpec) -> Result { let (program, args) = if let Some(shell_spec) = &spec.shell { let shell = openshell_core::shell::find_login_shell().ok_or_else(|| { @@ -356,6 +414,10 @@ impl Drop for SpawnedExec { #[async_trait] impl BoundaryExec for LocalBoundaryExec { async fn exec(&self, spec: ExecSpec) -> Result { + #[cfg(target_os = "linux")] + if spec.runtime_helper.is_some() { + return self.runtime_helper(spec); + } let executor = self.clone(); let (send, receive) = tokio::sync::oneshot::channel(); tokio::task::spawn_blocking(move || { @@ -376,6 +438,83 @@ impl BoundaryExec for LocalBoundaryExec { } } +fn helper_socket_pair() -> Result<(StdUnixStream, StdUnixStream), BackendError> { + StdUnixStream::pair() + .map_err(|error| BackendError::Process(format!("create runtime helper pipe: {error}"))) +} + +fn async_socket(socket: StdUnixStream) -> Result { + socket.set_nonblocking(true).map_err(|error| { + BackendError::Process(format!("configure runtime helper pipe: {error}")) + })?; + tokio::net::UnixStream::from_std(socket) + .map_err(|error| BackendError::Process(format!("adopt runtime helper pipe: {error}"))) +} + +struct RuntimeHelperProcess { + result: Arc>>, + exited: Arc, + abort: tokio::task::AbortHandle, + cancel_sockets: Vec, +} + +impl RuntimeHelperProcess { + fn new( + worker: tokio::task::JoinHandle>, + cancel_sockets: Vec, + ) -> Arc { + let result = Arc::new(std::sync::Mutex::new(None)); + let exited = Arc::new(tokio::sync::Notify::new()); + let process = Arc::new(Self { + result: result.clone(), + exited: exited.clone(), + abort: worker.abort_handle(), + cancel_sockets, + }); + tokio::spawn(async move { + let status = match worker.await { + Ok(Ok(())) => BoundaryExitStatus::Exited(0), + Err(error) if error.is_cancelled() => BoundaryExitStatus::Signaled(9), + Ok(Err(_)) | Err(_) => BoundaryExitStatus::Exited(1), + }; + if let Ok(mut slot) = result.lock() { + *slot = Some(status); + } + exited.notify_waiters(); + }); + process + } +} + +#[async_trait] +impl BoundaryProcess for RuntimeHelperProcess { + async fn wait(&self) -> Result { + loop { + let notified = self.exited.notified(); + let status = *self + .result + .lock() + .map_err(|_| BackendError::Process("runtime helper result lock poisoned".into()))?; + if let Some(status) = status { + return Ok(status); + } + notified.await; + } + } + + async fn signal(&self, _signal: BoundarySignal) -> Result<(), BackendError> { + for socket in &self.cancel_sockets { + let _ = socket.shutdown(Shutdown::Both); + } + self.abort.abort(); + Ok(()) + } + + async fn terminate(&self) -> Result<(), BackendError> { + self.signal(BoundarySignal::Kill).await + } +} + struct LocalTerminal { master: std::fs::File, } @@ -574,6 +713,7 @@ mod tests { command: Some("printf ready".to_string()), login: true, }), + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -603,6 +743,7 @@ mod tests { command: None, login: true, }), + runtime_helper: None, env: vec![], workdir: None, pty: true, @@ -623,6 +764,7 @@ mod tests { .to_string(), ], shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -654,6 +796,92 @@ mod tests { assert_eq!(stderr, "err:value"); } + #[tokio::test] + async fn trusted_sftp_helper_round_trips_through_boundary_streams() { + let root = tempfile::tempdir().unwrap(); + let mut executor = executor(); + executor.base_workdir = Some(root.path().to_string_lossy().into_owned()); + let mut session = executor + .exec(ExecSpec { + program: String::new(), + args: Vec::new(), + shell: None, + runtime_helper: Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp), + env: Vec::new(), + workdir: None, + pty: false, + }) + .await + .expect("start SFTP helper"); + let process = session.process.clone(); + let stream = tokio::io::join( + session.stdout, + session.stdin.take().expect("SFTP input stream"), + ); + let client = russh_sftp::client::SftpSession::new(stream) + .await + .expect("start SFTP client"); + let mut file = client.create("boundary.txt").await.expect("create file"); + file.write_all(b"through boundary") + .await + .expect("write file"); + drop(file); + drop(client); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(2), process.wait()) + .await + .expect("SFTP helper exits") + .expect("SFTP helper status"), + BoundaryExitStatus::Exited(0) + ); + assert_eq!( + std::fs::read(root.path().join("boundary.txt")).unwrap(), + b"through boundary" + ); + } + + #[tokio::test] + async fn cancelling_sftp_helper_closes_protocol_stream() { + let root = tempfile::tempdir().unwrap(); + let mut executor = executor(); + executor.base_workdir = Some(root.path().to_string_lossy().into_owned()); + let mut session = executor + .exec(ExecSpec { + program: String::new(), + args: Vec::new(), + shell: None, + runtime_helper: Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp), + env: Vec::new(), + workdir: None, + pty: false, + }) + .await + .expect("start SFTP helper"); + let process = session.process.clone(); + let stream = tokio::io::join( + session.stdout, + session.stdin.take().expect("SFTP input stream"), + ); + let client = russh_sftp::client::SftpSession::new(stream) + .await + .expect("start SFTP client"); + + process.terminate().await.expect("cancel SFTP helper"); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(2), process.wait()) + .await + .expect("SFTP helper exits") + .expect("SFTP helper status"), + BoundaryExitStatus::Signaled(9) + ); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(2), client.metadata(".")) + .await + .expect("SFTP client observes cancellation") + .is_err() + ); + } + #[tokio::test] async fn exec_rejects_after_boundary_end() { let executor = executor(); @@ -663,6 +891,7 @@ mod tests { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "exit 0".to_string()], shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -680,6 +909,7 @@ mod tests { program: "/definitely/missing/openshell-exec".to_string(), args: vec![], shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -700,6 +930,7 @@ mod tests { program: "/bin/sleep".to_string(), args: vec!["30".to_string()], shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -732,6 +963,7 @@ mod tests { program: "/bin/sleep".to_string(), args: vec!["30".to_string()], shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -764,6 +996,7 @@ mod tests { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "exit 0".to_string()], shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -784,6 +1017,7 @@ mod tests { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "exit 7".to_string()], shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: true, diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 6452e3046b..f126ec6d58 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -4891,6 +4891,7 @@ mod linux { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "printf '%s' \"$REPLAY_TEST\"".to_string()], shell: None, + runtime_helper: None, env: Vec::new(), workdir: None, pty: false, @@ -5172,6 +5173,7 @@ mod linux { program: "/bin/sleep".to_string(), args: vec!["30".to_string()], shell: None, + runtime_helper: None, env: Vec::new(), workdir: None, pty: false, @@ -5240,6 +5242,7 @@ mod linux { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), format!("exit {exit_code}")], shell: None, + runtime_helper: None, env: Vec::new(), workdir: None, pty: false, @@ -5278,6 +5281,7 @@ mod linux { .to_string(), ], shell: None, + runtime_helper: None, env: Vec::new(), workdir: None, pty: false, diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 4166d8d2c1..957b55c664 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -24,6 +24,8 @@ pub mod perf; pub mod process; mod pty; pub mod sandbox; +#[cfg(target_os = "linux")] +pub mod sftp; /// Results of actively qualifying the admitted workload runtime before the /// sandbox consumes protected bootstrap material. diff --git a/crates/openshell-sandbox/src/sftp.rs b/crates/openshell-sandbox/src/sftp.rs new file mode 100644 index 0000000000..a7b349dc35 --- /dev/null +++ b/crates/openshell-sandbox/src/sftp.rs @@ -0,0 +1,553 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! SFTP v3 adapter backed by a directory file descriptor. + +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::fs::File as StdFile; +use std::io; +use std::os::fd::{AsFd, OwnedFd}; +use std::os::unix::ffi::OsStringExt as _; +use std::path::{Component, Path, PathBuf}; + +use miette::{IntoDiagnostic as _, Result}; +use russh_sftp::protocol::{ + Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, +}; +use rustix::fs::{ + AtFlags, Dir, Gid, Mode, OFlags, ResolveFlags, Timestamps, Uid, fchmod, fchown, fstat, + futimens, mkdirat, openat, openat2, readlinkat, renameat, statat, symlinkat, unlinkat, +}; +use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _, AsyncWriteExt as _}; + +const MAX_HANDLES: usize = 256; +const MAX_PACKET_SIZE: u32 = 256 * 1024; +const MAX_READ_SIZE: usize = MAX_PACKET_SIZE as usize; +const MAX_DIRECTORY_ENTRIES: usize = 128; +const SAFE_MODE_MASK: u32 = 0o777; + +struct SftpHandler { + root: OwnedFd, + root_path: PathBuf, + files: HashMap, + directories: HashMap, + done: Option>, +} + +impl Drop for SftpHandler { + fn drop(&mut self) { + if let Some(done) = self.done.take() { + let _ = done.send(()); + } + } +} + +impl SftpHandler { + fn new(root: &Path, done: tokio::sync::oneshot::Sender<()>) -> io::Result { + let root_path = std::fs::canonicalize(root)?; + let root = openat( + rustix::fs::CWD, + &root_path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::empty(), + ) + .map_err(io::Error::from)?; + Ok(Self { + root, + root_path, + files: HashMap::new(), + directories: HashMap::new(), + done: Some(done), + }) + } + + fn path(&self, path: &str) -> Result { + let supplied = Path::new(path); + let supplied = if supplied.is_absolute() && supplied.starts_with(&self.root_path) { + supplied + .strip_prefix(&self.root_path) + .map_err(|_| StatusCode::PermissionDenied)? + } else { + supplied + }; + let mut relative = PathBuf::new(); + for component in supplied.components() { + match component { + Component::RootDir | Component::CurDir => {} + Component::Normal(value) => relative.push(value), + Component::ParentDir | Component::Prefix(_) => { + return Err(StatusCode::PermissionDenied); + } + } + } + if relative.as_os_str().is_empty() { + relative.push("."); + } + Ok(relative) + } + + fn parent(&self, path: &str) -> Result<(OwnedFd, OsString), StatusCode> { + let path = self.path(path)?; + if path == Path::new(".") { + return Err(StatusCode::PermissionDenied); + } + let leaf = path + .file_name() + .ok_or(StatusCode::PermissionDenied)? + .to_os_string(); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let fd = self.open_path( + parent, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + Ok((fd, leaf)) + } + + fn open_path(&self, path: &Path, flags: OFlags, mode: Mode) -> Result { + openat2( + &self.root, + path, + flags, + mode, + ResolveFlags::IN_ROOT | ResolveFlags::NO_MAGICLINKS, + ) + .map_err(status_code) + } + + fn reserve_handle(&self) -> Result<(), StatusCode> { + if self.files.len() + self.directories.len() >= MAX_HANDLES { + Err(StatusCode::Failure) + } else { + Ok(()) + } + } + + fn handle() -> String { + uuid::Uuid::new_v4().simple().to_string() + } +} + +fn status_code(error: rustix::io::Errno) -> StatusCode { + match error { + rustix::io::Errno::NOENT | rustix::io::Errno::NOTDIR => StatusCode::NoSuchFile, + rustix::io::Errno::ACCESS + | rustix::io::Errno::PERM + | rustix::io::Errno::LOOP + | rustix::io::Errno::XDEV => StatusCode::PermissionDenied, + _ => StatusCode::Failure, + } +} + +fn io_status(error: io::Error) -> StatusCode { + error + .raw_os_error() + .map(rustix::io::Errno::from_raw_os_error) + .map_or(StatusCode::Failure, status_code) +} + +fn ok(id: u32) -> Status { + Status { + id, + status_code: StatusCode::Ok, + error_message: String::new(), + language_tag: "en-US".to_string(), + } +} + +fn attributes(stat: &rustix::fs::Stat) -> FileAttributes { + FileAttributes { + size: stat.st_size.try_into().ok(), + uid: Some(stat.st_uid), + user: None, + gid: Some(stat.st_gid), + group: None, + permissions: Some(stat.st_mode), + atime: stat.st_atime.try_into().ok(), + mtime: stat.st_mtime.try_into().ok(), + } +} + +fn open_flags(flags: OpenFlags) -> OFlags { + let mut result = OFlags::CLOEXEC; + result |= if flags.contains(OpenFlags::READ) && flags.contains(OpenFlags::WRITE) { + OFlags::RDWR + } else if flags.contains(OpenFlags::WRITE) { + OFlags::WRONLY + } else { + OFlags::RDONLY + }; + if flags.contains(OpenFlags::APPEND) { + result |= OFlags::APPEND; + } + if flags.contains(OpenFlags::CREATE) { + result |= OFlags::CREATE; + } + if flags.contains(OpenFlags::TRUNCATE) { + result |= OFlags::TRUNC; + } + if flags.contains(OpenFlags::EXCLUDE) { + result |= OFlags::EXCL; + } + result +} + +fn set_attributes(fd: impl AsFd, attrs: &FileAttributes) -> Result<(), StatusCode> { + if attrs.uid.is_some() || attrs.gid.is_some() { + fchown( + &fd, + attrs.uid.map(Uid::from_raw), + attrs.gid.map(Gid::from_raw), + ) + .map_err(status_code)?; + } + if let Some(mode) = attrs.permissions { + fchmod(&fd, Mode::from_bits_truncate(mode & SAFE_MODE_MASK)).map_err(status_code)?; + } + if attrs.atime.is_some() || attrs.mtime.is_some() { + let current = fstat(&fd).map_err(status_code)?; + let times = Timestamps { + last_access: rustix::fs::Timespec { + tv_sec: attrs.atime.map_or(current.st_atime, i64::from), + tv_nsec: 0, + }, + last_modification: rustix::fs::Timespec { + tv_sec: attrs.mtime.map_or(current.st_mtime, i64::from), + tv_nsec: 0, + }, + }; + futimens(fd, ×).map_err(status_code)?; + } + Ok(()) +} + +impl russh_sftp::server::Handler for SftpHandler { + type Error = StatusCode; + + fn unimplemented(&self) -> Self::Error { + StatusCode::OpUnsupported + } + + async fn open( + &mut self, + id: u32, + filename: String, + pflags: OpenFlags, + attrs: FileAttributes, + ) -> Result { + self.reserve_handle()?; + if pflags.contains(OpenFlags::EXCLUDE) && !pflags.contains(OpenFlags::CREATE) { + return Err(StatusCode::BadMessage); + } + let path = self.path(&filename)?; + let mode = Mode::from_bits_truncate(attrs.permissions.unwrap_or(0o666) & SAFE_MODE_MASK); + let fd = self.open_path(&path, open_flags(pflags), mode)?; + let handle = Self::handle(); + self.files + .insert(handle.clone(), tokio::fs::File::from_std(StdFile::from(fd))); + Ok(Handle { id, handle }) + } + + async fn close(&mut self, id: u32, handle: String) -> Result { + if self.files.remove(&handle).is_none() && self.directories.remove(&handle).is_none() { + return Err(StatusCode::Failure); + } + Ok(ok(id)) + } + + async fn read( + &mut self, + id: u32, + handle: String, + offset: u64, + len: u32, + ) -> Result { + let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?; + file.seek(io::SeekFrom::Start(offset)) + .await + .map_err(io_status)?; + let requested = usize::try_from(len) + .map_err(|_| StatusCode::BadMessage)? + .min(MAX_READ_SIZE); + let mut data = vec![0; requested]; + let count = file.read(&mut data).await.map_err(io_status)?; + if count == 0 { + return Err(StatusCode::Eof); + } + data.truncate(count); + Ok(Data { id, data }) + } + + async fn write( + &mut self, + id: u32, + handle: String, + offset: u64, + data: Vec, + ) -> Result { + let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?; + file.seek(io::SeekFrom::Start(offset)) + .await + .map_err(io_status)?; + file.write_all(&data).await.map_err(io_status)?; + Ok(ok(id)) + } + + async fn lstat(&mut self, id: u32, path: String) -> Result { + if self.path(&path)? == Path::new(".") { + let stat = fstat(&self.root).map_err(status_code)?; + return Ok(Attrs { + id, + attrs: attributes(&stat), + }); + } + let (parent, leaf) = self.parent(&path)?; + let stat = statat(parent, leaf, AtFlags::SYMLINK_NOFOLLOW).map_err(status_code)?; + Ok(Attrs { + id, + attrs: attributes(&stat), + }) + } + + async fn stat(&mut self, id: u32, path: String) -> Result { + let path = self.path(&path)?; + let fd = self.open_path(&path, OFlags::PATH | OFlags::CLOEXEC, Mode::empty())?; + let stat = fstat(fd).map_err(status_code)?; + Ok(Attrs { + id, + attrs: attributes(&stat), + }) + } + + async fn fstat(&mut self, id: u32, handle: String) -> Result { + let file = self.files.get(&handle).ok_or(StatusCode::Failure)?; + let stat = fstat(file).map_err(status_code)?; + Ok(Attrs { + id, + attrs: attributes(&stat), + }) + } + + async fn fsetstat( + &mut self, + id: u32, + handle: String, + attrs: FileAttributes, + ) -> Result { + let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?; + if let Some(size) = attrs.size { + file.set_len(size).await.map_err(io_status)?; + } + set_attributes(&*file, &attrs)?; + Ok(ok(id)) + } + + async fn opendir(&mut self, id: u32, path: String) -> Result { + self.reserve_handle()?; + let path = self.path(&path)?; + let fd = self.open_path( + &path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + let directory = Dir::new(fd).map_err(status_code)?; + let handle = Self::handle(); + self.directories.insert(handle.clone(), directory); + Ok(Handle { id, handle }) + } + + async fn readdir(&mut self, id: u32, handle: String) -> Result { + let directory = self + .directories + .get_mut(&handle) + .ok_or(StatusCode::Failure)?; + let mut files = Vec::with_capacity(MAX_DIRECTORY_ENTRIES); + while files.len() < MAX_DIRECTORY_ENTRIES { + let Some(entry) = directory.next() else { + break; + }; + let entry = entry.map_err(status_code)?; + let name = entry.file_name(); + if name.to_bytes() == b"." || name.to_bytes() == b".." { + continue; + } + let stat = statat( + directory.fd().map_err(status_code)?, + name, + AtFlags::SYMLINK_NOFOLLOW, + ) + .map_err(status_code)?; + files.push(File::new(name.to_string_lossy(), attributes(&stat))); + } + if files.is_empty() { + Err(StatusCode::Eof) + } else { + Ok(Name { id, files }) + } + } + + async fn remove(&mut self, id: u32, filename: String) -> Result { + let (parent, leaf) = self.parent(&filename)?; + unlinkat(parent, leaf, AtFlags::empty()).map_err(status_code)?; + Ok(ok(id)) + } + + async fn mkdir( + &mut self, + id: u32, + path: String, + attrs: FileAttributes, + ) -> Result { + let (parent, leaf) = self.parent(&path)?; + let mode = Mode::from_bits_truncate(attrs.permissions.unwrap_or(0o777) & SAFE_MODE_MASK); + mkdirat(parent, leaf, mode).map_err(status_code)?; + Ok(ok(id)) + } + + async fn rmdir(&mut self, id: u32, path: String) -> Result { + let (parent, leaf) = self.parent(&path)?; + unlinkat(parent, leaf, AtFlags::REMOVEDIR).map_err(status_code)?; + Ok(ok(id)) + } + + async fn realpath(&mut self, id: u32, path: String) -> Result { + let path = self.path(&path)?; + self.open_path(&path, OFlags::PATH | OFlags::CLOEXEC, Mode::empty())?; + let display = if path == Path::new(".") { + "/".to_string() + } else { + format!("/{}", path.display()) + }; + Ok(Name { + id, + files: vec![File::dummy(display)], + }) + } + + async fn rename( + &mut self, + id: u32, + oldpath: String, + newpath: String, + ) -> Result { + let (old_parent, old_leaf) = self.parent(&oldpath)?; + let (new_parent, new_leaf) = self.parent(&newpath)?; + renameat(old_parent, old_leaf, new_parent, new_leaf).map_err(status_code)?; + Ok(ok(id)) + } + + async fn readlink(&mut self, id: u32, path: String) -> Result { + let (parent, leaf) = self.parent(&path)?; + let target = readlinkat(parent, leaf, Vec::new()).map_err(status_code)?; + let target = OsString::from_vec(target.into_bytes()); + Ok(Name { + id, + files: vec![File::dummy(target.to_string_lossy())], + }) + } + + async fn symlink( + &mut self, + id: u32, + linkpath: String, + targetpath: String, + ) -> Result { + let (parent, leaf) = self.parent(&linkpath)?; + symlinkat(OsStr::new(&targetpath), parent, leaf).map_err(status_code)?; + Ok(ok(id)) + } + + async fn setstat( + &mut self, + id: u32, + path: String, + attrs: FileAttributes, + ) -> Result { + let path = self.path(&path)?; + let flags = if attrs.size.is_some() { + OFlags::WRONLY | OFlags::CLOEXEC + } else { + OFlags::RDONLY | OFlags::CLOEXEC + }; + let fd = self.open_path(&path, flags, Mode::empty())?; + if let Some(size) = attrs.size { + StdFile::from(fd.try_clone().map_err(io_status)?) + .set_len(size) + .map_err(io_status)?; + } + set_attributes(&fd, &attrs)?; + Ok(ok(id)) + } +} + +pub(crate) async fn serve(stream: S, root: PathBuf) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static, +{ + let (done_tx, done_rx) = tokio::sync::oneshot::channel(); + let handler = SftpHandler::new(&root, done_tx).into_diagnostic()?; + russh_sftp::server::run_with_config( + stream, + handler, + russh_sftp::server::Config { + max_client_packet_len: MAX_PACKET_SIZE, + }, + ) + .await; + let _ = done_rx.await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use russh_sftp::client::SftpSession; + + async fn client(root: &Path) -> SftpSession { + let (done_tx, _done_rx) = tokio::sync::oneshot::channel(); + let handler = SftpHandler::new(root, done_tx).unwrap(); + let (client_stream, server_stream) = tokio::io::duplex(1024 * 1024); + russh_sftp::server::run_with_config( + server_stream, + handler, + russh_sftp::server::Config { + max_client_packet_len: MAX_PACKET_SIZE, + }, + ) + .await; + SftpSession::new(client_stream).await.unwrap() + } + + #[tokio::test] + async fn adapter_round_trips_files() { + let root = tempfile::tempdir().unwrap(); + let client = client(root.path()).await; + let mut file = client + .open_with_flags( + "hello.txt", + OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE | OpenFlags::READ, + ) + .await + .unwrap(); + file.write_all(b"hello from sftp").await.unwrap(); + file.rewind().await.unwrap(); + let mut contents = String::new(); + file.read_to_string(&mut contents).await.unwrap(); + assert_eq!(contents, "hello from sftp"); + } + + #[tokio::test] + async fn adapter_rejects_parent_and_symlink_escapes() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("root"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(parent.path().join("outside"), b"secret").unwrap(); + std::os::unix::fs::symlink("../outside", root.join("escape")).unwrap(); + let client = client(&root).await; + + assert!(client.metadata("../outside").await.is_err()); + assert!(client.metadata("escape").await.is_err()); + assert!(client.open("escape").await.is_err()); + } +} diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 572ab47dee..1eca60d933 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -719,17 +719,18 @@ impl russh::server::Handler for SshHandler { } } else if name == "sftp" { session.channel_success(channel)?; - // sftp-server speaks the SFTP binary protocol over stdin/stdout, - // which the boundary executor preserves as separate pipes. This enables - // modern scp (SFTP-based, OpenSSH 9.0+) and SFTP clients to - // transfer files into and out of the sandbox. + // The sandbox runtime implements SFTP over the boundary streams so + // the workload image does not need an sftp-server executable. self.start_exec_spec( channel, session.handle(), openshell_isolation_interface::contract::ExecSpec { - program: "/usr/lib/openssh/sftp-server".to_string(), - args: vec![], + program: String::new(), + args: Vec::new(), shell: None, + runtime_helper: Some( + openshell_isolation_interface::contract::RuntimeHelper::Sftp, + ), env: vec![], workdir: None, pty: false, @@ -907,6 +908,7 @@ impl SshHandler { command, login: !no_login_shell, }), + runtime_helper: None, env, workdir: None, pty: pty_requested, From 9904158699714bce4eada5be70c65c9b045b5b4f Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 21 Sep 2026 20:45:29 -0700 Subject: [PATCH 12/17] fix(sftp): gate runtime helper support to Linux Signed-off-by: Drew Newberry --- crates/openshell-sandbox/src/boundary_exec.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index 45c8545501..f4f5a13f3f 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -4,9 +4,12 @@ //! Workload-side implementation of RFC 0012 sandbox exec. use std::collections::HashMap; +#[cfg(target_os = "linux")] use std::io::Write as _; +#[cfg(target_os = "linux")] use std::net::Shutdown; use std::os::fd::{AsRawFd, OwnedFd}; +#[cfg(target_os = "linux")] use std::os::unix::net::UnixStream as StdUnixStream; use std::process::{Child, Command, Stdio}; use std::sync::Arc; @@ -438,11 +441,13 @@ impl BoundaryExec for LocalBoundaryExec { } } +#[cfg(target_os = "linux")] fn helper_socket_pair() -> Result<(StdUnixStream, StdUnixStream), BackendError> { StdUnixStream::pair() .map_err(|error| BackendError::Process(format!("create runtime helper pipe: {error}"))) } +#[cfg(target_os = "linux")] fn async_socket(socket: StdUnixStream) -> Result { socket.set_nonblocking(true).map_err(|error| { BackendError::Process(format!("configure runtime helper pipe: {error}")) @@ -451,6 +456,7 @@ fn async_socket(socket: StdUnixStream) -> Result>>, exited: Arc, @@ -458,6 +464,7 @@ struct RuntimeHelperProcess { cancel_sockets: Vec, } +#[cfg(target_os = "linux")] impl RuntimeHelperProcess { fn new( worker: tokio::task::JoinHandle>, @@ -486,6 +493,7 @@ impl RuntimeHelperProcess { } } +#[cfg(target_os = "linux")] #[async_trait] impl BoundaryProcess for RuntimeHelperProcess { async fn wait(&self) -> Result { From 1ed3478ec43969c14ed4a3c664a50936b9a3b3ac Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 21 Sep 2026 22:23:59 -0700 Subject: [PATCH 13/17] fix(sftp): support standard OpenSSH file operations Signed-off-by: Drew Newberry --- crates/openshell-sandbox/src/sftp.rs | 56 ++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/openshell-sandbox/src/sftp.rs b/crates/openshell-sandbox/src/sftp.rs index a7b349dc35..5857a7233d 100644 --- a/crates/openshell-sandbox/src/sftp.rs +++ b/crates/openshell-sandbox/src/sftp.rs @@ -96,7 +96,10 @@ impl SftpHandler { .file_name() .ok_or(StatusCode::PermissionDenied)? .to_os_string(); - let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); let fd = self.open_path( parent, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, @@ -241,8 +244,13 @@ impl russh_sftp::server::Handler for SftpHandler { return Err(StatusCode::BadMessage); } let path = self.path(&filename)?; - let mode = Mode::from_bits_truncate(attrs.permissions.unwrap_or(0o666) & SAFE_MODE_MASK); - let fd = self.open_path(&path, open_flags(pflags), mode)?; + let flags = open_flags(pflags); + let mode = if flags.contains(OFlags::CREATE) { + Mode::from_bits_truncate(attrs.permissions.unwrap_or(0o666) & SAFE_MODE_MASK) + } else { + Mode::empty() + }; + let fd = self.open_path(&path, flags, mode)?; let handle = Self::handle(); self.files .insert(handle.clone(), tokio::fs::File::from_std(StdFile::from(fd))); @@ -413,7 +421,20 @@ impl russh_sftp::server::Handler for SftpHandler { async fn realpath(&mut self, id: u32, path: String) -> Result { let path = self.path(&path)?; - self.open_path(&path, OFlags::PATH | OFlags::CLOEXEC, Mode::empty())?; + if let Err(error) = self.open_path(&path, OFlags::PATH | OFlags::CLOEXEC, Mode::empty()) { + if error != StatusCode::NoSuchFile { + return Err(error); + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + self.open_path( + parent, + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + } let display = if path == Path::new(".") { "/".to_string() } else { @@ -535,6 +556,33 @@ mod tests { let mut contents = String::new(); file.read_to_string(&mut contents).await.unwrap(); assert_eq!(contents, "hello from sftp"); + drop(file); + + let mut file = client.open("hello.txt").await.unwrap(); + let mut contents = String::new(); + file.read_to_string(&mut contents).await.unwrap(); + assert_eq!(contents, "hello from sftp"); + } + + #[tokio::test] + async fn adapter_creates_entries_at_virtual_root() { + let root = tempfile::tempdir().unwrap(); + let client = client(root.path()).await; + + client.create_dir("incoming").await.unwrap(); + + assert!(root.path().join("incoming").is_dir()); + } + + #[tokio::test] + async fn realpath_accepts_a_missing_leaf_under_an_existing_directory() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("incoming")).unwrap(); + let client = client(root.path()).await; + + let canonical = client.canonicalize("incoming/tree").await.unwrap(); + + assert_eq!(canonical, PathBuf::from("/incoming/tree")); } #[tokio::test] From 8ad3d42ab7d931c00009a3bc5137da65987af21b Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 21 Sep 2026 23:14:54 -0700 Subject: [PATCH 14/17] fix(sftp): harden rename and special file handling Signed-off-by: Drew Newberry --- crates/openshell-sandbox/src/sftp.rs | 131 +++++++++++++++++++++++++-- 1 file changed, 125 insertions(+), 6 deletions(-) diff --git a/crates/openshell-sandbox/src/sftp.rs b/crates/openshell-sandbox/src/sftp.rs index 5857a7233d..2cf02a8390 100644 --- a/crates/openshell-sandbox/src/sftp.rs +++ b/crates/openshell-sandbox/src/sftp.rs @@ -16,8 +16,9 @@ use russh_sftp::protocol::{ Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, }; use rustix::fs::{ - AtFlags, Dir, Gid, Mode, OFlags, ResolveFlags, Timestamps, Uid, fchmod, fchown, fstat, - futimens, mkdirat, openat, openat2, readlinkat, renameat, statat, symlinkat, unlinkat, + AtFlags, Dir, FileType, Gid, Mode, OFlags, RenameFlags, ResolveFlags, Timestamps, Uid, fchmod, + fchown, fstat, futimens, mkdirat, openat, openat2, readlinkat, renameat_with, statat, + symlinkat, unlinkat, }; use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _, AsyncWriteExt as _}; @@ -173,7 +174,7 @@ fn attributes(stat: &rustix::fs::Stat) -> FileAttributes { } fn open_flags(flags: OpenFlags) -> OFlags { - let mut result = OFlags::CLOEXEC; + let mut result = OFlags::CLOEXEC | OFlags::NONBLOCK; result |= if flags.contains(OpenFlags::READ) && flags.contains(OpenFlags::WRITE) { OFlags::RDWR } else if flags.contains(OpenFlags::WRITE) { @@ -196,6 +197,25 @@ fn open_flags(flags: OpenFlags) -> OFlags { result } +fn ensure_regular_file(fd: impl AsFd) -> Result<(), StatusCode> { + let stat = fstat(fd).map_err(status_code)?; + if FileType::from_raw_mode(stat.st_mode).is_file() { + Ok(()) + } else { + Err(StatusCode::OpUnsupported) + } +} + +fn ensure_setstat_target(fd: impl AsFd, changes_size: bool) -> Result<(), StatusCode> { + let stat = fstat(fd).map_err(status_code)?; + let file_type = FileType::from_raw_mode(stat.st_mode); + if file_type.is_file() || (!changes_size && file_type.is_dir()) { + Ok(()) + } else { + Err(StatusCode::OpUnsupported) + } +} + fn set_attributes(fd: impl AsFd, attrs: &FileAttributes) -> Result<(), StatusCode> { if attrs.uid.is_some() || attrs.gid.is_some() { fchown( @@ -251,6 +271,7 @@ impl russh_sftp::server::Handler for SftpHandler { Mode::empty() }; let fd = self.open_path(&path, flags, mode)?; + ensure_regular_file(&fd)?; let handle = Self::handle(); self.files .insert(handle.clone(), tokio::fs::File::from_std(StdFile::from(fd))); @@ -454,7 +475,14 @@ impl russh_sftp::server::Handler for SftpHandler { ) -> Result { let (old_parent, old_leaf) = self.parent(&oldpath)?; let (new_parent, new_leaf) = self.parent(&newpath)?; - renameat(old_parent, old_leaf, new_parent, new_leaf).map_err(status_code)?; + renameat_with( + old_parent, + old_leaf, + new_parent, + new_leaf, + RenameFlags::NOREPLACE, + ) + .map_err(status_code)?; Ok(ok(id)) } @@ -487,11 +515,12 @@ impl russh_sftp::server::Handler for SftpHandler { ) -> Result { let path = self.path(&path)?; let flags = if attrs.size.is_some() { - OFlags::WRONLY | OFlags::CLOEXEC + OFlags::WRONLY | OFlags::CLOEXEC | OFlags::NONBLOCK } else { - OFlags::RDONLY | OFlags::CLOEXEC + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NONBLOCK }; let fd = self.open_path(&path, flags, Mode::empty())?; + ensure_setstat_target(&fd, attrs.size.is_some())?; if let Some(size) = attrs.size { StdFile::from(fd.try_clone().map_err(io_status)?) .set_len(size) @@ -524,6 +553,8 @@ where mod tests { use super::*; use russh_sftp::client::SftpSession; + use std::os::unix::fs::PermissionsExt as _; + use std::time::Duration; async fn client(root: &Path) -> SftpSession { let (done_tx, _done_rx) = tokio::sync::oneshot::channel(); @@ -598,4 +629,92 @@ mod tests { assert!(client.metadata("escape").await.is_err()); assert!(client.open("escape").await.is_err()); } + + #[tokio::test] + async fn standard_rename_preserves_an_existing_destination() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("source"), b"source contents").unwrap(); + std::fs::write(root.path().join("destination"), b"destination contents").unwrap(); + let client = client(root.path()).await; + + assert!(client.rename("source", "destination").await.is_err()); + assert_eq!( + std::fs::read(root.path().join("source")).unwrap(), + b"source contents" + ); + assert_eq!( + std::fs::read(root.path().join("destination")).unwrap(), + b"destination contents" + ); + + client.rename("source", "renamed").await.unwrap(); + assert_eq!( + std::fs::read(root.path().join("renamed")).unwrap(), + b"source contents" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fifo_open_fails_without_blocking() { + let root = tempfile::tempdir().unwrap(); + let fifo = root.path().join("fifo"); + rustix::fs::mkfifoat(rustix::fs::CWD, &fifo, Mode::from_bits_truncate(0o600)).unwrap(); + let client = client(root.path()).await; + + let result = tokio::time::timeout(Duration::from_secs(1), client.open("fifo")).await; + if result.is_err() { + let _ = openat( + rustix::fs::CWD, + &fifo, + OFlags::WRONLY | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ); + } + + assert!(matches!(result, Ok(Err(_)))); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fifo_setstat_fails_without_blocking() { + let root = tempfile::tempdir().unwrap(); + let fifo = root.path().join("fifo"); + rustix::fs::mkfifoat(rustix::fs::CWD, &fifo, Mode::from_bits_truncate(0o600)).unwrap(); + let client = client(root.path()).await; + + let result = tokio::time::timeout( + Duration::from_secs(1), + client.set_metadata("fifo", FileAttributes::default()), + ) + .await; + if result.is_err() { + let _ = openat( + rustix::fs::CWD, + &fifo, + OFlags::WRONLY | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ); + } + + assert!(matches!(result, Ok(Err(_)))); + } + + #[tokio::test] + async fn setstat_preserves_directory_metadata_support() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("directory")).unwrap(); + let client = client(root.path()).await; + let attrs = FileAttributes { + permissions: Some(0o700), + ..FileAttributes::default() + }; + + client.set_metadata("directory", attrs).await.unwrap(); + + let mode = std::fs::metadata(root.path().join("directory")) + .unwrap() + .permissions() + .mode() + & SAFE_MODE_MASK; + assert_eq!(mode, 0o700); + } } From 9b2de64b3b67fa635410c519c415f073d5ed3e89 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 22 Sep 2026 00:22:58 -0700 Subject: [PATCH 15/17] refactor(runtime): remove community image dependencies Signed-off-by: Drew Newberry --- .agents/skills/helm-dev-environment/SKILL.md | 4 +- .agents/skills/update-docs/SKILL.md | 2 +- README.md | 65 +++++----- TESTING.md | 2 +- architecture/build.md | 2 +- architecture/compute-runtimes.md | 5 +- crates/openshell-cli/src/completers.rs | 1 + crates/openshell-cli/src/main.rs | 11 +- crates/openshell-cli/src/run.rs | 17 +-- crates/openshell-core/src/image.rs | 113 +----------------- crates/openshell-core/src/sandbox_env.rs | 2 +- crates/openshell-driver-docker/src/lib.rs | 2 +- crates/openshell-driver-docker/src/tests.rs | 29 ++++- .../openshell-driver-podman/src/container.rs | 4 +- .../openshell-driver-podman/src/isolation.rs | 2 +- crates/openshell-ocsf/tests/roundtrip.rs | 2 +- crates/openshell-policy/src/lib.rs | 2 +- crates/openshell-sdk/README.md | 2 +- crates/openshell-sdk/src/types.rs | 2 +- crates/openshell-server/src/compute/mod.rs | 2 +- crates/openshell-tui/src/lib.rs | 3 +- deploy/rpm/CONFIGURATION.md | 12 +- deploy/rpm/TROUBLESHOOTING.md | 6 +- docs/about/overview.mdx | 2 +- docs/about/release-notes.mdx | 19 ++- docs/about/supported-agents.mdx | 24 ++-- docs/get-started/quickstart.mdx | 57 +++------ docs/get-started/tutorials/docker-compose.mdx | 38 +++--- docs/get-started/tutorials/github-sandbox.mdx | 2 +- docs/get-started/tutorials/index.mdx | 2 +- .../tutorials/inference-ollama.mdx | 73 ++--------- docs/index.mdx | 4 +- docs/providers/profiles.mdx | 8 +- docs/reference/default-policy.mdx | 31 +++-- docs/reference/support-matrix.mdx | 12 +- docs/sandboxes/manage-providers.mdx | 6 +- docs/sandboxes/manage-sandboxes.mdx | 62 ++++++---- docs/sandboxes/policies.mdx | 4 +- e2e/configs/gateway/docker.toml | 2 +- e2e/configs/gateway/podman.toml | 2 +- ...schema-v2-compute-boundary-comparison.json | 36 +++--- .../Dockerfile.external-kubernetes-gateway | 2 +- e2e/gpu/images/cuda-basic/Dockerfile | 4 +- e2e/gpu/images/cuda-basic/README.md | 2 +- e2e/gpu/images/smoke-fail/Dockerfile | 4 +- e2e/gpu/images/smoke-pass/Dockerfile | 4 +- e2e/parity/kubernetes-options.sh | 2 +- e2e/parity/podman-options.sh | 2 +- e2e/parity/run.sh | 10 +- e2e/parity/test.sh | 12 +- e2e/parity/verify-results.py | 13 +- e2e/rust/src/harness/container.rs | 2 +- e2e/rust/src/harness/sandbox.rs | 51 +++++++- e2e/rust/tests/community_image.rs | 61 ---------- e2e/rust/tests/default_image.rs | 40 +++++++ e2e/rust/tests/driver_config_volume.rs | 2 +- e2e/rust/tests/host_gateway_alias.rs | 4 - e2e/rust/tests/podman_oci_identity.rs | 4 +- e2e/rust/tests/provider_readiness.rs | 4 +- e2e/rust/tests/provider_refresh_handles.rs | 18 ++- e2e/rust/tests/provider_token_exchange.rs | 34 +++--- e2e/rust/tests/vm_corporate_proxy.rs | 34 ++---- e2e/with-docker-gateway.sh | 4 +- e2e/with-podman-gateway.sh | 2 +- .../agent-driven-policy-management/demo.sh | 1 - examples/aws-s3-sts.md | 2 +- examples/multi-agent-notepad/README.md | 9 +- examples/multi-agent-notepad/demo.sh | 4 +- examples/private-ip-routing/README.md | 9 +- .../podman/README.md | 2 +- examples/sync-files.md | 11 +- .../tasks/development-gateway.yml | 2 +- providers/README.md | 2 +- providers/aws-s3.yaml | 2 +- providers/codex.yaml | 2 +- providers/copilot.yaml | 2 +- providers/cursor.yaml | 4 +- providers/pypi.yaml | 2 +- .../gateway_schema_v2_live_results_test.py | 2 +- rfc/0003-gateway-configuration/README.md | 10 +- scripts/agents/gator/Dockerfile | 6 +- scripts/agents/gator/README.md | 2 +- scripts/agents/gator/prompts/gator.md | 2 +- .../agents/gator/providers/github-gator.yaml | 21 ---- sdk/go/openshell/v1/integration_test.go | 2 +- sdk/typescript/README.md | 4 +- sdk/typescript/src/client.test.ts | 2 +- skills/generate-sandbox-policy/SKILL.md | 4 +- skills/openshell-cli/SKILL.md | 19 ++- tasks/scripts/e2e-gpu-build-images.sh | 2 +- 90 files changed, 483 insertions(+), 638 deletions(-) delete mode 100644 e2e/rust/tests/community_image.rs create mode 100644 e2e/rust/tests/default_image.rs diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index fddbe8f1e4..ba86ea4c3f 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -34,7 +34,7 @@ Also applies the upstream agent-sandbox CRDs/controller (pinned via `AGENT_SANDB in `tasks/scripts/helm-k3s-local.sh`, fetched from `github.com/kubernetes-sigs/agent-sandbox` releases), enables its OTLP tracing on v0.5 and later, installs an OTLP trace collector and UI in the `observability` namespace, -and preloads the default community sandbox image into k3d so the first sandbox create +and preloads the default sandbox image into k3d so the first sandbox create does not wait on a large registry pull. Traefik is disabled at cluster creation time. **Multi-worktree support:** the cluster name is derived from the last component of the @@ -52,7 +52,7 @@ Port mappings created at cluster time (cannot be changed without recreating): Override with env vars before running `helm:k3s:create`: - `HELM_K3S_LB_HOST_PORT` (default: `8080`) - `HELM_K3S_PRELOAD_SANDBOX_IMAGE` (default: - `ghcr.io/nvidia/openshell-community/sandboxes/base:latest`; set to an empty value to skip) + `nvcr.io/nvidia/base/ubuntu:24.04`; set to an empty value to skip) - `HELM_K3S_COLLECTOR_IMAGE` (default: `mcr.microsoft.com/dotnet/aspire-dashboard:latest`) - `HELM_K3S_COLLECTOR_HEALTH_TIMEOUT` (default: `120` seconds) diff --git a/.agents/skills/update-docs/SKILL.md b/.agents/skills/update-docs/SKILL.md index aa2db8cd58..86edcd2738 100644 --- a/.agents/skills/update-docs/SKILL.md +++ b/.agents/skills/update-docs/SKILL.md @@ -63,7 +63,7 @@ For each relevant commit, determine which doc page(s) it affects. Use this mappi | `python/` (SDK changes) | `docs/reference/` or `docs/get-started/quickstart.mdx` | | `proto/` (API changes) | `docs/reference/` | | `deploy/` (Dockerfile, Helm) | `docs/sandboxes/manage-gateways.mdx`, `docs/about/architecture.mdx` | -| Community sandbox definitions | `docs/sandboxes/community-sandboxes.mdx` | +| Sandbox image behavior | `docs/sandboxes/manage-sandboxes.mdx` | If a commit does not map to any existing page but introduces a user-visible concept, flag it as needing a new page. diff --git a/README.md b/README.md index f23ddb63c5..d441d5bcf9 100644 --- a/README.md +++ b/README.md @@ -56,19 +56,18 @@ For deploying OpenShell on OpenShift, see [`deploy/helm/openshell/README.md#inst ### Create a sandbox ```bash -openshell sandbox create -- claude # or opencode, codex, copilot +openshell sandbox create --name demo ``` -The sandbox container includes the following tools by default: +The gateway defaults to `nvcr.io/nvidia/base/ubuntu:24.04`, a minimal Ubuntu +Noble workload. To run an agent, build or select an OCI image that contains the +agent and pass its explicit reference: -| Category | Tools | -| ---------- | -------------------------------------------------------- | -| Agent | `claude`, `opencode`, `codex`, `copilot` | -| Language | `python` (3.14), `node` (22) | -| Developer | `gh`, `git`, `vim`, `nano` | -| Networking | `ping`, `dig`, `nslookup`, `nc`, `traceroute`, `netstat` | +```bash +openshell sandbox create --from registry.example.com/agents/my-agent:1.0 -- my-agent +``` -For more details see https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base. +Attach the providers and policy required by that workload. ### See network policy in action @@ -184,25 +183,27 @@ Inference access uses the same provider workflow. Attach an inference-capable pr OpenShell can pass host GPUs into sandboxes for local inference, fine-tuning, or any GPU workload. Add `--gpu` when creating a sandbox: ```bash -openshell sandbox create --gpu --from [gpu-enabled-sandbox] -- claude +openshell sandbox create --gpu --from registry.example.com/your-org/gpu-agent:latest -- claude ``` Docker-backed GPU sandboxes auto-select CDI when available and otherwise fall back to Docker's NVIDIA GPU request path (`--gpus all`). -**Requirements:** NVIDIA drivers and the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) must be installed on the host. The sandbox image itself must include the appropriate GPU drivers and libraries for your workload — the default `base` image does not. See the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container) for building a custom sandbox image with GPU support. +**Requirements:** NVIDIA drivers and the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) must be installed on the host. The sandbox image itself must include the appropriate GPU drivers and libraries for your workload — the default Ubuntu image does not. See the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container) for building a custom sandbox image with GPU support. ## Supported Agents -| Agent | Source | Notes | -| ------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `ANTHROPIC_API_KEY`. | -| [OpenCode](https://opencode.ai/) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `OPENAI_API_KEY` or `OPENROUTER_API_KEY`. | -| [Codex](https://developers.openai.com/codex) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `OPENAI_API_KEY`. | -| [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `GITHUB_TOKEN` or `COPILOT_GITHUB_TOKEN`. | -| [OpenClaw](https://openclaw.ai/) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Run OpenClaw more securely inside NVIDIA OpenShell with the NemoClaw blueprint. | -| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Run Hermes Agent more securely inside NVIDIA OpenShell with the NemoClaw blueprint. | -| [Ollama](https://ollama.com/) | [Community](https://github.com/NVIDIA/OpenShell-Community) | Launch with `openshell sandbox create --from ollama`. | -| [Pi](https://pi.dev/) | [Community](https://github.com/NVIDIA/OpenShell-Community) | Launch with `openshell sandbox create --from pi`. | +OpenShell can run Linux agents packaged in OCI images. The default Ubuntu +workload does not bundle agent CLIs. Build or select an image containing your +agent, then authorize its binary paths, service endpoints, and credentials. + +| Agent | Integration | +| ----- | ----------- | +| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | Package Claude Code in a workload image and attach a `claude-code` provider or another endpoint-bearing model profile. | +| [OpenCode](https://opencode.ai/) | Package OpenCode in a workload image and attach its model provider and policy. | +| [Codex](https://developers.openai.com/codex) | Package Codex in a workload image and attach an OpenAI provider and policy. | +| [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) | Package the CLI in a workload image and attach GitHub credentials and policy. | +| [OpenClaw](https://openclaw.ai/) | Use the [NemoClaw](https://github.com/NVIDIA/NemoClaw) blueprint. | +| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | Use the [NemoClaw](https://github.com/NVIDIA/NemoClaw) blueprint. | ## Key Commands @@ -234,23 +235,23 @@ openshell term The TUI gives you a live, keyboard-driven view of your gateway and sandboxes. Navigate with `Tab` to switch panels, `j`/`k` to move through lists, `Enter` to select, and `:` for command mode. Gateway health and sandbox status auto-refresh every two seconds. -## Community Sandboxes and BYOC +## Workload Images and BYOC -Use `--from` to create sandboxes from the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) catalog or a container image: +Use `--from` with an explicit OCI image reference: ```bash -openshell sandbox create --from gemini # community catalog -docker build -t my-sandbox:latest ./my-sandbox-dir # Docker gateway -openshell sandbox create --from my-sandbox:latest # Docker built image -podman build -t localhost/my-sandbox:latest ./my-sandbox-dir # Podman gateway -openshell sandbox create --from localhost/my-sandbox:latest # Podman built image -openshell sandbox create --from registry.io/img:v1 # container image +docker build -t my-sandbox:latest ./my-sandbox-dir +openshell sandbox create --from my-sandbox:latest + +podman build -t localhost/my-sandbox:latest ./my-sandbox-dir +openshell sandbox create --from localhost/my-sandbox:latest + +openshell sandbox create --from registry.example.com/agents/my-agent:1.0 ``` Build with the container engine used by your local gateway. For a remote -gateway, push the image to a registry that the gateway can pull from. - -See the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) catalog and the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container) for details. +gateway, push the image to a registry that the gateway can pull from. See the +[BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container). ## Use OpenShell with Your Agent diff --git a/TESTING.md b/TESTING.md index 699f44a728..e0de6dd91e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -207,7 +207,7 @@ the current checkout. The phase-1 scenario verifies the complete CLI-to-gateway-to-driver path without depending on how the gateway was installed or which driver is configured. It requires machine-readable gRPC status, creates a uniquely named detached -sandbox with `--from base`, verifies the sandbox is `Ready` by finding its +sandbox with the configured default image, verifies the sandbox is `Ready` by finding its unique name in paginated JSON list output, executes `echo` with a run-specific marker, deletes the sandbox, and verifies that its name no longer appears. Driver suites enable the same profile diff --git a/architecture/build.md b/architecture/build.md index 121ab0449c..1610d66c88 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -23,7 +23,7 @@ OpenShell builds these main artifacts: | VM driver/runtime assets | `crates/openshell-driver-vm` | | Published docs site | `docs/` rendered by Fern config in `fern/` | -Sandbox community images are built outside this repository. +Workload images are standard OCI images supplied by operators or users. ## Build Features diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 4fd27e2b9c..03ab6ef980 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -401,9 +401,8 @@ readiness. ## Images -The gateway image and Helm chart are built from this repository. Sandbox images -are maintained separately in the OpenShell Community repository or supplied by -users. +The gateway image and Helm chart are built from this repository. Users supply +workload images as standard OCI images. Custom sandbox images must include the agent runtime and any system dependencies, but they should not need to include the gateway. GPU-capable diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index c5fec20634..a0ab9a9b5a 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -217,6 +217,7 @@ mod tests { with_vars( [ ("XDG_CONFIG_HOME", Some(tmp.as_str())), + ("OPENSHELL_SYSTEM_GATEWAY_DIR", Some(tmp.as_str())), ("OPENSHELL_GATEWAY", None::<&str>), ], f, diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 047f35f6a0..778cc7dfd1 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -320,7 +320,7 @@ const SANDBOX_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m \x1b[1mEXAMPLES\x1b[0m $ openshell sandbox create - $ openshell sandbox create --from python + $ openshell sandbox create --from registry.example.com/agents/python:latest $ openshell sandbox connect my-sandbox $ openshell sandbox list $ openshell sandbox delete my-sandbox @@ -1409,13 +1409,8 @@ 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 rootfs tar archive (`.tar`, `.tar.gz`, or `.tgz`) + /// or a container image reference (e.g., `myregistry.com/img:tag`). /// /// 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 4a9919c1e4..489db3136f 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1273,7 +1273,7 @@ enum ResolvedSource { /// 1. Existing file with `.tar`, `.tar.gz`, or `.tgz` extension → rootfs tar archive. /// 2. Local Dockerfile and directory paths → an actionable build-and-tag error. /// 3. Other explicit local paths → an actionable error. -/// 4. Full image reference or community sandbox name → resolve as an image. +/// 4. Any other value is passed through as an explicit image reference. fn resolve_from(value: &str) -> Result { let path = Path::new(value); @@ -1314,11 +1314,7 @@ 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), - )) + Ok(ResolvedSource::Image(value.to_string())) } #[allow(clippy::case_sensitive_file_extension_comparisons)] // already lowercased @@ -6907,7 +6903,7 @@ mod tests { } #[test] - fn resolve_from_keeps_bare_community_name_when_local_directory_matches() { + fn resolve_from_keeps_bare_image_reference_when_local_directory_matches() { let _lock = TEST_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -6919,11 +6915,8 @@ mod tests { let result = resolve_from("python"); std::env::set_current_dir(original_dir).expect("restore current directory"); - match result.expect("bare community name should not be a local path") { - super::ResolvedSource::Image(image) => assert_eq!( - image, - "ghcr.io/nvidia/openshell-community/sandboxes/python:latest" - ), + match result.expect("bare image reference should not be a local path") { + super::ResolvedSource::Image(image) => assert_eq!(image, "python"), other @ super::ResolvedSource::RootfsTar { .. } => { panic!("expected image source, got {other:?}"); } diff --git a/crates/openshell-core/src/image.rs b/crates/openshell-core/src/image.rs index a3d314b3e8..e092b9afb6 100644 --- a/crates/openshell-core/src/image.rs +++ b/crates/openshell-core/src/image.rs @@ -1,22 +1,12 @@ // 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. -//! -//! 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 workload image selection. -/// Default registry prefix for community sandbox images. +/// Default sandbox workload image. /// -/// 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"; - -/// Default sandbox base image reference. -/// -/// A version-qualified NVIDIA Ubuntu Noble image so a fresh install does not -/// depend on the community image catalog or Docker Hub availability. +/// `OpenShell` uses a version-qualified NVIDIA Ubuntu Noble image so a fresh +/// installation does not depend on a separately maintained image catalog. pub const DEFAULT_SANDBOX_BASE_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; /// Return the default sandbox image reference. @@ -28,103 +18,12 @@ 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) }, - } + fn default_image_is_version_qualified_nvidia_ubuntu() { + assert_eq!(default_sandbox_image(), "nvcr.io/nvidia/base/ubuntu:24.04"); } } diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index d298c5e99b..24640c7908 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -244,7 +244,7 @@ pub const SANDBOX_UID: &str = "OPENSHELL_SANDBOX_UID"; 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). +/// `USER` (for example, a minimal base image). /// /// 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 diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 2f1d40933a..ee9e3a6d60 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -629,7 +629,7 @@ fn resolve_docker_identity_from_accounts( requested_user }; if user_selector.is_empty() { - // The image declares no USER (e.g. a plain Alpine base) and the policy + // The image declares no USER (for example, a minimal base image) 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. diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 4b0c9cef92..0908fc2739 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -59,7 +59,7 @@ fn test_sandbox() -> DriverSandbox { log_level: "debug".to_string(), environment: HashMap::from([("SPEC_ENV".to_string(), "spec".to_string())]), template: Some(DriverSandboxTemplate { - image: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), + image: "nvcr.io/nvidia/base/ubuntu:24.04".to_string(), agent_socket_path: String::new(), labels: HashMap::new(), environment: HashMap::from([("TEMPLATE_ENV".to_string(), "template".to_string())]), @@ -1334,6 +1334,33 @@ fn docker_identity_resolution_uses_pinned_image_accounts_and_exact_groups() { assert_eq!(resolved.resource_digest, "sha256:image"); } +#[test] +fn docker_identity_resolution_uses_numeric_default_for_userless_image() { + let image = DockerImageMetadata { + id: "sha256:image".to_string(), + user: String::new(), + working_dir: "/".to_string(), + volumes: Vec::new(), + }; + let resolved = resolve_docker_identity_from_accounts( + &test_sandbox(), + &image, + b"root:x:0:0:root:/root:/bin/sh\n", + b"root:x:0:\n", + ) + .unwrap(); + + assert_eq!( + (resolved.uid, resolved.gid), + ( + openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + ) + ); + assert_eq!(resolved.source, "default"); + assert_eq!(resolved.resource_digest, "sha256:image"); +} + #[test] fn docker_identity_resolution_honors_policy_selectors_and_rejects_root() { let mut sandbox = test_sandbox(); diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 49de68b7fe..e25c30a1fb 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -594,7 +594,7 @@ fn build_env( // and intercept the sandbox JWT. env.remove(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME); if oci_user.is_empty() { - // The image declares no OCI USER (e.g. a plain Alpine base). Assign a + // The image declares no OCI USER (for example, a minimal base image). 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( @@ -1147,7 +1147,7 @@ fn build_base_spec( image_volumes, hostname: format!("sandbox-{}", sandbox.name), // Override the image's ENTRYPOINT so the supervisor binary runs - // directly. Sandbox images (e.g. the community base image) set + // directly. Workload images can set // ENTRYPOINT ["/bin/bash"], and Podman's `command` field only // overrides CMD — which gets appended as args to the entrypoint. // Without this, the container would run the entrypoint binary with diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index ed13c50e56..36b9be92f9 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -124,7 +124,7 @@ pub fn resolve_identity( requested_group }; if user.is_empty() && group.is_empty() { - // The image declares no OCI USER (e.g. a plain Alpine base) and the + // The image declares no OCI USER (for example, a minimal base image) and the // policy requested no identity. Synthesize a numeric non-root identity // instead of rejecting the image, matching Docker, Kubernetes, and VM. return ResolvedWorkloadIdentity::new( diff --git a/crates/openshell-ocsf/tests/roundtrip.rs b/crates/openshell-ocsf/tests/roundtrip.rs index 42664400ab..4617cbfe21 100644 --- a/crates/openshell-ocsf/tests/roundtrip.rs +++ b/crates/openshell-ocsf/tests/roundtrip.rs @@ -22,7 +22,7 @@ fn ctx() -> EventContext { EventContext { sandbox_id: "sb-7f3a9c2e14b8".to_string(), sandbox_name: "agent-workspace-01".to_string(), - container_image: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), + container_image: "nvcr.io/nvidia/base/ubuntu:24.04".to_string(), hostname: "openshell-sb-7f3a9c2e14b8".to_string(), product_version: "0.42.1".to_string(), proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index b231458181..f6ba71fed7 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -970,7 +970,7 @@ pub use openshell_core::container_paths::CONTAINER_POLICY_PATH; /// Legacy path used before the navigator → openshell rename. /// -/// Existing community sandbox images still ship their policy at this path. +/// Older images may still ship their policy at this path. /// The sandbox supervisor tries [`CONTAINER_POLICY_PATH`] first, then falls /// back to this legacy path for backward compatibility. pub const LEGACY_CONTAINER_POLICY_PATH: &str = "/etc/navigator/policy.yaml"; diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index 423d04488c..47805e38ee 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -113,7 +113,7 @@ client }), spec: Some(SandboxWorkloadTemplateSpec { workload: Some(SandboxWorkloadConfig { - image: "ghcr.io/nvidia/openshell-community/sandboxes/python:latest".to_string(), + image: "registry.example.com/agents/python:latest".to_string(), ..Default::default() }), ..Default::default() diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index d09eaabf19..a9fd544f14 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -146,7 +146,7 @@ impl From for SandboxPhase { pub struct SandboxSpec { /// Optional user-supplied sandbox name. When empty the server generates one. pub name: Option, - /// Container image reference (e.g. `ghcr.io/nvidia/openshell-community/sandboxes/python:latest`). + /// Container image reference (e.g. `registry.example.com/agents/python:latest`). pub image: Option, /// Labels attached to the sandbox. pub labels: HashMap, diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index a0219ab1bb..d5a3c084eb 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -12340,7 +12340,7 @@ mod tests { ..Default::default() }), template: Some(SandboxTemplate { - image: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), + image: "nvcr.io/nvidia/base/ubuntu:24.04".to_string(), driver_config: Some(prost_types::Struct { fields: [ ( 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 { diff --git a/deploy/rpm/CONFIGURATION.md b/deploy/rpm/CONFIGURATION.md index 7ae1f6f15e..ac57e02eba 100644 --- a/deploy/rpm/CONFIGURATION.md +++ b/deploy/rpm/CONFIGURATION.md @@ -220,7 +220,7 @@ overrides that persist across package upgrades. |-------------|---------|-------------| | `bind_address` | `127.0.0.1:17670` (gateway default) | Address for the primary gRPC/HTTP API listener. | | `compute_driver` | `"podman"` (RPM default) | When unset, the gateway auto-detects Kubernetes, then Podman, then Docker. The RPM default pins to Podman; legacy `compute_drivers` lists are rejected. | -| `[openshell.drivers.podman].default_image` | `ghcr.io/nvidia/openshell-community/sandboxes/base:latest` | Default sandbox image. | +| `[openshell.drivers.podman].default_image` | `nvcr.io/nvidia/base/ubuntu:24.04` | Default sandbox image. | | `[openshell.drivers.podman].sandbox_runtime_image` | `ghcr.io/nvidia/openshell/sandbox:latest` | Static musl sandbox runtime image mounted into Podman workloads. | | `[openshell.drivers.podman].supervisor_image` | `ghcr.io/nvidia/openshell/supervisor:latest` | Dynamic glibc supervisor image used outside the workload. | | `[openshell.gateway].guest_tls_ca`, `guest_tls_cert`, `guest_tls_key` | auto-generated paths | Gateway-owned client TLS material injected into the selected local driver and mounted into sandbox containers. | @@ -244,7 +244,7 @@ compute_driver = "podman" [openshell.drivers.podman] network_name = "openshell" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" health_check_interval_secs = 10 stop_timeout_secs = 10 @@ -260,7 +260,7 @@ To update cached images: ```shell podman pull ghcr.io/nvidia/openshell/supervisor:latest -podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest +podman pull nvcr.io/nvidia/base/ubuntu:24.04 ``` Or set `image_pull_policy = "always"` in @@ -272,7 +272,7 @@ To pin specific image versions instead of `:latest`, set these values in ```toml sandbox_runtime_image = "ghcr.io/nvidia/openshell/sandbox:v0.0.37" supervisor_image = "ghcr.io/nvidia/openshell/supervisor:v0.0.37" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:v0.0.37" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" ``` For air-gapped environments: @@ -281,9 +281,9 @@ For air-gapped environments: ```shell podman pull ghcr.io/nvidia/openshell/supervisor:latest - podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest + podman pull nvcr.io/nvidia/base/ubuntu:24.04 podman save -o supervisor.tar ghcr.io/nvidia/openshell/supervisor:latest - podman save -o sandbox.tar ghcr.io/nvidia/openshell-community/sandboxes/base:latest + podman save -o sandbox.tar nvcr.io/nvidia/base/ubuntu:24.04 ``` 1. Transfer the tarballs to the air-gapped host and load them: diff --git a/deploy/rpm/TROUBLESHOOTING.md b/deploy/rpm/TROUBLESHOOTING.md index 29b27ad209..b9b2fdb56a 100644 --- a/deploy/rpm/TROUBLESHOOTING.md +++ b/deploy/rpm/TROUBLESHOOTING.md @@ -176,7 +176,7 @@ sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER **Image pull failure.** Verify ghcr.io is reachable: ```shell -podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest +podman pull nvcr.io/nvidia/base/ubuntu:24.04 ``` ### Images not updating @@ -185,7 +185,7 @@ The default image pull policy is `if_not_present` -- images are pulled once and cached. To update: ```shell -podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest +podman pull nvcr.io/nvidia/base/ubuntu:24.04 podman pull ghcr.io/nvidia/openshell/supervisor:latest ``` @@ -239,7 +239,7 @@ To pick up new container images after an upgrade: ```shell podman pull ghcr.io/nvidia/openshell/supervisor:latest -podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest +podman pull nvcr.io/nvidia/base/ubuntu:24.04 ``` ### Migrating a TLS-enabled local driver to schema version 2 diff --git a/docs/about/overview.mdx b/docs/about/overview.mdx index 90b565ae38..4c8a234e73 100644 --- a/docs/about/overview.mdx +++ b/docs/about/overview.mdx @@ -47,7 +47,7 @@ OpenShell supports a range of agent deployment patterns. | Secure coding agents | Run Claude Code, OpenCode, Codex, or GitHub Copilot CLI with constrained file and network access. | | Private enterprise development | Grant selected sandboxes access to self-hosted or private model endpoints while keeping sensitive context under your control. | | Compliance and audit | Treat policy YAML as version-controlled security controls that can be reviewed and audited. | -| Reusable environments | Use community sandbox images or bring your own containerized runtime. | +| Reusable environments | Use the default Ubuntu workload or bring your own containerized runtime. | ## Next Steps diff --git a/docs/about/release-notes.mdx b/docs/about/release-notes.mdx index 0b774e541c..3b9d3f5613 100644 --- a/docs/about/release-notes.mdx +++ b/docs/about/release-notes.mdx @@ -12,6 +12,23 @@ NVIDIA OpenShell follows a frequent release cadence. Use the following GitHub re ## 0.1.0 migration notes +### Workload images + +The default workload image is now `nvcr.io/nvidia/base/ubuntu:24.04`. It is a +minimal Ubuntu Noble environment and does not include agent CLIs or an +image-baked OpenShell policy. OpenShell applies its built-in restrictive policy +when no explicit policy is supplied. + +The CLI no longer expands bare catalog names or supports the former catalog +registry override. Build or select an OCI image and pass its explicit reference: + +```shell +openshell sandbox create --from registry.example.com/agents/my-agent:1.0 -- my-agent +``` + +Gateway operators can replace the default with the driver configuration or the +Helm `server.sandboxImage` value. + ### Provider profiles are import-only OpenShell 0.1.0 stops compiling provider profiles into the gateway binary. A gateway's catalog contains exactly the profiles an operator imported, the default `provider_profile_sources` is `[{ type = "user" }]`, and the `builtin` source type is removed: a `gateway.toml` that still names it is rejected at startup with the import command. A gateway with nothing imported reaches ready and serves an empty catalog. @@ -34,7 +51,7 @@ A provider whose profile is absent after the upgrade stays listable and exportab Command-to-provider inference is removed. `openshell sandbox create -- ` no longer derives a provider from the trailing command, and the compiled alias table that drove it is gone. A profile's `binaries` list authorizes a binary to reach that profile's endpoints; it is not a statement that running the binary asks for the provider, so treating it as attachment intent could attach credentials the operator did not select. Name providers explicitly with `--provider `, which still creates a missing provider from local discovery when the name matches an imported profile ID. -The files under `providers/` are reviewable examples, not defaults. Read each file's header before importing it: several bind network access to binary paths that exist only in the OpenShell Community image, and imported unchanged into another image the profile matches nothing. +The files under `providers/` are reviewable examples, not defaults. Read each file's header before importing it: several bind network access to binary paths from the image layout where they were authored, and imported unchanged into another image the profile matches nothing. ### Network policy binaries diff --git a/docs/about/supported-agents.mdx b/docs/about/supported-agents.mdx index 13704aa94b..601a91ebe8 100644 --- a/docs/about/supported-agents.mdx +++ b/docs/about/supported-agents.mdx @@ -6,19 +6,17 @@ description: "AI agent frameworks and runtimes compatible with OpenShell sandbox keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Claude, Codex, Cursor" position: 5 --- -The following table summarizes the agents that run in OpenShell sandboxes. Most agent sandbox images are maintained in the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) repository. Agents in the base image are auto-configured when passed as the trailing command to `openshell sandbox create`. -| Agent | Source | Default Policy | Notes | -|---|---|---|---| -| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Full coverage | Works out of the box with an attached `claude-code` provider. For another model platform, attach an endpoint-bearing profile and configure Claude Code for that platform's native API. | -| [OpenCode](https://opencode.ai/) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Partial coverage | Pre-installed. Attach the selected model provider, configure its native base URL and model, and ensure the imported profile names OpenCode's service endpoint and binary paths. | -| [Codex](https://developers.openai.com/codex) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | No coverage | Pre-installed. Requires a custom policy with OpenAI endpoints and Codex binary paths. Requires `OPENAI_API_KEY`. | -| [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Full coverage | Pre-installed. Works out of the box. Requires `GITHUB_TOKEN` or `COPILOT_GITHUB_TOKEN`. | -| [OpenClaw](https://openclaw.ai/) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Blueprint-managed | Run OpenClaw more securely inside NVIDIA OpenShell with the NemoClaw blueprint. | -| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Blueprint-managed | Run Hermes Agent more securely inside NVIDIA OpenShell with the NemoClaw blueprint. | -| [Ollama](https://ollama.com/) | [`ollama`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/ollama) | Bundled | Run cloud and local models. Includes Claude Code, Codex, and OpenCode. Launch with `openshell sandbox create --from ollama`. | -| [Pi](https://pi.dev/) | [`pi`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/pi) | Bundled | Comes with Pi pre-installed. Launch with `openshell sandbox create --from pi`. | +OpenShell can run containerized agents that support Linux. The default Ubuntu workload image does not bundle agent CLIs. Build or select an OCI image containing the agent, pass its explicit image reference to `--from`, and attach the providers and policy it needs. -For base image details and `--from` usage, refer to [Sandboxes](/sandboxes/manage-sandboxes#base-sandbox-container). +| Agent | Image workflow | Provider and policy requirements | +|---|---|---| +| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | Add Claude Code to your workload image. | Attach a `claude-code` provider or an endpoint-bearing profile for the selected model platform. Authorize the agent binary and service endpoints. | +| [OpenCode](https://opencode.ai/) | Add OpenCode to your workload image. | Attach the selected model provider and authorize OpenCode's binary paths and service endpoints. | +| [Codex](https://developers.openai.com/codex) | Add Codex to your workload image. | Attach an OpenAI provider and authorize the Codex binary paths and OpenAI endpoints. | +| [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) | Add GitHub Copilot CLI to your workload image. | Attach GitHub credentials and authorize the CLI binary and required GitHub endpoints. | +| [OpenClaw](https://openclaw.ai/) | Use the [NemoClaw](https://github.com/NVIDIA/NemoClaw) blueprint. | The blueprint manages the workload and its policy. | +| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | Use the [NemoClaw](https://github.com/NVIDIA/NemoClaw) blueprint. | The blueprint manages the workload and its policy. | +| [Ollama](https://ollama.com/) | Build an image containing Ollama, or connect a sandbox to a separately managed Ollama service. | Authorize the client binary and the Ollama endpoint. Refer to [Inference with Ollama](/get-started/tutorials/inference-ollama). | -For a complete support matrix, refer to the [Support Matrix](/reference/support-matrix) page. +For image selection and `--from` usage, refer to [Manage Sandboxes](/sandboxes/manage-sandboxes#sandbox-images). For a complete platform matrix, refer to [Support Matrix](/reference/support-matrix). diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 91565d71af..55b1eb3ab4 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -64,66 +64,37 @@ If your sandbox image installs the agent somewhere else, edit `binaries` first otherwise the profile matches nothing and the credential is never injected. -## Create Your First OpenShell Sandbox +## Create Your First Sandbox -Create a sandbox and launch an agent inside it. -Choose the tab that matches your agent: - - - - -Run the following command to create a sandbox with Claude Code: +Create a sandbox with the gateway's default workload image: ```shell -openshell sandbox create --provider claude-code -- claude +openshell sandbox create --name quickstart ``` -`--provider` names the profile to use. If no provider by that name exists yet, -the CLI prompts you to create one from local credentials; type `yes` to continue. -If `ANTHROPIC_API_KEY` is set in your environment, the CLI picks it up automatically. -If not, you can configure it from inside the sandbox after it launches. - - -`ANTHROPIC_API_KEY` is an API key from [console.anthropic.com](https://console.anthropic.com), not a subscription token. Subscription users must generate a separate API key. - - - - +The default is `nvcr.io/nvidia/base/ubuntu:24.04`. It provides a minimal Ubuntu Noble environment; install workload-specific tools in your own image. -Run the following command to create a sandbox with OpenCode: +Connect to the sandbox: ```shell -openshell sandbox create --provider openai -- opencode +openshell sandbox connect quickstart ``` -`--provider` names the profile to use. If no provider by that name exists yet, -the CLI prompts you to create one from local credentials; type `yes` to continue. -If `OPENAI_API_KEY` or `OPENROUTER_API_KEY` is set in your environment, the CLI picks it up automatically. -If not, you can configure it from inside the sandbox after it launches. - - - - -Run the following command to create a sandbox with Codex: +Run a command without opening an interactive session: ```shell -openshell sandbox create --provider codex -- codex +openshell sandbox exec -n quickstart -- cat /etc/os-release ``` -`--provider` names the profile to use. If no provider by that name exists yet, -the CLI prompts you to create one from local credentials; type `yes` to continue. -If `OPENAI_API_KEY` is set in your environment, the CLI picks it up automatically. -If not, you can configure it from inside the sandbox after it launches. - - - +## Run an Agent Image -Use the `--from` flag to create a sandbox from the base container: +Build an OCI image containing your agent and its dependencies, then pass its full image reference: ```shell -openshell sandbox create --from base +docker build -t my-agent:latest ./my-agent +openshell sandbox create --from my-agent:latest -- my-agent ``` - +For a Podman gateway, build and use a Podman-visible name such as `localhost/my-agent:latest`. For a remote gateway, push the image to a registry that the gateway can pull from. - +Attach a provider and a policy that authorize the agent's credentials, endpoints, and executable paths. Refer to [Bring Your Own Container](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container), [Providers](/providers), and [Customize Sandbox Policies](/sandboxes/policies). diff --git a/docs/get-started/tutorials/docker-compose.mdx b/docs/get-started/tutorials/docker-compose.mdx index 1efc2abfeb..33f6b0c1d3 100644 --- a/docs/get-started/tutorials/docker-compose.mdx +++ b/docs/get-started/tutorials/docker-compose.mdx @@ -137,40 +137,38 @@ Confirm the provider was stored: openshell provider list ``` -## Pre-pull sandbox images (optional) +## Pre-pull workload images (optional) -Sandbox images are pulled automatically on first use, but the initial pull can take several minutes for large images. Pre-pull to avoid long waits at sandbox creation time: +Workload images are pulled automatically on first use. Pre-pull the default +Ubuntu workload or your own agent image to avoid waiting during sandbox +creation: ```shell -# Base image — includes Claude Code, OpenCode, Codex, and Copilot -docker pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest +docker pull nvcr.io/nvidia/base/ubuntu:24.04 +docker pull registry.example.com/your-org/agent:latest ``` -## Create a sandbox +## Create a Sandbox - - - -OpenClaw runs inside OpenShell through [NemoClaw](https://github.com/NVIDIA/NemoClaw), which manages the sandbox image, model-provider setup, and security policies. - -Follow the [NemoClaw Quickstart](https://docs.nvidia.com/nemoclaw/latest/get-started/quickstart/) to set up an OpenClaw sandbox. - - - +Create a minimal Ubuntu sandbox: ```shell -openshell sandbox create -- claude +openshell sandbox create --name compose-demo ``` - - +To run an agent, use an explicit OCI image that contains it: ```shell -openshell sandbox create -- opencode +openshell sandbox create \ + --from registry.example.com/your-org/agent:latest \ + --provider \ + -- my-agent ``` - - +OpenClaw runs through [NemoClaw](https://github.com/NVIDIA/NemoClaw), which +manages its sandbox image, model provider, and security policies. Follow the +[NemoClaw Quickstart](https://docs.nvidia.com/nemoclaw/latest/get-started/quickstart/) +for that workflow. Wait for the phase to change from `Provisioning` to `Ready`: diff --git a/docs/get-started/tutorials/github-sandbox.mdx b/docs/get-started/tutorials/github-sandbox.mdx index e420bb697a..6be5b3445c 100644 --- a/docs/get-started/tutorials/github-sandbox.mdx +++ b/docs/get-started/tutorials/github-sandbox.mdx @@ -57,7 +57,7 @@ openshell provider profile import -f github.yaml --global GITHUB_TOKEN= openshell provider create --name my-github --type github --from-existing -openshell sandbox create --provider my-github -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --provider my-github -- claude ``` The import step registers the `github` profile on your gateway. OpenShell ships diff --git a/docs/get-started/tutorials/index.mdx b/docs/get-started/tutorials/index.mdx index 2050480b90..cda35bfffd 100644 --- a/docs/get-started/tutorials/index.mdx +++ b/docs/get-started/tutorials/index.mdx @@ -29,7 +29,7 @@ Configure a Microsoft Graph provider profile with gateway-managed OAuth2 refresh -Run Ollama models in a community sandbox or attach a profile for a host-level service. +Attach a profile that authorizes a sandbox to use a host-level Ollama service. diff --git a/docs/get-started/tutorials/inference-ollama.mdx b/docs/get-started/tutorials/inference-ollama.mdx index c23e6eaf6c..017e8ec889 100644 --- a/docs/get-started/tutorials/inference-ollama.mdx +++ b/docs/get-started/tutorials/inference-ollama.mdx @@ -4,65 +4,19 @@ title: "Run Local Inference with Ollama" sidebar-title: "Inference with Ollama" slug: "get-started/tutorials/inference-ollama" -description: "Run Ollama inside a community sandbox or attach a profile for a host-level Ollama server." +description: "Authorize an OpenShell sandbox to use an Ollama service running on the gateway host." keywords: "Generative AI, Cybersecurity, Tutorial, Ollama, Local Inference, Sandbox, Provider Profile" --- -This tutorial covers two Ollama deployments: - -1. The self-contained Ollama community sandbox. This is the recommended path. -2. A host-level Ollama service shared by explicitly authorized sandboxes. +This tutorial connects an explicitly authorized sandbox to an Ollama service running on the same machine as the OpenShell gateway. ## Prerequisites -Complete the [Quickstart](/get-started/quickstart) before proceeding. - -## Option A: Ollama Community Sandbox - -The community image bundles Ollama and supported coding agents: - -```shell -openshell sandbox create --from ollama -``` - -Inside the sandbox, run a local or cloud model: +Complete the [Quickstart](/get-started/quickstart) before proceeding. Your workload image must contain the client or agent you plan to use. -```shell -ollama run qwen3.5 -ollama run kimi-k2.5:cloud -``` - -Or launch a coding agent against Ollama: - -```shell -ollama launch claude -ollama launch codex -ollama launch opencode -``` +## Start Ollama on a Reachable Address -For an automated workflow: - -```shell -ollama launch claude --yes --model qwen3.5 -``` - -Useful starter models include `qwen3.5:0.8b` for smoke tests and `qwen3.5` -for coding and tool use. Check the -[Ollama model library](https://ollama.com/library) for current model details. - -Update the bundled Ollama installation with `update-ollama`, or request an -update at sandbox start: - -```shell -openshell sandbox create --from ollama -e OLLAMA_UPDATE=1 -``` - -## Option B: Host-level Ollama - -Use this path when Ollama runs on the same machine as the OpenShell gateway and -selected sandboxes should share it. - -### Start Ollama on a Reachable Address +Install Ollama on the gateway host and listen on an address reachable from sandboxes: ```shell curl -fsSL https://ollama.com/install.sh | sh @@ -75,7 +29,7 @@ In another terminal, pull a model: ollama pull qwen3.5:0.8b ``` -### Import an Endpoint-bearing Profile +## Import an Endpoint-bearing Profile Save this as `ollama-openai.yaml`: @@ -107,14 +61,16 @@ openshell provider profile import -f ollama-openai.yaml openshell provider create --name ollama --type ollama-openai ``` -The provider has no secret because this Ollama server does not authenticate. -Its attachment still carries the endpoint and binary policy. +The provider has no secret because this Ollama server does not authenticate. Its attachment still carries the endpoint and binary policy. + +## Attach and Verify -### Attach and Verify +Use an explicit workload image containing `curl`: ```shell openshell sandbox create \ --name ollama-client \ + --from registry.example.com/tools/curl:latest \ --provider ollama \ -- \ curl http://host.openshell.internal:11434/v1/chat/completions \ @@ -125,9 +81,7 @@ openshell sandbox create \ }' ``` -For an OpenAI SDK client, set its base URL to -`http://host.openshell.internal:11434/v1`, use any non-empty API key value the -SDK accepts, and select the real Ollama model in the request. +For an OpenAI SDK client, set its base URL to `http://host.openshell.internal:11434/v1`, use any non-empty API key value the SDK accepts, and select the real Ollama model in the request. ## Troubleshooting @@ -136,8 +90,7 @@ SDK accepts, and select the real Ollama model in the request. - Confirm attachment with `openshell sandbox provider list ollama-client`. - Inspect the effective policy with `openshell policy get ollama-client --full`. - Run `ollama ps` and `ollama pull ` when the model is unavailable. -- If the gateway is remote, the hostname refers to the remote gateway host, - not your laptop. Use a shared service address or tunnel instead. +- If the gateway is remote, the hostname refers to the remote gateway host, not your laptop. Use a shared service address or tunnel instead. ## Next Steps diff --git a/docs/index.mdx b/docs/index.mdx index b7623332ec..9a40bca095 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -49,7 +49,7 @@ Install OpenShell and create your first sandbox in two commands. ```shell curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh -openshell sandbox create -- claude +openshell sandbox create --name quickstart ``` @@ -86,7 +86,7 @@ Hands-on walkthroughs from first sandbox to custom policies. -Deploy gateways, create sandboxes, configure policies, providers, and community images for your AI agents. +Deploy gateways, create sandboxes, configure policies, providers, and workload images for your AI agents. Concept diff --git a/docs/providers/profiles.mdx b/docs/providers/profiles.mdx index 8b574fb341..66f498ee11 100644 --- a/docs/providers/profiles.mdx +++ b/docs/providers/profiles.mdx @@ -257,10 +257,10 @@ inference and agent providers in reviewable starting points, not platform defaults. Read a file's header before importing it. Each one names the client binaries it -expects, the image layout those paths assume, the credential scope, the endpoint -access it grants, and a smoke test. Several name paths that exist only in the -OpenShell Community image, such as `/sandbox/.venv` or -`/usr/lib/node_modules/@openai`. Imported unchanged into a different image the +expects, the reference image layout those paths assume, the credential scope, +the endpoint access it grants, and a smoke test. Several name layout-specific +paths, such as `/sandbox/.venv` or `/usr/lib/node_modules/@openai`. Imported +unchanged into a different image the profile matches nothing: the catalog still lists it, but the credential is never injected and the traffic is denied. Copy the file, edit `binaries` and `endpoints` for your image and workload, and import your copy. diff --git a/docs/reference/default-policy.mdx b/docs/reference/default-policy.mdx index bd5c01facc..fd564ed610 100644 --- a/docs/reference/default-policy.mdx +++ b/docs/reference/default-policy.mdx @@ -7,23 +7,28 @@ description: "Breakdown of the built-in default policy applied when you create a keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Security, Policy" position: 2 --- -The default policy is the policy applied when you create an OpenShell sandbox without `--policy`. It is baked into the community base image ([`ghcr.io/nvidia/openshell-community/sandboxes/base`](https://github.com/NVIDIA/OpenShell-Community)) and defined in the community repo's `dev-sandbox-policy.yaml`. -## Agent Compatibility +When you create a sandbox without `--policy`, OpenShell applies a restrictive built-in fallback. The policy comes from the OpenShell runtime and does not depend on the selected workload image. -The following table shows the coverage of the default policy for common agents. +## Filesystem Access -| Agent | Coverage | Action Required | -|---|---|---| -| Claude Code | Full | None. Works out of the box. | -| OpenCode | Partial | Add `opencode.ai` endpoint and OpenCode binary paths. | -| Codex | None | Provide a complete custom policy with OpenAI endpoints and Codex binary paths. | +The fallback includes the sandbox working directory and grants read-only access to standard runtime paths: - -If you run a non-Claude agent without a custom policy, the agent's API calls are denied by the proxy. You must provide a policy that declares the agent's endpoints and binaries. +- `/usr` +- `/lib` +- `/proc` +- `/dev/urandom` +- `/etc` +- `/var/log` - +It grants read-write access to `/tmp` and `/dev/null`. Landlock enforcement uses `best_effort` compatibility so OpenShell can use the strongest ABI available on the host while retaining its mandatory baseline protections. -## Default Policy Blocks +## Network Access -The default policy blocks are defined in the community base image. Refer to the [OpenShell Community repository](https://github.com/NVIDIA/OpenShell-Community) for the full `dev-sandbox-policy.yaml` source. +The fallback defines no network policies or provider-derived endpoints, so outbound network access is denied. Attach a provider or apply a custom policy that names the required endpoints and executable paths before running a networked agent. + +## Process Identity + +The fallback leaves process identity selection to the compute driver. Docker and Podman honor a non-root OCI `USER`; when an image declares no user, they use numeric UID and GID `1000`. Kubernetes and MicroVM drivers apply their configured non-root identities. + +Use `openshell policy get --full` to inspect the effective policy. Refer to [Customize Sandbox Policies](/sandboxes/policies) to replace the fallback. diff --git a/docs/reference/support-matrix.mdx b/docs/reference/support-matrix.mdx index 740b5c997f..c587ee6a91 100644 --- a/docs/reference/support-matrix.mdx +++ b/docs/reference/support-matrix.mdx @@ -71,22 +71,22 @@ Install the software for the compute driver you use: | kubectl | Compatible with your cluster | Required for Kubernetes operational inspection and secret creation. | | Host virtualization | Host dependent | Required for MicroVM-backed gateways. MicroVM uses Hypervisor.framework on macOS and KVM on Linux. | -## Sandbox Runtime Versions +## Workload Images -Sandbox container images are maintained in the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) repository. Refer to that repository for the current list of installed components and their versions. +OpenShell accepts standard Linux OCI images. The built-in default workload is +`nvcr.io/nvidia/base/ubuntu:24.04` for `linux/amd64` and `linux/arm64`. It is a +minimal Ubuntu Noble userspace and does not bundle agent CLIs. Operators can +change the default, and users can select an explicit image with `--from`. ## Container Images -OpenShell publishes the gateway image for `linux/amd64` and `linux/arm64`. - | Image | Reference | Pulled When | |---|---|---| | Gateway | `ghcr.io/nvidia/openshell/gateway:latest` | Helm chart install or upgrade, or standalone container deployment | +| Default workload | `nvcr.io/nvidia/base/ubuntu:24.04` | First sandbox creation unless preloaded or overridden | The Helm chart in `deploy/helm/openshell` deploys the gateway workload, service account, service, optional persistent storage, and network policy for Kubernetes. It defaults to a StatefulSet for SQLite-backed installs and can render a Deployment for external database-backed installs. -Sandbox images are maintained separately in the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) repository. - To override the default image references, use Helm values: | Helm value | Purpose | diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 5a281a0d02..a1837c0a07 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -322,7 +322,7 @@ with an error after it attempts every requested deletion if any entry failed. Pass one or more `--provider` flags when creating a sandbox: ```shell -openshell sandbox create --provider my-claude --provider my-github -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --provider my-claude --provider my-github -- claude ``` Each `--provider` flag attaches one provider. The sandbox receives eligible @@ -348,7 +348,7 @@ name matches an imported profile ID, the CLI creates one from local profile discovery, so you do not need to create it separately: ```shell -openshell sandbox create --provider claude-code -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --provider claude-code -- claude ``` This finds your `ANTHROPIC_API_KEY`, creates a `claude-code` provider, attaches @@ -485,5 +485,5 @@ Explore related topics: - To manage workspace access for providers, refer to [Manage Workspaces and Access](/sandboxes/manage-workspaces). - To control what the agent can access, refer to [Policies](/sandboxes/policies). -- To use the base sandbox container, refer to [Sandboxes](/sandboxes/manage-sandboxes#base-sandbox-container). +- To use the default workload image, refer to [Sandboxes](/sandboxes/manage-sandboxes#default-workload-image). - To view the complete field reference for the policy YAML, refer to the [Policy Schema Reference](/reference/policy-schema). diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 056332c37b..f5ccbe3325 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -17,7 +17,7 @@ A sandbox is the OpenShell data plane: a safe, private execution environment whe Create a sandbox with a single command. For example, to create a sandbox with Claude, run: ```shell -openshell sandbox create -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest -- claude ``` The trailing command is the sandbox's canonical main process. OpenShell starts @@ -80,7 +80,7 @@ openshell gateway select local Set per-sandbox CPU and memory amounts with `--cpu` and `--memory`: ```shell -openshell sandbox create --cpu 2 --memory 4Gi -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --cpu 2 --memory 4Gi -- claude ``` CPU values use Kubernetes-style quantities such as `500m`, `1`, or `2.5`. @@ -115,13 +115,13 @@ the same behavior. To request GPU resources, add `--gpu`: ```shell -openshell sandbox create --gpu -- claude +openshell sandbox create --gpu --from registry.example.com/your-org/gpu-agent:latest -- claude ``` Request a specific number of GPUs by passing a count to `--gpu`: ```shell -openshell sandbox create --gpu 2 -- claude +openshell sandbox create --gpu 2 --from registry.example.com/your-org/gpu-agent:latest -- claude ``` When you omit the count, OpenShell treats the request as `--gpu 1`. @@ -153,22 +153,22 @@ openshell sandbox create \ -- claude ``` -### Custom Containers +### Sandbox Images -Use `--from` to create a sandbox from the base image, another pre-built sandbox name, a rootfs tar archive, or a container image: +Without `--from`, the gateway uses its configured default workload image. The +built-in default is `nvcr.io/nvidia/base/ubuntu:24.04`. + +Use `--from` with an explicit OCI image reference or a rootfs tar archive: ```shell -openshell sandbox create --from base -openshell sandbox create --from ollama -openshell sandbox create --from ./rootfs.tar +openshell sandbox create --from nvcr.io/nvidia/base/ubuntu:24.04 openshell sandbox create --from my-registry.example.com/my-image:latest +openshell sandbox create --from ./rootfs.tar ``` -Bare names such as `base` and `ollama` resolve to images under `ghcr.io/nvidia/openshell-community/sandboxes`. Set `OPENSHELL_COMMUNITY_REGISTRY` when you need to use an internal mirror. - -`--from` does not build local Dockerfiles or directories. Build and tag the -image with the container engine used by your local gateway, then pass the -resulting image reference: +`--from` does not expand catalog aliases and does not build local Dockerfiles or +directories. Build and tag the image with the container engine used by your +local gateway, then pass the resulting image reference: ```shell # Docker gateway @@ -185,8 +185,8 @@ from and use that registry image reference. **Pre-0.1.0 breaking change:** `openshell sandbox create --from ./Dockerfile` -and directory sources no longer build images. Build and tag the image before -you create the sandbox. +and directory sources no longer build images. Bare catalog names are no longer +expanded. Build or select an image and pass its explicit reference. #### Rootfs Tar Archives @@ -277,17 +277,27 @@ For JSON or YAML, template list output contains `templates` and `next_page_token` fields. Pass the returned token to `--page-token` to continue. -## Base Sandbox Container +## Default Workload Image -The `base` sandbox container is the default runtime image for standard OpenShell sandboxes unless the gateway overrides its default sandbox image. It is published as `ghcr.io/nvidia/openshell-community/sandboxes/base:latest` and maintained in the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) repository. +OpenShell defaults to `nvcr.io/nvidia/base/ubuntu:24.04` unless the gateway +operator configures another image. The image provides a minimal Ubuntu Noble +userspace. It does not include agent CLIs or an image-baked OpenShell policy. +OpenShell applies its built-in restrictive policy when no explicit policy is +provided. + +Create a sandbox with the default image: + +```shell +openshell sandbox create +``` -The base container includes common development tooling, supported agent CLIs, and the default sandbox policy. Use it when you want a general-purpose agent environment without a workflow-specific image: +Override it with any image visible to the active compute driver: ```shell -openshell sandbox create --from base +openshell sandbox create --from registry.example.com/agents/my-agent:1.0 ``` -For default policy coverage by agent, refer to [Default Policy](/reference/default-policy). For the supported agent list, refer to [Supported Agents](/about/supported-agents). +Refer to [Default Policy](/reference/default-policy), [Supported Agents](/about/supported-agents), and the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container). ## Connect to a Sandbox @@ -390,7 +400,7 @@ Environment variable names starting with `OPENSHELL_` are reserved. Keys must ma Attach labels when you create a sandbox to track ownership, environment, or workflow grouping: ```shell -openshell sandbox create --label env=dev --label team=platform -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --label env=dev --label team=platform -- claude ``` List only the sandboxes that match a label selector: @@ -431,7 +441,7 @@ with SandboxClient.from_active_cluster() as client: templates.create( workspace="default", name="python", - image="ghcr.io/nvidia/openshell-community/sandboxes/python:latest", + image="registry.example.com/agents/python:latest", ) sandbox = client.create_from_template(workspace="default", workload_template="python") @@ -711,7 +721,7 @@ The default table colorizes the `STATUS` column only when both standard output a You can also forward a port at creation time with `--forward`: ```shell -openshell sandbox create --forward 8000 -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --forward 8000 -- claude ``` @@ -763,7 +773,7 @@ You can also upload files at creation time with the `--upload` flag on several paths in a single command: ```shell -openshell sandbox create --upload ./src:/workspace/src --upload ./config:/workspace/config -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --upload ./src:/workspace/src --upload ./config:/workspace/config -- claude ``` @@ -895,4 +905,4 @@ For Docker, Podman, MicroVM, and Kubernetes behavior, refer to [Sandbox Compute - To select a workspace or understand access roles, refer to [Manage Workspaces and Access](/sandboxes/manage-workspaces). - To supply API keys or tokens, refer to [Manage Providers](/sandboxes/manage-providers). - To control what the agent can access, refer to [Policies](/sandboxes/policies). -- To use the default runtime image, refer to [Base Sandbox Container](#base-sandbox-container). +- To use the default runtime image, refer to [Default Workload Image](#default-workload-image). diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 0fd92f966d..d3fb6235d2 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -172,7 +172,7 @@ The first TCP endpoint is a deliberate exception to ordinary dynamic network pol Pass a policy YAML file when creating the sandbox: ```shell -openshell sandbox create --policy ./my-policy.yaml -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --policy ./my-policy.yaml -- claude ``` The trailing command is the sandbox's canonical main process. If it exits, the @@ -183,7 +183,7 @@ To avoid passing `--policy` every time, set a default policy with an environment ```shell export OPENSHELL_SANDBOX_POLICY=./my-policy.yaml -openshell sandbox create -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest -- claude ``` The CLI uses the policy from `OPENSHELL_SANDBOX_POLICY` whenever `--policy` is not explicitly provided. diff --git a/e2e/configs/gateway/docker.toml b/e2e/configs/gateway/docker.toml index 3e872885e1..e186004cd1 100644 --- a/e2e/configs/gateway/docker.toml +++ b/e2e/configs/gateway/docker.toml @@ -20,7 +20,7 @@ kid_path = ".cache/openshell-e2e/gateway-jwt/kid" gateway_id = "openshell-e2e" [openshell.drivers.docker] -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" sandbox_label = "openshell-e2e" sandbox_runtime_image = "localhost/openshell/sandbox:e2e-vm" diff --git a/e2e/configs/gateway/podman.toml b/e2e/configs/gateway/podman.toml index a53708c61a..332accc982 100644 --- a/e2e/configs/gateway/podman.toml +++ b/e2e/configs/gateway/podman.toml @@ -20,7 +20,7 @@ kid_path = ".cache/openshell-e2e/gateway-jwt/kid" gateway_id = "openshell-e2e" [openshell.drivers.podman] -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" health_check_interval_secs = 10 network_name = "openshell-e2e" diff --git a/e2e/configs/gateway/schema-v2-compute-boundary-comparison.json b/e2e/configs/gateway/schema-v2-compute-boundary-comparison.json index 783bb00932..b29829a786 100644 --- a/e2e/configs/gateway/schema-v2-compute-boundary-comparison.json +++ b/e2e/configs/gateway/schema-v2-compute-boundary-comparison.json @@ -53,11 +53,11 @@ "supervisor_base_image_digest": "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", "supervisor_base_runtime_image": "docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce", "supervisor_package_manifest_sha256": "6966057ffaf1ef0d4617d413dad53646bcd049a4b08f572796392fb0ffbb01b9", - "sandbox_image_request": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "sandbox_image_request": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "sandbox_image_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", - "sandbox_image_digest": "sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_runtime_image": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_client_image_alias": "ghcr.io/nvidia/openshell-community/sandboxes/base:latest", + "sandbox_image_digest": "sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_runtime_image": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_client_image_alias": "nvcr.io/nvidia/base/ubuntu:24.04", "sandbox_client_image_alias_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", "gateway_sha256_before_execution": "264cf3d809bd1d54633bce9251ec1a5540b567b8150640091df85bdfbe61797a", "cli_sha256_before_execution": "3ad0ec143bf6850af780bf643c303242956d6ef1066d4a9372bc62fef33ada20", @@ -121,11 +121,11 @@ "supervisor_base_image_digest": "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", "supervisor_base_runtime_image": "docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce", "supervisor_package_manifest_sha256": "6966057ffaf1ef0d4617d413dad53646bcd049a4b08f572796392fb0ffbb01b9", - "sandbox_image_request": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "sandbox_image_request": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "sandbox_image_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", - "sandbox_image_digest": "sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_runtime_image": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_client_image_alias": "ghcr.io/nvidia/openshell-community/sandboxes/base:latest", + "sandbox_image_digest": "sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_runtime_image": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_client_image_alias": "nvcr.io/nvidia/base/ubuntu:24.04", "sandbox_client_image_alias_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", "gateway_sha256_before_execution": "2bd42b145f0438f48a579b010537f501e036035b0e22a4ff70e37db733a6e6ff", "cli_sha256_before_execution": "82827d9068f3e9c75681a804f8fb48274ecf8acb179a46f179fb9fbfe828f69a", @@ -196,11 +196,11 @@ "supervisor_base_image_digest": "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", "supervisor_base_runtime_image": "docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce", "supervisor_package_manifest_sha256": "6966057ffaf1ef0d4617d413dad53646bcd049a4b08f572796392fb0ffbb01b9", - "sandbox_image_request": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "sandbox_image_request": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "sandbox_image_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", - "sandbox_image_digest": "sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_runtime_image": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_client_image_alias": "ghcr.io/nvidia/openshell-community/sandboxes/base:latest", + "sandbox_image_digest": "sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_runtime_image": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_client_image_alias": "nvcr.io/nvidia/base/ubuntu:24.04", "sandbox_client_image_alias_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", "gateway_sha256_before_execution": "5cc2f700d93dde5dd09060d9c9190ae44a99440b1399530a2b23941ec4e88f85", "cli_sha256_before_execution": "3ad0ec143bf6850af780bf643c303242956d6ef1066d4a9372bc62fef33ada20", @@ -218,7 +218,7 @@ "external_driver_environment": { "OPENSHELL_COMPUTE_DRIVER_SOCKET": "/tmp/openshell-e2e-podman.H8oMaO/compute-driver.sock", "OPENSHELL_PODMAN_SOCKET": "/tmp/openshell-e2e-podman.H8oMaO/podman/podman.sock", - "OPENSHELL_SANDBOX_IMAGE": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "OPENSHELL_SANDBOX_IMAGE": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "OPENSHELL_SANDBOX_IMAGE_PULL_POLICY": "missing", "OPENSHELL_HEALTH_CHECK_INTERVAL_SECS": 10, "OPENSHELL_GRPC_ENDPOINT": "https://host.containers.internal:50871", @@ -290,11 +290,11 @@ "supervisor_base_image_digest": "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", "supervisor_base_runtime_image": "docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce", "supervisor_package_manifest_sha256": "6966057ffaf1ef0d4617d413dad53646bcd049a4b08f572796392fb0ffbb01b9", - "sandbox_image_request": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "sandbox_image_request": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "sandbox_image_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", - "sandbox_image_digest": "sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_runtime_image": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_client_image_alias": "ghcr.io/nvidia/openshell-community/sandboxes/base:latest", + "sandbox_image_digest": "sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_runtime_image": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_client_image_alias": "nvcr.io/nvidia/base/ubuntu:24.04", "sandbox_client_image_alias_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", "gateway_sha256_before_execution": "fdefc047c1b675c311b1796db9f1b1a944456fc34b76547efc0352c6a75a7707", "cli_sha256_before_execution": "82827d9068f3e9c75681a804f8fb48274ecf8acb179a46f179fb9fbfe828f69a", @@ -312,7 +312,7 @@ "external_driver_environment": { "OPENSHELL_COMPUTE_DRIVER_SOCKET": "/tmp/openshell-e2e-podman.M8xsgQ/compute-driver.sock", "OPENSHELL_PODMAN_SOCKET": "/tmp/openshell-e2e-podman.M8xsgQ/podman/podman.sock", - "OPENSHELL_SANDBOX_IMAGE": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "OPENSHELL_SANDBOX_IMAGE": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "OPENSHELL_SANDBOX_IMAGE_PULL_POLICY": "if_not_present", "OPENSHELL_HEALTH_CHECK_INTERVAL_SECS": 10, "OPENSHELL_GRPC_ENDPOINT": "https://host.containers.internal:32901", diff --git a/e2e/docker/Dockerfile.external-kubernetes-gateway b/e2e/docker/Dockerfile.external-kubernetes-gateway index 20738a512a..3b860dacc7 100644 --- a/e2e/docker/Dockerfile.external-kubernetes-gateway +++ b/e2e/docker/Dockerfile.external-kubernetes-gateway @@ -17,7 +17,7 @@ ENV OPENSHELL_COMPUTE_DRIVER=kubernetes \ OPENSHELL_GATEWAY_ID=openshell \ OPENSHELL_SANDBOX_NAMESPACE=openshell \ OPENSHELL_K8S_SANDBOX_SERVICE_ACCOUNT=openshell-sandbox \ - OPENSHELL_SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest \ + OPENSHELL_SANDBOX_IMAGE=nvcr.io/nvidia/base/ubuntu:24.04 \ OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=if_not_present \ OPENSHELL_GRPC_ENDPOINT=http://openshell.openshell.svc.cluster.local:8080 \ OPENSHELL_SUPERVISOR_IMAGE=${SUPERVISOR_IMAGE} \ diff --git a/e2e/gpu/images/cuda-basic/Dockerfile b/e2e/gpu/images/cuda-basic/Dockerfile index a7dde74223..9721038884 100644 --- a/e2e/gpu/images/cuda-basic/Dockerfile +++ b/e2e/gpu/images/cuda-basic/Dockerfile @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 ARG CUDA_BUILD_IMAGE=nvcr.io/nvidia/cuda:12.8.1-base-ubuntu22.04 -ARG OPENSHELL_SANDBOX_BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +ARG OPENSHELL_SANDBOX_BASE_IMAGE=nvcr.io/nvidia/base/ubuntu:24.04 FROM ${CUDA_BUILD_IMAGE} AS builder @@ -68,5 +68,5 @@ RUN chmod 0755 /usr/local/bin/openshell-gpu-workload \ /usr/local/lib/openshell-gpu-workload/deviceQuery \ /usr/local/lib/openshell-gpu-workload/vectorAdd -USER sandbox +USER 1000:1000 ENTRYPOINT ["/usr/local/bin/openshell-gpu-workload"] diff --git a/e2e/gpu/images/cuda-basic/README.md b/e2e/gpu/images/cuda-basic/README.md index deb0b5c5ae..096d51302c 100644 --- a/e2e/gpu/images/cuda-basic/README.md +++ b/e2e/gpu/images/cuda-basic/README.md @@ -12,7 +12,7 @@ runtime workload. It is a single image that runs two validation steps: The image builds the samples from `NVIDIA/cuda-samples` tag `v12.8` with a CUDA 12.8 builder image, then copies only the compiled binaries into the OpenShell -community base final image. +default workload final image. The workload prints `OPENSHELL_GPU_WORKLOAD_SUCCESS` only after both samples pass. On failure it prints `OPENSHELL_GPU_WORKLOAD_FAILURE` and exits non-zero. diff --git a/e2e/gpu/images/smoke-fail/Dockerfile b/e2e/gpu/images/smoke-fail/Dockerfile index f74aa3c5ea..9e288d886c 100644 --- a/e2e/gpu/images/smoke-fail/Dockerfile +++ b/e2e/gpu/images/smoke-fail/Dockerfile @@ -3,7 +3,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -ARG OPENSHELL_SANDBOX_BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +ARG OPENSHELL_SANDBOX_BASE_IMAGE=nvcr.io/nvidia/base/ubuntu:24.04 FROM ${OPENSHELL_SANDBOX_BASE_IMAGE} @@ -11,5 +11,5 @@ USER root COPY workload.sh /usr/local/bin/openshell-gpu-workload RUN chmod 0755 /usr/local/bin/openshell-gpu-workload -USER sandbox +USER 1000:1000 ENTRYPOINT ["/usr/local/bin/openshell-gpu-workload"] diff --git a/e2e/gpu/images/smoke-pass/Dockerfile b/e2e/gpu/images/smoke-pass/Dockerfile index f74aa3c5ea..9e288d886c 100644 --- a/e2e/gpu/images/smoke-pass/Dockerfile +++ b/e2e/gpu/images/smoke-pass/Dockerfile @@ -3,7 +3,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -ARG OPENSHELL_SANDBOX_BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +ARG OPENSHELL_SANDBOX_BASE_IMAGE=nvcr.io/nvidia/base/ubuntu:24.04 FROM ${OPENSHELL_SANDBOX_BASE_IMAGE} @@ -11,5 +11,5 @@ USER root COPY workload.sh /usr/local/bin/openshell-gpu-workload RUN chmod 0755 /usr/local/bin/openshell-gpu-workload -USER sandbox +USER 1000:1000 ENTRYPOINT ["/usr/local/bin/openshell-gpu-workload"] diff --git a/e2e/parity/kubernetes-options.sh b/e2e/parity/kubernetes-options.sh index 658e751c3e..3ddca0355e 100644 --- a/e2e/parity/kubernetes-options.sh +++ b/e2e/parity/kubernetes-options.sh @@ -22,7 +22,7 @@ KUBE_CONTEXT="${OPENSHELL_PARITY_KUBE_CONTEXT:-}" HOST_GATEWAY_IP="${OPENSHELL_PARITY_HOST_GATEWAY_IP:-}" RUN_ID="${OPENSHELL_PARITY_RUN_ID:-$(date +%s)-$$}" OUT="${OPENSHELL_PARITY_OUTPUT_DIR:-${ROOT}/target/parity/step8-kubernetes-${CANDIDATE_SHA:0:8}}" -SANDBOX_IMAGE="${OPENSHELL_PARITY_KUBERNETES_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}" +SANDBOX_IMAGE="${OPENSHELL_PARITY_KUBERNETES_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" SUPERVISOR_IMAGE="${OPENSHELL_PARITY_KUBERNETES_SUPERVISOR_IMAGE:-ghcr.io/nvidia/openshell/supervisor:latest}" RUNTIME_CLASS="openshell-parity-runc-${RUN_ID}" diff --git a/e2e/parity/podman-options.sh b/e2e/parity/podman-options.sh index f1894b5875..ae24ec036a 100755 --- a/e2e/parity/podman-options.sh +++ b/e2e/parity/podman-options.sh @@ -10,7 +10,7 @@ set -euo pipefail CLI="${OPENSHELL_BIN:?OPENSHELL_BIN is required}" RESULT="${OPENSHELL_PARITY_ORACLE_RESULT:?OPENSHELL_PARITY_ORACLE_RESULT is required}" VARIANT="${OPENSHELL_PARITY_VARIANT:?OPENSHELL_PARITY_VARIANT is required}" -IMAGE="${OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE:-${OPENSHELL_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}}" +IMAGE="${OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE:-${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}}" GATEWAY_LOG="${OPENSHELL_E2E_GATEWAY_LOG:?OPENSHELL_E2E_GATEWAY_LOG is required}" NAME="po-${VARIANT:0:1}-${RANDOM}" WORKDIR="${TMPDIR:-/tmp}/openshell-parity-options-${NAME}" diff --git a/e2e/parity/run.sh b/e2e/parity/run.sh index e6217df0e7..87da1c9396 100755 --- a/e2e/parity/run.sh +++ b/e2e/parity/run.sh @@ -21,10 +21,9 @@ PODMAN_OPTIONS_ORACLE="${OPENSHELL_PARITY_PODMAN_OPTIONS_ORACLE:-${ROOT}/e2e/par CONFORMANCE_TRACE_WRAPPER="${ROOT}/e2e/parity/trace-conformance.sh" RESULTS_VERIFIER="${ROOT}/e2e/parity/verify-results.py" PODMAN_BIN="${OPENSHELL_PARITY_PODMAN_BIN:-podman}" -DEFAULT_SANDBOX_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +DEFAULT_SANDBOX_IMAGE="nvcr.io/nvidia/base/ubuntu:24.04" SANDBOX_IMAGE_REQUEST="${OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE:-${DEFAULT_SANDBOX_IMAGE}}" PARITY_SANDBOX_RUNTIME_IMAGE="" -PARITY_SANDBOX_COMMUNITY_REGISTRY="" PARITY_SUPERVISOR_BASE_IMAGE="" PARITY_SUPERVISOR_BASE_RUNTIME_IMAGE="" TEMP_WORKTREE="" @@ -404,11 +403,6 @@ resolve_parity_sandbox_image() { echo "ERROR: could not resolve one immutable parity sandbox image from ${SANDBOX_IMAGE_REQUEST}." >&2 exit 2 fi - PARITY_SANDBOX_COMMUNITY_REGISTRY="${repository%/base}" - if [ "${PARITY_SANDBOX_COMMUNITY_REGISTRY}" = "${repository}" ]; then - echo "ERROR: parity sandbox repository must end in /base for the conformance alias: ${repository}." >&2 - exit 2 - fi echo "Using one immutable parity sandbox image for both variants: ${PARITY_SANDBOX_RUNTIME_IMAGE} (ID ${image_id})" } @@ -551,7 +545,6 @@ run_variant() { -u OPENSHELL_SANDBOX_PROXY_AUTH_FILE -u OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE \ -u OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME -u OPENSHELL_SANDBOX_PROXY_CA_BUNDLE \ -u OPENSHELL_OTLP_ENDPOINT -u OPENSHELL_GATEWAY_NAME -u OPENSHELL_COMPUTE_DRIVER_BIND \ - -u OPENSHELL_COMMUNITY_REGISTRY \ OPENSHELL_PARITY_VARIANT="${variant}" \ OPENSHELL_E2E_CONFIG_SCHEMA_VERSION="${schema}" \ OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER="$([ "${SCENARIO}" = external-driver ] && printf 1 || printf 0)" \ @@ -561,7 +554,6 @@ run_variant() { OPENSHELL_E2E_FORCE_TEMP_PODMAN_SERVICE=1 \ OPENSHELL_E2E_REQUIRE_DIGEST_PINNED_SANDBOX_IMAGE=1 \ OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE="${PARITY_SANDBOX_RUNTIME_IMAGE}" \ - OPENSHELL_COMMUNITY_REGISTRY="${PARITY_SANDBOX_COMMUNITY_REGISTRY}" \ OPENSHELL_E2E_SUPERVISOR_BASE_IMAGE="${PARITY_SUPERVISOR_BASE_IMAGE}" \ OPENSHELL_E2E_SUPERVISOR_BASE_RUNTIME_IMAGE="${PARITY_SUPERVISOR_BASE_RUNTIME_IMAGE}" \ OPENSHELL_E2E_EXPECTED_GATEWAY_SHA256="${gateway_digest}" \ diff --git a/e2e/parity/test.sh b/e2e/parity/test.sh index e982ccefac..ad4ad65410 100755 --- a/e2e/parity/test.sh +++ b/e2e/parity/test.sh @@ -138,10 +138,9 @@ for variable in \ OPENSHELL_OTLP_ENDPOINT OPENSHELL_GATEWAY_NAME OPENSHELL_COMPUTE_DRIVER_BIND; do [ -z "${!variable:-}" ] || exit 23 done -expected_sandbox="ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:$(printf '%064d' 0)" +expected_sandbox="nvcr.io/nvidia/base/ubuntu@sha256:$(printf '%064d' 0)" [ "${OPENSHELL_E2E_REQUIRE_DIGEST_PINNED_SANDBOX_IMAGE:-0}" = 1 ] || exit 24 [ "${OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE:-}" = "${expected_sandbox}" ] || exit 25 -[ "${OPENSHELL_COMMUNITY_REGISTRY:-}" = "ghcr.io/nvidia/openshell-community/sandboxes" ] || exit 28 expected_base="docker.io/library/debian@sha256:$(printf '%064d' 0)" [ "${OPENSHELL_E2E_SUPERVISOR_BASE_IMAGE:-}" = "${OPENSHELL_PARITY_TEST_SUPERVISOR_BASE}" ] || exit 26 [ "${OPENSHELL_E2E_SUPERVISOR_BASE_RUNTIME_IMAGE:-}" = "${expected_base}" ] || exit 27 @@ -159,7 +158,7 @@ schema = int(os.environ["OPENSHELL_E2E_CONFIG_SCHEMA_VERSION"]) external = os.environ.get("OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER") == "1" zero = "0" * 64 image_digest = f"sha256:{zero}" -sandbox_runtime = f"ghcr.io/nvidia/openshell-community/sandboxes/base@{image_digest}" +sandbox_runtime = f"nvcr.io/nvidia/base/ubuntu@{image_digest}" sandbox_boundary = "localhost/openshell/sandbox:dev" supervisor_runtime = f"localhost/openshell/supervisor@{image_digest}" base_runtime = f"docker.io/library/debian@{image_digest}" @@ -218,7 +217,7 @@ launch = { "sandbox_image_digest": image_digest, "sandbox_runtime_image": sandbox_runtime, "sandbox_boundary_image": sandbox_boundary, - "sandbox_client_image_alias": "ghcr.io/nvidia/openshell-community/sandboxes/base:latest", + "sandbox_client_image_alias": "nvcr.io/nvidia/base/ubuntu:24.04", "sandbox_client_image_alias_id": zero, "gateway_sha256_before_execution": os.environ[ "OPENSHELL_E2E_EXPECTED_GATEWAY_SHA256" @@ -292,7 +291,7 @@ done printf '%s %064d sha256:%064d %s %s %s %s\n' \ "${expected_sandbox}" 0 0 "${expected_base}" \ "localhost/openshell/supervisor@sha256:$(printf '%064d' 0)" \ - "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" \ + "nvcr.io/nvidia/base/ubuntu:24.04" \ "${package_hash}" >&2 if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = 1 ]; then printf 'fixture external driver log\n' >"${OPENSHELL_PARITY_EXTERNAL_DRIVER_LOG_CAPTURE}" @@ -422,7 +421,6 @@ OPENSHELL_SANDBOX_PROXY_CA_BUNDLE=/tmp/untrusted-proxy-ca \ OPENSHELL_OTLP_ENDPOINT=http://untrusted.invalid:4317 \ OPENSHELL_GATEWAY_NAME=untrusted \ OPENSHELL_COMPUTE_DRIVER_BIND=192.0.2.2:50061 \ -OPENSHELL_COMMUNITY_REGISTRY=untrusted.invalid/community \ run_harness assert_contains "${WORKDIR}/calls" "baseline|1|${WORKDIR}/results/artifacts/baseline/gateway|${WORKDIR}/results/artifacts/baseline/cli|${WORKDIR}/results/artifacts/baseline/conformance" assert_contains "${WORKDIR}/calls" "candidate|2|${WORKDIR}/results/artifacts/candidate/gateway|${WORKDIR}/results/artifacts/candidate/cli|${WORKDIR}/results/artifacts/candidate/conformance" @@ -439,7 +437,7 @@ assert_contains "${WORKDIR}/results/semantic-verification.json" '"accepted": tru assert_not_contains "${WORKDIR}/results/baseline.json" '"scenarios"' assert_contains "${WORKDIR}/results/baseline.log" '"scenarios"' assert_contains "${WORKDIR}/results/baseline.conformance.json" '"passed":true' -assert_contains "${WORKDIR}/podman-calls" 'pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest' +assert_contains "${WORKDIR}/podman-calls" 'pull nvcr.io/nvidia/base/ubuntu:24.04' assert_contains "${WORKDIR}/podman-calls" "pull ${TEST_SUPERVISOR_BASE}" assert_contains "${WORKDIR}/podman-calls" 'unshare rm -rf -- ' assert_contains "${WORKDIR}/podman-calls" 'openshell-parity-run.' diff --git a/e2e/parity/verify-results.py b/e2e/parity/verify-results.py index d785c1f3ef..4b9bc1e7b0 100644 --- a/e2e/parity/verify-results.py +++ b/e2e/parity/verify-results.py @@ -56,6 +56,16 @@ def require(condition: bool, message: str) -> None: raise ValueError(message) +def image_repository(reference: str) -> str: + """Return an OCI image repository without a tag or digest.""" + repository = reference.split("@", 1)[0] + last_slash = repository.rfind("/") + last_colon = repository.rfind(":") + if last_colon > last_slash: + repository = repository[:last_colon] + return repository + + def verify_conformance_report(path: Path) -> None: report = load_json(path) require(report.get("passed") is True, f"{path}: conformance report did not pass") @@ -330,7 +340,8 @@ def verify_variant( sandbox_alias = launch.get("sandbox_client_image_alias") require( isinstance(sandbox_alias, str) - and sandbox_alias == sandbox_runtime.rsplit("@", 1)[0] + ":latest" + and "@" not in sandbox_alias + and image_repository(sandbox_alias) == image_repository(sandbox_runtime) and launch.get("sandbox_client_image_alias_id") == sandbox_id, f"{launch_path}: sandbox client alias is not bound to the pinned image", ) diff --git a/e2e/rust/src/harness/container.rs b/e2e/rust/src/harness/container.rs index 5e23a70ff9..fa8bf440d4 100644 --- a/e2e/rust/src/harness/container.rs +++ b/e2e/rust/src/harness/container.rs @@ -16,7 +16,7 @@ use tokio::time::{interval, timeout}; use super::port::find_free_port; -const DEFAULT_TEST_SERVER_IMAGE: &str = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; +const DEFAULT_TEST_SERVER_IMAGE: &str = super::sandbox::E2E_WORKLOAD_IMAGE; #[must_use] pub fn e2e_driver() -> Option { diff --git a/e2e/rust/src/harness/sandbox.rs b/e2e/rust/src/harness/sandbox.rs index e0a1f35898..15586c2652 100644 --- a/e2e/rust/src/harness/sandbox.rs +++ b/e2e/rust/src/harness/sandbox.rs @@ -17,6 +17,12 @@ use tokio::time::timeout; use super::binary::openshell_cmd; use super::output::{extract_field, strip_ansi}; +/// Tool-capable workload image used by the E2E harness. +/// +/// Product defaults remain on the minimal NVIDIA Ubuntu image. Tests that +/// explicitly pass `--from` continue to exercise their requested image. +pub const E2E_WORKLOAD_IMAGE: &str = "ghcr.io/astral-sh/uv:0.12.17-python3.12-trixie-slim@sha256:9a59bb7206905ccaae4f7dab222fbac47c125a21e5fc16f43f427cd6c940ade3"; + /// Extract the sandbox name from CLI create output. /// /// The CLI prints `Created sandbox: ` (current format). Falls back to @@ -26,10 +32,9 @@ fn extract_sandbox_name(output: &str) -> Option { } /// Default timeout for waiting for a sandbox to become ready. -/// In VM mode, the overlayfs snapshotter re-extracts all image layers -/// from the content store on every boot (~250s for the 1GB sandbox -/// base image), so 600s accommodates extraction + workspace-init + pod -/// startup. +/// In VM mode, the overlayfs snapshotter re-extracts image layers from the +/// content store on every boot, so 600s accommodates cold image preparation, +/// workspace initialization, and sandbox startup. const SANDBOX_READY_TIMEOUT: Duration = Duration::from_secs(600); static NEXT_SANDBOX_NAME: AtomicU64 = AtomicU64::new(1); @@ -39,6 +44,17 @@ fn has_explicit_sandbox_name(args: &[&str]) -> bool { .any(|arg| *arg == "--name" || arg.starts_with("--name=")) } +fn has_explicit_sandbox_image(args: &[&str]) -> bool { + args.iter() + .any(|arg| *arg == "--from" || arg.starts_with("--from=")) +} + +fn add_test_image_if_missing(command: &mut tokio::process::Command, args: &[&str]) { + if !has_explicit_sandbox_image(args) { + command.arg("--from").arg(E2E_WORKLOAD_IMAGE); + } +} + fn add_unique_name_if_missing(command: &mut tokio::process::Command, args: &[&str]) { if !has_explicit_sandbox_name(args) { command.arg("--name").arg(format!( @@ -96,6 +112,18 @@ impl SandboxGuard { /// Returns an error if the CLI exits with a non-zero status or the sandbox /// name cannot be parsed from the output. pub async fn create(args: &[&str]) -> Result { + Self::create_inner(args, true).await + } + + /// Create a sandbox using the gateway's configured default image. + /// + /// Most E2E tests use [`Self::create`], which supplies the tool-capable E2E + /// image. This variant is reserved for coverage of the product default. + pub async fn create_with_gateway_default(args: &[&str]) -> Result { + Self::create_inner(args, false).await + } + + async fn create_inner(args: &[&str], use_test_image: bool) -> Result { let separator = args.iter().position(|arg| *arg == "--"); let (create_args, command) = separator.map_or((args, &[][..]), |index| { (&args[..index], &args[index + 1..]) @@ -111,6 +139,9 @@ impl SandboxGuard { let mut cmd = openshell_cmd(); cmd.arg("sandbox").arg("create").arg("--detach"); add_unique_name_if_missing(&mut cmd, create_args); + if use_test_image { + add_test_image_if_missing(&mut cmd, create_args); + } for arg in create_args { cmd.arg(arg); } @@ -187,6 +218,7 @@ impl SandboxGuard { let mut cmd = openshell_cmd(); cmd.arg("sandbox").arg("create").arg("--detach"); add_unique_name_if_missing(&mut cmd, &[]); + add_test_image_if_missing(&mut cmd, &[]); cmd.arg("--") .args(command) .stdout(Stdio::piped()) @@ -235,6 +267,7 @@ impl SandboxGuard { let mut create_cmd = openshell_cmd(); create_cmd.arg("sandbox").arg("create").arg("--detach"); add_unique_name_if_missing(&mut create_cmd, create_args); + add_test_image_if_missing(&mut create_cmd, create_args); for arg in create_args { create_cmd.arg(arg); } @@ -374,6 +407,7 @@ impl SandboxGuard { let mut cmd = openshell_cmd(); cmd.arg("sandbox").arg("create").arg("--detach"); add_unique_name_if_missing(&mut cmd, &[]); + add_test_image_if_missing(&mut cmd, &[]); for (local, dest) in uploads { cmd.arg("--upload").arg(format!("{local}:{dest}")); } @@ -703,7 +737,7 @@ impl Drop for SandboxGuard { #[cfg(test)] mod tests { - use super::has_explicit_sandbox_name; + use super::{has_explicit_sandbox_image, has_explicit_sandbox_name}; #[test] fn detects_explicit_sandbox_names() { @@ -711,4 +745,11 @@ mod tests { assert!(has_explicit_sandbox_name(&["--name=example"])); assert!(!has_explicit_sandbox_name(&["--policy", "policy.yaml"])); } + + #[test] + fn detects_explicit_sandbox_images() { + assert!(has_explicit_sandbox_image(&["--from", "example:latest"])); + assert!(has_explicit_sandbox_image(&["--from=example:latest"])); + assert!(!has_explicit_sandbox_image(&["--policy", "policy.yaml"])); + } } diff --git a/e2e/rust/tests/community_image.rs b/e2e/rust/tests/community_image.rs deleted file mode 100644 index 2fab46385c..0000000000 --- a/e2e/rust/tests/community_image.rs +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "e2e")] - -//! E2E test: pull and launch a community sandbox image from GHCR. -//! -//! This test verifies that: -//! 1. The `base` community sandbox image can be pulled from GHCR -//! 2. A sandbox can be created and run using the community image -//! 3. Basic command execution works inside the community sandbox -//! -//! Prerequisites: -//! - A running openshell gateway (`mise run gateway:docker`) -//! - Network access to ghcr.io/nvidia/openshell-community/sandboxes/ - -use openshell_e2e::harness::output::strip_ansi; -use openshell_e2e::harness::sandbox::SandboxGuard; - -/// Create a sandbox using the community `base` image and verify it works. -/// -/// The `--from base` argument should resolve to: -/// `ghcr.io/nvidia/openshell-community/sandboxes/base:latest` -#[tokio::test] -async fn sandbox_from_community_base_image() { - // Create a sandbox using the community "base" image. - // The CLI should expand "base" to the full GHCR path. - let mut guard = SandboxGuard::create(&["--from", "base", "--", "echo", "community-image-ok"]) - .await - .expect("sandbox create from community base image"); - - // Verify the command output contains our marker. - let clean_output = strip_ansi(&guard.create_output); - assert!( - clean_output.contains("community-image-ok"), - "expected 'community-image-ok' in sandbox output:\n{clean_output}" - ); - - guard.cleanup().await; -} - -/// Create a sandbox using the full GHCR image path explicitly. -/// -/// This tests that explicit image references work correctly. -#[tokio::test] -async fn sandbox_from_explicit_ghcr_image() { - let image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; - - let mut guard = SandboxGuard::create(&["--from", image, "--", "cat", "/etc/os-release"]) - .await - .expect("sandbox create from explicit GHCR image"); - - // The base image should have an /etc/os-release file. - let clean_output = strip_ansi(&guard.create_output); - assert!( - clean_output.contains("ID=") || clean_output.contains("NAME="), - "expected OS release info in sandbox output:\n{clean_output}" - ); - - guard.cleanup().await; -} diff --git a/e2e/rust/tests/default_image.rs b/e2e/rust/tests/default_image.rs new file mode 100644 index 0000000000..6a1dbb3cd8 --- /dev/null +++ b/e2e/rust/tests/default_image.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E coverage for the default NVIDIA Ubuntu workload image. + +use openshell_e2e::harness::output::strip_ansi; +use openshell_e2e::harness::sandbox::SandboxGuard; + +#[tokio::test] +async fn sandbox_from_default_image() { + let mut guard = SandboxGuard::create_with_gateway_default(&["--", "cat", "/etc/os-release"]) + .await + .expect("sandbox create from default image"); + + let clean_output = strip_ansi(&guard.create_output); + assert!( + clean_output.contains("ID=ubuntu"), + "expected Ubuntu OS release info in sandbox output:\n{clean_output}" + ); + + guard.cleanup().await; +} + +#[tokio::test] +async fn sandbox_from_explicit_nvidia_ubuntu_image() { + let image = "nvcr.io/nvidia/base/ubuntu:24.04"; + let mut guard = SandboxGuard::create(&["--from", image, "--", "cat", "/etc/os-release"]) + .await + .expect("sandbox create from explicit NVIDIA Ubuntu image"); + + let clean_output = strip_ansi(&guard.create_output); + assert!( + clean_output.contains("ID=ubuntu"), + "expected Ubuntu OS release info in sandbox output:\n{clean_output}" + ); + + guard.cleanup().await; +} diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 8aaf1e3cb6..054ba3bab5 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -21,7 +21,7 @@ use openshell_e2e::harness::container::{ImageGuard, e2e_driver}; use openshell_e2e::harness::sandbox::SandboxGuard; use serde_json::{Map, Value}; -const TEST_IMAGE: &str = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; +const TEST_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; const VOLUME_TARGET: &str = "/sandbox/e2e-volume"; const BIND_TARGET: &str = "/sandbox/e2e-bind"; #[cfg(feature = "e2e-docker")] diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs index 6acf538f04..e4ae811472 100644 --- a/e2e/rust/tests/host_gateway_alias.rs +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -321,8 +321,6 @@ async fn sandbox_reaches_host_openshell_internal_via_host_gateway_alias() { server.port ); let guard = SandboxGuard::create(&[ - "--from", - "base", "--policy", &policy_path, "--", @@ -429,8 +427,6 @@ printf 'ALLOWED=%s HOST_DENIED=%s PATH_DENIED=%s\n' "$allowed" "$host_denied" "$ server.port ); let mut guard = SandboxGuard::create(&[ - "--from", - "base", "--policy", &policy_path, "--provider", diff --git a/e2e/rust/tests/podman_oci_identity.rs b/e2e/rust/tests/podman_oci_identity.rs index e74888c84c..c56c700d01 100644 --- a/e2e/rust/tests/podman_oci_identity.rs +++ b/e2e/rust/tests/podman_oci_identity.rs @@ -18,7 +18,7 @@ use openshell_e2e::harness::container::{ContainerEngine, is_e2e_driver}; use openshell_e2e::harness::output::strip_ansi; use openshell_e2e::harness::sandbox::SandboxGuard; -const BASE_IMAGE: &str = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; +const BASE_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; const READY_MARKER: &str = "podman-oci-identity-ready"; const OCI_UID: &str = "2345"; const OCI_GID: &str = "2346"; @@ -182,7 +182,7 @@ async fn podman_uses_oci_identity_and_inspected_image_id() { } let image = ImageGuard::build().expect("build Podman OCI identity image"); - // The community base image contains a baked default policy with an + // The fixture image contains a policy with an // explicit `sandbox` process identity. Supply a complete policy that // intentionally omits `process` so this test exercises OCI fallback. let policy = tempfile::NamedTempFile::new().expect("create OCI fallback policy"); diff --git a/e2e/rust/tests/provider_readiness.rs b/e2e/rust/tests/provider_readiness.rs index 58278faa5a..aa7ee7d670 100644 --- a/e2e/rust/tests/provider_readiness.rs +++ b/e2e/rust/tests/provider_readiness.rs @@ -20,7 +20,7 @@ use std::time::Duration; use openshell_e2e::harness::binary::openshell_cmd; use openshell_e2e::harness::container::{ContainerEngine, e2e_network_name}; use openshell_e2e::harness::gateway::ManagedGateway; -use openshell_e2e::harness::sandbox::SandboxGuard; +use openshell_e2e::harness::sandbox::{E2E_WORKLOAD_IMAGE, SandboxGuard}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tempfile::TempDir; @@ -1313,7 +1313,7 @@ async fn acknowledged_provider_changes_apply_to_fresh_clients_and_revoke_retaine let backend_tls = directory.path().join("backend-tls"); std::fs::create_dir(&backend_tls).map_err(|_| "could not allocate backend TLS directory")?; let base = std::env::var("OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE") - .unwrap_or_else(|_| "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string()); + .unwrap_or_else(|_| E2E_WORKLOAD_IMAGE.to_string()); if base.chars().any(char::is_whitespace) { return Err("fixture image reference contains whitespace".to_string()); } diff --git a/e2e/rust/tests/provider_refresh_handles.rs b/e2e/rust/tests/provider_refresh_handles.rs index 78e1adfe7d..b853d35950 100644 --- a/e2e/rust/tests/provider_refresh_handles.rs +++ b/e2e/rust/tests/provider_refresh_handles.rs @@ -106,7 +106,7 @@ fn write_profile(resource_port: u16, token_port: u16) -> Result Result { .tempfile() .map_err(|error| format!("create policy: {error}"))?; let policy = format!( - r#"version: 1 + r"version: 1 filesystem_policy: include_workdir: true read_only: [/usr, /lib, /proc, /etc, /dev/urandom] @@ -181,8 +181,8 @@ network_policies: - 172.0.0.0/8 - 192.168.0.0/16 binaries: - - path: /usr/bin/curl -"# + - path: /usr/local/bin/python3 +" ); file.write_all(policy.as_bytes()) .map_err(|error| format!("write policy: {error}"))?; @@ -311,9 +311,7 @@ echo {READY_MARKER} while true; do if [ -f /sandbox/probe-trigger ]; then rm -f /sandbox/probe-trigger - if curl --fail --silent --output /dev/null \ - --header "Authorization: Bearer $REFRESH_E2E_ACCESS_TOKEN" \ - {resource_url}; then + if python3 -c 'import os, urllib.request; request = urllib.request.Request("{resource_url}", headers=dict(Authorization="Bearer " + os.environ["REFRESH_E2E_ACCESS_TOKEN"])); urllib.request.urlopen(request, timeout=5).read()'; then echo ok > /sandbox/probe-result else echo failed > /sandbox/probe-result @@ -343,7 +341,7 @@ done"# wait_for_probe_failure(&sandbox).await?; let fresh_probe = format!( - "curl --fail --silent --output /dev/null --header \"Authorization: Bearer $REFRESH_E2E_ACCESS_TOKEN\" {resource_url}" + r#"python3 -c 'import os, urllib.request; request = urllib.request.Request("{resource_url}", headers=dict(Authorization="Bearer " + os.environ["REFRESH_E2E_ACCESS_TOKEN"])); urllib.request.urlopen(request, timeout=5).read()'"# ); sandbox.exec(&["sh", "-c", &fresh_probe]).await?; Ok(()) diff --git a/e2e/rust/tests/provider_token_exchange.rs b/e2e/rust/tests/provider_token_exchange.rs index b6340d6465..4d2dd703fc 100644 --- a/e2e/rust/tests/provider_token_exchange.rs +++ b/e2e/rust/tests/provider_token_exchange.rs @@ -554,7 +554,7 @@ fn write_profile(profile_type: &str, token_port: u16, target_port: u16) -> Named .tempfile() .expect("create provider profile temp file"); let profile = format!( - r#"id: {profile_type} + r"id: {profile_type} display_name: Podman token exchange e2e description: Podman e2e provider profile for two-stage token exchange category: other @@ -592,9 +592,8 @@ endpoints: - 172.0.0.0/8 - 192.168.0.0/16 binaries: - - /usr/bin/curl - - /usr/local/bin/curl -"# + - /usr/local/bin/python3 +" ); file.write_all(profile.as_bytes()) .expect("write provider profile"); @@ -604,10 +603,10 @@ binaries: fn sandbox_script(token_port: u16) -> String { let _ = token_port; - r#"set -eu + r"set -eu echo token-server-ready while true; do sleep 60; done -"# +" .to_string() } @@ -773,8 +772,11 @@ async fn container_loopback_port_ready(container_name: &str, token_port: u16) -> .unwrap_or(false) } -async fn sandbox_exec_curl(sandbox_name: &str, target_port: u16) -> Result { +async fn sandbox_exec_http(sandbox_name: &str, target_port: u16) -> Result { let url = format!("http://host.openshell.internal:{target_port}/resource"); + let script = format!( + "import urllib.request; print(urllib.request.urlopen({url:?}, timeout=5).read().decode())" + ); let mut last_output = String::new(); for _ in 0..20 { let output = openshell_cmd() @@ -785,11 +787,9 @@ async fn sandbox_exec_curl(sandbox_name: &str, target_port: u16) -> Result Result output, Err(err) => { let debug = provider_token_debug(&sandbox.name, token_port, target_port).await; - panic!("curl protected target from kept sandbox: {err}\n{debug}"); + panic!("request protected target from kept sandbox: {err}\n{debug}"); } }; @@ -887,8 +887,8 @@ async fn podman_provider_token_exchange_injects_bearer_header() { sandbox.cleanup().await; assert!( - curl_output.contains("token-exchange-ok"), + request_output.contains("token-exchange-ok"), "protected target should receive the final exchanged bearer token:\n{}", - curl_output + request_output ); } diff --git a/e2e/rust/tests/vm_corporate_proxy.rs b/e2e/rust/tests/vm_corporate_proxy.rs index 1b7b588026..1e34e71d0d 100644 --- a/e2e/rust/tests/vm_corporate_proxy.rs +++ b/e2e/rust/tests/vm_corporate_proxy.rs @@ -738,20 +738,10 @@ async fn vm_corporate_proxy_routes_approved_tls_egress() { // ── Run the workload ────────────────────────────────────────────── let (_policy, policy_path) = temp_file_with(&policy_yaml(&ports), "policy file"); let script = workload_script(&ports); - // The workload runs python3, which the VM driver's default - // nvcr.io/nvidia/base/ubuntu image does not ship. - let mut sandbox = SandboxGuard::create(&[ - "--from", - "base", - "--policy", - &policy_path, - "--", - "python3", - "-c", - &script, - ]) - .await - .expect("create VM sandbox behind the corporate proxy"); + let mut sandbox = + SandboxGuard::create(&["--policy", &policy_path, "--", "python3", "-c", &script]) + .await + .expect("create VM sandbox behind the corporate proxy"); assert_proxied_egress( &sandbox.create_output, @@ -815,18 +805,10 @@ async fn vm_corporate_proxy_trusts_ca_bundle_for_https_proxy() { let (_policy, policy_path) = temp_file_with(&policy_yaml(&ports), "policy file"); let script = workload_script(&ports); - let mut sandbox = SandboxGuard::create(&[ - "--from", - "base", - "--policy", - &policy_path, - "--", - "python3", - "-c", - &script, - ]) - .await - .expect("create VM sandbox behind the https corporate proxy"); + let mut sandbox = + SandboxGuard::create(&["--policy", &policy_path, "--", "python3", "-c", &script]) + .await + .expect("create VM sandbox behind the https corporate proxy"); let proxy_logs = proxy.logs().expect("read https proxy logs"); assert!( diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 7334d31f72..e4d0d8128f 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -18,7 +18,7 @@ # OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE=... # OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE_PULL_POLICY=always|if_not_present|never # -# The default community sandbox image uses :latest. This wrapper refreshes it +# The default sandbox image uses a mutable tag. This wrapper refreshes it # before starting the gateway, while the Docker driver defaults to # if_not_present so local Dockerfile-built images remain usable. # @@ -516,7 +516,7 @@ build_local_docker_sandbox_runtime_image_if_required "${SANDBOX_RUNTIME_IMAGE}" ensure_docker_sandbox_runtime_image "${SANDBOX_RUNTIME_IMAGE}" echo "Using Docker sandbox runtime image: ${SANDBOX_RUNTIME_IMAGE}" -DEFAULT_SANDBOX_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +DEFAULT_SANDBOX_IMAGE="nvcr.io/nvidia/base/ubuntu:24.04" SANDBOX_IMAGE="${OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE:-${OPENSHELL_SANDBOX_IMAGE:-${DEFAULT_SANDBOX_IMAGE}}}" SANDBOX_IMAGE_PULL_POLICY="${OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE_PULL_POLICY:-${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}}" if ! ensure_sandbox_image_available "${SANDBOX_IMAGE}"; then diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index f851c92957..97cb750dde 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -613,7 +613,7 @@ SANDBOX_BOUNDARY_IMAGE="$(resolve_podman_sandbox_runtime_image)" ensure_podman_sandbox_runtime_image "${SANDBOX_BOUNDARY_IMAGE}" echo "Using Podman sandbox runtime image: ${SANDBOX_BOUNDARY_IMAGE}" -DEFAULT_SANDBOX_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +DEFAULT_SANDBOX_IMAGE="nvcr.io/nvidia/base/ubuntu:24.04" SANDBOX_IMAGE_REQUEST="${OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE:-${OPENSHELL_SANDBOX_IMAGE:-${DEFAULT_SANDBOX_IMAGE}}}" if [ "${OPENSHELL_E2E_REQUIRE_DIGEST_PINNED_SANDBOX_IMAGE:-0}" = "1" ] \ && ! [[ "${SANDBOX_IMAGE_REQUEST}" =~ ^[^@]+@sha256:[0-9a-f]{64}$ ]]; then diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh index b123c66ad2..2cb10dedb9 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -362,7 +362,6 @@ start_agent_sandbox() { ( "$OPENSHELL_BIN" sandbox create \ --name "$DEMO_SANDBOX_NAME" \ - --from base \ --provider "$DEMO_CODEX_PROVIDER_NAME" \ --provider "$DEMO_GITHUB_PROVIDER_NAME" \ --policy "$POLICY_FILE" \ diff --git a/examples/aws-s3-sts.md b/examples/aws-s3-sts.md index 94b73c73cc..76bace9f3d 100644 --- a/examples/aws-s3-sts.md +++ b/examples/aws-s3-sts.md @@ -104,7 +104,7 @@ gateway_id = "podman-dev" ttl_secs = 3600 [openshell.drivers.podman] -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" supervisor_image = "localhost/openshell/supervisor:dev" image_pull_policy = "if_not_present" health_check_interval_secs = 10 diff --git a/examples/multi-agent-notepad/README.md b/examples/multi-agent-notepad/README.md index 2bff46cd3d..910128316a 100644 --- a/examples/multi-agent-notepad/README.md +++ b/examples/multi-agent-notepad/README.md @@ -85,6 +85,7 @@ for this demo, or one you're comfortable with the demo creating files in. export DEMO_GITHUB_OWNER= export DEMO_GITHUB_REPO= export DEMO_GITHUB_TOKEN="$(gh auth token)" +export DEMO_AGENT_IMAGE="registry.example.com/your-org/codex-agent:latest" bash examples/multi-agent-notepad/demo.sh ``` @@ -93,9 +94,9 @@ bash examples/multi-agent-notepad/demo.sh usually broader than `contents:write`. If you'd rather use a scope-limited PAT, set `DEMO_GITHUB_TOKEN` to that value instead. -By default the script launches five worker agents and one synthesis agent in -the OpenShell `base` image, where Codex is preinstalled. To run a faster -smoke test: +The selected image must contain Codex, `curl`, and Bash. The script launches +five worker agents and one synthesis agent by default. To run a faster smoke +test: ```bash export DEMO_AGENT_COUNT=2 @@ -139,7 +140,7 @@ values never sit in the sandbox filesystem. The script renders `policy.template.yaml` for the configured GitHub repository and run id. The policy allows: -- Codex traffic to OpenAI and ChatGPT endpoints used by the community base +- Codex traffic to OpenAI and ChatGPT endpoints used by the selected workload image - Limited Codex plugin metadata reads from `github.com/openai/plugins.git` - GitHub REST `GET` and `PUT` calls scoped to diff --git a/examples/multi-agent-notepad/demo.sh b/examples/multi-agent-notepad/demo.sh index 0d68b3cdbf..53535a644d 100755 --- a/examples/multi-agent-notepad/demo.sh +++ b/examples/multi-agent-notepad/demo.sh @@ -12,6 +12,7 @@ PROMPTS_DIR="${SCRIPT_DIR}/prompts" OPENSHELL_BIN="${OPENSHELL_BIN:-openshell}" DEMO_TOPIC="${DEMO_TOPIC:-How should teams evaluate sandboxed coding agents?}" DEMO_AGENT_COUNT="${DEMO_AGENT_COUNT:-5}" +DEMO_AGENT_IMAGE="${DEMO_AGENT_IMAGE:-}" DEMO_BRANCH="${DEMO_BRANCH:-main}" DEMO_RUN_ID="${DEMO_RUN_ID:-$(date +%Y%m%d-%H%M%S)}" # Sandbox names are capped at 19 characters. Derive a short tag from the @@ -157,9 +158,10 @@ create_providers() { run_sandbox() { local name="$1" shift + [[ -n "$DEMO_AGENT_IMAGE" ]] || fail "set DEMO_AGENT_IMAGE to an OCI image containing Codex, curl, and bash" "$OPENSHELL_BIN" sandbox create \ --name "$name" \ - --from base \ + --from "$DEMO_AGENT_IMAGE" \ --provider "$DEMO_CODEX_PROVIDER_NAME" \ --provider "$DEMO_GITHUB_PROVIDER_NAME" \ --policy "$POLICY_FILE" \ diff --git a/examples/private-ip-routing/README.md b/examples/private-ip-routing/README.md index aae18d7aef..148871c2ef 100644 --- a/examples/private-ip-routing/README.md +++ b/examples/private-ip-routing/README.md @@ -11,8 +11,8 @@ When an endpoint in the sandbox policy includes an `allowed_ips` field, the proxy validates the resolved IP against that CIDR allowlist instead of blanket-blocking. Loopback and link-local remain always-blocked regardless. -The default sandbox policy (baked into the community base image) includes a `cluster_pods` -entry that allows any binary to reach port 8080 on the configured pod network: +Save an explicit policy as `private-policy.yaml` that allows the client binary +to reach port 8080 on the configured pod network: ```yaml cluster_pods: @@ -62,7 +62,10 @@ Create a sandbox and curl the private API through the proxy. Replace the IP with whatever `kubectl get pod` showed above: ```bash -openshell sandbox create -- bash -c \ +openshell sandbox create \ + --from registry.example.com/tools/curl:latest \ + --policy ./private-policy.yaml \ + -- bash -c \ 'curl -s --proxytunnel -x http://10.200.0.1:3128 http://10.42.0.128:8080/' ``` diff --git a/examples/spiffe-token-exchange-demo/podman/README.md b/examples/spiffe-token-exchange-demo/podman/README.md index 197fe70cd4..2fc379fa4f 100644 --- a/examples/spiffe-token-exchange-demo/podman/README.md +++ b/examples/spiffe-token-exchange-demo/podman/README.md @@ -109,7 +109,7 @@ TOKEN_ISSUER_PORT=18080 MANAGED_GATEWAY_PORT=18082 MANAGED_GATEWAY_HEALTH_PORT=18083 GATEWAY_IMAGE=ghcr.io/nvidia/openshell/gateway:latest -SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +SANDBOX_IMAGE=nvcr.io/nvidia/base/ubuntu:24.04 SUPERVISOR_IMAGE=ghcr.io/nvidia/openshell/supervisor:latest SANDBOX_IMAGE_PULL_POLICY=if_not_present PODMAN_STOP_TIMEOUT_SECS=3 diff --git a/examples/sync-files.md b/examples/sync-files.md index 2047df2ce6..d50e4065ed 100644 --- a/examples/sync-files.md +++ b/examples/sync-files.md @@ -66,9 +66,8 @@ openshell sandbox download dev /sandbox/coverage ./coverage ## How it works -File sync uses **tar-over-SSH**. The CLI streams a tar archive through the -existing SSH proxy tunnel -- no `rsync` or other external tools required on -your machine. The sandbox base image provides GNU `tar` for extraction. - -- **Push**: `tar::Builder` (Rust) -> stdin | `ssh sandbox "tar xf - -C "` -- **Pull**: `ssh sandbox "tar cf - -C "` | stdout -> `tar::Archive` (Rust) +File sync uses the native OpenShell streaming file-transfer protocol. The CLI +creates and extracts tar streams in Rust, and the sandbox performs the matching +operation under the workload identity. Transfers use bounded frames with +backpressure, cancellation, and explicit completion; they do not require +`ssh`, `scp`, `rsync`, or a `tar` executable in the workload image. diff --git a/nix/test-guest/provisioners/roles/gateway-podman/tasks/development-gateway.yml b/nix/test-guest/provisioners/roles/gateway-podman/tasks/development-gateway.yml index 939400f5f8..d1959ec51c 100644 --- a/nix/test-guest/provisioners/roles/gateway-podman/tasks/development-gateway.yml +++ b/nix/test-guest/provisioners/roles/gateway-podman/tasks/development-gateway.yml @@ -56,7 +56,7 @@ gateway_id = "openshell-test-guest" [openshell.drivers.podman] - default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" + default_image = "nvcr.io/nvidia/base/ubuntu:24.04" supervisor_image = "{{ openshell_supervisor_image }}" image_pull_policy = "always" network_name = "openshell-test-guest" diff --git a/providers/README.md b/providers/README.md index 740bb05f12..c7a5762643 100644 --- a/providers/README.md +++ b/providers/README.md @@ -30,7 +30,7 @@ Every file opens with a comment block naming its expected client binaries, the image layout those paths assume, the credential scope, the endpoint access it grants, and a smoke test. Read it. A profile's `binaries` list is the control that decides which processes may reach its endpoints, and several of these -examples name paths that only exist in the OpenShell Community image +examples name paths from a particular reference image layout (`/sandbox/.venv`, `/app/.venv`, `/sandbox/.cursor-server`, `/usr/lib/node_modules/...`). Imported unchanged into a different image, such a profile matches nothing: the catalog still advertises it, but the credential is diff --git a/providers/aws-s3.yaml b/providers/aws-s3.yaml index a3f13625c7..0cd973e6b7 100644 --- a/providers/aws-s3.yaml +++ b/providers/aws-s3.yaml @@ -11,7 +11,7 @@ # # Client binaries: python, python3, curl, aws, bash. # Reference layout: the venv and uv paths below (/sandbox/.venv, -# /sandbox/.uv/python) are OpenShell Community image paths, +# /sandbox/.uv/python) come from one reference image layout, # and /usr/local/bin/aws assumes the AWS CLI v2 installer. # Edit `binaries` to match your image before importing. # `binaries` also matches a process's ancestors, so the diff --git a/providers/codex.yaml b/providers/codex.yaml index c9572a0a1b..bafc5bd063 100644 --- a/providers/codex.yaml +++ b/providers/codex.yaml @@ -12,7 +12,7 @@ # Client binaries: codex. # Reference layout: /usr/bin/codex or /usr/local/bin/codex, plus the npm global # install at /usr/lib/node_modules/@openai/**. The npm path is -# an OpenShell Community image layout; check where `npm root +# one reference image layout; check where `npm root # -g` points in your image. # Credential scope: the CODEX_AUTH_* OAuth tokens and account id. They are # injected into the sandbox environment for the codex CLI to diff --git a/providers/copilot.yaml b/providers/copilot.yaml index 7a9419a1fc..abb8373e0c 100644 --- a/providers/copilot.yaml +++ b/providers/copilot.yaml @@ -12,7 +12,7 @@ # Client binaries: copilot. # Reference layout: /usr/bin/copilot plus the npm global install under # /usr/lib/node_modules/@github/copilot/**. That npm path is -# an OpenShell Community image layout; check `npm root -g` in +# one reference image layout; check `npm root -g` in # your image. # Credential scope: COPILOT_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN, sent as a # bearer authorization header to the Copilot API hosts below. diff --git a/providers/cursor.yaml b/providers/cursor.yaml index 8f60619381..2834ff4218 100644 --- a/providers/cursor.yaml +++ b/providers/cursor.yaml @@ -11,8 +11,8 @@ # # Client binaries: curl, wget, and the Cursor remote server. # Reference layout: the server path below (/sandbox/.cursor-server/**) is where -# Cursor unpacks itself when it connects to an OpenShell -# Community image over SSH. A different HOME or user means a +# Cursor may unpack itself when it connects over SSH. A +# different HOME or user means a # different path — check where the server actually lands. # Credential scope: none. This profile only opens the bootstrap and update # endpoints; Cursor authenticates the user separately. diff --git a/providers/pypi.yaml b/providers/pypi.yaml index 73c9573e19..78ceaef9a5 100644 --- a/providers/pypi.yaml +++ b/providers/pypi.yaml @@ -10,7 +10,7 @@ # below, so it has to name the paths in *your* image. # # Client binaries: python, python3, pip, uv. -# Reference layout: the paths below are OpenShell Community image paths — a +# Reference layout: the paths below assume a particular image layout — a # virtualenv at /sandbox/.venv or /app/.venv, uv at # /usr/local/bin/uv, uv-managed interpreters under # /sandbox/.uv/python. Almost every other image differs diff --git a/python/openshell/gateway_schema_v2_live_results_test.py b/python/openshell/gateway_schema_v2_live_results_test.py index 3cb52c5d62..cfd0a01515 100644 --- a/python/openshell/gateway_schema_v2_live_results_test.py +++ b/python/openshell/gateway_schema_v2_live_results_test.py @@ -85,7 +85,7 @@ "compute-driver-boundary-parity" ] STEP_10_REPORT_SHA256 = ( - "65541eec5f642461a88b04b5459474fd7a475adeb7071a65a53fe183caad6a01" + "3c1297eef6c0a3b22530b980b571bda2d967a39a79a3e3286bcc09e30df84bf3" ) STEP_10_EVIDENCE_BUNDLES = { "in_tree": "target/parity/step10-intree-4a39da51", diff --git a/rfc/0003-gateway-configuration/README.md b/rfc/0003-gateway-configuration/README.md index 8007236d7a..4527c85d08 100644 --- a/rfc/0003-gateway-configuration/README.md +++ b/rfc/0003-gateway-configuration/README.md @@ -122,7 +122,7 @@ scopes_claim = "" # empty disables scope enforcement [openshell.drivers.kubernetes] namespace = "openshell" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" supervisor_image_pull_policy = "if_not_present" @@ -132,7 +132,7 @@ host_gateway_ip = "10.0.0.1" ssh_socket_path = "/run/openshell/ssh.sock" [openshell.drivers.docker] -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" sandbox_label = "docker-dev" grpc_endpoint = "https://host.openshell.internal:8080" @@ -142,7 +142,7 @@ supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" # used to [openshell.drivers.podman] socket_path = "/run/podman/podman.sock" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" # always | if_not_present | never | newer supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" network_name = "openshell" @@ -225,7 +225,7 @@ disable_tls = true [openshell.drivers.kubernetes] namespace = "agents" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" supervisor_image = "ghcr.io/nvidia/openshell/supervisor:0.9.0" grpc_endpoint = "https://openshell-gateway.agents.svc:8080" ``` @@ -245,7 +245,7 @@ gateway: drivers: kubernetes: namespace: agents - default_image: ghcr.io/nvidia/openshell-community/sandboxes/base:latest + default_image: nvcr.io/nvidia/base/ubuntu:24.04 supervisor_image: ghcr.io/nvidia/openshell/supervisor:0.9.0 ``` diff --git a/scripts/agents/gator/Dockerfile b/scripts/agents/gator/Dockerfile index 6484746dbb..db1f5218a1 100644 --- a/scripts/agents/gator/Dockerfile +++ b/scripts/agents/gator/Dockerfile @@ -5,7 +5,7 @@ # Gator sandbox image. # -# This mirrors the OpenShell Community base image's core system and developer +# This installs the core system and developer # tooling, but keeps the initial agent surface focused on Codex + GitHub tooling # for the gator-gate workflow. @@ -17,7 +17,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ WORKDIR /sandbox -# Core system dependencies copied from the community base sandbox image. +# Core system dependencies required by the gator workload. # iproute2: network namespace management (ip netns, veth pairs) # iptables: legacy bypass detection (kept for transition) # nftables: bypass detection; log + reject rules for direct connection diagnostics @@ -43,7 +43,7 @@ RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supe FROM system AS devtools # Node.js 22 + build toolchain. Keep the default apt installs aligned with the -# community base image, then add the small CLI tools gator commonly needs. +# workload, then add the small CLI tools gator commonly needs. RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get install -y --no-install-recommends \ build-essential \ diff --git a/scripts/agents/gator/README.md b/scripts/agents/gator/README.md index b1b8219633..a683a2030b 100644 --- a/scripts/agents/gator/README.md +++ b/scripts/agents/gator/README.md @@ -4,7 +4,7 @@ Launch a headless sandbox agent that runs the `gator-gate` skill against OpenShe ## Prerequisites -- `gh` is authenticated on the host and has access to `NVIDIA/OpenShell` and `NVIDIA/OpenShell-Community`. +- `gh` is authenticated on the host and has access to `NVIDIA/OpenShell`. - For `--harness codex`, `codex login` has created `$HOME/.codex/auth.json`. - For `--harness codex`, local Codex auth must include an access token, refresh token, and account ID. - A local gateway and either Docker or Podman are available to build the diff --git a/scripts/agents/gator/prompts/gator.md b/scripts/agents/gator/prompts/gator.md index fee69f38d4..27cba2a0c8 100644 --- a/scripts/agents/gator/prompts/gator.md +++ b/scripts/agents/gator/prompts/gator.md @@ -10,7 +10,7 @@ Load and follow this skill exactly: Important sandbox constraints: -- GitHub REST write access is scoped to NVIDIA/OpenShell and NVIDIA/OpenShell-Community. +- GitHub REST write access is scoped to NVIDIA/OpenShell. - GitHub GraphQL access is read-only except for the named `ResolveGatorReviewThread` mutation used by `resolve-gator-review-threads`. Prefer REST endpoints for all other write diff --git a/scripts/agents/gator/providers/github-gator.yaml b/scripts/agents/gator/providers/github-gator.yaml index d0c1f32d49..5d8e30238f 100644 --- a/scripts/agents/gator/providers/github-gator.yaml +++ b/scripts/agents/gator/providers/github-gator.yaml @@ -28,7 +28,6 @@ endpoints: q: any: - "*repo:NVIDIA/OpenShell*" - - "*repo:NVIDIA/OpenShell-Community*" - allow: { method: GET, path: /repos/NVIDIA/OpenShell } - allow: { method: GET, path: /repos/NVIDIA/OpenShell/** } - allow: { method: POST, path: /repos/NVIDIA/OpenShell/issues/*/comments } @@ -43,20 +42,6 @@ endpoints: - allow: { method: POST, path: /repos/NVIDIA/OpenShell/pulls/*/reviews } - allow: { method: POST, path: /repos/NVIDIA/OpenShell/pulls/*/reviews/*/comments } - allow: { method: POST, path: /repos/NVIDIA/OpenShell/statuses/* } - - allow: { method: GET, path: /repos/NVIDIA/OpenShell-Community } - - allow: { method: GET, path: /repos/NVIDIA/OpenShell-Community/** } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/issues/*/comments } - - allow: { method: PATCH, path: /repos/NVIDIA/OpenShell-Community/issues/comments/* } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/issues/*/labels } - - allow: { method: PUT, path: /repos/NVIDIA/OpenShell-Community/issues/*/labels } - - allow: { method: DELETE, path: /repos/NVIDIA/OpenShell-Community/issues/*/labels/* } - - allow: { method: PATCH, path: /repos/NVIDIA/OpenShell-Community/issues/* } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/labels } - - allow: { method: PATCH, path: /repos/NVIDIA/OpenShell-Community/labels/* } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/pulls/*/comments } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/pulls/*/reviews } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/pulls/*/reviews/*/comments } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/statuses/* } - host: api.github.com port: 443 path: /graphql @@ -79,18 +64,12 @@ endpoints: - allow: { method: GET, path: /NVIDIA/OpenShell.git/** } - allow: { method: POST, path: /NVIDIA/OpenShell/**/git-upload-pack } - allow: { method: POST, path: /NVIDIA/OpenShell.git/**/git-upload-pack } - - allow: { method: GET, path: /NVIDIA/OpenShell-Community } - - allow: { method: GET, path: /NVIDIA/OpenShell-Community/** } - - allow: { method: GET, path: /NVIDIA/OpenShell-Community.git/** } - - allow: { method: POST, path: /NVIDIA/OpenShell-Community/**/git-upload-pack } - - allow: { method: POST, path: /NVIDIA/OpenShell-Community.git/**/git-upload-pack } - host: codeload.github.com port: 443 protocol: rest enforcement: enforce rules: - allow: { method: GET, path: /NVIDIA/OpenShell/** } - - allow: { method: GET, path: /NVIDIA/OpenShell-Community/** } - host: results-receiver.actions.githubusercontent.com port: 443 protocol: rest diff --git a/sdk/go/openshell/v1/integration_test.go b/sdk/go/openshell/v1/integration_test.go index d4c0beeafc..d8e70daf9c 100644 --- a/sdk/go/openshell/v1/integration_test.go +++ b/sdk/go/openshell/v1/integration_test.go @@ -44,7 +44,7 @@ func TestIntegration_SandboxExecSmoke(t *testing.T) { image := os.Getenv("OPENSHELL_GO_SDK_TEST_IMAGE") if image == "" { - image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" + image = "nvcr.io/nvidia/base/ubuntu:24.04" } name := fmt.Sprintf("go-smoke-%09d", time.Now().UnixNano()%1_000_000_000) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index e563485773..8237e91c57 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -34,7 +34,7 @@ const client = await OpenShellClient.connect({ }) const sandbox = await client.sandbox.create({ - image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', + image: 'registry.example.com/agents/python:latest', serviceExposures: [{ targetPort: 8080 }], }) console.log(sandbox.serviceUrls['']) @@ -188,7 +188,7 @@ const template: SandboxWorkloadTemplate = await client.sandboxTemplates.create( metadata: { name: 'python', labels: { team: 'runtime' } }, spec: { workload: { - image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', + image: 'registry.example.com/agents/python:latest', environment: { FEATURE_FLAG: 'on' }, resources: { cpu: '1', memory: '512Mi' }, }, diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index f5f3dcf534..60d93dbe2d 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -707,7 +707,7 @@ describe('sandbox templates', () => { metadata: { name: 'python', labels: { team: 'runtime' } }, spec: { workload: { - image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', + image: 'registry.example.com/agents/python:latest', environment: { FEATURE_FLAG: 'on' }, resources: { cpu: '1', memory: '512Mi', gpu: { count: 1 } }, }, diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 89e6223ac2..37e1438cb7 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -171,7 +171,7 @@ Key sections to reference: When middleware is requested, also read the published [supervisor middleware guide](https://docs.nvidia.com/openshell/latest/extensibility/supervisor-middleware.md). -For enforcement concepts and the shipped baseline, read [sandbox policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.md) and the [default policy reference](https://docs.nvidia.com/openshell/latest/reference/default-policy.md). The default policy is baked into the community base image (`ghcr.io/nvidia/openshell-community/sandboxes/base:latest`). +For enforcement concepts and the shipped baseline, read [sandbox policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.md) and the [default policy reference](https://docs.nvidia.com/openshell/latest/reference/default-policy.md). The default policy is built into the OpenShell runtime and applies when no explicit policy is supplied. Validate the intended provider combination as well as the authored policy. An image endpoint can become credentialed after provider composition and block @@ -642,5 +642,5 @@ private_services: - [Sandbox policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.md) - [Default policy](https://docs.nvidia.com/openshell/latest/reference/default-policy.md) - [Supervisor middleware](https://docs.nvidia.com/openshell/latest/extensibility/supervisor-middleware.md) -- Default policy: baked into the community base image (`ghcr.io/nvidia/openshell-community/sandboxes/base:latest`) +- Default policy: built into the OpenShell runtime - For translation examples from real API docs, see [examples.md](examples.md) diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index ad58c9a67a..b6711e5140 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -97,8 +97,8 @@ exist but a profile with that ID is available, the CLI can create it from local credentials: ```bash -openshell sandbox create --provider claude-code -- claude -openshell sandbox create --provider codex -- codex +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --provider claude-code -- claude +openshell sandbox create --from registry.example.com/your-org/codex-agent:latest --provider codex -- codex ``` The agent will be prompted interactively if credentials are missing. @@ -292,7 +292,7 @@ image, environment, sizing, or driver-specific configuration: ```bash openshell sandbox template create gpu-kata \ - --image ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ + --image registry.example.com/agents/python:latest \ --cpu 2 \ --memory 4Gi \ --gpu 1 \ @@ -308,7 +308,7 @@ creates. Put driver config on a template only when it should be reused. ```bash openshell sandbox template create gpu-kata \ - --image ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ + --image registry.example.com/agents/python:latest \ --cpu 2 \ --memory 4Gi \ --gpu 1 \ @@ -501,7 +501,7 @@ Create sandbox with initial policy ### Step 1: Create sandbox with initial policy ```bash -openshell sandbox create --name dev --policy ./initial-policy.yaml -- claude +openshell sandbox create --name dev --from registry.example.com/your-org/claude-agent:latest --policy ./initial-policy.yaml -- claude ``` Sandboxes stay alive by default for iteration. Add `--no-keep` only when the sandbox should be deleted automatically after the initial session. @@ -634,15 +634,14 @@ docker build -t my-app:latest . openshell sandbox create --from my-app:latest --name my-app ``` -The `--from` flag accepts an existing full image reference such as `myregistry.com/img:tag`, or a community sandbox name such as `ollama`. Build local Dockerfiles first with the same container engine as the local gateway, then pass the image tag. +The `--from` flag accepts an explicit OCI image reference such as `myregistry.com/img:tag`. It does not expand catalog aliases. Build local Dockerfiles first with the same container engine as the local gateway, then pass the image tag. -Use `docker build -t my-app:latest` for Docker gateways. For Podman gateways, use `podman build -t localhost/my-app:latest` and pass `localhost/my-app:latest` to `--from`. For remote gateways, push the image to a registry reachable by the gateway. Bare community names resolve under `ghcr.io/nvidia/openshell-community/sandboxes` unless `OPENSHELL_COMMUNITY_REGISTRY` overrides the prefix. +Use `docker build -t my-app:latest` for Docker gateways. For Podman gateways, use `podman build -t localhost/my-app:latest` and pass `localhost/my-app:latest` to `--from`. For remote gateways, push the image to a registry reachable by the gateway. For Docker and Podman gateways, custom images should declare a non-root OCI -`USER`. Each explicit `process.run_as_user` or `process.run_as_group` policy +`USER`. Images without one run as numeric UID and GID `1000`. Each explicit `process.run_as_user` or `process.run_as_group` policy field wins independently; omitted fields fall back to the image declaration. -An image with no `USER` fails before readiness unless policy supplies both -fields. Explicit numeric fields may use any UID/GID from `1` through +Explicit numeric fields may use any UID/GID from `1` through `4294967294`; `0` is root and `4294967295` is the invalid identity sentinel. Warn users that low IDs can inherit permissions from matching accounts, image files, mounted volumes, or devices. diff --git a/tasks/scripts/e2e-gpu-build-images.sh b/tasks/scripts/e2e-gpu-build-images.sh index efe3d8378c..d7bebcbc80 100644 --- a/tasks/scripts/e2e-gpu-build-images.sh +++ b/tasks/scripts/e2e-gpu-build-images.sh @@ -12,7 +12,7 @@ source "${SCRIPT_DIR}/container-engine.sh" IMAGES_ROOT="${ROOT}/e2e/gpu/images" BUILD_DIR="${IMAGES_ROOT}/.build" -BASE_IMAGE="${OPENSHELL_SANDBOX_BASE_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}" +BASE_IMAGE="${OPENSHELL_SANDBOX_BASE_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" CUDA_BUILD_IMAGE="${CUDA_BUILD_IMAGE:-nvcr.io/nvidia/cuda:12.8.1-base-ubuntu22.04}" CUDA_SAMPLES_REPO="${CUDA_SAMPLES_REPO:-https://github.com/NVIDIA/cuda-samples}" CUDA_SAMPLES_REF="${CUDA_SAMPLES_REF:-v12.8}" From 06ea61e8a2316716d3fe8ca6c2acca7e3e3d9878 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 22 Sep 2026 01:16:58 -0700 Subject: [PATCH 16/17] test(e2e): build provider readiness tool fixture Signed-off-by: Drew Newberry --- e2e/rust/tests/provider_readiness.rs | 47 ++++++++++++++-------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/e2e/rust/tests/provider_readiness.rs b/e2e/rust/tests/provider_readiness.rs index aa7ee7d670..5d8f462f2a 100644 --- a/e2e/rust/tests/provider_readiness.rs +++ b/e2e/rust/tests/provider_readiness.rs @@ -1312,18 +1312,6 @@ async fn acknowledged_provider_changes_apply_to_fresh_clients_and_revoke_retaine std::fs::create_dir(&context).map_err(|_| "could not allocate public image context")?; let backend_tls = directory.path().join("backend-tls"); std::fs::create_dir(&backend_tls).map_err(|_| "could not allocate backend TLS directory")?; - let base = std::env::var("OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE") - .unwrap_or_else(|_| E2E_WORKLOAD_IMAGE.to_string()); - if base.chars().any(char::is_whitespace) { - return Err("fixture image reference contains whitespace".to_string()); - } - let binaries = base_binaries(&base).await?; - let python = binaries["python"] - .as_str() - .ok_or("Python executable was absent")?; - let curl = binaries["curl"] - .as_str() - .ok_or("curl executable was absent")?; let image = FixtureImage::new()?; let supervisor_image = FixtureImage::new()?; // Each backend has its own network namespace, so fixed internal ports need @@ -1340,7 +1328,28 @@ async fn acknowledged_provider_changes_apply_to_fresh_clients_and_revoke_retaine let result = async { // Begin container mutation inside this scope so certificate, image, // and enrollment failures still reach explicit bounded teardown. - let [host, other_host] = backend.spawn(&base, &backend_tls).await?; + std::fs::write(context.join("client.py"), CLIENT) + .map_err(|_| "could not write client source")?; + let dockerfile = context.join("Dockerfile"); + std::fs::write( + &dockerfile, + format!( + "FROM {E2E_WORKLOAD_IMAGE}\nUSER root\nRUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*\nCOPY client.py /opt/provider-readiness-client.py\nUSER 1000:1000\n" + ), + ) + .map_err(|_| "could not write fixture Dockerfile")?; + image + .build(&dockerfile, &context, "build workload fixture image") + .await?; + let binaries = base_binaries(image.tag()).await?; + let python = binaries["python"] + .as_str() + .ok_or("Python executable was absent")?; + let curl = binaries["curl"] + .as_str() + .ok_or("curl executable was absent")?; + + let [host, other_host] = backend.spawn(image.tag(), &backend_tls).await?; let (certificate, private_key) = generate_certificates(directory.path(), &host, &other_host).await?; std::fs::copy(&certificate, backend_tls.join("backend.crt")) @@ -1362,12 +1371,6 @@ async fn acknowledged_provider_changes_apply_to_fresh_clients_and_revoke_retaine .await?; std::fs::copy(directory.path().join("ca.crt"), context.join("fixture-ca.crt")) .map_err(|_| "could not copy public fixture CA")?; - std::fs::write(context.join("client.py"), CLIENT) - .map_err(|_| "could not write client source")?; - let dockerfile = context.join("Dockerfile"); - std::fs::write(&dockerfile, format!( - "FROM {base}\nUSER root\nCOPY client.py /opt/provider-readiness-client.py\nUSER sandbox\n" - )).map_err(|_| "could not write fixture Dockerfile")?; let supervisor_dockerfile = context.join("Dockerfile.supervisor"); // Outbound TLS belongs to the separate supervisor. Assemble its combined // public trust bundle in the shell-capable workload image because the @@ -1375,13 +1378,11 @@ async fn acknowledged_provider_changes_apply_to_fresh_clients_and_revoke_retaine // image's user setting: Docker's archive upload applies an explicit image // user to the supervisor's private bootstrap files. std::fs::write(&supervisor_dockerfile, format!( - "FROM {} AS supervisor\nFROM {base} AS trust-bundle\nUSER 0\nCOPY --from=supervisor /etc/ssl/certs/ca-certificates.crt /tmp/ca-certificates.crt\nCOPY fixture-ca.crt /tmp/readiness-fixture-ca.crt\nRUN [\"/usr/bin/python3\", \"-c\", \"from pathlib import Path; bundle = Path('/tmp/ca-certificates.crt'); bundle.write_bytes(bundle.read_bytes() + Path('/tmp/readiness-fixture-ca.crt').read_bytes())\"]\nFROM {}\nCOPY --from=trust-bundle /tmp/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt\n", + "FROM {} AS supervisor\nFROM {} AS trust-bundle\nUSER 0\nCOPY --from=supervisor /etc/ssl/certs/ca-certificates.crt /tmp/ca-certificates.crt\nCOPY fixture-ca.crt /tmp/readiness-fixture-ca.crt\nRUN [\"/usr/bin/python3\", \"-c\", \"from pathlib import Path; bundle = Path('/tmp/ca-certificates.crt'); bundle.write_bytes(bundle.read_bytes() + Path('/tmp/readiness-fixture-ca.crt').read_bytes())\"]\nFROM {}\nCOPY --from=trust-bundle /tmp/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt\n", gateway_config.supervisor_image, + image.tag(), gateway_config.supervisor_image, )).map_err(|_| "could not write fixture supervisor Dockerfile")?; - image - .build(&dockerfile, &context, "build workload fixture image") - .await?; supervisor_image .build( &supervisor_dockerfile, From c1bb9fdc0d9bedb30d2096880dfeabeb4b8f8a69 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Tue, 22 Sep 2026 13:45:53 +0200 Subject: [PATCH 17/17] fix(e2e): use a dedicated Noble fixture for Docker tests Signed-off-by: Evan Lezar --- TESTING.md | 15 ++++++++++ architecture/build.md | 6 ++++ e2e/python/Dockerfile.workload | 29 ++++++++++++++++++++ e2e/python/conftest.py | 3 +- e2e/rust/e2e-docker.sh | 4 +++ e2e/rust/src/harness/container.rs | 11 ++++---- e2e/rust/src/harness/sandbox.rs | 38 +++++++++++++++++++------- e2e/rust/tests/provider_auto_create.rs | 9 ++++-- e2e/rust/tests/provider_readiness.rs | 2 +- tasks/scripts/e2e-build-workload.sh | 12 ++++++++ tasks/test.toml | 10 ++++++- 11 files changed, 116 insertions(+), 23 deletions(-) create mode 100644 e2e/python/Dockerfile.workload create mode 100644 tasks/scripts/e2e-build-workload.sh diff --git a/TESTING.md b/TESTING.md index e0de6dd91e..42495fc23a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -106,6 +106,21 @@ requires mTLS. ### Python E2E (`e2e/python/`) +`mise run e2e:python` builds `openshell/e2e-python:dev` from +`e2e/python/Dockerfile.workload` and selects it only for the test gateway. +This Noble-based fixture supplies the `sandbox` user, Python tooling, Git, +and a writable `/sandbox/.venv`. Its Python version comes from `.python-version` +so cloudpickle code objects match the test runner. The production workload +default remains the unmodified NVIDIA Ubuntu image. + +The Rust Docker harness also selects this fixture for tests that need tools +or the named user. Explicit `--from` or `--template` arguments and the default-image +tests retain their own image selection. Docker sandbox and support-container +fixtures use the fixed image name; no workload-image override is needed. +Podman, VM, and Kubernetes retain their existing pinned, pullable fixture and +image setup. Conformance-only runs do not need the Docker fixture. +Build the Docker fixture separately with `mise run e2e:workload:build`. + Tests use the `sandbox` fixture from `conftest.py` to create real sandboxes: ```python diff --git a/architecture/build.md b/architecture/build.md index 1610d66c88..1894de538e 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -102,6 +102,12 @@ with GNU libc and uses the same glibc 2.28 compatibility floor as the gateway. ## Container Builds +Docker E2E tool-dependent workloads use a dedicated Noble-based fixture, +separate from the product's minimal default image. The fixture supplies the +test identity and tools, with Python aligned to the host test runner for +serialized callable compatibility. Default-image coverage retains the product +image. Other compute-driver test lanes retain their existing workload fixtures. + The Docker image pipeline is a two-step flow: build the Rust binary natively for the target architecture, then assemble the container image from the prebuilt binary. The gateway, sandbox, and supervisor images use distinct diff --git a/e2e/python/Dockerfile.workload b/e2e/python/Dockerfile.workload new file mode 100644 index 0000000000..0da6a88f6d --- /dev/null +++ b/e2e/python/Dockerfile.workload @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +ARG WORKLOAD_BASE=nvcr.io/nvidia/base/ubuntu:24.04 +FROM ghcr.io/astral-sh/uv:0.12.17@sha256:10787c682e4184e4f290de1171fd4703dc63de99221f10fe1c99002ce7fa9acc AS uv +FROM ${WORKLOAD_BASE} + +COPY --from=uv /uv /uvx /usr/local/bin/ +# Supplied from .python-version by the build task for cloudpickle compatibility. +ARG PYTHON_VERSION +# curl and openssl also support the Rust Docker tests sharing this fixture. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash ca-certificates curl git openssl python3 python3-pip python3-venv python-is-python3 \ + && rm -rf /var/lib/apt/lists/* \ + && if getent passwd ubuntu >/dev/null; then userdel ubuntu; fi \ + && if getent group ubuntu >/dev/null; then groupdel ubuntu; fi \ + && groupadd --gid 1000 sandbox \ + && useradd --uid 1000 --gid sandbox --create-home --shell /bin/bash sandbox \ + && UV_PYTHON_INSTALL_DIR=/sandbox/.uv/python uv python install "${PYTHON_VERSION}" \ + && UV_PYTHON_INSTALL_DIR=/sandbox/.uv/python uv venv --python "${PYTHON_VERSION}" --seed /sandbox/.venv \ + && uv pip install --python /sandbox/.venv/bin/python cloudpickle==3.1.2 \ + && chown -R sandbox:sandbox /sandbox \ + && uv cache clean + +ENV VIRTUAL_ENV=/sandbox/.venv +ENV PATH="/sandbox/.venv/bin:${PATH}" +WORKDIR /sandbox +USER sandbox:sandbox diff --git a/e2e/python/conftest.py b/e2e/python/conftest.py index ed8775017e..3c2bea236a 100644 --- a/e2e/python/conftest.py +++ b/e2e/python/conftest.py @@ -93,8 +93,7 @@ def _create(*, spec: object | None = None, delete_on_exit: bool = True) -> Sandb cluster=cluster_name, spec=spec, delete_on_exit=delete_on_exit, - # The sandbox image is large (Python, Node.js, coding agents) so the - # first pod in the cluster may need extra time for the image pull. + # Allow time to pull an explicitly supplied workload fixture. ready_timeout_seconds=300.0, ) diff --git a/e2e/rust/e2e-docker.sh b/e2e/rust/e2e-docker.sh index ba97b85a5b..b19477513d 100755 --- a/e2e/rust/e2e-docker.sh +++ b/e2e/rust/e2e-docker.sh @@ -44,5 +44,9 @@ if [ "${1:-}" = "${RUN_WITH_GATEWAY_COMMAND}" ]; then exit 0 fi +if [ -n "${E2E_FEATURES}" ]; then + CONTAINER_ENGINE=docker bash "${ROOT}/tasks/scripts/e2e-build-workload.sh" +fi + exec "${ROOT}/e2e/with-docker-gateway.sh" \ bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" diff --git a/e2e/rust/src/harness/container.rs b/e2e/rust/src/harness/container.rs index fa8bf440d4..5ef7ca9262 100644 --- a/e2e/rust/src/harness/container.rs +++ b/e2e/rust/src/harness/container.rs @@ -15,8 +15,7 @@ use std::time::Duration; use tokio::time::{interval, timeout}; use super::port::find_free_port; - -const DEFAULT_TEST_SERVER_IMAGE: &str = super::sandbox::E2E_WORKLOAD_IMAGE; +use super::sandbox::E2E_WORKLOAD_IMAGE; #[must_use] pub fn e2e_driver() -> Option { @@ -197,7 +196,7 @@ impl ContainerHttpServer { ]); } args.extend([ - DEFAULT_TEST_SERVER_IMAGE.to_string(), + E2E_WORKLOAD_IMAGE.to_string(), "-c".to_string(), script.to_string(), ]); @@ -325,7 +324,7 @@ impl HostSupportContainer { "python3", "-p", &format!("{port}:{container_port}"), - DEFAULT_TEST_SERVER_IMAGE, + E2E_WORKLOAD_IMAGE, "-c", script, ]) @@ -380,7 +379,7 @@ impl HostSupportContainer { .map(|capability| format!("--cap-add={capability}")), ); args.extend([ - DEFAULT_TEST_SERVER_IMAGE.to_string(), + E2E_WORKLOAD_IMAGE.to_string(), "-c".to_string(), script.to_string(), ]); @@ -493,7 +492,7 @@ impl SupportContainer { .map(|capability| format!("--cap-add={capability}")), ); args.extend([ - DEFAULT_TEST_SERVER_IMAGE.to_string(), + E2E_WORKLOAD_IMAGE.to_string(), "-c".to_string(), script.to_string(), ]); diff --git a/e2e/rust/src/harness/sandbox.rs b/e2e/rust/src/harness/sandbox.rs index 15586c2652..fb7aff9d93 100644 --- a/e2e/rust/src/harness/sandbox.rs +++ b/e2e/rust/src/harness/sandbox.rs @@ -20,7 +20,13 @@ use super::output::{extract_field, strip_ansi}; /// Tool-capable workload image used by the E2E harness. /// /// Product defaults remain on the minimal NVIDIA Ubuntu image. Tests that -/// explicitly pass `--from` continue to exercise their requested image. +/// explicitly pass `--from` or `--template` retain their requested workload. +/// Docker setup builds this Noble-based fixture before running the tests. +#[cfg(feature = "e2e-docker")] +pub const E2E_WORKLOAD_IMAGE: &str = "openshell/e2e-python:dev"; + +/// Preserve the existing pullable fixture for non-Docker E2E lanes. +#[cfg(not(feature = "e2e-docker"))] pub const E2E_WORKLOAD_IMAGE: &str = "ghcr.io/astral-sh/uv:0.12.17-python3.12-trixie-slim@sha256:9a59bb7206905ccaae4f7dab222fbac47c125a21e5fc16f43f427cd6c940ade3"; /// Extract the sandbox name from CLI create output. @@ -44,13 +50,17 @@ fn has_explicit_sandbox_name(args: &[&str]) -> bool { .any(|arg| *arg == "--name" || arg.starts_with("--name=")) } -fn has_explicit_sandbox_image(args: &[&str]) -> bool { - args.iter() - .any(|arg| *arg == "--from" || arg.starts_with("--from=")) +fn has_explicit_sandbox_workload(args: &[&str]) -> bool { + args.iter().take_while(|arg| **arg != "--").any(|arg| { + *arg == "--from" + || arg.starts_with("--from=") + || *arg == "--template" + || arg.starts_with("--template=") + }) } fn add_test_image_if_missing(command: &mut tokio::process::Command, args: &[&str]) { - if !has_explicit_sandbox_image(args) { + if !has_explicit_sandbox_workload(args) { command.arg("--from").arg(E2E_WORKLOAD_IMAGE); } } @@ -737,7 +747,7 @@ impl Drop for SandboxGuard { #[cfg(test)] mod tests { - use super::{has_explicit_sandbox_image, has_explicit_sandbox_name}; + use super::{has_explicit_sandbox_name, has_explicit_sandbox_workload}; #[test] fn detects_explicit_sandbox_names() { @@ -747,9 +757,17 @@ mod tests { } #[test] - fn detects_explicit_sandbox_images() { - assert!(has_explicit_sandbox_image(&["--from", "example:latest"])); - assert!(has_explicit_sandbox_image(&["--from=example:latest"])); - assert!(!has_explicit_sandbox_image(&["--policy", "policy.yaml"])); + fn detects_explicit_sandbox_workloads() { + assert!(has_explicit_sandbox_workload(&["--from", "example:latest"])); + assert!(has_explicit_sandbox_workload(&["--from=example:latest"])); + assert!(has_explicit_sandbox_workload(&["--template", "example"])); + assert!(has_explicit_sandbox_workload(&["--template=example"])); + assert!(!has_explicit_sandbox_workload(&["--policy", "policy.yaml"])); + assert!(!has_explicit_sandbox_workload(&["--", "echo", "--from=x"])); + assert!(!has_explicit_sandbox_workload(&[ + "--", + "echo", + "--template=x" + ])); } } diff --git a/e2e/rust/tests/provider_auto_create.rs b/e2e/rust/tests/provider_auto_create.rs index 86d5d336c2..43fdc27293 100644 --- a/e2e/rust/tests/provider_auto_create.rs +++ b/e2e/rust/tests/provider_auto_create.rs @@ -22,6 +22,8 @@ use std::sync::Mutex; use openshell_e2e::harness::binary::openshell_cmd; use openshell_e2e::harness::output::{extract_field, strip_ansi}; +#[cfg(feature = "e2e-docker")] +use openshell_e2e::harness::sandbox::E2E_WORKLOAD_IMAGE; const TEST_API_KEY: &str = "sk-e2e-auto-provider-test-key"; static CLAUDE_PROVIDER_LOCK: Mutex<()> = Mutex::new(()); @@ -108,9 +110,10 @@ network_policies: {} // Create a sandbox that prints the ANTHROPIC_API_KEY env var. // --auto-providers skips the interactive prompt. let mut cmd = openshell_cmd(); - cmd.arg("sandbox") - .arg("create") - .arg("--detach") + cmd.arg("sandbox").arg("create"); + #[cfg(feature = "e2e-docker")] + cmd.arg("--from").arg(E2E_WORKLOAD_IMAGE); + cmd.arg("--detach") .arg("--policy") .arg(policy.path()) .arg("--provider") diff --git a/e2e/rust/tests/provider_readiness.rs b/e2e/rust/tests/provider_readiness.rs index 5d8f462f2a..700c4880da 100644 --- a/e2e/rust/tests/provider_readiness.rs +++ b/e2e/rust/tests/provider_readiness.rs @@ -1334,7 +1334,7 @@ async fn acknowledged_provider_changes_apply_to_fresh_clients_and_revoke_retaine std::fs::write( &dockerfile, format!( - "FROM {E2E_WORKLOAD_IMAGE}\nUSER root\nRUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*\nCOPY client.py /opt/provider-readiness-client.py\nUSER 1000:1000\n" + "FROM {E2E_WORKLOAD_IMAGE}\nCOPY client.py /opt/provider-readiness-client.py\nUSER 1000:1000\n", ), ) .map_err(|_| "could not write fixture Dockerfile")?; diff --git a/tasks/scripts/e2e-build-workload.sh b/tasks/scripts/e2e-build-workload.sh new file mode 100644 index 0000000000..a7679d9679 --- /dev/null +++ b/tasks/scripts/e2e-build-workload.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=tasks/scripts/container-engine.sh +source "${ROOT}/tasks/scripts/container-engine.sh" +ce_build --load --file "${ROOT}/e2e/python/Dockerfile.workload" \ + --build-arg "PYTHON_VERSION=$(cat "${ROOT}/.python-version")" \ + --tag openshell/e2e-python:dev \ + "${ROOT}/e2e/python" diff --git a/tasks/test.toml b/tasks/test.toml index 317f676c19..74166db440 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -102,6 +102,10 @@ description = "Run Rust CLI e2e tests against a Docker-backed gateway" depends = ["e2e:conformance:build"] run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-docker.sh" +["e2e:workload:build"] +description = "Build the Docker E2E workload fixture" +run = "CONTAINER_ENGINE=docker bash tasks/scripts/e2e-build-workload.sh" + ["e2e:conformance:build"] description = "Build the standalone CLI conformance binary" run = "if [ -z \"${OPENSHELL_CONFORMANCE_BIN:-}\" ]; then cargo build -p openshell-conformance-cli; fi" @@ -118,6 +122,7 @@ run = [ ["e2e:websocket-conformance"] description = "Run focused WebSocket conformance e2e tests against a Docker-backed gateway" run = [ + "CONTAINER_ENGINE=docker bash tasks/scripts/e2e-build-workload.sh", "e2e/with-docker-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-docker --test websocket_conformance", ] @@ -133,7 +138,10 @@ depends = ["e2e:mcp"] description = "Run Python e2e tests against a Docker-backed gateway (E2E_PARALLEL=N or 'auto'; default 5)" depends = ["python:proto"] env = { UV_NO_SYNC = "1", PYTHONPATH = "python" } -run = "e2e/with-docker-gateway.sh uv run pytest -o python_files='test_*.py *_test.py' -m 'not gpu' -n ${E2E_PARALLEL:-5} e2e/python" +run = [ + "CONTAINER_ENGINE=docker bash tasks/scripts/e2e-build-workload.sh", + "OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE=openshell/e2e-python:dev OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE_PULL_POLICY=never e2e/with-docker-gateway.sh uv run pytest -o python_files='test_*.py *_test.py' -m 'not gpu' -n ${E2E_PARALLEL:-5} e2e/python", +] ["e2e:podman"] description = "Run Rust CLI e2e tests against a Podman-backed gateway"