@@ -40,21 +40,21 @@ use openshell_core::proto::{
4040 DeleteProviderProfileRequest , DeleteProviderRefreshRequest , DeleteProviderRequest ,
4141 DeleteSandboxRequest , DeleteServiceRequest , DetachSandboxProviderRequest , ExecSandboxRequest ,
4242 ExposeServiceRequest , GetCurrentUserRequest , GetDraftHistoryRequest , GetDraftPolicyRequest ,
43- GetGatewayConfigRequest , GetInferenceRouteRequest , GetProviderProfileRequest ,
44- GetProviderRefreshStatusRequest , GetProviderRequest , GetSandboxConfigRequest ,
45- GetSandboxConfigResponse , GetSandboxLogsRequest , GetSandboxPolicyStatusRequest ,
46- GetSandboxRequest , GetServiceRequest , GpuResourceRequirements , ImportProviderProfilesRequest ,
47- LintProviderProfilesRequest , ListProviderProfilesRequest , ListProvidersRequest ,
48- ListSandboxPoliciesRequest , ListSandboxProvidersRequest , ListSandboxesRequest ,
49- ListServicesRequest , PolicySource , PolicyStatus , Provider , ProviderCredentialRefreshStatus ,
50- ProviderCredentialRefreshStrategy , ProviderCredentialTokenGrantType , ProviderProfile ,
51- ProviderProfileDiagnostic , ProviderProfileImportItem , RejectDraftChunkRequest ,
52- ResourceRequirements , RevokeSshSessionRequest , RotateProviderCredentialRequest , Sandbox ,
53- SandboxPhase , SandboxPolicy , SandboxSpec , SandboxTemplate , ServiceEndpointResponse ,
54- SetInferenceRouteRequest , SettingScope , StartSandboxRequest , StopSandboxRequest ,
55- TcpForwardFrame , TcpForwardInit , TcpRelayTarget , UpdateConfigRequest ,
56- UpdateProviderProfilesRequest , UpdateProviderRequest , WatchSandboxRequest , exec_sandbox_event ,
57- setting_value, tcp_forward_init,
43+ GetGatewayConfigRequest , GetGatewayInfoRequest , GetInferenceRouteRequest ,
44+ GetProviderProfileRequest , GetProviderRefreshStatusRequest , GetProviderRequest ,
45+ GetSandboxConfigRequest , GetSandboxConfigResponse , GetSandboxLogsRequest ,
46+ GetSandboxPolicyStatusRequest , GetSandboxRequest , GetServiceRequest , GpuResourceRequirements ,
47+ ImportProviderProfilesRequest , LintProviderProfilesRequest , ListProviderProfilesRequest ,
48+ ListProvidersRequest , ListSandboxPoliciesRequest , ListSandboxProvidersRequest ,
49+ ListSandboxesRequest , ListServicesRequest , PolicySource , PolicyStatus , Provider ,
50+ ProviderCredentialRefreshStatus , ProviderCredentialRefreshStrategy ,
51+ ProviderCredentialTokenGrantType , ProviderProfile , ProviderProfileDiagnostic ,
52+ ProviderProfileImportItem , RejectDraftChunkRequest , ResourceRequirements ,
53+ RevokeSshSessionRequest , RotateProviderCredentialRequest , Sandbox , SandboxPhase , SandboxPolicy ,
54+ SandboxSpec , SandboxTemplate , ServiceEndpointResponse , SetInferenceRouteRequest , SettingScope ,
55+ StartSandboxRequest , StopSandboxRequest , TcpForwardFrame , TcpForwardInit , TcpRelayTarget ,
56+ UpdateConfigRequest , UpdateProviderProfilesRequest , UpdateProviderRequest , WatchSandboxRequest ,
57+ exec_sandbox_event , setting_value, tcp_forward_init,
5858} ;
5959use openshell_core:: settings;
6060use openshell_core:: { ObjectId , ObjectName , ObjectWorkspace } ;
@@ -473,22 +473,26 @@ pub async fn sandbox_create(
473473 let effective_tls = tls. clone ( ) ;
474474
475475 // Resolve the --from flag into a container image reference, building from
476- // a Dockerfile first if necessary.
477- let image: Option < String > = match from {
476+ // a Dockerfile first if necessary, or a rootfs tar path for the VM driver .
477+ let ( image, rootfs_tar_path ) : ( Option < String > , Option < PathBuf > ) = match from {
478478 Some ( val) => {
479479 let resolved = resolve_from ( val) ?;
480480 match resolved {
481- ResolvedSource :: Image ( img) => Some ( img) ,
481+ ResolvedSource :: Image ( img) => ( Some ( img) , None ) ,
482482 ResolvedSource :: Dockerfile {
483483 dockerfile,
484484 context,
485485 } => {
486486 let tag = build_from_dockerfile ( & dockerfile, & context, gateway_name) . await ?;
487- Some ( tag)
487+ ( Some ( tag) , None )
488+ }
489+ ResolvedSource :: RootfsTar { path } => {
490+ validate_rootfs_tar_source ( gateway_name, & mut client, & path) . await ?;
491+ ( None , Some ( path) )
488492 }
489493 }
490494 }
491- None => None ,
495+ None => ( None , None ) ,
492496 } ;
493497 let inferred_provider = inferred_provider_type ( command) ;
494498 let providers_v2_enabled =
@@ -513,11 +517,20 @@ pub async fn sandbox_create(
513517
514518 let policy = load_sandbox_policy ( policy) ?;
515519 let resource_limits = build_sandbox_resource_limits ( cpu, memory) ?;
516- let driver_config = driver_config_json
520+ let mut driver_config = driver_config_json
517521 . map ( parse_driver_config_json)
518522 . transpose ( ) ?;
519523
520- let template = if image. is_some ( ) || resource_limits. is_some ( ) || driver_config. is_some ( ) {
524+ if let Some ( tar_path) = & rootfs_tar_path {
525+ let rootfs_config = rootfs_tar_driver_config ( tar_path) ?;
526+ driver_config = Some ( merge_driver_config ( driver_config, rootfs_config) ) ;
527+ }
528+
529+ let template = if image. is_some ( )
530+ || resource_limits. is_some ( )
531+ || driver_config. is_some ( )
532+ || rootfs_tar_path. is_some ( )
533+ {
521534 Some ( SandboxTemplate {
522535 image : image. unwrap_or_default ( ) ,
523536 resources : resource_limits,
@@ -1031,17 +1044,23 @@ enum ResolvedSource {
10311044 dockerfile : PathBuf ,
10321045 context : PathBuf ,
10331046 } ,
1047+ /// A flat rootfs tar archive (`.tar`, `.tar.gz`, `.tgz`) to pass directly
1048+ /// to the VM compute driver.
1049+ RootfsTar { path : PathBuf } ,
10341050}
10351051
1036- /// Classify the `--from` value into an image reference or a Dockerfile that
1037- /// needs building.
1052+ /// Classify the `--from` value into an image reference, a Dockerfile that
1053+ /// needs building, or a rootfs tar to pass to the VM driver .
10381054///
10391055/// Resolution order:
1040- /// 1. Existing file whose name contains "Dockerfile " → build from file .
1056+ /// 1. Existing file whose name contains "dockerfile " → build from Dockerfile .
10411057/// 2. Existing directory that contains a `Dockerfile` → build from directory.
1042- /// 3. Missing explicit local paths → local error, not image pull.
1043- /// 4. Value contains `/`, `:`, or `.` → treat as a full image reference.
1044- /// 5. Otherwise → community sandbox name, expanded via the registry prefix.
1058+ /// 3. Existing file with `.tar`, `.tar.gz`, or `.tgz` extension → rootfs tar archive.
1059+ /// 4. Other existing local paths → error.
1060+ /// 5. Non-existent path-like values (`./…`, `../…`, `/…`, `~/…`) → local
1061+ /// error, so they don't reach the gateway as broken image-pull requests.
1062+ /// 6. Value contains `/`, `:`, or `.` → treat as a full image reference.
1063+ /// 7. Otherwise → community sandbox name, expanded via the registry prefix.
10451064fn resolve_from ( value : & str ) -> Result < ResolvedSource > {
10461065 let path = Path :: new ( value) ;
10471066
@@ -1062,9 +1081,17 @@ fn resolve_from(value: &str) -> Result<ResolvedSource> {
10621081 } ) ;
10631082 }
10641083
1084+ if filename_looks_like_rootfs_tar ( path) {
1085+ let tar_path = path
1086+ . canonicalize ( )
1087+ . into_diagnostic ( )
1088+ . wrap_err_with ( || format ! ( "failed to resolve path: {}" , path. display( ) ) ) ?;
1089+ return Ok ( ResolvedSource :: RootfsTar { path : tar_path } ) ;
1090+ }
1091+
10651092 if value_looks_like_local_source ( value) {
10661093 return Err ( miette:: miette!(
1067- "local --from file is not a Dockerfile: {}" ,
1094+ "local --from file is not a Dockerfile or rootfs tar (.tar/.tar.gz/.tgz) : {}" ,
10681095 path. display( )
10691096 ) ) ;
10701097 }
@@ -1103,7 +1130,7 @@ fn resolve_from(value: &str) -> Result<ResolvedSource> {
11031130 if value_looks_like_local_source ( value) {
11041131 return Err ( miette:: miette!(
11051132 "local --from path does not exist: {}\n \
1106- Use an existing Dockerfile, a directory containing Dockerfile, or a container image reference.",
1133+ Use an existing Dockerfile, directory containing Dockerfile, rootfs tar (.tar/.tar.gz/.tgz) , or a container image reference.",
11071134 path. display( )
11081135 ) ) ;
11091136 }
@@ -1121,7 +1148,17 @@ fn filename_looks_like_dockerfile(path: &Path) -> bool {
11211148 . map ( |n| n. to_string_lossy ( ) )
11221149 . unwrap_or_default ( ) ;
11231150 let lower = name. to_lowercase ( ) ;
1124- lower. contains ( "dockerfile" ) || lower. ends_with ( ".dockerfile" )
1151+ lower. contains ( "dockerfile" )
1152+ }
1153+
1154+ #[ allow( clippy:: case_sensitive_file_extension_comparisons) ] // already lowercased
1155+ fn filename_looks_like_rootfs_tar ( path : & Path ) -> bool {
1156+ let name = path
1157+ . file_name ( )
1158+ . map ( |n| n. to_string_lossy ( ) )
1159+ . unwrap_or_default ( ) ;
1160+ let lower = name. to_lowercase ( ) ;
1161+ lower. ends_with ( ".tar.gz" ) || lower. ends_with ( ".tar" ) || lower. ends_with ( ".tgz" )
11251162}
11261163
11271164fn value_looks_like_local_source ( value : & str ) -> bool {
@@ -1203,6 +1240,73 @@ async fn build_from_dockerfile(
12031240 Ok ( tag)
12041241}
12051242
1243+ /// Validate that a rootfs tar source is usable with the current gateway.
1244+ async fn validate_rootfs_tar_source (
1245+ gateway_name : & str ,
1246+ client : & mut crate :: tls:: GrpcClient ,
1247+ tar_path : & Path ,
1248+ ) -> Result < ( ) > {
1249+ let metadata = get_gateway_metadata ( gateway_name) ;
1250+ if !dockerfile_sources_supported_for_gateway ( metadata. as_ref ( ) ) {
1251+ return Err ( miette ! (
1252+ "local rootfs tar sources are only supported for local gateways; gateway '{}' is remote" ,
1253+ gateway_name
1254+ ) ) ;
1255+ }
1256+
1257+ let info = client
1258+ . get_gateway_info ( GetGatewayInfoRequest { } )
1259+ . await
1260+ . into_diagnostic ( )
1261+ . wrap_err ( "failed to query gateway compute driver" ) ?
1262+ . into_inner ( ) ;
1263+
1264+ let driver_name = info. compute_drivers . first ( ) . map_or ( "" , |d| d. name . as_str ( ) ) ;
1265+
1266+ if driver_name != "vm" {
1267+ return Err ( miette ! (
1268+ "rootfs tar sources are only supported by the VM compute driver, \
1269+ but gateway '{}' uses the '{}' driver",
1270+ gateway_name,
1271+ driver_name
1272+ ) ) ;
1273+ }
1274+
1275+ eprintln ! (
1276+ "Using rootfs tar {} for gateway '{}'" ,
1277+ tar_path. display( ) . to_string( ) . cyan( ) ,
1278+ gateway_name,
1279+ ) ;
1280+ eprintln ! ( ) ;
1281+
1282+ Ok ( ( ) )
1283+ }
1284+
1285+ /// Build a `driver_config` struct carrying the rootfs tar path for the VM driver.
1286+ fn rootfs_tar_driver_config ( tar_path : & Path ) -> Result < prost_types:: Struct > {
1287+ let fields = serde_json:: Map :: from_iter ( [ (
1288+ "rootfs_tar_path" . to_string ( ) ,
1289+ serde_json:: Value :: String ( tar_path. to_string_lossy ( ) . into_owned ( ) ) ,
1290+ ) ] ) ;
1291+ openshell_core:: proto_struct:: json_object_to_struct ( fields)
1292+ . into_diagnostic ( )
1293+ . wrap_err ( "failed to encode rootfs_tar_path in driver_config" )
1294+ }
1295+
1296+ /// Merge a rootfs tar config into an existing `driver_config`, if any.
1297+ fn merge_driver_config (
1298+ base : Option < prost_types:: Struct > ,
1299+ overlay : prost_types:: Struct ,
1300+ ) -> prost_types:: Struct {
1301+ match base {
1302+ Some ( mut base) => {
1303+ base. fields . extend ( overlay. fields ) ;
1304+ base
1305+ }
1306+ None => overlay,
1307+ }
1308+ }
1309+
12061310/// Load sandbox policy YAML.
12071311///
12081312/// Resolution order: `--policy` flag > `OPENSHELL_SANDBOX_POLICY` env var.
@@ -7950,8 +8054,8 @@ mod tests {
79508054 . expect( "failed to canonicalize context" )
79518055 ) ;
79528056 }
7953- super :: ResolvedSource :: Image ( image ) => {
7954- panic ! ( "expected Dockerfile source, got image {image }" ) ;
8057+ other => {
8058+ panic ! ( "expected Dockerfile source, got {other:? }" ) ;
79558059 }
79568060 }
79578061 }
@@ -7976,12 +8080,101 @@ mod tests {
79768080
79778081 match resolve_from ( image_ref) . expect ( "expected image source" ) {
79788082 super :: ResolvedSource :: Image ( image) => assert_eq ! ( image, image_ref) ,
7979- super :: ResolvedSource :: Dockerfile { .. } => {
7980- panic ! ( "expected image ref, got Dockerfile source" ) ;
8083+ other => {
8084+ panic ! ( "expected image ref, got {other:?}" ) ;
8085+ }
8086+ }
8087+ }
8088+
8089+ #[ test]
8090+ fn resolve_from_classifies_tar_archive ( ) {
8091+ let temp = tempfile:: tempdir ( ) . expect ( "failed to create tempdir" ) ;
8092+ let archive = temp. path ( ) . join ( "rootfs.tar" ) ;
8093+ fs:: write ( & archive, b"fake tar content" ) . expect ( "failed to write archive" ) ;
8094+
8095+ match resolve_from ( archive. to_str ( ) . expect ( "temp path is not UTF-8" ) )
8096+ . expect ( "expected RootfsTar source" )
8097+ {
8098+ super :: ResolvedSource :: RootfsTar { path } => {
8099+ assert_eq ! (
8100+ path,
8101+ archive
8102+ . canonicalize( )
8103+ . expect( "failed to canonicalize archive" )
8104+ ) ;
79818105 }
8106+ other => panic ! ( "expected RootfsTar source, got {other:?}" ) ,
79828107 }
79838108 }
79848109
8110+ #[ test]
8111+ fn resolve_from_classifies_tar_gz_archive ( ) {
8112+ let temp = tempfile:: tempdir ( ) . expect ( "failed to create tempdir" ) ;
8113+ let archive = temp. path ( ) . join ( "rootfs.tar.gz" ) ;
8114+ fs:: write ( & archive, b"fake tar.gz content" ) . expect ( "failed to write archive" ) ;
8115+
8116+ match resolve_from ( archive. to_str ( ) . expect ( "temp path is not UTF-8" ) )
8117+ . expect ( "expected RootfsTar source" )
8118+ {
8119+ super :: ResolvedSource :: RootfsTar { path } => {
8120+ assert_eq ! (
8121+ path,
8122+ archive
8123+ . canonicalize( )
8124+ . expect( "failed to canonicalize archive" )
8125+ ) ;
8126+ }
8127+ other => panic ! ( "expected RootfsTar source, got {other:?}" ) ,
8128+ }
8129+ }
8130+
8131+ #[ test]
8132+ fn resolve_from_classifies_tgz_archive ( ) {
8133+ let temp = tempfile:: tempdir ( ) . expect ( "failed to create tempdir" ) ;
8134+ let archive = temp. path ( ) . join ( "rootfs.tgz" ) ;
8135+ fs:: write ( & archive, b"fake tgz content" ) . expect ( "failed to write archive" ) ;
8136+
8137+ match resolve_from ( archive. to_str ( ) . expect ( "temp path is not UTF-8" ) )
8138+ . expect ( "expected RootfsTar source" )
8139+ {
8140+ super :: ResolvedSource :: RootfsTar { path } => {
8141+ assert_eq ! (
8142+ path,
8143+ archive
8144+ . canonicalize( )
8145+ . expect( "failed to canonicalize archive" )
8146+ ) ;
8147+ }
8148+ other => panic ! ( "expected RootfsTar source, got {other:?}" ) ,
8149+ }
8150+ }
8151+
8152+ #[ test]
8153+ fn resolve_from_rejects_missing_tar_archive ( ) {
8154+ let temp = tempfile:: tempdir ( ) . expect ( "failed to create tempdir" ) ;
8155+ let missing = temp. path ( ) . join ( "missing.tar" ) ;
8156+
8157+ let err = resolve_from ( missing. to_str ( ) . expect ( "temp path is not UTF-8" ) )
8158+ . expect_err ( "expected missing archive to be rejected" ) ;
8159+
8160+ assert ! (
8161+ err. to_string( ) . contains( "local --from path does not exist" ) ,
8162+ "unexpected error: {err}"
8163+ ) ;
8164+ }
8165+
8166+ #[ test]
8167+ fn filename_looks_like_rootfs_tar_detects_extensions ( ) {
8168+ use super :: filename_looks_like_rootfs_tar;
8169+ assert ! ( filename_looks_like_rootfs_tar( Path :: new( "rootfs.tar" ) ) ) ;
8170+ assert ! ( filename_looks_like_rootfs_tar( Path :: new( "rootfs.tar.gz" ) ) ) ;
8171+ assert ! ( filename_looks_like_rootfs_tar( Path :: new( "rootfs.tgz" ) ) ) ;
8172+ assert ! ( filename_looks_like_rootfs_tar( Path :: new( "IMAGE.TAR" ) ) ) ;
8173+ assert ! ( filename_looks_like_rootfs_tar( Path :: new( "my-image.TAR.GZ" ) ) ) ;
8174+ assert ! ( !filename_looks_like_rootfs_tar( Path :: new( "Dockerfile" ) ) ) ;
8175+ assert ! ( !filename_looks_like_rootfs_tar( Path :: new( "image.zip" ) ) ) ;
8176+ }
8177+
79858178 #[ test]
79868179 fn dockerfile_sources_are_rejected_for_remote_gateways ( ) {
79878180 let metadata = GatewayMetadata {
0 commit comments