From fa28e69711c2f0e5fe97ec490ddde3c99054c904 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 2 Jul 2026 12:10:45 +0000 Subject: [PATCH 1/2] feat: add --image-mount flag to bake images-to-mount init scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for loading images-to-mount YAML files and appending their shell init snippets to /sandbox/.bashrc and /sandbox/.zshrc inside the built image. - src/image_mount.rs (new): parse images-to-mount YAML files (local path or URL), derive the mount name from the filename stem, and replace $MOUNT with /sandbox/mnt/ in the init value. - src/containerfile.rs: introduce ContainerfileOptions struct to replace the positional argument list in generate(); add image_mount_inits field that emits printf calls appending each resolved init snippet to .bashrc and .zshrc in the same RUN layer that creates the profile files; add init_for_printf() helper that escapes backslashes, newlines, and single quotes for safe use in a single-quoted shell printf argument; add unit tests covering ordering, multiple mounts, single-quote escaping, and the no-mount-no-zshrc invariant. - src/main.rs: add --image-mount CLI flag (clap Append action, repeatable); thread image_mounts through run(); add unit tests for single mount, multiple mounts, and invalid path error. - tests/integration_test.rs: add image_mount module with integration tests (marked #[ignore] for those requiring podman) covering .bashrc and .zshrc content, sandbox ownership, and absence of .zshrc when the flag is not used; add non-ignored binary smoke-test for the invalid-path error path; register cleanup of the new test image tag. - README.md: document the new flag — YAML format, $MOUNT substitution rule, files written, CLI examples, and option table entry. Co-authored-by: Claude Signed-off-by: Philippe Martin --- README.md | 51 ++++++- src/containerfile.rs | 291 ++++++++++++++++++++++++++++++++++---- src/image_mount.rs | 190 +++++++++++++++++++++++++ src/main.rs | 121 +++++++++++++++- tests/integration_test.rs | 110 ++++++++++++++ 5 files changed, 731 insertions(+), 32 deletions(-) create mode 100644 src/image_mount.rs diff --git a/README.md b/README.md index 112bc3f..99b02da 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OpenShell ships a set of [pre-built sandbox images](https://github.com/NVIDIA/OpenShell-Community), but they are general-purpose. `openshell-image-builder` lets you build your own: lightweight, workspace-specific images that contain only what you need — without writing a Containerfile by hand. -The tool assembles the image in layers — base image, agent installation, agent settings, OpenShell network policy, and project-specific toolchains. Use `--runtime` to select which container CLI drives the build (`podman`, `docker`, or the macOS `container` CLI): +The tool assembles the image in layers — base image, agent installation, agent settings, OpenShell network policy, project-specific toolchains, and image-mount init scripts. Use `--runtime` to select which container CLI drives the build (`podman`, `docker`, or the macOS `container` CLI): 1. **Base image** — Ubuntu, Fedora, Red Hat UBI, or Red Hat Hardened Images (HummingBird), any tag. Ubuntu 24.04 is the default. 2. **Agent installation** (`--agent`) — the agent binary is pre-installed in `PATH`. @@ -21,6 +21,7 @@ The tool assembles the image in layers — base image, agent installation, agent - **Inference network rules** — LLM backend endpoints are added by `--inference`. - **Workspace network rules** — user-defined hosts declared in `.kaiden/workspace.json` are added to the policy when `--with-workspace-config` is used. 5. **Installation of project-specific toolchains** — toolchains and utilities declared as Dev Container Features in `.kaiden/workspace.json` are installed in the image when `--with-workspace-config` is used. +6. **Image-mount init scripts** (`--image-mount`) — shell init snippets from images-to-mount YAML files are appended to `/sandbox/.bashrc` and `/sandbox/.zshrc`. ### workspace.json fields @@ -498,6 +499,53 @@ An invalid or unparseable host entry (e.g. a bare space or malformed URL) causes With this configuration, `cargo build` and `cargo fetch` inside the sandbox can download crate metadata and source tarballs. +## Image-mount init scripts + +Use `--image-mount` to bake the initialisation snippet from an [images-to-mount](https://github.com/feloy/images-to-mount) YAML file into the image's shell startup files. The flag can be repeated to process multiple YAML files. + +### YAML format + +```yaml +image: docker.io/curlimages/curl:latest # the image to mount — not used by this tool +init: export PATH=$MOUNT/usr/bin:$PATH # shell snippet added to .bashrc and .zshrc +``` + +The `image` field is present in the file but is ignored by `openshell-image-builder`. Only the `init` field is read. + +`$MOUNT` in the `init` value is replaced at build time with `/sandbox/mnt/`, where `` is the filename stem of the YAML file — for example, `curl.yaml` → `/sandbox/mnt/curl`. + +### What gets written + +The resolved `init` snippet (with `$MOUNT` replaced) is appended to: + +- `/sandbox/.bashrc` — sourced by interactive bash sessions +- `/sandbox/.zshrc` — sourced by interactive zsh sessions (created if it does not already exist) + +The append happens in the same `RUN` layer that creates the profile files, so both files are owned by the `sandbox` user. + +### Example + +```sh +# From a local YAML file — mount name derived from filename: "curl" +openshell-image-builder \ + --runtime podman \ + --image-mount /path/to/curl.yaml \ + myimage:latest + +# From a URL +openshell-image-builder \ + --runtime podman \ + --image-mount https://raw.githubusercontent.com/feloy/images-to-mount/main/curl.yaml \ + myimage:latest + +# Multiple mounts — the flag can be repeated +openshell-image-builder \ + --runtime podman \ + --image-mount /path/to/curl.yaml \ + --image-mount /path/to/jq.yaml \ + myimage:latest +``` + ## Full option reference ``` @@ -516,6 +564,7 @@ openshell-image-builder [OPTIONS] | `--with-workspace-config` | Read `.kaiden/workspace.json` and apply its features, skills, and network rules | | `--with-policy` | Include OpenShell sandbox policy (`/etc/openshell/policy.yaml`) in the image | | `--with-agent-settings` | Generate and include agent settings in the image (see [Agent settings](#agent-settings)) | +| `--image-mount ` | Append the `init` snippet from an images-to-mount YAML file to `.bashrc` and `.zshrc`; may be repeated (see [Image-mount init scripts](#image-mount-init-scripts)) | | `-v` / `-vv` | Increase log verbosity (info / debug) | ## Examples diff --git a/src/containerfile.rs b/src/containerfile.rs index 707cb12..a58669e 100644 --- a/src/containerfile.rs +++ b/src/containerfile.rs @@ -37,14 +37,21 @@ impl std::fmt::Display for ContainerfileError { impl std::error::Error for ContainerfileError {} +/// Options forwarded from the CLI that control the content of the generated Containerfile. +pub struct ContainerfileOptions<'a> { + pub agent: Option<&'a dyn Agent>, + pub features: &'a [StagedFeature], + pub with_agent_settings: bool, + pub skill_names: &'a [String], + pub env_vars: &'a HashMap, + pub with_policy: bool, + /// Shell init snippets, one per `--image-mount` YAML, with `$MOUNT` already resolved. + pub image_mount_inits: &'a [String], +} + pub fn generate( config: &Config, - agent: Option<&dyn Agent>, - features: &[StagedFeature], - with_agent_settings: bool, - skill_names: &[String], - env_vars: &HashMap, - with_policy: bool, + opts: &ContainerfileOptions<'_>, ) -> Result { let tag = &config.base_image.tag; let system_stage = match config.base_image.image.as_str() { @@ -104,12 +111,13 @@ pub fn generate( Ok(format!( "{system_stage}\n{}", final_stage( - agent, - features, - with_agent_settings, - skill_names, - env_vars, - with_policy + opts.agent, + opts.features, + opts.with_agent_settings, + opts.skill_names, + opts.env_vars, + opts.with_policy, + opts.image_mount_inits, ) )) } @@ -245,6 +253,17 @@ RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supe ) } +/// Escapes `init` for use as the format string in a shell `printf '...\n'` call. +/// +/// - Backslashes are doubled so printf does not interpret them as escape sequences. +/// - Real newline characters are replaced with `\n` for printf to output as newlines. +/// - Single quotes are escaped for the surrounding single-quoted shell string. +fn init_for_printf(init: &str) -> String { + init.replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\'', "'\\''") +} + fn final_stage( agent: Option<&dyn Agent>, features: &[StagedFeature], @@ -252,6 +271,7 @@ fn final_stage( skill_names: &[String], env_vars: &HashMap, with_policy: bool, + image_mount_inits: &[String], ) -> String { let agent_section = agent .map(|a| format!("{}\n\n", a.install())) @@ -280,13 +300,26 @@ fn final_stage( } else { "" }; + // Build the shell snippet that appends each image-mount init to .bashrc and .zshrc. + // Each init line is emitted as a printf call so that $VAR references in the init + // are written literally (single-quoted) and expanded at container runtime. + let mount_init_snippet: String = image_mount_inits + .iter() + .filter(|s| !s.trim().is_empty()) + .map(|init| { + let escaped = init_for_printf(init.trim()); + // Regular (non-raw) string: \\\n → backslash + newline (Dockerfile continuation) + // \\n → backslash-n two chars (printf newline escape) + format!(" && \\\n printf '{escaped}\\n' >> /sandbox/.bashrc && \\\n printf '{escaped}\\n' >> /sandbox/.zshrc") + }) + .collect(); format!( r#"# Final base image FROM system AS final {features_section}{policy_section}RUN printf 'export PS1="\\u@\\h:\\w\\$ "\n' \ > /sandbox/.bashrc && \ - printf '[ -f ~/.bashrc ] && . ~/.bashrc\n' > /sandbox/.profile && \ + printf '[ -f ~/.bashrc ] && . ~/.bashrc\n' > /sandbox/.profile{mount_init_snippet} && \ chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile && \ chown -R sandbox:sandbox /sandbox @@ -314,12 +347,15 @@ mod tests { ) -> Result { generate( config, - agent, - features, - with_agent_settings, - skill_names, - &HashMap::new(), - with_policy, + &ContainerfileOptions { + agent, + features, + with_agent_settings, + skill_names, + env_vars: &HashMap::new(), + with_policy, + image_mount_inits: &[], + }, ) } @@ -985,8 +1021,19 @@ mod tests { "ANTHROPIC_BASE_URL".to_string(), "https://proxy.example.com".to_string(), ); - let content = - generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars, false).unwrap(); + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &vars, + with_policy: false, + image_mount_inits: &[], + }, + ) + .unwrap(); assert!(content.contains("ENV ANTHROPIC_BASE_URL=\"https://proxy.example.com\"")); } @@ -997,8 +1044,19 @@ mod tests { "ANTHROPIC_BASE_URL".to_string(), "https://proxy.example.com".to_string(), ); - let content = - generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars, false).unwrap(); + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &vars, + with_policy: false, + image_mount_inits: &[], + }, + ) + .unwrap(); let user_pos = content.find("USER sandbox").unwrap(); let env_pos = content.find("ENV ANTHROPIC_BASE_URL=").unwrap(); assert!( @@ -1019,10 +1077,193 @@ mod tests { let mut vars = HashMap::new(); vars.insert("Z_VAR".to_string(), "z".to_string()); vars.insert("A_VAR".to_string(), "a".to_string()); - let content = - generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars, false).unwrap(); + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &vars, + with_policy: false, + image_mount_inits: &[], + }, + ) + .unwrap(); let a_pos = content.find("ENV A_VAR=").unwrap(); let z_pos = content.find("ENV Z_VAR=").unwrap(); assert!(a_pos < z_pos, "env vars must be sorted alphabetically"); } + + // image_mount_inits + + #[test] + fn image_mount_init_appended_to_bashrc() { + let inits = vec!["export PATH=/sandbox/mnt/curl/usr/bin:$PATH".to_string()]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + assert!(content.contains( + "printf 'export PATH=/sandbox/mnt/curl/usr/bin:$PATH\\n' >> /sandbox/.bashrc" + )); + } + + #[test] + fn image_mount_init_appended_to_zshrc() { + let inits = vec!["export PATH=/sandbox/mnt/curl/usr/bin:$PATH".to_string()]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + assert!(content.contains( + "printf 'export PATH=/sandbox/mnt/curl/usr/bin:$PATH\\n' >> /sandbox/.zshrc" + )); + } + + #[test] + fn image_mount_init_appears_before_chown() { + let inits = vec!["export PATH=/sandbox/mnt/curl/usr/bin:$PATH".to_string()]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + let init_pos = content + .find("printf 'export PATH=/sandbox/mnt/curl/usr/bin:$PATH\\n' >> /sandbox/.bashrc") + .unwrap(); + let chown_pos = content + .find("chown sandbox:sandbox /sandbox/.bashrc") + .unwrap(); + assert!( + init_pos < chown_pos, + "image-mount init printf must appear before chown" + ); + } + + #[test] + fn image_mount_init_appears_after_profile_creation() { + let inits = vec!["export PATH=/sandbox/mnt/curl/usr/bin:$PATH".to_string()]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + let profile_pos = content.find("> /sandbox/.profile").unwrap(); + let init_pos = content.find(">> /sandbox/.bashrc").unwrap(); + assert!( + init_pos > profile_pos, + "image-mount init printf must appear after profile creation" + ); + } + + #[test] + fn no_image_mount_produces_no_zshrc_line() { + let content = build_cf(&ubuntu_config("24.04"), None, &[], false, &[], false).unwrap(); + assert!( + !content.contains(".zshrc"), + "no .zshrc line expected when no --image-mount is given" + ); + } + + #[test] + fn multiple_image_mounts_each_produce_bashrc_and_zshrc_lines() { + let inits = vec![ + "export PATH=/sandbox/mnt/curl/usr/bin:$PATH".to_string(), + "export PATH=/sandbox/mnt/jq/usr/bin:$PATH".to_string(), + ]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + assert!(content.contains("export PATH=/sandbox/mnt/curl/usr/bin:$PATH")); + assert!(content.contains("export PATH=/sandbox/mnt/jq/usr/bin:$PATH")); + // Both must appear twice (once for .bashrc, once for .zshrc) + assert_eq!( + content + .matches("export PATH=/sandbox/mnt/curl/usr/bin:$PATH") + .count(), + 2, + "curl init must appear twice (bashrc + zshrc)" + ); + assert_eq!( + content + .matches("export PATH=/sandbox/mnt/jq/usr/bin:$PATH") + .count(), + 2, + "jq init must appear twice (bashrc + zshrc)" + ); + } + + #[test] + fn image_mount_init_single_quote_escaped() { + let inits = vec!["export X='hello'".to_string()]; + let content = generate( + &ubuntu_config("24.04"), + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + // Single quotes inside a single-quoted shell string are escaped as '\'' + assert!( + content.contains("export X='\\''hello'\\''"), + "single quotes in init must be escaped for shell" + ); + } + + #[test] + fn empty_image_mount_inits_produces_no_zshrc() { + let content = build_cf(&ubuntu_config("24.04"), None, &[], false, &[], false).unwrap(); + assert!(!content.contains(".zshrc")); + } } diff --git a/src/image_mount.rs b/src/image_mount.rs new file mode 100644 index 0000000..4b6065f --- /dev/null +++ b/src/image_mount.rs @@ -0,0 +1,190 @@ +// Copyright (C) 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use serde::Deserialize; + +/// The structure of the images-to-mount YAML file. +/// The `image` field is present in the file but is not used by this tool. +#[derive(Deserialize)] +struct ImageMountFile { + #[allow(dead_code)] + image: Option, + init: Option, +} + +/// Derives the mount name from a local path or URL. +/// +/// The name is the last path component with any `.yaml` or `.yml` extension +/// stripped. For example `/some/path/curl.yaml` → `"curl"`. +pub fn mount_name(path_or_url: &str) -> Option { + let last = path_or_url.rsplit('/').next()?; + let stem = last + .strip_suffix(".yaml") + .or_else(|| last.strip_suffix(".yml")) + .unwrap_or(last); + if stem.is_empty() { + None + } else { + Some(stem.to_string()) + } +} + +fn load_yaml_content(path_or_url: &str) -> Result> { + if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") { + Ok(ureq::get(path_or_url).call()?.into_string()?) + } else { + Ok(std::fs::read_to_string(path_or_url)?) + } +} + +/// Loads an images-to-mount YAML file (from a local path or URL) and returns +/// the `init` value with every `$MOUNT` placeholder replaced by +/// `/sandbox/mnt/`, where `` is the file stem of `path_or_url`. +pub fn load_init(path_or_url: &str) -> Result> { + let name = mount_name(path_or_url).ok_or_else(|| { + format!("--image-mount: cannot determine mount name from '{path_or_url}'") + })?; + let content = load_yaml_content(path_or_url)?; + let file: ImageMountFile = serde_yml::from_str(&content) + .map_err(|e| format!("--image-mount: invalid YAML in '{path_or_url}': {e}"))?; + let raw_init = file.init.unwrap_or_default(); + let mount = format!("/sandbox/mnt/{name}"); + Ok(raw_init.trim_end().replace("$MOUNT", &mount)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // mount_name + + #[test] + fn mount_name_strips_yaml_extension_from_filename() { + assert_eq!(mount_name("curl.yaml"), Some("curl".to_string())); + } + + #[test] + fn mount_name_strips_yml_extension_from_filename() { + assert_eq!(mount_name("curl.yml"), Some("curl".to_string())); + } + + #[test] + fn mount_name_strips_extension_from_absolute_path() { + assert_eq!( + mount_name("/some/path/to/curl.yaml"), + Some("curl".to_string()) + ); + } + + #[test] + fn mount_name_strips_extension_from_url() { + assert_eq!( + mount_name("https://example.com/curl.yaml"), + Some("curl".to_string()) + ); + } + + #[test] + fn mount_name_strips_extension_from_github_raw_url() { + assert_eq!( + mount_name("https://raw.githubusercontent.com/feloy/images-to-mount/main/curl.yaml"), + Some("curl".to_string()) + ); + } + + #[test] + fn mount_name_returns_stem_without_extension() { + assert_eq!(mount_name("my-tool"), Some("my-tool".to_string())); + } + + #[test] + fn mount_name_returns_none_for_empty_stem() { + assert_eq!(mount_name(".yaml"), None); + } + + // load_init + + #[test] + fn load_init_replaces_mount_placeholder() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("curl.yaml"); + std::fs::write( + &path, + "image: docker.io/curlimages/curl:latest\ninit: export PATH=$MOUNT/usr/bin:$PATH\n", + ) + .unwrap(); + let init = load_init(path.to_str().unwrap()).unwrap(); + assert_eq!(init, "export PATH=/sandbox/mnt/curl/usr/bin:$PATH"); + } + + #[test] + fn load_init_uses_filename_stem_as_mount_name() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("my-tool.yaml"); + std::fs::write(&path, "image: example\ninit: source $MOUNT/init.sh\n").unwrap(); + let init = load_init(path.to_str().unwrap()).unwrap(); + assert_eq!(init, "source /sandbox/mnt/my-tool/init.sh"); + } + + #[test] + fn load_init_trims_trailing_whitespace() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tool.yaml"); + std::fs::write(&path, "image: example\ninit: \"export X=$MOUNT \"\n").unwrap(); + let init = load_init(path.to_str().unwrap()).unwrap(); + assert_eq!(init, "export X=/sandbox/mnt/tool"); + } + + #[test] + fn load_init_handles_missing_init_field() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tool.yaml"); + std::fs::write(&path, "image: example\n").unwrap(); + let init = load_init(path.to_str().unwrap()).unwrap(); + assert_eq!(init, ""); + } + + #[test] + fn load_init_handles_multiline_init() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tool.yaml"); + std::fs::write( + &path, + "image: example\ninit: |\n export PATH=$MOUNT/bin:$PATH\n export LD_LIBRARY_PATH=$MOUNT/lib:$LD_LIBRARY_PATH\n", + ) + .unwrap(); + let init = load_init(path.to_str().unwrap()).unwrap(); + assert_eq!( + init, + "export PATH=/sandbox/mnt/tool/bin:$PATH\nexport LD_LIBRARY_PATH=/sandbox/mnt/tool/lib:$LD_LIBRARY_PATH" + ); + } + + #[test] + fn load_init_fails_on_missing_file() { + let result = load_init("/nonexistent/path/tool.yaml"); + assert!(result.is_err()); + } + + #[test] + fn load_init_fails_on_invalid_yaml() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tool.yaml"); + std::fs::write(&path, "not: valid: yaml: [[[").unwrap(); + let result = load_init(path.to_str().unwrap()); + assert!(result.is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 89c0e44..858cddd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ mod config; mod containerfile; mod feature; mod host; +mod image_mount; mod inference; mod policy; mod workspace; @@ -100,6 +101,12 @@ struct Cli { with_policy: bool, #[arg(long, help = "Generate and include agent settings in the image")] with_agent_settings: bool, + #[arg( + long, + action = clap::ArgAction::Append, + help = "Path or URL to a YAML file describing a container image to mount (may be repeated)" + )] + image_mount: Vec, } fn main() { @@ -129,6 +136,10 @@ fn main() { cli.model.as_deref(), cli.with_policy, cli.with_agent_settings, + &cli.image_mount + .iter() + .map(|s| s.as_str()) + .collect::>(), &container_cli, &ContainerRunner, ) { @@ -148,6 +159,7 @@ fn run( model: Option<&str>, with_policy: bool, with_agent_settings: bool, + image_mounts: &[&str], runtime: &ContainerCli, runner: &dyn Runner, ) -> Result<(), Box> { @@ -208,14 +220,21 @@ fn run( )?; std::fs::write(context_dir.path().join("policy.yaml"), policy_yaml)?; } + let image_mount_inits: Vec = image_mounts + .iter() + .map(|path_or_url| image_mount::load_init(path_or_url)) + .collect::>()?; let output = containerfile::generate( &config, - agent.as_deref(), - &features, - has_agent_settings, - &skill_names, - &agent_env_vars, - with_policy, + &containerfile::ContainerfileOptions { + agent: agent.as_deref(), + features: &features, + with_agent_settings: has_agent_settings, + skill_names: &skill_names, + env_vars: &agent_env_vars, + with_policy, + image_mount_inits: &image_mount_inits, + }, )?; build(&output, tag, runtime, runner, context_dir.path())?; Ok(()) @@ -863,6 +882,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -882,6 +902,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -901,6 +922,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -920,6 +942,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -945,6 +968,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(1), ); @@ -964,6 +988,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -989,6 +1014,7 @@ mod tests { Some("claude-opus-4-5"), false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1150,6 +1176,7 @@ mod tests { None, true, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1169,6 +1196,7 @@ mod tests { None, false, true, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1188,6 +1216,7 @@ mod tests { None, false, false, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1200,6 +1229,86 @@ mod tests { ); } + #[test] + fn run_with_image_mount_from_file_succeeds() { + let yaml_dir = tempfile::tempdir().unwrap(); + let yaml_path = yaml_dir.path().join("curl.yaml"); + std::fs::write( + &yaml_path, + "image: docker.io/curlimages/curl:latest\ninit: export PATH=$MOUNT/usr/bin:$PATH\n", + ) + .unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let result = run( + "test:latest", + Some(tmp.path().to_path_buf()), + false, + None, + None, + None, + None, + false, + false, + &[yaml_path.to_str().unwrap()], + &ContainerCli::Podman, + &FakeRunner(0), + ); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + } + + #[test] + fn run_with_multiple_image_mounts_succeeds() { + let yaml_dir = tempfile::tempdir().unwrap(); + let curl_path = yaml_dir.path().join("curl.yaml"); + let jq_path = yaml_dir.path().join("jq.yaml"); + std::fs::write( + &curl_path, + "image: docker.io/curlimages/curl:latest\ninit: export PATH=$MOUNT/usr/bin:$PATH\n", + ) + .unwrap(); + std::fs::write( + &jq_path, + "image: ghcr.io/jqlang/jq:latest\ninit: export PATH=$MOUNT/bin:$PATH\n", + ) + .unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let result = run( + "test:latest", + Some(tmp.path().to_path_buf()), + false, + None, + None, + None, + None, + false, + false, + &[curl_path.to_str().unwrap(), jq_path.to_str().unwrap()], + &ContainerCli::Podman, + &FakeRunner(0), + ); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + } + + #[test] + fn run_with_image_mount_invalid_path_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let result = run( + "test:latest", + Some(tmp.path().to_path_buf()), + false, + None, + None, + None, + None, + false, + false, + &["/nonexistent/path/tool.yaml"], + &ContainerCli::Podman, + &FakeRunner(0), + ); + assert!(result.is_err(), "expected Err for missing image-mount file"); + } + #[test] fn stage_agent_settings_with_openai_and_model_creates_opencode_config() { let context = tempfile::tempdir().unwrap(); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index f9111d6..9e9d94f 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -115,6 +115,7 @@ static HUMMINGBIRD_OPENCODE_OPENAI_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_OPENCODE_OPENAI_MODEL_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_NO_POLICY_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_CLAUDE_NO_AGENT_SETTINGS_IMAGE: OnceLock = OnceLock::new(); +static UBUNTU_IMAGE_MOUNT_IMAGE: OnceLock = OnceLock::new(); fn config_dir_with_agent_settings(agent: &str, files: &[(&str, &str)]) -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); @@ -1330,6 +1331,25 @@ fn ubuntu_claude_no_agent_settings_image() -> &'static str { }) } +fn ubuntu_image_mount_image() -> &'static str { + UBUNTU_IMAGE_MOUNT_IMAGE.get_or_init(|| { + // Write a temporary YAML file with a known name so the mount name is predictable. + let dir = tempfile::tempdir().unwrap(); + let yaml_path = dir.path().join("curl.yaml"); + std::fs::write( + &yaml_path, + "image: docker.io/curlimages/curl:latest\ninit: export PATH=$MOUNT/usr/bin:$PATH\n", + ) + .unwrap(); + let tag = build_image( + "openshell-test-ubuntu-image-mount:integration", + &["--image-mount", yaml_path.to_str().unwrap()], + ); + // `dir` is dropped here; the image is already built so the file is no longer needed. + tag + }) +} + // --------------------------------------------------------------------------- // Feature integration tests — one macro per feature, instantiated per base image // --------------------------------------------------------------------------- @@ -2649,6 +2669,95 @@ mod with_agent_settings { } } +// --------------------------------------------------------------------------- +// --image-mount flag tests +// --------------------------------------------------------------------------- + +mod image_mount { + use super::*; + + #[test] + #[ignore] + fn bashrc_contains_init_with_resolved_mount_path() { + let out = run_in_image( + ubuntu_image_mount_image(), + "grep -q 'export PATH=/sandbox/mnt/curl/usr/bin' /sandbox/.bashrc", + ); + assert!( + out.status.success(), + "resolved init not found in /sandbox/.bashrc" + ); + } + + #[test] + #[ignore] + fn zshrc_contains_init_with_resolved_mount_path() { + let out = run_in_image( + ubuntu_image_mount_image(), + "grep -q 'export PATH=/sandbox/mnt/curl/usr/bin' /sandbox/.zshrc", + ); + assert!( + out.status.success(), + "resolved init not found in /sandbox/.zshrc" + ); + } + + #[test] + #[ignore] + fn bashrc_owned_by_sandbox() { + let out = run_in_image(ubuntu_image_mount_image(), "stat -c '%U' /sandbox/.bashrc"); + assert!(out.status.success(), "failed to stat /sandbox/.bashrc"); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "sandbox", + "/sandbox/.bashrc not owned by sandbox" + ); + } + + #[test] + #[ignore] + fn zshrc_owned_by_sandbox() { + let out = run_in_image(ubuntu_image_mount_image(), "stat -c '%U' /sandbox/.zshrc"); + assert!(out.status.success(), "failed to stat /sandbox/.zshrc"); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "sandbox", + "/sandbox/.zshrc not owned by sandbox" + ); + } + + #[test] + #[ignore] + fn base_image_without_flag_has_no_zshrc() { + // The base ubuntu image (built without --image-mount) must not have .zshrc. + let out = run_in_image(ubuntu_image(), "test -f /sandbox/.zshrc"); + assert!( + !out.status.success(), + "/sandbox/.zshrc should not exist in an image built without --image-mount" + ); + } + + // Rejection test: invalid path — does not call podman, so no #[ignore]. + #[test] + fn invalid_path_exits_nonzero() { + let binary = env!("CARGO_BIN_EXE_openshell-image-builder"); + let output = Command::new(binary) + .args([ + "--runtime", + "podman", + "--image-mount", + "/nonexistent/path/tool.yaml", + "should-not-be-built:test", + ]) + .output() + .expect("binary should run"); + assert!( + !output.status.success(), + "--image-mount with nonexistent file must exit non-zero" + ); + } +} + // --------------------------------------------------------------------------- // Cleanup — runs when the test process exits, after all tests complete // --------------------------------------------------------------------------- @@ -2711,6 +2820,7 @@ fn cleanup_images() { "openshell-test-no-workspace-config-network-hosts-ubuntu:integration", "openshell-test-ubuntu-no-policy:integration", "openshell-test-ubuntu-claude-no-agent-settings:integration", + "openshell-test-ubuntu-image-mount:integration", ] { Command::new("podman") .args(["rmi", "--force", tag]) From 669246778bf7b6f65977e96c08954399f6b8fb6c Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 2 Jul 2026 14:26:19 +0000 Subject: [PATCH 2/2] fix(image_mount): use std::path::Path for local path filename extraction mount_name() was using rsplit('/') to extract the filename from all inputs. On Windows, local temp paths use backslash separators, so the entire path was returned as the "last component" instead of just the filename, producing broken mount paths like: /sandbox/mnt/C:\Users\...\curl.yaml Fix by delegating to std::path::Path::file_name() for local paths, which handles OS-native separators correctly. URL inputs (http/https) continue to use rsplit('/') since URL paths always use forward slashes. Co-authored-by: Claude Signed-off-by: Philippe Martin --- src/image_mount.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/image_mount.rs b/src/image_mount.rs index 4b6065f..e00c31d 100644 --- a/src/image_mount.rs +++ b/src/image_mount.rs @@ -14,6 +14,8 @@ // // SPDX-License-Identifier: Apache-2.0 +use std::path::Path; + use serde::Deserialize; /// The structure of the images-to-mount YAML file. @@ -30,16 +32,23 @@ struct ImageMountFile { /// The name is the last path component with any `.yaml` or `.yml` extension /// stripped. For example `/some/path/curl.yaml` → `"curl"`. pub fn mount_name(path_or_url: &str) -> Option { - let last = path_or_url.rsplit('/').next()?; + // For URLs (which always use '/'), extract the last '/'-delimited segment. + // For local filesystem paths, delegate to std::path::Path so that the + // OS-native separator (e.g. '\' on Windows) is handled correctly. + let last = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") { + path_or_url.rsplit('/').next()?.to_string() + } else { + Path::new(path_or_url) + .file_name()? + .to_string_lossy() + .into_owned() + }; let stem = last .strip_suffix(".yaml") .or_else(|| last.strip_suffix(".yml")) - .unwrap_or(last); - if stem.is_empty() { - None - } else { - Some(stem.to_string()) - } + .unwrap_or(&last) + .to_string(); + if stem.is_empty() { None } else { Some(stem) } } fn load_yaml_content(path_or_url: &str) -> Result> {