From 296188590790bbd25974d69ecbb774e22adb4d18 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 2 Jul 2026 12:10:45 +0000 Subject: [PATCH 1/3] 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 | 329 ++++++++++++++++++++++++++++++++------ src/image_mount.rs | 190 ++++++++++++++++++++++ src/main.rs | 128 ++++++++++++++- tests/integration_test.rs | 108 +++++++++++++ 5 files changed, 750 insertions(+), 56 deletions(-) create mode 100644 src/image_mount.rs diff --git a/README.md b/README.md index 6e2ee95..b7ab5c4 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 @@ -558,6 +559,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 ``` @@ -578,6 +626,7 @@ openshell-image-builder [OPTIONS] | `--with-agent-settings` | Generate and include agent settings in the image (see [Agent settings](#agent-settings)) | | `--ssl-certs ` | Use a specific CA bundle instead of the auto-discovered one (see [Corporate proxy support](#corporate-proxy-support---ssl-certs)). The build fails immediately if the file does not exist. | | `--disable-ssl-certs` | Disable bundling CA certificates into the image. By default, the tool auto-discovers and includes system CA certificates. | +| `--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 160e9de..4927516 100644 --- a/src/containerfile.rs +++ b/src/containerfile.rs @@ -37,16 +37,22 @@ impl std::fmt::Display for ContainerfileError { impl std::error::Error for ContainerfileError {} -#[allow(clippy::too_many_arguments)] +/// 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, + pub with_ca_certs: 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, - with_ca_certs: bool, + opts: &ContainerfileOptions<'_>, ) -> Result { let tag = &config.base_image.tag; let system_stage = match config.base_image.image.as_str() { @@ -68,7 +74,7 @@ pub fn generate( "traceroute", "which", ], - with_ca_certs, + opts.with_ca_certs, ), "ubi" => dnf_system_stage( "registry.access.redhat.com/ubi10/ubi", @@ -84,7 +90,7 @@ pub fn generate( "procps-ng", "which", ], - with_ca_certs, + opts.with_ca_certs, ), "hummingbird" => dnf_system_stage( "registry.access.redhat.com/hi/core-runtime", @@ -97,9 +103,9 @@ pub fn generate( "which", "tar", ], - with_ca_certs, + opts.with_ca_certs, ), - "ubuntu" => ubuntu_system_stage(tag, with_ca_certs), + "ubuntu" => ubuntu_system_stage(tag, opts.with_ca_certs), image => { return Err(ContainerfileError::NotSupported { image: image.to_string(), @@ -109,12 +115,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, ) )) } @@ -260,6 +267,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], @@ -267,6 +285,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())) @@ -295,13 +314,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 @@ -329,18 +361,34 @@ mod tests { ) -> Result { generate( config, - agent, - features, - with_agent_settings, - skill_names, - &HashMap::new(), - with_policy, - false, + &ContainerfileOptions { + agent, + features, + with_agent_settings, + skill_names, + env_vars: &HashMap::new(), + with_policy, + with_ca_certs: false, + image_mount_inits: &[], + }, ) } fn build_cf_with_ca_certs(config: &Config) -> String { - generate(config, None, &[], false, &[], &HashMap::new(), false, true).unwrap() + generate( + config, + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &HashMap::new(), + with_policy: false, + with_ca_certs: true, + image_mount_inits: &[], + }, + ) + .unwrap() } fn ubuntu_config(tag: &str) -> Config { @@ -1007,13 +1055,16 @@ mod tests { ); let content = generate( &ubuntu_config("24.04"), - None, - &[], - false, - &[], - &vars, - false, - false, + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &vars, + with_policy: false, + with_ca_certs: false, + image_mount_inits: &[], + }, ) .unwrap(); assert!(content.contains("ENV ANTHROPIC_BASE_URL=\"https://proxy.example.com\"")); @@ -1028,13 +1079,16 @@ mod tests { ); let content = generate( &ubuntu_config("24.04"), - None, - &[], - false, - &[], - &vars, - false, - false, + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &vars, + with_policy: false, + with_ca_certs: false, + image_mount_inits: &[], + }, ) .unwrap(); let user_pos = content.find("USER sandbox").unwrap(); @@ -1059,13 +1113,16 @@ mod tests { vars.insert("A_VAR".to_string(), "a".to_string()); let content = generate( &ubuntu_config("24.04"), - None, - &[], - false, - &[], - &vars, - false, - false, + &ContainerfileOptions { + agent: None, + features: &[], + with_agent_settings: false, + skill_names: &[], + env_vars: &vars, + with_policy: false, + with_ca_certs: false, + image_mount_inits: &[], + }, ) .unwrap(); let a_pos = content.find("ENV A_VAR=").unwrap(); @@ -1151,4 +1208,180 @@ mod tests { "CA cert COPY must appear after apt-get install" ); } + + // 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, + with_ca_certs: 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, + with_ca_certs: 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, + with_ca_certs: 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, + with_ca_certs: 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, + with_ca_certs: 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")); + 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, + with_ca_certs: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + 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 544536b..7126c6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod config; mod containerfile; mod feature; mod host; +mod image_mount; mod inference; mod policy; mod workspace; @@ -115,6 +116,12 @@ struct Cli { help = "Disable bundling CA certificates into the image." )] disable_ssl_certs: 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() { @@ -139,6 +146,7 @@ fn main() { } else { Some(cli.ssl_certs.map(std::path::PathBuf::from)) }; + let image_mounts: Vec<&str> = cli.image_mount.iter().map(|s| s.as_str()).collect(); if let Err(e) = run( &cli.tag, cli.config, @@ -150,6 +158,7 @@ fn main() { cli.with_policy, cli.with_agent_settings, ssl_certs, + &image_mounts, &container_cli, &ContainerRunner, ) { @@ -170,6 +179,7 @@ fn run( with_policy: bool, with_agent_settings: bool, ssl_certs: Option>, + image_mounts: &[&str], runtime: &ContainerCli, runner: &dyn Runner, ) -> Result<(), Box> { @@ -238,15 +248,22 @@ fn run( true } }; + 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, - ca_certs_copied, + &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, + with_ca_certs: ca_certs_copied, + image_mount_inits: &image_mount_inits, + }, )?; build(&output, tag, runtime, runner, context_dir.path())?; Ok(()) @@ -913,6 +930,7 @@ mod tests { false, false, None, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -933,6 +951,7 @@ mod tests { false, false, None, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -953,6 +972,7 @@ mod tests { false, false, None, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -973,6 +993,7 @@ mod tests { false, false, None, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -999,6 +1020,7 @@ mod tests { false, false, None, + &[], &ContainerCli::Podman, &FakeRunner(1), ); @@ -1019,6 +1041,7 @@ mod tests { false, false, None, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1045,6 +1068,7 @@ mod tests { false, false, None, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1207,6 +1231,7 @@ mod tests { true, false, None, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1227,6 +1252,7 @@ mod tests { false, true, None, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1247,6 +1273,7 @@ mod tests { false, false, None, + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1259,6 +1286,89 @@ 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, + None, + &[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, + None, + &[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, + None, + &["/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(); @@ -1415,6 +1525,7 @@ mod tests { false, false, Some(None), + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1437,6 +1548,7 @@ mod tests { false, false, Some(Some(cert)), + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1457,6 +1569,7 @@ mod tests { false, false, Some(Some(PathBuf::from("/nonexistent/bundle.crt"))), + &[], &ContainerCli::Podman, &FakeRunner(0), ); @@ -1478,6 +1591,7 @@ mod tests { false, false, None, + &[], &ContainerCli::Podman, &capture, ) diff --git a/tests/integration_test.rs b/tests/integration_test.rs index e48822b..d3f5e84 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -119,6 +119,7 @@ static UBUNTU_SSL_CERTS_IMAGE: OnceLock = OnceLock::new(); static FEDORA_SSL_CERTS_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_NO_CERTS_IMAGE: OnceLock = OnceLock::new(); static FEDORA_NO_CERTS_IMAGE: OnceLock = OnceLock::new(); +static UBUNTU_IMAGE_MOUNT_IMAGE: OnceLock = OnceLock::new(); fn ssl_cert_fixture_path() -> std::path::PathBuf { std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/test-ca.crt") @@ -1389,6 +1390,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 // --------------------------------------------------------------------------- @@ -2794,6 +2814,93 @@ mod ssl_certs { } } +// --------------------------------------------------------------------------- +// --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() { + 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" + ); + } + + #[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 // --------------------------------------------------------------------------- @@ -2860,6 +2967,7 @@ fn cleanup_images() { "openshell-test-fedora-ssl-certs:integration", "openshell-test-ubuntu-no-certs:integration", "openshell-test-fedora-no-certs:integration", + "openshell-test-ubuntu-image-mount:integration", ] { Command::new("podman") .args(["rmi", "--force", tag]) From 0989dec4d7288f8e4207905703733d2ea2d1cf4b Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 2 Jul 2026 14:26:19 +0000 Subject: [PATCH 2/3] 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> { From d75e8159b05132dfb5b9270a627a988df36c07eb Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Mon, 6 Jul 2026 07:48:09 +0000 Subject: [PATCH 3/3] fix(image_mount): address correctness and stability issues - Escape % in init_for_printf so printf does not treat user-provided init text (e.g. export DATE_FMT=%Y-%m-%d) as format specifiers; add regression test. - Add explicit 30 s read/write timeouts to the ureq agent used when fetching --image-mount URLs to prevent indefinite blocking. - Reject duplicate --image-mount entries that resolve to the same mount name before calling containerfile::generate. Co-authored-by: Claude Signed-off-by: Philippe Martin --- src/containerfile.rs | 26 ++++++++++++++++++++++++++ src/image_mount.rs | 7 ++++++- src/main.rs | 11 +++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/containerfile.rs b/src/containerfile.rs index 4927516..9e0aa60 100644 --- a/src/containerfile.rs +++ b/src/containerfile.rs @@ -272,10 +272,12 @@ RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supe /// - 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. +/// - Percent signs are doubled so printf does not treat them as format specifiers. fn init_for_printf(init: &str) -> String { init.replace('\\', "\\\\") .replace('\n', "\\n") .replace('\'', "'\\''") + .replace('%', "%%") } fn final_stage( @@ -1379,6 +1381,30 @@ mod tests { ); } + #[test] + fn image_mount_init_percent_escaped() { + let inits = vec!["export DATE_FMT=%Y-%m-%d".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, + with_ca_certs: false, + image_mount_inits: &inits, + }, + ) + .unwrap(); + // % must be doubled so printf does not treat it as a format specifier + assert!( + content.contains("export DATE_FMT=%%Y-%%m-%%d"), + "percent signs in init must be doubled for printf" + ); + } + #[test] fn empty_image_mount_inits_produces_no_zshrc() { let content = build_cf(&ubuntu_config("24.04"), None, &[], false, &[], false).unwrap(); diff --git a/src/image_mount.rs b/src/image_mount.rs index e00c31d..6d63245 100644 --- a/src/image_mount.rs +++ b/src/image_mount.rs @@ -15,6 +15,7 @@ // SPDX-License-Identifier: Apache-2.0 use std::path::Path; +use std::time::Duration; use serde::Deserialize; @@ -53,7 +54,11 @@ pub fn mount_name(path_or_url: &str) -> Option { 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()?) + let agent = ureq::AgentBuilder::new() + .timeout_read(Duration::from_secs(30)) + .timeout_write(Duration::from_secs(30)) + .build(); + Ok(agent.get(path_or_url).call()?.into_string()?) } else { Ok(std::fs::read_to_string(path_or_url)?) } diff --git a/src/main.rs b/src/main.rs index 7126c6a..53a4e61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -252,6 +252,17 @@ fn run( .iter() .map(|path_or_url| image_mount::load_init(path_or_url)) .collect::>()?; + let mut seen_names = std::collections::HashSet::new(); + for path_or_url in image_mounts { + if let Some(name) = image_mount::mount_name(path_or_url) + && !seen_names.insert(name.clone()) + { + return Err(format!( + "--image-mount: duplicate mount name '{name}' derived from '{path_or_url}'" + ) + .into()); + } + } let output = containerfile::generate( &config, &containerfile::ContainerfileOptions {