From b3599532285a9df8356cc5f0e02eee00ff4e3c8e Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Wed, 13 May 2026 01:48:26 +0100 Subject: [PATCH] feat(cli): add --volume flag for host directory bind mounts in Docker sandboxes Add a new --volume HOST_PATH[:SANDBOX_PATH][:ro] flag to sandbox create that bind-mounts a host directory into the sandbox at create time. This is the live-edit alternative to --upload: changes made inside the sandbox are immediately visible on the host and vice versa, with no upload/download cycle required. Key design decisions: - Only supported by local Docker-backed gateways. Kubernetes and VM drivers reject a non-empty mounts list at ValidateSandboxCreate time with a clear error pointing to sandbox upload as the alternative. - Proto: adds SandboxMount / DriverSandboxMount messages (host_path, sandbox_path, read_only) and mounts fields to SandboxTemplate (field 11) and DriverSandboxTemplate (field 12). - Server: when mounts are present, automatically injects each sandbox_path into the persisted FilesystemPolicy (seeded from restrictive_default_policy so the supervisor receives a complete Landlock allowlist rather than a sparse one that locks it out of system paths). - Landlock: Docker Desktop on macOS uses a fakeowner filesystem for host bind mounts whose ReadDir enforcement is incompatible with Landlock rules (rules are applied but the kernel-side lookup fails at access time). Fix by excluding ReadDir from the Landlock restriction mask so directory listing is unrestricted while all file-level access controls remain enforced. - Validation: max 32 mounts per sandbox, path length <= 4096 bytes. Tested end-to-end: ls and cat of files in the bind-mounted repo directory work correctly inside the sandbox. --- .agents/skills/openshell-cli/cli-reference.md | 1 + crates/openshell-cli/src/main.rs | 58 ++++++++ crates/openshell-cli/src/run.rs | 38 ++++-- .../sandbox_create_lifecycle_integration.rs | 5 + crates/openshell-driver-docker/src/lib.rs | 26 +++- crates/openshell-driver-docker/src/tests.rs | 4 +- .../openshell-driver-kubernetes/src/driver.rs | 11 ++ crates/openshell-driver-vm/src/driver.rs | 6 + .../src/sandbox/linux/landlock.rs | 21 ++- crates/openshell-server/src/compute/mod.rs | 125 ++++++++++++------ crates/openshell-server/src/grpc/sandbox.rs | 39 ++++++ .../openshell-server/src/grpc/validation.rs | 31 +++++ mise.lock | 1 + proto/compute_driver.proto | 16 +++ proto/openshell.proto | 20 +++ 15 files changed, 349 insertions(+), 53 deletions(-) diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index adfa849dd..2c9041c73 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -140,6 +140,7 @@ Create a sandbox through the active gateway, wait for readiness, then connect or | `--name ` | Sandbox name (auto-generated if omitted) | | `--from ` | Sandbox source: community name, Dockerfile path, directory, or image reference (BYOC) | | `--upload [:]` | Upload local files into sandbox (default dest: `/sandbox`) | +| `--volume [:][:ro]` | Bind-mount a host directory into the sandbox. Only supported by Docker-backed gateways. Repeatable. | | `--no-keep` | Delete sandbox after the initial command or shell exits | | `--provider ` | Provider to attach (repeatable) | | `--policy ` | Path to custom policy YAML | diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 9cffb243b..afb3e5097 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1126,6 +1126,24 @@ enum SandboxCommands { #[arg(long, overrides_with = "auto_providers")] no_auto_providers: bool, + /// Bind-mount a host directory into the sandbox. + /// + /// Format: `HOST_PATH[:SANDBOX_PATH][:ro]` + /// + /// - `HOST_PATH` is the absolute path on the gateway host. + /// - `SANDBOX_PATH` is the absolute path inside the sandbox + /// (defaults to the same as `HOST_PATH` when omitted). + /// - Append `:ro` to mount read-only. + /// + /// Only supported by local Docker-backed gateways. Kubernetes and + /// VM gateways will reject this flag at sandbox create time. + /// + /// Examples: + /// `--volume /home/user/project:/workspace` + /// `--volume /data:/data:ro` + #[arg(long = "volume", value_name = "HOST_PATH[:SANDBOX_PATH][:ro]")] + volumes: Vec, + /// Attach labels to the sandbox (key=value format, repeatable). #[arg(long = "label")] labels: Vec, @@ -2372,6 +2390,7 @@ async fn main() -> Result<()> { no_tty, auto_providers, no_auto_providers, + volumes, labels, command, } => { @@ -2412,6 +2431,12 @@ async fn main() -> Result<()> { (local, remote, !no_git_ignore) }); + // Parse --volume specs into (host_path, sandbox_path, read_only) tuples. + let mut volume_specs = Vec::new(); + for vol in &volumes { + volume_specs.push(parse_volume_spec(vol)?); + } + let editor = editor.map(Into::into); let forward = forward .map(|s| openshell_core::forward::ForwardSpec::parse(&s)) @@ -2438,6 +2463,7 @@ async fn main() -> Result<()> { &command, tty_override, auto_providers_override, + &volume_specs, &labels_map, &tls, )) @@ -2829,6 +2855,38 @@ async fn main() -> Result<()> { Ok(()) } +/// Parse a volume spec like `HOST_PATH[:SANDBOX_PATH][:ro]` into components. +/// +/// Returns `(host_path, sandbox_path, read_only)`. When `SANDBOX_PATH` is +/// omitted, `sandbox_path` defaults to `host_path`. The only recognised +/// option suffix is `ro` (read-only); any other suffix is rejected. +fn parse_volume_spec(spec: &str) -> Result<(String, String, bool), miette::Report> { + // Split into at most three colon-separated parts. + let parts: Vec<&str> = spec.splitn(3, ':').collect(); + let host_path = parts[0]; + if host_path.is_empty() { + return Err(miette::miette!( + "invalid volume spec '{spec}': host path must not be empty" + )); + } + let sandbox_path = parts.get(1).copied().unwrap_or(host_path); + if sandbox_path.is_empty() { + return Err(miette::miette!( + "invalid volume spec '{spec}': sandbox path must not be empty when specified" + )); + } + let read_only = match parts.get(2).copied() { + Some("ro") => true, + Some("rw") | None => false, + Some(opt) => { + return Err(miette::miette!( + "invalid volume option '{opt}' in spec '{spec}': only 'ro' and 'rw' are supported" + )); + } + }; + Ok((host_path.to_string(), sandbox_path.to_string(), read_only)) +} + /// Parse an upload spec like `[:]` into (`local_path`, `optional_sandbox_path`). fn parse_upload_spec(spec: &str) -> (String, Option) { if let Some((local, remote)) = spec.split_once(':') { diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 3205b8f68..af196e314 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -37,10 +37,11 @@ use openshell_core::proto::{ ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, - RevokeSshSessionRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, - ServiceEndpointResponse, SetClusterInferenceRequest, SettingScope, SettingValue, - TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, UpdateProviderRequest, - WatchSandboxRequest, exec_sandbox_event, setting_value, tcp_forward_init, + RevokeSshSessionRequest, Sandbox, SandboxMount, SandboxPhase, SandboxPolicy, SandboxSpec, + SandboxTemplate, ServiceEndpointResponse, SetClusterInferenceRequest, SettingScope, + SettingValue, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, + UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value, + tcp_forward_init, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_core::{ObjectId, ObjectName}; @@ -1475,6 +1476,7 @@ pub async fn sandbox_create( command: &[String], tty_override: Option, auto_providers_override: Option, + volumes: &[(String, String, bool)], labels: &HashMap, tls: &TlsOptions, ) -> Result<()> { @@ -1531,10 +1533,30 @@ pub async fn sandbox_create( let policy = load_sandbox_policy(policy)?; - let template = image.map(|img| SandboxTemplate { - image: img, - ..SandboxTemplate::default() - }); + let sandbox_mounts: Vec = volumes + .iter() + .map(|(host_path, sandbox_path, read_only)| SandboxMount { + host_path: host_path.clone(), + sandbox_path: sandbox_path.clone(), + read_only: *read_only, + }) + .collect(); + + // Build the sandbox template. An explicit image (via --from) or any + // mounts (via --volume) are both reasons to include a template in the + // request. When neither is present the gateway fills in the default. + let template = match (image, sandbox_mounts.is_empty()) { + (Some(img), _) => Some(SandboxTemplate { + image: img, + mounts: sandbox_mounts, + ..SandboxTemplate::default() + }), + (None, false) => Some(SandboxTemplate { + mounts: sandbox_mounts, + ..SandboxTemplate::default() + }), + (None, true) => None, + }; let request = CreateSandboxRequest { spec: Some(SandboxSpec { diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 6e7d66d11..4c1b0f540 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -677,6 +677,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { &["echo".to_string(), "OK".to_string()], Some(false), Some(false), + &[], &HashMap::new(), &tls, ) @@ -716,6 +717,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { &["echo".to_string(), "OK".to_string()], Some(false), Some(false), + &[], &HashMap::new(), &tls, ) @@ -758,6 +760,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { &[], Some(true), Some(false), + &[], &HashMap::new(), &tls, ) @@ -800,6 +803,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { &["echo".to_string(), "OK".to_string()], Some(false), Some(false), + &[], &HashMap::new(), &tls, ) @@ -842,6 +846,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { &["echo".to_string(), "OK".to_string()], Some(false), Some(false), + &[], &HashMap::new(), &tls, ) diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 6059596ab..cbf21c898 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -22,7 +22,7 @@ use openshell_core::config::{DEFAULT_DOCKER_NETWORK_NAME, DEFAULT_STOP_TIMEOUT_S use openshell_core::gpu::cdi_gpu_device_ids; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DriverCondition, DriverSandbox, DriverSandboxStatus, DriverSandboxTemplate, + DriverCondition, DriverSandbox, DriverSandboxMount, DriverSandboxStatus, DriverSandboxTemplate, GetCapabilitiesRequest, GetCapabilitiesResponse, GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest, ListSandboxesResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent, @@ -326,6 +326,14 @@ impl DockerComputeDriver { )); } + for (i, mount) in template.mounts.iter().enumerate() { + if mount.host_path.is_empty() { + return Err(Status::invalid_argument(format!( + "template.mounts[{i}].host_path must not be empty" + ))); + } + } + let _ = docker_resource_limits(template)?; Ok(()) } @@ -869,7 +877,10 @@ impl ComputeDriver for DockerComputeDriver { } } -fn build_binds(config: &DockerDriverRuntimeConfig) -> Vec { +fn build_binds( + config: &DockerDriverRuntimeConfig, + user_mounts: &[DriverSandboxMount], +) -> Vec { let mut binds = vec![format!( "{}:{}:ro,z", config.supervisor_bin.display(), @@ -884,6 +895,15 @@ fn build_binds(config: &DockerDriverRuntimeConfig) -> Vec { )); binds.push(format!("{}:{}:ro,z", tls.key.display(), TLS_KEY_MOUNT_PATH)); } + for mount in user_mounts { + let sandbox_path = if mount.sandbox_path.is_empty() { + &mount.host_path + } else { + &mount.sandbox_path + }; + let options = if mount.read_only { ":ro" } else { "" }; + binds.push(format!("{}:{}{}", mount.host_path, sandbox_path, options)); + } binds } @@ -997,7 +1017,7 @@ fn build_container_create_body( nano_cpus: resource_limits.nano_cpus, memory: resource_limits.memory_bytes, device_requests: docker_gpu_device_requests(spec.gpu, &spec.gpu_device), - binds: Some(build_binds(config)), + binds: Some(build_binds(config, &template.mounts)), restart_policy: Some(RestartPolicy { name: Some(RestartPolicyNameEnum::UNLESS_STOPPED), maximum_retry_count: None, diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index df68d39d6..ee3301bea 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -30,6 +30,7 @@ fn test_sandbox() -> DriverSandbox { environment: HashMap::from([("TEMPLATE_ENV".to_string(), "template".to_string())]), resources: None, platform_config: None, + mounts: vec![], }), gpu: false, gpu_device: String::new(), @@ -359,6 +360,7 @@ fn docker_resource_limits_rejects_requests() { memory_limit: String::new(), }), platform_config: None, + mounts: vec![], }; let err = docker_resource_limits(&template).unwrap_err(); @@ -410,7 +412,7 @@ fn build_environment_keeps_path_driver_controlled() { #[test] fn build_binds_uses_docker_tls_directory() { - let binds = build_binds(&runtime_config()); + let binds = build_binds(&runtime_config(), &[]); let targets = binds .iter() .filter_map(|bind| bind.split(':').nth(1).map(String::from)) diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 56b73447a..84181f330 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -194,6 +194,17 @@ impl KubernetesComputeDriver { } pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), tonic::Status> { + let has_mounts = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .is_some_and(|tmpl| !tmpl.mounts.is_empty()); + if has_mounts { + return Err(tonic::Status::failed_precondition( + "--volume bind mounts are not supported by Kubernetes-backed gateways; \ + use `openshell sandbox upload` to transfer files into the sandbox instead", + )); + } let gpu_requested = sandbox.spec.as_ref().is_some_and(|spec| spec.gpu); if gpu_requested && !self.has_gpu_capacity().await.map_err(|err| { diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index b797f4835..06408af85 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -1487,6 +1487,12 @@ fn validate_vm_sandbox(sandbox: &Sandbox, gpu_enabled: bool) -> Result<(), Statu "vm sandboxes do not support template.resources", )); } + if !template.mounts.is_empty() { + return Err(Status::failed_precondition( + "--volume bind mounts are not supported by VM-backed gateways; \ + use `openshell sandbox upload` to transfer files into the sandbox instead", + )); + } } Ok(()) } diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index 214fc700a..205e2a8a8 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -141,10 +141,29 @@ pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result { - self.sandbox_watch_bus.notify(sandbox.object_id()); - Ok(sandbox) - } - Err(status) if status.code() == Code::AlreadyExists => { - let _ = self - .store - .delete(Sandbox::object_type(), sandbox.object_id()) - .await; - self.sandbox_index.remove_sandbox(sandbox.object_id()); - Err(Status::already_exists("sandbox already exists")) - } - Err(status) if status.code() == Code::FailedPrecondition => { - let _ = self - .store - .delete(Sandbox::object_type(), sandbox.object_id()) - .await; - self.sandbox_index.remove_sandbox(sandbox.object_id()); - Err(Status::failed_precondition(status.message().to_string())) - } - Err(err) => { - let _ = self - .store - .delete(Sandbox::object_type(), sandbox.object_id()) + let sandbox_id = sandbox.object_id().to_string(); + let sandbox_name = sandbox.object_name().to_string(); + + // Clone all fields needed by the spawned background task. + let driver = self.driver.clone(); + let store = self.store.clone(); + let sandbox_index = self.sandbox_index.clone(); + let sandbox_watch_bus = self.sandbox_watch_bus.clone(); + let sync_lock = self.sync_lock.clone(); + + // Drive the underlying create in a background task. On Docker/Podman + // the driver pulls the container image synchronously, which can take + // minutes for large images. Blocking the gRPC call for that duration + // leaves the client with no feedback or provisioning display. Spawning + // here lets `create_sandbox` return the Provisioning-phase sandbox + // immediately so the client can render progress while the pull runs. + tokio::spawn(async move { + match driver + .create_sandbox(Request::new(CreateSandboxRequest { + sandbox: Some(driver_sandbox), + })) + .await + { + Ok(_) => { + sandbox_watch_bus.notify(&sandbox_id); + } + Err(err) => { + warn!( + %sandbox_id, + %sandbox_name, + error = %err, + "Driver failed to create sandbox; marking sandbox as error" + ); + // Attempt to persist an Error phase so the watch stream + // delivers a terminal failure to the client instead of + // silently closing. + let persist_result = async { + let _guard = sync_lock.lock_owned().await; + let mut sb = store + .get_message::(&sandbox_id) + .await + .map_err(|e| format!("fetch: {e}"))? + .ok_or_else(|| "sandbox not found".to_string())?; + sb.phase = SandboxPhase::Error as i32; + sb.status = Some(SandboxStatus { + conditions: vec![SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "CreateFailed".to_string(), + message: err.message().to_string(), + ..Default::default() + }], + ..SandboxStatus::default() + }); + store + .put_message(&sb) + .await + .map_err(|e| format!("persist: {e}")) + } .await; - self.sandbox_index.remove_sandbox(sandbox.object_id()); - Err(Status::internal(format!( - "create sandbox failed: {}", - err.message() - ))) + if let Err(e) = persist_result { + warn!( + %sandbox_id, + error = %e, + "Failed to persist sandbox error state; deleting instead" + ); + let _ = store.delete(Sandbox::object_type(), &sandbox_id).await; + sandbox_index.remove_sandbox(&sandbox_id); + } + sandbox_watch_bus.notify(&sandbox_id); + } } - } + }); + + Ok(sandbox) } pub async fn delete_sandbox(&self, name: &str) -> Result { @@ -1143,6 +1179,15 @@ fn driver_sandbox_template_from_public(template: &SandboxTemplate) -> DriverSand environment: template.environment.clone(), resources: extract_typed_resources(&template.resources), platform_config: build_platform_config(template), + mounts: template + .mounts + .iter() + .map(|m| DriverSandboxMount { + host_path: m.host_path.clone(), + sandbox_path: m.sandbox_path.clone(), + read_only: m.read_only, + }) + .collect(), } } diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index ad37a5482..d33771e8c 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -87,6 +87,45 @@ pub(super) async fn handle_create_sandbox( template.image = state.compute.default_image().to_string(); } + // Automatically add bind-mount sandbox paths to the filesystem policy so + // that Landlock (the kernel-level filesystem allowlist enforced inside the + // sandbox) permits access to them. Without this, the mount is visible in + // the container's namespace but every open(2) on it returns EACCES. + // + // We inject the paths here, at create time, so they are persisted with the + // sandbox record and delivered to the supervisor via GetSandboxConfig. + // + // IMPORTANT: when no user policy was provided we seed from the full + // restrictive default (which includes /usr, /lib, /etc, /sandbox, /tmp, + // etc.) rather than an empty SandboxPolicy. The installed supervisor + // applies the gateway-delivered policy verbatim for Landlock; a sparse + // policy causes it to lock itself out of every system path it needs. + if !template.mounts.is_empty() { + let policy = spec + .policy + .get_or_insert_with(openshell_policy::restrictive_default_policy); + let fs = policy + .filesystem + .get_or_insert_with(openshell_core::proto::FilesystemPolicy::default); + for mount in &template.mounts { + let sandbox_path = if mount.sandbox_path.is_empty() { + &mount.host_path + } else { + &mount.sandbox_path + }; + if sandbox_path.is_empty() { + continue; + } + if mount.read_only { + if !fs.read_only.contains(sandbox_path) { + fs.read_only.push(sandbox_path.clone()); + } + } else if !fs.read_write.contains(sandbox_path) { + fs.read_write.push(sandbox_path.clone()); + } + } + } + // Ensure process identity defaults to "sandbox" when missing or // empty, then validate policy safety before persisting. if let Some(ref mut policy) = spec.policy { diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 160b7e031..7c69bf66d 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -31,6 +31,10 @@ pub(super) const MAX_EXEC_COMMAND_ARGS: usize = 1024; pub(super) const MAX_EXEC_ARG_LEN: usize = 32 * 1024; // 32 KiB /// Maximum length of the workdir field (bytes). pub(super) const MAX_EXEC_WORKDIR_LEN: usize = 4096; +/// Maximum number of bind mounts per sandbox template. +const MAX_MOUNTS: usize = 32; +/// Maximum byte length of a mount path (host or sandbox side). +const MAX_MOUNT_PATH_LEN: usize = 4096; /// Validate fields of an `ExecSandboxRequest` for control characters and size /// limits before constructing a shell command string. @@ -201,6 +205,33 @@ fn validate_sandbox_template(tmpl: &SandboxTemplate) -> Result<(), Status> { } } + // Validate mounts. + if tmpl.mounts.len() > MAX_MOUNTS { + return Err(Status::invalid_argument(format!( + "template.mounts exceeds maximum count ({} > {MAX_MOUNTS})", + tmpl.mounts.len() + ))); + } + for (i, mount) in tmpl.mounts.iter().enumerate() { + if mount.host_path.is_empty() { + return Err(Status::invalid_argument(format!( + "template.mounts[{i}].host_path must not be empty" + ))); + } + if mount.host_path.len() > MAX_MOUNT_PATH_LEN { + return Err(Status::invalid_argument(format!( + "template.mounts[{i}].host_path exceeds maximum length ({} > {MAX_MOUNT_PATH_LEN})", + mount.host_path.len() + ))); + } + if mount.sandbox_path.len() > MAX_MOUNT_PATH_LEN { + return Err(Status::invalid_argument(format!( + "template.mounts[{i}].sandbox_path exceeds maximum length ({} > {MAX_MOUNT_PATH_LEN})", + mount.sandbox_path.len() + ))); + } + } + Ok(()) } diff --git a/mise.lock b/mise.lock index f5d959069..60467eeb2 100644 --- a/mise.lock +++ b/mise.lock @@ -299,6 +299,7 @@ url = "https://storage.googleapis.com/skaffold/releases/v2.19.0/skaffold-linux-a url = "https://storage.googleapis.com/skaffold/releases/v2.19.0/skaffold-linux-amd64" [tools.skaffold."platforms.macos-arm64"] +checksum = "blake3:0e91ba4f1d53adfdf70868424b3678de1b4e194488652ba5ed1805eb7142f929" url = "https://storage.googleapis.com/skaffold/releases/v2.19.0/skaffold-darwin-arm64" [tools.skaffold."platforms.macos-x64"] diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 3c4308f3f..4d3c442e4 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -115,6 +115,22 @@ message DriverSandboxTemplate { // For the Kubernetes driver this carries fields such as runtimeClassName, // annotations, and volumeClaimTemplates. google.protobuf.Struct platform_config = 11; + // Host directories to bind-mount into the sandbox. + // + // Only supported by the Docker driver. Other drivers must reject a + // non-empty list at ValidateSandboxCreate time. + repeated DriverSandboxMount mounts = 12; +} + +// A host directory bind-mounted into a sandbox by the compute driver. +message DriverSandboxMount { + // Absolute path on the gateway host to bind-mount. + string host_path = 1; + // Absolute path inside the sandbox where the host directory appears. + // When empty, the driver uses the same path as host_path. + string sandbox_path = 2; + // When true, the directory is mounted read-only. + bool read_only = 3; } // Typed compute-resource requirements. diff --git a/proto/openshell.proto b/proto/openshell.proto index bb2ce6cec..493ab509f 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -282,6 +282,26 @@ message SandboxTemplate { // available (beta through 1.35, GA in 1.36+) and a supporting runtime. // When unset, the cluster-wide default is used. optional bool user_namespaces = 10; + // Host directories to bind-mount into the sandbox. + // + // Only supported by local Docker-backed gateways. Kubernetes and VM drivers + // will reject a non-empty list at sandbox create time. + repeated SandboxMount mounts = 11; +} + +// A host directory bind-mounted into a sandbox at create time. +// +// Mirrors the Docker `-v HOST:CONTAINER[:ro]` bind-mount semantics. +// The gateway passes this through to the compute driver unchanged; +// only the Docker driver currently supports it. +message SandboxMount { + // Absolute path on the gateway host to bind-mount. + string host_path = 1; + // Absolute path inside the sandbox where the host directory appears. + // When empty, the driver uses the same path as host_path. + string sandbox_path = 2; + // When true, the directory is mounted read-only. + bool read_only = 3; } // User-facing sandbox status derived by the gateway from compute-driver observations.