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
22 changes: 5 additions & 17 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2455,13 +2455,6 @@ pub async fn sandbox_create(
}
}

/// The default community sandbox registry prefix.
///
/// Bare sandbox names (e.g., `openclaw`) are expanded to
/// `{prefix}/{name}:latest` using this value. Override with the
/// `OPENSHELL_COMMUNITY_REGISTRY` environment variable.
const DEFAULT_COMMUNITY_REGISTRY: &str = "ghcr.io/nvidia/openshell-community/sandboxes";

/// Resolved source for the `--from` flag on `sandbox create`.
enum ResolvedSource {
/// A ready-to-use container image reference.
Expand Down Expand Up @@ -2527,16 +2520,11 @@ fn resolve_from(value: &str) -> Result<ResolvedSource> {
));
}

// 3. Looks like a full image reference (contains / : or .).
if value.contains('/') || value.contains(':') || value.contains('.') {
return Ok(ResolvedSource::Image(value.to_string()));
}

// 4. Community sandbox name.
let prefix = std::env::var("OPENSHELL_COMMUNITY_REGISTRY")
.unwrap_or_else(|_| DEFAULT_COMMUNITY_REGISTRY.to_string());
let prefix = prefix.trim_end_matches('/');
Ok(ResolvedSource::Image(format!("{prefix}/{value}:latest")))
// 3. Full image reference or community sandbox name — delegate to shared
// resolution in openshell-core.
Ok(ResolvedSource::Image(
openshell_core::image::resolve_community_image(value),
))
}

fn source_requests_gpu(source: &str) -> bool {
Expand Down
103 changes: 103 additions & 0 deletions crates/openshell-core/src/image.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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 registry prefix for community sandbox images.
///
/// Bare sandbox names are expanded to `{prefix}/{name}:latest`.
/// Override at runtime with the `OPENSHELL_COMMUNITY_REGISTRY` env var.
pub const DEFAULT_COMMUNITY_REGISTRY: &str = "ghcr.io/nvidia/openshell-community/sandboxes";

/// 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::*;

#[test]
fn bare_name_expands_to_community_registry() {
let result = resolve_community_image("base");
assert_eq!(
result,
"ghcr.io/nvidia/openshell-community/sandboxes/base:latest"
);
}

#[test]
fn bare_name_with_env_override() {
// 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 input = "ghcr.io/myorg/myimage:v1";
assert_eq!(resolve_community_image(input), input);
}

#[test]
fn reference_with_colon_passes_through() {
let input = "myimage:latest";
assert_eq!(resolve_community_image(input), input);
}

#[test]
fn reference_with_dot_passes_through() {
let input = "registry.example.com";
assert_eq!(resolve_community_image(input), input);
}

#[test]
fn trailing_slash_in_env_is_trimmed() {
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) },
}
}
}
1 change: 1 addition & 0 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
pub mod config;
pub mod error;
pub mod forward;
pub mod image;
pub mod inference;
pub mod paths;
pub mod proto;
Expand Down
3 changes: 2 additions & 1 deletion crates/openshell-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1274,8 +1274,9 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender<Event>) {
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,
image: resolved,
..Default::default()
})
} else {
Expand Down
Loading