diff --git a/crates/openshell-core/src/driver_mounts.rs b/crates/openshell-core/src/driver_mounts.rs index 0b27e0a3b..c95529a87 100644 --- a/crates/openshell-core/src/driver_mounts.rs +++ b/crates/openshell-core/src/driver_mounts.rs @@ -3,6 +3,7 @@ //! Shared validation helpers for driver-config mounts. +use std::collections::HashSet; use std::path::Path; const RESERVED_MOUNT_TARGETS: &[&str] = &[ @@ -12,6 +13,11 @@ const RESERVED_MOUNT_TARGETS: &[&str] = &[ "/run/netns", ]; +/// Serde default helper for mount options that default to read-only. +pub fn default_true() -> bool { + true +} + /// Validate a non-empty driver mount source. pub fn validate_mount_source(source: &str, field: &str) -> Result { let source = source.trim(); @@ -96,13 +102,30 @@ fn normalize_container_mount_target(target: &str) -> String { target.trim_end_matches('/').to_string() } -fn path_is_or_under(path: &str, parent: &str) -> bool { +/// Return true when `path` is exactly `parent` or is contained below it. +pub fn path_is_or_under(path: &str, parent: &str) -> bool { path == parent || path .strip_prefix(parent) .is_some_and(|rest| rest.starts_with('/')) } +/// Validate that already-normalized driver mount targets are unique. +pub fn validate_unique_mount_targets<'a>( + targets: impl IntoIterator, + driver_name: &str, +) -> Result<(), String> { + let mut seen = HashSet::new(); + for target in targets { + if !seen.insert(target) { + return Err(format!( + "duplicate {driver_name} driver_config mount target '{target}'" + )); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -144,6 +167,24 @@ mod tests { ); } + #[test] + fn path_is_or_under_matches_boundaries() { + assert!(path_is_or_under("/sandbox", "/sandbox")); + assert!(path_is_or_under("/sandbox/work", "/sandbox")); + assert!(!path_is_or_under("/sandbox-work", "/sandbox")); + } + + #[test] + fn unique_mount_targets_rejects_duplicates() { + let err = + validate_unique_mount_targets(["/sandbox/work", "/sandbox/work"], "test").unwrap_err(); + + assert_eq!( + err, + "duplicate test driver_config mount target '/sandbox/work'" + ); + } + #[test] fn mount_subpath_must_be_relative_without_parent_dirs() { assert_eq!(validate_mount_subpath(" project/a ").unwrap(), "project/a"); diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 913054934..1deaf200e 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -49,7 +49,7 @@ use openshell_core::proto_struct::{ deserialize_optional_non_empty_string_list, struct_to_json_value, }; use openshell_core::{Config, Error, Result as CoreResult}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::io::Read; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; @@ -309,13 +309,13 @@ enum DockerDriverMountConfig { Bind { source: String, target: String, - #[serde(default = "default_true")] + #[serde(default = "driver_mounts::default_true")] read_only: bool, }, Volume { source: String, target: String, - #[serde(default = "default_true")] + #[serde(default = "driver_mounts::default_true")] read_only: bool, #[serde(default)] subpath: Option, @@ -332,17 +332,13 @@ enum DockerDriverMountConfig { Image { source: String, target: String, - #[serde(default = "default_true")] + #[serde(default = "driver_mounts::default_true")] read_only: bool, #[serde(default)] subpath: Option, }, } -fn default_true() -> bool { - true -} - type WatchStream = Pin> + Send + 'static>>; @@ -1854,7 +1850,7 @@ fn validate_docker_driver_mounts( mounts: &[DockerDriverMountConfig], enable_bind_mounts: bool, ) -> Result<(), Status> { - let mut targets = HashSet::new(); + let mut targets = Vec::with_capacity(mounts.len()); for mount in mounts { let target = match mount { DockerDriverMountConfig::Bind { source, target, .. } => { @@ -1908,13 +1904,10 @@ fn validate_docker_driver_mounts( }; let target = driver_mounts::validate_container_mount_target(target) .map_err(Status::failed_precondition)?; - if !targets.insert(target.clone()) { - return Err(Status::failed_precondition(format!( - "duplicate docker driver_config mount target '{target}'" - ))); - } + targets.push(target); } - Ok(()) + driver_mounts::validate_unique_mount_targets(targets.iter().map(String::as_str), "docker") + .map_err(Status::failed_precondition) } fn validate_optional_positive_integral_i64( diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 923c6d618..35d2b974d 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -755,6 +755,39 @@ fn driver_config_allows_explicit_writable_volume_mounts() { assert_eq!(mounts[0].read_only, Some(false)); } +#[test] +fn driver_config_rejects_duplicate_mount_targets() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [ + { + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work" + }, + { + "type": "tmpfs", + "target": "/sandbox/work" + } + ] + }))); + + let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!( + err.message() + .contains("duplicate docker driver_config mount target") + ); +} + #[test] fn driver_config_rejects_bind_mounts_unless_enabled() { let mut sandbox = test_sandbox(); diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 831e4edf2..4cc4b31cd 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -82,6 +82,13 @@ nested schema and currently accepts: - `pod.priority_class_name` - `containers.agent.resources.requests` - `containers.agent.resources.limits` +- `volumes[].name` +- `volumes[].persistent_volume_claim.claim_name` +- `volumes[].persistent_volume_claim.read_only` +- `containers.agent.volume_mounts[].name` +- `containers.agent.volume_mounts[].mount_path` +- `containers.agent.volume_mounts[].sub_path` +- `containers.agent.volume_mounts[].read_only` Nested keys inside the `kubernetes` block use snake_case. The top-level `driver_config` envelope is keyed by driver names, so `kubernetes` is not part @@ -104,3 +111,50 @@ driver's configured `default_runtime_class_name`; the typed public public `--gpu` flag for the default GPU request, pass a count to `--gpu` for counted GPU requests, and use `driver_config` only for additional driver-owned resource details. + +Use PVC volumes to mount existing Kubernetes PersistentVolumeClaims into the +agent container. PVC volumes and mounts default to read-only unless +`read_only: false` is set explicitly. Read-write access requires +`read_only: false` on both the PVC volume and each writable mount. The driver +rejects duplicate volume names, invalid DNS-1123 volume or PVC claim names, +mounts that reference unknown volumes, non-normalized or protected mount paths, +and absolute or parent-traversing `sub_path` values. + +Any explicit driver-config mount under `/sandbox` disables the driver's +default `/sandbox` workspace PVC injection for that sandbox. Only the explicit +mount paths persist through the external PVC; other `/sandbox` paths come from +the current sandbox image. + +```shell +openshell sandbox create \ + --driver-config-json '{ + "kubernetes": { + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data-123", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory", + "read_only": false + } + ] + } + } + } + }' \ + -- claude +``` diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 909568302..719afb463 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -14,6 +14,7 @@ use kube::core::gvk::GroupVersionKind; use kube::core::{DynamicObject, ObjectMeta}; use kube::runtime::watcher::{self, Event}; use kube::{Client, Error as KubeError}; +use openshell_core::driver_mounts; use openshell_core::driver_utils::{ LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, SUPERVISOR_IMAGE_BINARY_PATH, }; @@ -32,7 +33,7 @@ use openshell_core::proto::compute::v1::{ }; use openshell_core::proto_struct::{struct_to_json_object, value_to_json}; use serde::Deserialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; @@ -105,6 +106,7 @@ struct AgentSandboxApi { struct KubernetesSandboxDriverConfig { pod: KubernetesPodDriverConfig, containers: KubernetesDriverContainersConfig, + volumes: Vec, } impl KubernetesSandboxDriverConfig { @@ -126,8 +128,28 @@ impl KubernetesSandboxDriverConfig { }; let json = serde_json::Value::Object(struct_to_json_object(config)); - serde_json::from_value(json) - .map_err(|err| format!("invalid kubernetes driver_config: {err}")) + let mut config: Self = serde_json::from_value(json) + .map_err(|err| format!("invalid kubernetes driver_config: {err}"))?; + config + .normalize() + .map_err(|err| format!("invalid kubernetes driver_config: {err}"))?; + Ok(config) + } + + fn normalize(&mut self) -> Result<(), String> { + normalize_kubernetes_driver_volumes(&mut self.volumes)?; + normalize_kubernetes_driver_volume_mounts( + &self.volumes, + &mut self.containers.agent.volume_mounts, + ) + } + + fn has_explicit_sandbox_data_mount(&self) -> bool { + self.containers + .agent + .volume_mounts + .iter() + .any(|mount| driver_mounts::path_is_or_under(&mount.mount_path, WORKSPACE_MOUNT_PATH)) } } @@ -150,6 +172,7 @@ struct KubernetesDriverContainersConfig { #[serde(default, deny_unknown_fields)] struct KubernetesContainerDriverConfig { resources: KubernetesContainerResourceConfig, + volume_mounts: Vec, } #[derive(Debug, Clone, Default, Deserialize)] @@ -159,6 +182,238 @@ struct KubernetesContainerResourceConfig { limits: BTreeMap, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct KubernetesDriverVolumeConfig { + name: String, + persistent_volume_claim: KubernetesPersistentVolumeClaimConfig, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct KubernetesPersistentVolumeClaimConfig { + claim_name: String, + #[serde(default = "driver_mounts::default_true")] + read_only: bool, +} + +impl Default for KubernetesPersistentVolumeClaimConfig { + fn default() -> Self { + Self { + claim_name: String::new(), + read_only: true, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct KubernetesDriverVolumeMountConfig { + name: String, + mount_path: String, + sub_path: Option, + #[serde(default = "driver_mounts::default_true")] + read_only: bool, +} + +impl Default for KubernetesDriverVolumeMountConfig { + fn default() -> Self { + Self { + name: String::new(), + mount_path: String::new(), + sub_path: None, + read_only: true, + } + } +} + +const CLIENT_TLS_VOLUME_NAME: &str = "openshell-client-tls"; +const SERVICE_ACCOUNT_TOKEN_VOLUME_NAME: &str = "openshell-sa-token"; +const SERVICE_ACCOUNT_TOKEN_MOUNT_PATH: &str = "/var/run/secrets/openshell"; + +const KUBERNETES_DRIVER_RESERVED_VOLUME_NAMES: &[&str] = &[ + CLIENT_TLS_VOLUME_NAME, + SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, + SPIFFE_WORKLOAD_API_VOLUME_NAME, + SUPERVISOR_VOLUME_NAME, + WORKSPACE_VOLUME_NAME, +]; + +const KUBERNETES_DRIVER_PROTECTED_MOUNT_PATHS: &[&str] = + &[SERVICE_ACCOUNT_TOKEN_MOUNT_PATH, "/spiffe-workload-api"]; + +fn normalize_kubernetes_driver_volumes( + volumes: &mut [KubernetesDriverVolumeConfig], +) -> Result<(), String> { + let mut names = HashSet::new(); + for volume in volumes { + let name = validate_kubernetes_dns1123_label(&volume.name, "volumes[].name")?; + if KUBERNETES_DRIVER_RESERVED_VOLUME_NAMES.contains(&name.as_str()) { + return Err(format!( + "volume name '{name}' is reserved for OpenShell-managed volumes" + )); + } + if !names.insert(name.clone()) { + return Err(format!( + "duplicate kubernetes driver_config volume '{name}'" + )); + } + volume.name = name; + volume.persistent_volume_claim.claim_name = validate_kubernetes_dns1123_label( + &volume.persistent_volume_claim.claim_name, + "volumes[].persistent_volume_claim.claim_name", + )?; + } + Ok(()) +} + +fn normalize_kubernetes_driver_volume_mounts( + volumes: &[KubernetesDriverVolumeConfig], + volume_mounts: &mut [KubernetesDriverVolumeMountConfig], +) -> Result<(), String> { + let mut volume_read_only = BTreeMap::new(); + for volume in volumes { + volume_read_only.insert( + volume.name.clone(), + volume.persistent_volume_claim.read_only, + ); + } + + let mut mount_paths = Vec::with_capacity(volume_mounts.len()); + for mount in volume_mounts { + let volume_name = validate_kubernetes_dns1123_label( + &mount.name, + "containers.agent.volume_mounts[].name", + )?; + let Some(volume_is_read_only) = volume_read_only.get(&volume_name) else { + return Err(format!( + "volume mount references unknown kubernetes driver_config volume '{volume_name}'" + )); + }; + if *volume_is_read_only && !mount.read_only { + return Err(format!( + "volume mount '{volume_name}' cannot set read_only=false because the PVC volume is read_only=true" + )); + } + mount.name = volume_name; + + let mount_path = validate_kubernetes_mount_path(&mount.mount_path)?; + mount.mount_path.clone_from(&mount_path); + mount_paths.push(mount_path); + + if let Some(sub_path) = mount.sub_path.as_mut() { + *sub_path = driver_mounts::validate_mount_subpath(sub_path)?; + } + } + driver_mounts::validate_unique_mount_targets( + mount_paths.iter().map(String::as_str), + "kubernetes", + ) +} + +fn validate_kubernetes_driver_runtime_mounts( + config: &KubernetesSandboxDriverConfig, + provider_spiffe_workload_api_socket_path: Option<&str>, +) -> Result<(), String> { + let Some(socket_path) = provider_spiffe_workload_api_socket_path else { + return Ok(()); + }; + let spiffe_mount_path = spiffe_socket_mount_path(socket_path); + for mount in &config.containers.agent.volume_mounts { + let mount_path = mount.mount_path.as_str(); + if mount_path_conflicts_with_protected_path(mount_path, &spiffe_mount_path) { + return Err(format!( + "mount path '{mount_path}' conflicts with reserved OpenShell path '{spiffe_mount_path}'" + )); + } + } + Ok(()) +} + +fn validate_kubernetes_dns1123_label(value: &str, field: &str) -> Result { + let normalized = driver_mounts::validate_mount_source(value, field)?; + if normalized != value { + return Err(format!( + "{field} must not contain leading or trailing whitespace" + )); + } + let is_dns1123_label = normalized.len() <= 63 + && normalized + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && normalized + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && normalized + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric); + if !is_dns1123_label { + return Err(format!( + "{field} must be a DNS-1123 label: use lowercase alphanumeric characters or '-', start and end with an alphanumeric character, and use at most 63 characters" + )); + } + Ok(normalized) +} + +fn validate_kubernetes_mount_path(mount_path: &str) -> Result { + let raw = mount_path.trim(); + if raw != mount_path { + return Err("mount_path must not contain leading or trailing whitespace".to_string()); + } + if raw != "/" + && raw + .split('/') + .skip(1) + .any(|segment| segment.is_empty() || segment == ".") + { + return Err( + "mount_path must be normalized and must not contain empty path segments or '.'" + .to_string(), + ); + } + + let mount_path = driver_mounts::validate_container_mount_target(raw)?; + for protected_path in KUBERNETES_DRIVER_PROTECTED_MOUNT_PATHS { + if mount_path_conflicts_with_protected_path(&mount_path, protected_path) { + return Err(format!( + "mount path '{mount_path}' conflicts with reserved OpenShell path '{protected_path}'" + )); + } + } + Ok(mount_path) +} + +fn mount_path_conflicts_with_protected_path(mount_path: &str, protected_path: &str) -> bool { + driver_mounts::path_is_or_under(mount_path, protected_path) + || driver_mounts::path_is_or_under(protected_path, mount_path) +} + +fn kubernetes_driver_volume_to_k8s(volume: &KubernetesDriverVolumeConfig) -> serde_json::Value { + serde_json::json!({ + "name": volume.name.as_str(), + "persistentVolumeClaim": { + "claimName": volume.persistent_volume_claim.claim_name.as_str(), + "readOnly": volume.persistent_volume_claim.read_only, + } + }) +} + +fn kubernetes_driver_volume_mount_to_k8s( + mount: &KubernetesDriverVolumeMountConfig, +) -> serde_json::Value { + let mut value = serde_json::json!({ + "name": mount.name.as_str(), + "mountPath": mount.mount_path.as_str(), + "readOnly": mount.read_only, + }); + if let Some(sub_path) = mount.sub_path.as_ref() { + value["subPath"] = serde_json::json!(sub_path); + } + value +} + // --------------------------------------------------------------------------- // Default workspace persistence (temporary — will be replaced by snapshotting) // --------------------------------------------------------------------------- @@ -266,6 +521,22 @@ impl KubernetesComputeDriver { &self.config.ssh_socket_path } + fn validate_driver_config_for_sandbox( + &self, + sandbox: &Sandbox, + ) -> Result { + let config = KubernetesSandboxDriverConfig::from_sandbox(sandbox)?; + validate_kubernetes_driver_runtime_mounts( + &config, + self.config.provider_spiffe_enabled().then_some( + self.config + .provider_spiffe_workload_api_socket_path + .as_str(), + ), + )?; + Ok(config) + } + fn agent_sandbox_api(&self, client: Client, sandbox_api_version: &str) -> AgentSandboxApi { let gvk = GroupVersionKind::gvk(SANDBOX_GROUP, sandbox_api_version, SANDBOX_KIND); let resource = ApiResource::from_gvk(&gvk); @@ -342,7 +613,8 @@ impl KubernetesComputeDriver { } pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), tonic::Status> { - let _ = KubernetesSandboxDriverConfig::from_sandbox(sandbox) + let _ = self + .validate_driver_config_for_sandbox(sandbox) .map_err(tonic::Status::invalid_argument)?; let gpu_requirements = sandbox .spec @@ -450,7 +722,8 @@ impl KubernetesComputeDriver { } pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { - let _ = KubernetesSandboxDriverConfig::from_sandbox(sandbox) + let driver_config = self + .validate_driver_config_for_sandbox(sandbox) .map_err(KubernetesDriverError::InvalidArgument)?; let gpu_requirements = sandbox .spec @@ -502,7 +775,7 @@ impl KubernetesComputeDriver { .config .provider_spiffe_workload_api_socket_path, }; - obj.data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), ¶ms); + obj.data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), ¶ms, &driver_config); match tokio::time::timeout( KUBE_API_TIMEOUT, agent_sandbox_api.api.create(&PostParams::default(), &obj), @@ -1287,37 +1560,35 @@ fn spec_pod_env(spec: Option<&SandboxSpec>) -> std::collections::HashMap KubernetesSandboxDriverConfig { - KubernetesSandboxDriverConfig::from_template(template) - .expect("validated Kubernetes driver_config") -} - fn sandbox_to_k8s_spec( spec: Option<&SandboxSpec>, params: &SandboxPodParams<'_>, + driver_config: &KubernetesSandboxDriverConfig, ) -> serde_json::Value { let mut root = serde_json::Map::new(); - // Determine early whether the user provided custom volumeClaimTemplates. - // When they haven't, we inject a default workspace VCT and corresponding - // init container + volume mount so sandbox data persists. We need this - // flag before building the podTemplate because the workspace persistence - // transforms are applied inside sandbox_template_to_k8s. - let user_has_vct = spec - .and_then(|s| s.template.as_ref()) + // Determine early whether OpenShell should inject its default workspace + // PVC. Custom volumeClaimTemplates or explicit Kubernetes driver-config + // mounts under /sandbox/ take ownership of workspace persistence. + // We need this flag before building the podTemplate because the workspace + // persistence transforms are applied inside sandbox_template_to_k8s. + let template = spec.and_then(|s| s.template.as_ref()); + let user_has_vct = template .and_then(|t| platform_config_struct(t, "volume_claim_templates")) .is_some(); - let inject_workspace = !user_has_vct; + let user_has_explicit_workspace_mount = driver_config.has_explicit_sandbox_data_mount(); + let inject_workspace = !user_has_vct && !user_has_explicit_workspace_mount; if let Some(spec) = spec { let pod_env = spec_pod_env(Some(spec)); if let Some(template) = spec.template.as_ref() { root.insert( "podTemplate".to_string(), - sandbox_template_to_k8s_with_gpu_requirements( + sandbox_template_to_k8s_with_validated_config( template, driver_gpu_requirements(spec.resource_requirements.as_ref()), &pod_env, + driver_config, inject_workspace, params, ), @@ -1350,10 +1621,11 @@ fn sandbox_to_k8s_spec( let pod_env = spec_pod_env(spec); root.insert( "podTemplate".to_string(), - sandbox_template_to_k8s_with_gpu_requirements( + sandbox_template_to_k8s_with_validated_config( &SandboxTemplate::default(), driver_gpu_requirements(spec.and_then(|s| s.resource_requirements.as_ref())), &pod_env, + driver_config, inject_workspace, params, ), @@ -1374,15 +1646,19 @@ fn sandbox_template_to_k8s( params: &SandboxPodParams<'_>, ) -> serde_json::Value { let gpu_requirements = gpu.then_some(GpuResourceRequirements { count: None }); - sandbox_template_to_k8s_with_gpu_requirements( + let driver_config = KubernetesSandboxDriverConfig::from_template(template) + .expect("test Kubernetes driver_config should be valid"); + sandbox_template_to_k8s_with_validated_config( template, gpu_requirements.as_ref(), spec_environment, + &driver_config, inject_workspace, params, ) } +#[cfg(test)] fn sandbox_template_to_k8s_with_gpu_requirements( template: &SandboxTemplate, gpu_requirements: Option<&GpuResourceRequirements>, @@ -1390,8 +1666,26 @@ fn sandbox_template_to_k8s_with_gpu_requirements( inject_workspace: bool, params: &SandboxPodParams<'_>, ) -> serde_json::Value { - let driver_config = kubernetes_driver_config(template); + let driver_config = KubernetesSandboxDriverConfig::from_template(template) + .expect("test Kubernetes driver_config should be valid"); + sandbox_template_to_k8s_with_validated_config( + template, + gpu_requirements, + spec_environment, + &driver_config, + inject_workspace, + params, + ) +} +fn sandbox_template_to_k8s_with_validated_config( + template: &SandboxTemplate, + gpu_requirements: Option<&GpuResourceRequirements>, + spec_environment: &std::collections::HashMap, + driver_config: &KubernetesSandboxDriverConfig, + inject_workspace: bool, + params: &SandboxPodParams<'_>, +) -> serde_json::Value { let mut metadata = serde_json::Map::new(); let mut pod_labels = template .labels @@ -1555,7 +1849,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( let mut volume_mounts: Vec = Vec::new(); if !params.client_tls_secret_name.is_empty() { volume_mounts.push(serde_json::json!({ - "name": "openshell-client-tls", + "name": CLIENT_TLS_VOLUME_NAME, "mountPath": "/etc/openshell-tls/client", "readOnly": true })); @@ -1568,10 +1862,18 @@ fn sandbox_template_to_k8s_with_gpu_requirements( })); } volume_mounts.push(serde_json::json!({ - "name": "openshell-sa-token", - "mountPath": "/var/run/secrets/openshell", + "name": SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, + "mountPath": SERVICE_ACCOUNT_TOKEN_MOUNT_PATH, "readOnly": true, })); + volume_mounts.extend( + driver_config + .containers + .agent + .volume_mounts + .iter() + .map(kubernetes_driver_volume_mount_to_k8s), + ); container.insert( "volumeMounts".to_string(), serde_json::Value::Array(volume_mounts), @@ -1591,7 +1893,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( let mut volumes: Vec = Vec::new(); if !params.client_tls_secret_name.is_empty() { volumes.push(serde_json::json!({ - "name": "openshell-client-tls", + "name": CLIENT_TLS_VOLUME_NAME, "secret": { "secretName": params.client_tls_secret_name, "defaultMode": 256 } })); } @@ -1609,7 +1911,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( // it automatically. The supervisor exchanges this for a gateway-minted // JWT via `IssueSandboxToken` once at startup. volumes.push(serde_json::json!({ - "name": "openshell-sa-token", + "name": SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, "projected": { "sources": [{ "serviceAccountToken": { @@ -1621,6 +1923,12 @@ fn sandbox_template_to_k8s_with_gpu_requirements( "defaultMode": 256 } })); + volumes.extend( + driver_config + .volumes + .iter() + .map(kubernetes_driver_volume_to_k8s), + ); spec.insert("volumes".to_string(), serde_json::Value::Array(volumes)); // Add hostAliases so sandbox pods can reach the Docker host. @@ -1650,8 +1958,8 @@ fn sandbox_template_to_k8s_with_gpu_requirements( ); // Inject workspace persistence (init container + PVC volume mount) so - // that /sandbox data survives pod rescheduling. Skipped when the user - // provides custom volumeClaimTemplates to avoid conflicts. + // that /sandbox data survives pod rescheduling. Skipped when the user + // provides custom storage through volumeClaimTemplates or driver_config. if inject_workspace { apply_workspace_persistence(&mut result, image, params.image_pull_policy); } @@ -2106,6 +2414,20 @@ mod tests { } } + fn sandbox_to_k8s_spec_for_test( + spec: Option<&SandboxSpec>, + params: &SandboxPodParams<'_>, + ) -> serde_json::Value { + let driver_config = spec + .and_then(|spec| spec.template.as_ref()) + .map(KubernetesSandboxDriverConfig::from_template) + .transpose() + .expect("test Kubernetes driver_config should be valid") + .unwrap_or_default(); + + sandbox_to_k8s_spec(spec, params, &driver_config) + } + fn kube_api_error(code: u16, message: &str) -> KubeError { KubeError::Api(kube::core::ErrorResponse { status: if code == 404 { @@ -2183,6 +2505,501 @@ mod tests { assert!(err.contains("gpu_device_ids")); } + #[test] + fn driver_config_pvc_subpath_mounts_render_in_pod_template() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data-123", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory" + } + ] + } + } + }))), + ..SandboxTemplate::default() + }; + let spec = SandboxSpec { + template: Some(template), + ..SandboxSpec::default() + }; + + let cr = sandbox_to_k8s_spec_for_test(Some(&spec), &SandboxPodParams::default()); + let pod_template = &cr["spec"]["podTemplate"]; + + let volumes = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist"); + let user_volume = volumes + .iter() + .find(|volume| volume["name"] == "user-data") + .expect("user PVC volume should be rendered"); + assert_eq!( + user_volume["persistentVolumeClaim"]["claimName"], + "pvc-user-data-123" + ); + assert_eq!(user_volume["persistentVolumeClaim"]["readOnly"], false); + + let mounts = pod_template["spec"]["containers"][0]["volumeMounts"] + .as_array() + .expect("volumeMounts should exist"); + let workspace_mount = mounts + .iter() + .find(|mount| mount["mountPath"] == "/sandbox/.openshell/workspace") + .expect("workspace subPath mount should be rendered"); + assert_eq!(workspace_mount["name"], "user-data"); + assert_eq!(workspace_mount["subPath"], "workspace"); + assert_eq!(workspace_mount["readOnly"], false); + + let memory_mount = mounts + .iter() + .find(|mount| mount["mountPath"] == "/sandbox/.openshell/memory") + .expect("memory subPath mount should be rendered"); + assert_eq!(memory_mount["name"], "user-data"); + assert_eq!(memory_mount["subPath"], "memory"); + assert_eq!(memory_mount["readOnly"], true); + + let spec_obj = cr["spec"].as_object().expect("spec should be an object"); + assert!( + !spec_obj.contains_key("volumeClaimTemplates"), + "explicit /sandbox driver_config mounts should skip the default workspace VCT" + ); + let has_workspace_init = pod_template["spec"]["initContainers"] + .as_array() + .is_some_and(|containers| { + containers + .iter() + .any(|container| container["name"] == WORKSPACE_INIT_CONTAINER_NAME) + }); + assert!( + !has_workspace_init, + "explicit /sandbox driver_config mounts should skip the default workspace init container" + ); + } + + #[test] + fn driver_config_accepts_read_write_pvc_with_multiple_subpath_mounts() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/sessions", + "sub_path": "sessions", + "read_only": false + } + ] + } + } + }))), + ..SandboxTemplate::default() + }; + + let config = KubernetesSandboxDriverConfig::from_template(&template) + .expect("read-write PVC with multiple subPath mounts should validate"); + + assert_eq!(config.volumes.len(), 1); + assert_eq!(config.volumes[0].name, "user-data"); + assert_eq!( + config.volumes[0].persistent_volume_claim.claim_name, + "pvc-user-data" + ); + assert!(!config.volumes[0].persistent_volume_claim.read_only); + assert_eq!(config.containers.agent.volume_mounts.len(), 3); + assert!( + config + .containers + .agent + .volume_mounts + .iter() + .all(|mount| !mount.read_only) + ); + assert!(config.has_explicit_sandbox_data_mount()); + } + + #[test] + fn driver_config_rejects_duplicate_pvc_volume_names() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [ + { + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-a"} + }, + { + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-b"} + } + ] + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("duplicate kubernetes driver_config volume")); + } + + #[test] + fn driver_config_rejects_duplicate_pvc_volume_mount_targets() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace" + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace" + } + ] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("duplicate kubernetes driver_config mount target")); + } + + #[test] + fn driver_config_rejects_invalid_dns1123_volume_and_claim_names() { + for (field, config) in [ + ( + "volumes[].name", + serde_json::json!({ + "volumes": [{ + "name": "User_Data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }] + }), + ), + ( + "volumes[].persistent_volume_claim.claim_name", + serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc.user.data"} + }] + }), + ), + ] { + let template = SandboxTemplate { + driver_config: Some(json_struct(config)), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains(field) && err.contains("DNS-1123 label"), + "expected invalid {field} to fail DNS-1123 validation, got {err}" + ); + } + } + + #[test] + fn driver_config_rejects_mounts_referencing_unknown_volumes() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "known-data", + "persistent_volume_claim": {"claim_name": "pvc-known"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "missing-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace" + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("unknown kubernetes driver_config volume 'missing-data'")); + } + + #[test] + fn driver_config_rejects_protected_kubernetes_mount_targets() { + for mount_path in [ + "/", + "/sandbox", + "/etc/openshell", + "/etc/openshell-tls/client", + "/var/run/secrets/openshell", + "/opt/openshell/bin", + "/spiffe-workload-api", + ] { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": mount_path + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains("mount path") || err.contains("mount target"), + "expected protected mount target {mount_path:?} to be rejected, got {err}" + ); + } + } + + #[test] + fn driver_config_rejects_invalid_kubernetes_sub_paths() { + for sub_path in ["/workspace", "../workspace"] { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": sub_path + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains("mount subpath must be relative"), + "expected invalid sub_path {sub_path:?} to be rejected, got {err}" + ); + } + } + + #[test] + fn driver_config_defaults_pvc_mounts_to_read_only() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace" + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let pod_template = sandbox_template_to_k8s( + &template, + false, + &std::collections::HashMap::new(), + false, + &SandboxPodParams::default(), + ); + + let volume = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist") + .iter() + .find(|volume| volume["name"] == "user-data") + .expect("user volume should exist"); + assert_eq!(volume["persistentVolumeClaim"]["readOnly"], true); + + let mount = pod_template["spec"]["containers"][0]["volumeMounts"] + .as_array() + .expect("volumeMounts should exist") + .iter() + .find(|mount| mount["mountPath"] == "/sandbox/.openshell/workspace") + .expect("user mount should exist"); + assert_eq!(mount["readOnly"], true); + } + + #[test] + fn driver_config_rejects_read_write_mount_for_read_only_pvc_volume() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data", + "read_only": true + } + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "read_only": false + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("cannot set read_only=false")); + } + + #[test] + fn driver_config_rejects_reserved_kubernetes_volume_names() { + for volume_name in [ + CLIENT_TLS_VOLUME_NAME, + SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, + SPIFFE_WORKLOAD_API_VOLUME_NAME, + SUPERVISOR_VOLUME_NAME, + WORKSPACE_VOLUME_NAME, + ] { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": volume_name, + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }] + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains("reserved for OpenShell-managed volumes"), + "expected reserved volume name {volume_name:?} to be rejected, got {err}" + ); + } + } + + #[test] + fn reserved_kubernetes_volume_names_cover_managed_pod_volumes() { + let params = SandboxPodParams { + client_tls_secret_name: "openshell-client-tls-secret", + provider_spiffe_enabled: true, + provider_spiffe_workload_api_socket_path: "/spiffe-workload-api/spire-agent.sock", + ..SandboxPodParams::default() + }; + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + &std::collections::HashMap::new(), + true, + ¶ms, + ); + let volume_names = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist") + .iter() + .filter_map(|volume| volume["name"].as_str()) + .collect::>(); + + for volume_name in volume_names { + assert!( + KUBERNETES_DRIVER_RESERVED_VOLUME_NAMES.contains(&volume_name), + "managed volume {volume_name:?} should be reserved" + ); + } + } + + #[test] + fn driver_config_rejects_runtime_provider_spiffe_mount_path() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/custom-spiffe" + }] + } + } + }))), + ..SandboxTemplate::default() + }; + let config = KubernetesSandboxDriverConfig::from_template(&template) + .expect("static driver_config should be valid"); + + let err = validate_kubernetes_driver_runtime_mounts( + &config, + Some("/custom-spiffe/spire-agent.sock"), + ) + .unwrap_err(); + + assert!(err.contains("/custom-spiffe")); + } + #[test] fn validate_rejects_zero_gpu_count() { let sandbox = Sandbox { @@ -2917,7 +3734,7 @@ mod tests { .expect("volumes should exist"); let tls_vol = volumes .iter() - .find(|v| v["name"] == "openshell-client-tls") + .find(|v| v["name"] == CLIENT_TLS_VOLUME_NAME) .expect("TLS volume should exist"); assert_eq!( tls_vol["secret"]["defaultMode"], @@ -3419,7 +4236,7 @@ mod tests { && volume["csi"]["driver"] == "csi.spiffe.io" })); assert!(volumes.iter().any(|volume| { - volume["name"] == "openshell-sa-token" + volume["name"] == SERVICE_ACCOUNT_TOKEN_VOLUME_NAME && volume["projected"]["sources"][0]["serviceAccountToken"]["path"] == "token" })); @@ -3472,7 +4289,7 @@ mod tests { log_level: "debug".to_string(), ..SandboxSpec::default() }; - let cr = sandbox_to_k8s_spec(Some(&spec), &SandboxPodParams::default()); + let cr = sandbox_to_k8s_spec_for_test(Some(&spec), &SandboxPodParams::default()); let env = cr["spec"]["podTemplate"]["spec"]["containers"][0]["env"] .as_array() .unwrap(); @@ -3499,7 +4316,7 @@ mod tests { )]), ..SandboxSpec::default() }; - let cr = sandbox_to_k8s_spec(Some(&spec), &SandboxPodParams::default()); + let cr = sandbox_to_k8s_spec_for_test(Some(&spec), &SandboxPodParams::default()); let env = cr["spec"]["podTemplate"]["spec"]["containers"][0]["env"] .as_array() .unwrap(); diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 66d0d9d90..cbc2ceba8 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -12,7 +12,7 @@ use openshell_core::proto_struct::deserialize_optional_non_empty_string_list; use openshell_core::{driver_mounts, proto_struct}; use serde::Serialize; use serde_json::Value; -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use std::path::Path; /// Returns `true` when `SELinux` is enabled (enforcing or permissive). @@ -103,13 +103,13 @@ enum PodmanDriverMountConfig { Bind { source: String, target: String, - #[serde(default = "default_true")] + #[serde(default = "driver_mounts::default_true")] read_only: bool, }, Volume { source: String, target: String, - #[serde(default = "default_true")] + #[serde(default = "driver_mounts::default_true")] read_only: bool, #[serde(default)] subpath: Option, @@ -126,17 +126,13 @@ enum PodmanDriverMountConfig { Image { source: String, target: String, - #[serde(default = "default_true")] + #[serde(default = "driver_mounts::default_true")] read_only: bool, #[serde(default)] subpath: Option, }, } -fn default_true() -> bool { - true -} - /// Build a Podman container name from the sandbox name. #[must_use] pub fn container_name(sandbox_name: &str) -> String { @@ -626,7 +622,7 @@ fn validate_podman_driver_mounts( mounts: &[PodmanDriverMountConfig], enable_bind_mounts: bool, ) -> Result<(), String> { - let mut targets = HashSet::new(); + let mut targets = Vec::with_capacity(mounts.len()); for mount in mounts { let target = match mount { PodmanDriverMountConfig::Bind { source, target, .. } => { @@ -672,13 +668,9 @@ fn validate_podman_driver_mounts( } }; let target = driver_mounts::validate_container_mount_target(target)?; - if !targets.insert(target.clone()) { - return Err(format!( - "duplicate podman driver_config mount target '{target}'" - )); - } + targets.push(target); } - Ok(()) + driver_mounts::validate_unique_mount_targets(targets.iter().map(String::as_str), "podman") } fn reject_subpath(subpath: Option<&str>, mount_type: &str) -> Result<(), String> { @@ -1956,6 +1948,40 @@ mod tests { })); } + #[test] + fn driver_config_rejects_duplicate_mount_targets() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [ + { + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work" + }, + { + "type": "tmpfs", + "target": "/sandbox/work" + } + ] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + + let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); + + assert!( + err.to_string() + .contains("duplicate podman driver_config mount target") + ); + } + #[test] fn driver_config_rejects_bind_mounts_unless_enabled() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 341d9e9f4..a20301fa7 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -315,3 +315,66 @@ If Agent Sandbox is upgraded in place, restart the OpenShell gateway after the c `Sandbox.spec.volumeClaimTemplates` is immutable after creation. To change storage configuration, delete the sandbox and create a new one with the updated spec. + +### Kubernetes Driver Config PVC Mounts + +Kubernetes driver config can mount existing PersistentVolumeClaims into the +agent container. Use this when storage is provisioned outside OpenShell and a +sandbox should mount selected PVC subpaths instead of using the default +OpenShell-created `/sandbox` workspace PVC. + +```shell +openshell sandbox create \ + --driver-config-json '{ + "kubernetes": { + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data-123", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory", + "read_only": false + } + ] + } + } + } + }' \ + -- claude +``` + +Kubernetes PVC mount schema: + +| Field | Description | +|---|---| +| `volumes[].name` | Pod volume name. It must be a DNS-1123 label, unique, and not use OpenShell-managed volume names. | +| `volumes[].persistent_volume_claim.claim_name` | Existing PVC name in the sandbox namespace. It must be a DNS-1123 label. | +| `volumes[].persistent_volume_claim.read_only` | Optional. Defaults to `true`. Set `false` to allow read-write mounts. | +| `containers.agent.volume_mounts[].name` | References a volume declared in `volumes`. | +| `containers.agent.volume_mounts[].mount_path` | Absolute, normalized container path for the agent mount. | +| `containers.agent.volume_mounts[].sub_path` | Optional relative PVC subpath. Absolute paths and `..` are rejected. | +| `containers.agent.volume_mounts[].read_only` | Optional. Defaults to `true`. It cannot be `false` when the PVC volume is read-only. | + +OpenShell rejects duplicate volume names, mounts that reference unknown volumes, +protected mount targets, and mounts that replace OpenShell TLS, supervisor, +ServiceAccount token, or SPIFFE paths. Read-write PVC access requires +`read_only: false` on both the PVC volume and each writable mount. + +Any driver-config mount under `/sandbox` disables the default `/sandbox` +workspace PVC injection for that sandbox. Only the explicit mount paths persist +through the external PVC; other `/sandbox` paths come from the current sandbox +image.