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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ Driver-controlled environment variables must override sandbox image or template
values for sandbox ID, sandbox name, gateway endpoint, relay socket path, TLS
paths, and command metadata.

## User Bind Volumes

Sandboxes accept `--volume <HOST>:<CONTAINER>[:ro]` at creation time. The host
path must be absolute and exist; the container path must be absolute. The
optional `:ro` suffix makes the bind read-only.

On rootless Podman, the driver inspects the sandbox image's `Config.User`
directive and sets the libpod `userns` field to `keep-id` with the image uid.
This maps the container sandbox uid bidirectionally to the host caller's uid so
bind files are mutually readable and writable across the namespace boundary
without manual ownership changes. The `userns` override is applied only when at
least one `--volume` is present; sandboxes without bind volumes continue to use
the default rootless mapping.

Docker and Kubernetes do not receive automatic userns remapping from the driver.
Docker rootless requires daemon-wide `userns-remap` configuration. Kubernetes
bind volumes follow cluster storage and security context policies.

## Images

The gateway image and Helm chart are built from this repository. Sandbox images
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ workspace = true
[features]
bundled-z3 = ["openshell-prover/bundled-z3"]
dev-settings = ["openshell-core/dev-settings"]
e2e = []

[dev-dependencies]
futures = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ pub(crate) mod policy_update;
pub mod run;
pub mod ssh;
pub mod tls;
pub mod volume_spec;
28 changes: 28 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use openshell_bootstrap::{
use openshell_cli::completers;
use openshell_cli::run;
use openshell_cli::tls::TlsOptions;
use openshell_cli::volume_spec;

/// Resolved gateway context: name + gateway endpoint.
struct GatewayContext {
Expand Down Expand Up @@ -1126,6 +1127,9 @@ enum DoctorCommands {
}

#[derive(Subcommand, Debug)]
// Create variant is 297 bytes (vs next-largest 78 bytes) because it holds every clap field for
// `sandbox create`. Boxing would scatter heap allocations across command startup for no benefit.
#[allow(clippy::large_enum_variant)]
enum SandboxCommands {
/// Create a sandbox.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Expand Down Expand Up @@ -1234,6 +1238,21 @@ enum SandboxCommands {
#[arg(long = "label")]
labels: Vec<String>,

/// Bind-mount a host path into the sandbox.
///
/// Format: `<HOST_PATH>:<CONTAINER_PATH>[:ro]`. Repeatable.
/// Host path must be absolute and exist. Container path must be
/// absolute. The optional `:ro` suffix makes the mount read-only.
///
/// On rootless podman, the driver auto-applies
/// `--userns=keep-id:uid=<image-sandbox-uid>,gid=<image-sandbox-gid>`
/// when any `--volume` is set, so bind file ownership maps
/// bidirectionally between host and container.
///
/// Not supported on the vm driver.
#[arg(long = "volume", help_heading = "MOUNT FLAGS")]
volumes: Vec<String>,

/// Command to run after "--" (defaults to an interactive shell).
#[arg(last = true, allow_hyphen_values = true)]
command: Vec<String>,
Expand Down Expand Up @@ -2483,6 +2502,7 @@ async fn main() -> Result<()> {
auto_providers,
no_auto_providers,
labels,
volumes,
command,
} => {
// Resolve --tty / --no-tty into an Option<bool> override.
Expand Down Expand Up @@ -2528,6 +2548,13 @@ async fn main() -> Result<()> {
.transpose()?;
let keep = keep || !no_keep || editor.is_some() || forward.is_some();

// Parse --volume specs into BindVolumeSpec entries.
let parsed_volumes = volumes
.iter()
.map(|s| volume_spec::parse_volume_spec(s))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| miette::miette!("{}", e))?;

let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
let endpoint = &ctx.endpoint;
let mut tls = tls.with_gateway_name(&ctx.name);
Expand All @@ -2551,6 +2578,7 @@ async fn main() -> Result<()> {
tty_override,
auto_providers_override,
&labels_map,
&parsed_volumes,
&tls,
))
.await?;
Expand Down
14 changes: 13 additions & 1 deletion crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::tls::{
TlsOptions, build_insecure_rustls_config, build_rustls_config, grpc_client,
grpc_inference_client, require_tls_materials,
};
use crate::volume_spec::BindVolumeSpec;
use bytes::Bytes;
use chrono::DateTime;
use dialoguer::{Confirm, Select, theme::ColorfulTheme};
Expand All @@ -32,7 +33,7 @@ use openshell_core::progress::{
use openshell_core::proto::ProviderProfileCategory;
use openshell_core::proto::{
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest,
ClearDraftChunksRequest, ConfigureProviderRefreshRequest, CreateProviderRequest,
BindVolume, ClearDraftChunksRequest, ConfigureProviderRefreshRequest, CreateProviderRequest,
CreateSandboxRequest, CreateSshSessionRequest, DeleteProviderProfileRequest,
DeleteProviderRefreshRequest, DeleteProviderRequest, DeleteSandboxRequest,
DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest,
Expand Down Expand Up @@ -1626,6 +1627,7 @@ pub async fn sandbox_create(
tty_override: Option<bool>,
auto_providers_override: Option<bool>,
labels: &HashMap<String, String>,
volumes: &[BindVolumeSpec],
tls: &TlsOptions,
) -> Result<()> {
if editor.is_some() && !command.is_empty() {
Expand Down Expand Up @@ -1692,13 +1694,23 @@ pub async fn sandbox_create(
None
};

let proto_volumes: Vec<BindVolume> = volumes
.iter()
.map(|v| BindVolume {
host_path: v.host.clone(),
container_path: v.container.clone(),
read_only: v.read_only,
})
.collect();

let request = CreateSandboxRequest {
spec: Some(SandboxSpec {
gpu: requested_gpu,
gpu_device: gpu_device.unwrap_or_default().to_string(),
policy,
providers: configured_providers,
template,
volumes: proto_volumes,
..SandboxSpec::default()
}),
name: name.unwrap_or_default().to_string(),
Expand Down
122 changes: 122 additions & 0 deletions crates/openshell-cli/src/volume_spec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Parsing for `--volume HOST:CONTAINER[:ro]` specs.

use std::path::Path;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BindVolumeSpec {
pub host: String,
pub container: String,
pub read_only: bool,
}

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum VolumeParseError {
#[error("--volume spec must have 2 or 3 colon-separated fields: {0}")]
BadFieldCount(String),
#[error("--volume spec third field must be 'ro': {0}")]
BadReadOnlyToken(String),
#[error("--volume host path must be absolute: {0}")]
HostNotAbsolute(String),
#[error("--volume host path does not exist: {0}")]
HostMissing(String),
#[error("--volume container path must be absolute: {0}")]
ContainerNotAbsolute(String),
}

pub fn parse_volume_spec(s: &str) -> Result<BindVolumeSpec, VolumeParseError> {
let parts: Vec<&str> = s.split(':').collect();
let (host, container, read_only) = match parts.as_slice() {
[h, c] => (h.to_string(), c.to_string(), false),
[h, c, ro] => {
if *ro != "ro" {
return Err(VolumeParseError::BadReadOnlyToken(s.to_string()));
}
(h.to_string(), c.to_string(), true)
}
_ => return Err(VolumeParseError::BadFieldCount(s.to_string())),
};
if !Path::new(&host).is_absolute() {
return Err(VolumeParseError::HostNotAbsolute(host));
}
if !Path::new(&host).exists() {
return Err(VolumeParseError::HostMissing(host));
}
if !Path::new(&container).is_absolute() {
return Err(VolumeParseError::ContainerNotAbsolute(container));
}
Ok(BindVolumeSpec {
host,
container,
read_only,
})
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

#[test]
fn parses_two_field_spec() {
let dir = TempDir::new().unwrap();
let host = dir.path().to_string_lossy().to_string();
let spec = format!("{host}:/sandbox/repo");
let parsed = parse_volume_spec(&spec).unwrap();
assert_eq!(parsed.host, host);
assert_eq!(parsed.container, "/sandbox/repo");
assert!(!parsed.read_only);
}

#[test]
fn parses_three_field_ro_spec() {
let dir = TempDir::new().unwrap();
let host = dir.path().to_string_lossy().to_string();
let spec = format!("{host}:/c:ro");
let parsed = parse_volume_spec(&spec).unwrap();
assert!(parsed.read_only);
}

#[test]
fn rejects_bad_field_count() {
assert!(matches!(
parse_volume_spec("a:b:c:d"),
Err(VolumeParseError::BadFieldCount(_))
));
assert!(matches!(
parse_volume_spec("only-one"),
Err(VolumeParseError::BadFieldCount(_))
));
}

#[test]
fn rejects_bad_ro_token() {
// /tmp exists on every Unix CI host so the path checks pass before
// reaching the read-only token check.
let r = parse_volume_spec("/tmp:/c:readonly");
assert!(matches!(r, Err(VolumeParseError::BadReadOnlyToken(_))));
}

#[test]
fn rejects_non_absolute_host() {
let r = parse_volume_spec("rel/path:/c");
assert!(matches!(r, Err(VolumeParseError::HostNotAbsolute(_))));
}

#[test]
fn rejects_missing_host() {
let r = parse_volume_spec("/definitely/does/not/exist/here:/c");
assert!(matches!(r, Err(VolumeParseError::HostMissing(_))));
}

#[test]
fn rejects_non_absolute_container() {
let dir = TempDir::new().unwrap();
let host = dir.path().to_string_lossy().to_string();
let spec = format!("{host}:rel/path");
let r = parse_volume_spec(&spec);
assert!(matches!(r, Err(VolumeParseError::ContainerNotAbsolute(_))));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() {
Some(false),
Some(false),
&HashMap::new(),
&[],
&tls,
)
.await
Expand Down Expand Up @@ -810,6 +811,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() {
Some(false),
Some(false),
&HashMap::new(),
&[],
&tls,
)
.await
Expand Down Expand Up @@ -895,6 +897,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() {
Some(false),
Some(false),
&HashMap::new(),
&[],
&tls,
)
.await
Expand Down Expand Up @@ -947,6 +950,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() {
Some(false),
Some(false),
&HashMap::new(),
&[],
&tls,
)
.await
Expand Down Expand Up @@ -991,6 +995,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() {
Some(false),
Some(false),
&HashMap::new(),
&[],
&tls,
)
.await
Expand Down Expand Up @@ -1031,6 +1036,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() {
Some(false),
Some(false),
&HashMap::new(),
&[],
&tls,
)
.await
Expand Down Expand Up @@ -1075,6 +1081,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() {
Some(true),
Some(false),
&HashMap::new(),
&[],
&tls,
)
.await
Expand Down Expand Up @@ -1119,6 +1126,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() {
Some(false),
Some(false),
&HashMap::new(),
&[],
&tls,
)
.await
Expand Down Expand Up @@ -1163,6 +1171,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() {
Some(false),
Some(false),
&HashMap::new(),
&[],
&tls,
)
.await
Expand Down
Loading
Loading