diff --git a/CLAUDE.md b/CLAUDE.md index 3a72ed1..a94734d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ Content-addressed caching keyed by lockfile SHA256 hash. **States:** - `Miss` -> create volume, mount read-write - `Building` -> mount read-write (resume after crash) -- `Complete` -> mount read-only (immutable) +- `Complete` -> skip re-finalization, eligible for GC **Volume naming:** `mino-cache-{ecosystem}-{hash12}` diff --git a/README.md b/README.md index 13ab1e1..2733494 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ Mino automatically caches package manager dependencies using content-addressed v |-------|-------|------| | Miss | read-write | No cache exists, creating new | | Building | read-write | In progress or crashed (retryable) | - | Complete | read-only | Finalized, immutable | + | Complete | read-write | Finalized, skip re-finalization | 4. **Environment Variables**: Automatically configured: ``` @@ -419,8 +419,7 @@ Mino automatically caches package manager dependencies using content-addressed v ### Security -- **Tamper-proof**: Complete caches are mounted read-only -- **Content-addressed**: Changing dependencies = new hash = new cache +- **Content-addressed**: Same lockfile = same cache volume; changing dependencies = new hash = new cache - **Isolated**: Each unique lockfile gets its own cache volume ### Cache Management diff --git a/images/base/Dockerfile b/images/base/Dockerfile index 57bd71e..a6f7cb6 100644 --- a/images/base/Dockerfile +++ b/images/base/Dockerfile @@ -205,8 +205,8 @@ RUN useradd -m -s /bin/zsh -u 1000 developer \ && mkdir -p /workspace /cache \ && chown -R developer:developer /workspace /cache -# PATH: ~/.local/bin (symlinks) > ~/.npm-global/bin > /opt/mino-tools (fallback) -ENV PATH="/home/developer/.local/bin:/home/developer/.npm-global/bin:/opt/mino-tools:${PATH}" +# PATH: ~/.claude/bin > ~/.local/bin (symlinks) > ~/.npm-global/bin > /opt/mino-tools (fallback) +ENV PATH="/home/developer/.claude/bin:/home/developer/.local/bin:/home/developer/.npm-global/bin:/opt/mino-tools:${PATH}" # Git configuration for better diffs RUN git config --system core.pager delta \ @@ -262,7 +262,7 @@ RUN chmod +x /usr/local/bin/mino-bootstrap /usr/local/bin/mino-entrypoint ARG MINO_BASE_VERSION=dev RUN echo "$MINO_BASE_VERSION" > /etc/mino-base-version -# Healthcheck script (sources nvm first — claude and node both live there) +# Healthcheck script (sources nvm for node; claude is on PATH via ~/.claude/bin) RUN printf '#!/bin/bash\nset -e\nif [ -d "$HOME/.nvm" ]; then\n export NVM_DIR="$HOME/.nvm"\n [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"\nfi\ncommand -v claude >/dev/null 2>&1\ncommand -v node >/dev/null 2>&1\ncommand -v git >/dev/null 2>&1\ntest -d /workspace\ntest -d /cache\ntest "$(whoami)" = "developer"\necho "ok"\n' \ > /usr/local/bin/mino-healthcheck \ && chmod +x /usr/local/bin/mino-healthcheck @@ -304,13 +304,14 @@ export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" ZSHRC -# Claude Code CLI (user-level install via nvm node, updatable by developer) -RUN bash -c 'source ~/.nvm/nvm.sh && npm install -g @anthropic-ai/claude-code' +# Claude Code CLI (native installer, auto-updates) +RUN curl -fsSL https://claude.ai/install.sh | bash # Copy developer home to /etc/skel/ for bootstrap on new home volumes USER root RUN cp -a /home/developer/.oh-my-zsh /etc/skel/.oh-my-zsh \ && cp -a /home/developer/.nvm /etc/skel/.nvm \ + && cp -a /home/developer/.claude /etc/skel/.claude \ && cp /home/developer/.zshrc /etc/skel/.zshrc \ && touch /etc/skel/.zsh_history @@ -335,7 +336,8 @@ RUN /opt/mino-tools/rg --version \ && /opt/mino-tools/sd --version # Verify user-level installs -RUN bash -c 'source ~/.nvm/nvm.sh && claude --version && node --version && npm --version' \ +RUN claude --version \ + && bash -c 'source ~/.nvm/nvm.sh && node --version && npm --version' \ && git --version HEALTHCHECK CMD mino-healthcheck diff --git a/images/base/mino-bootstrap b/images/base/mino-bootstrap index 501d5eb..e55e6ae 100644 --- a/images/base/mino-bootstrap +++ b/images/base/mino-bootstrap @@ -55,7 +55,7 @@ fi # --- Step: skeleton (copy dotfiles from /etc/skel/) --- # Skip if already bootstrapped (image or volume has files from podman auto-copy) if [ "$ALREADY_BOOTSTRAPPED" = false ] && [ ! -f "$STEP_DIR/skeleton" ]; then - for item in .oh-my-zsh .nvm .local .zshrc .zsh_history; do + for item in .oh-my-zsh .nvm .claude .local .zshrc .zsh_history; do if [ ! -e "$HOME/$item" ] && [ -e "/etc/skel/$item" ]; then cp -a "/etc/skel/$item" "$HOME/$item" 2>/dev/null || true fi diff --git a/images/base/mino-entrypoint b/images/base/mino-entrypoint index 8efb2ae..f9eb4b8 100644 --- a/images/base/mino-entrypoint +++ b/images/base/mino-entrypoint @@ -1,7 +1,10 @@ #!/usr/bin/env bash /usr/local/bin/mino-bootstrap || true -# Source nvm so node/claude/npm are on PATH for all commands (not just zsh) +# Claude Code native install (on PATH before nvm) +export PATH="${HOME}/.claude/bin:${PATH}" + +# Source nvm so node/npm are on PATH for all commands (not just zsh) export NVM_DIR="${HOME}/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 631bf46..9cf1e55 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,14 +1,13 @@ //! Persistent cache system for dependency caching //! //! Provides content-addressed caching keyed by lockfile hashes. -//! Caches are immutable once finalized, ensuring tamper-proof builds. //! //! # Security Model //! //! - Cache keys derived from lockfile SHA256 hash -//! - Complete caches mounted read-only (immutable) +//! - Content-addressed: same lockfile = same cache volume //! - Changing cache contents requires different lockfile = different hash -//! - Incomplete caches (from crashes) remain writable for retry +//! - Incomplete caches (from crashes) remain retryable //! //! # Cache States //! @@ -16,7 +15,7 @@ //! |-------|-------|-------------| //! | Miss | rw | No volume exists, creating new | //! | Building | rw | In progress or crashed, retryable | -//! | Complete | ro | Finalized, immutable | +//! | Complete | rw | Finalized, skip re-finalization | pub mod lockfile; pub mod sidecar; diff --git a/src/cache/volume.rs b/src/cache/volume.rs index 7926da3..45ea972 100644 --- a/src/cache/volume.rs +++ b/src/cache/volume.rs @@ -89,16 +89,11 @@ pub enum CacheState { Miss, /// Volume exists but session hasn't completed cleanly Building, - /// Volume is finalized and immutable + /// Volume is finalized (skip re-finalization, eligible for GC) Complete, } impl CacheState { - /// Whether this cache should be mounted read-only - pub fn is_readonly(&self) -> bool { - matches!(self, Self::Complete) - } - /// Parse from label value pub fn from_label(s: &str) -> Self { match s { @@ -237,8 +232,6 @@ pub struct CacheMount { pub volume_name: String, /// Mount path inside container pub container_path: String, - /// Whether to mount read-only - pub readonly: bool, /// Ecosystem for setting env vars pub ecosystem: Ecosystem, } @@ -246,31 +239,18 @@ pub struct CacheMount { impl CacheMount { /// Generate the volume mount string for podman pub fn volume_arg(&self) -> String { - let ro = if self.readonly { ":ro" } else { "" }; - format!("{}:{}{}", self.volume_name, self.container_path, ro) + format!("{}:{}", self.volume_name, self.container_path) } } /// Determine cache mounts for a set of lockfiles -pub fn plan_cache_mounts( - lockfiles: &[LockfileInfo], - volume_states: &HashMap, -) -> Vec { +pub fn plan_cache_mounts(lockfiles: &[LockfileInfo]) -> Vec { lockfiles .iter() - .map(|info| { - let volume_name = info.volume_name(); - let state = volume_states - .get(&volume_name) - .copied() - .unwrap_or(CacheState::Miss); - - CacheMount { - volume_name, - container_path: "/cache".to_string(), - readonly: state.is_readonly(), - ecosystem: info.ecosystem, - } + .map(|info| CacheMount { + volume_name: info.volume_name(), + container_path: "/cache".to_string(), + ecosystem: info.ecosystem, }) .collect() } @@ -292,13 +272,6 @@ mod tests { use super::*; use std::path::PathBuf; - #[test] - fn cache_state_readonly() { - assert!(!CacheState::Miss.is_readonly()); - assert!(!CacheState::Building.is_readonly()); - assert!(CacheState::Complete.is_readonly()); - } - #[test] fn cache_state_label_roundtrip() { for state in [CacheState::Building, CacheState::Complete] { @@ -370,33 +343,25 @@ mod tests { let mount = CacheMount { volume_name: "mino-cache-npm-abc123".to_string(), container_path: "/cache".to_string(), - readonly: true, ecosystem: Ecosystem::Npm, }; - assert_eq!(mount.volume_arg(), "mino-cache-npm-abc123:/cache:ro"); - - let mount_rw = CacheMount { - readonly: false, - ..mount - }; - assert_eq!(mount_rw.volume_arg(), "mino-cache-npm-abc123:/cache"); + assert_eq!(mount.volume_arg(), "mino-cache-npm-abc123:/cache"); } #[test] - fn plan_cache_mounts_miss() { + fn plan_cache_mounts_creates_mounts() { let lockfiles = vec![LockfileInfo { ecosystem: Ecosystem::Npm, path: PathBuf::from("/test/package-lock.json"), hash: "abc123def456".to_string(), }]; - let states = HashMap::new(); // No existing volumes - - let mounts = plan_cache_mounts(&lockfiles, &states); + let mounts = plan_cache_mounts(&lockfiles); assert_eq!(mounts.len(), 1); - assert!(!mounts[0].readonly); // Miss = read-write + assert_eq!(mounts[0].volume_name, "mino-cache-npm-abc123def456"); + assert_eq!(mounts[0].container_path, "/cache"); } #[test] @@ -417,24 +382,4 @@ mod tests { assert_eq!(vol.hash, "uvhash123456"); assert_eq!(vol.state, CacheState::Building); } - - #[test] - fn plan_cache_mounts_complete() { - let lockfiles = vec![LockfileInfo { - ecosystem: Ecosystem::Npm, - path: PathBuf::from("/test/package-lock.json"), - hash: "abc123def456".to_string(), - }]; - - let mut states = HashMap::new(); - states.insert( - "mino-cache-npm-abc123def456".to_string(), - CacheState::Complete, - ); - - let mounts = plan_cache_mounts(&lockfiles, &states); - - assert_eq!(mounts.len(), 1); - assert!(mounts[0].readonly); // Complete = read-only - } } diff --git a/src/cli/commands/run/cache.rs b/src/cli/commands/run/cache.rs index dac3573..af1c300 100644 --- a/src/cli/commands/run/cache.rs +++ b/src/cli/commands/run/cache.rs @@ -84,7 +84,7 @@ async fn setup_cache_for_lockfile( runtime.volume_inspect(&volume_name).await? }; - let (state, should_finalize) = match existing { + let should_finalize = match existing { Some(vol_info) => { let label_state = CacheVolume::from_labels(&vol_info.name, &vol_info.labels) .map(|c| c.state) @@ -96,11 +96,11 @@ async fn setup_cache_for_lockfile( match resolved { CacheState::Complete => { debug!( - "Cache hit for {} ({}), mounting read-only", + "Cache hit for {} ({}), reusing complete cache", info.ecosystem, &info.hash[..8] ); - (CacheState::Complete, false) + false } CacheState::Building | CacheState::Miss => { debug!( @@ -125,7 +125,7 @@ async fn setup_cache_for_lockfile( warn!("Failed to backfill sidecar for {}: {}", volume_name, e); } } - (CacheState::Building, true) + true } } } @@ -160,18 +160,13 @@ async fn setup_cache_for_lockfile( None => CacheState::Building, }; - if resolved == CacheState::Complete { - (CacheState::Complete, false) - } else { - (CacheState::Building, true) - } + resolved != CacheState::Complete } }; let mount = CacheMount { volume_name, container_path: "/cache".to_string(), - readonly: state.is_readonly(), ecosystem: info.ecosystem, }; diff --git a/src/cli/commands/run/container.rs b/src/cli/commands/run/container.rs index c6e56b7..5d71dfd 100644 --- a/src/cli/commands/run/container.rs +++ b/src/cli/commands/run/container.rs @@ -25,9 +25,52 @@ pub(super) struct ContainerBuildParams<'a> { pub home_mount: Option, } +/// Derive container workdir from project directory name. +/// Falls back to /workspace for system dir conflicts or if user overrode the config. +fn resolve_workdir(config_workdir: &str, project_dir: &Path) -> String { + // User explicitly set a custom workdir — respect it + if config_workdir != "/workspace" { + return config_workdir.to_string(); + } + + let folder_name = project_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("workspace"); + + // Block system directory names and mino-reserved paths to prevent overlay + const BLOCKED: &[&str] = &[ + "bin", + "dev", + "etc", + "home", + "lib", + "lib64", + "opt", + "proc", + "root", + "run", + "sbin", + "sys", + "tmp", + "usr", + "var", + "cache", + "workspace", + "ssh-agent", // SSH agent socket mount point + ]; + + if BLOCKED.contains(&folder_name) { + return "/workspace".to_string(); + } + + format!("/{folder_name}") +} + /// Build the container configuration from resolved parameters. pub(super) fn build_container_config(params: &ContainerBuildParams) -> MinoResult { let image = params.resolution.image.clone(); + let workdir = resolve_workdir(¶ms.config.container.workdir, params.project_dir); let mut volumes = Vec::new(); @@ -36,11 +79,7 @@ pub(super) fn build_container_config(params: &ContainerBuildParams) -> MinoResul volumes.push(home.clone()); } - volumes.push(format!( - "{}:{}", - params.project_dir.display(), - params.config.container.workdir - )); + volumes.push(format!("{}:{}", params.project_dir.display(), workdir)); volumes.extend(params.cache_mounts.iter().map(|m| m.volume_arg())); @@ -67,7 +106,7 @@ pub(super) fn build_container_config(params: &ContainerBuildParams) -> MinoResul Ok(ContainerConfig { image, - workdir: params.config.container.workdir.clone(), + workdir, volumes, env: final_env, network: params.network_mode.to_podman_network().to_string(), @@ -249,4 +288,63 @@ mod tests { assert!(result.read_only); assert!(result.tmpfs.contains(&"/home/developer".to_string())); } + + #[test] + fn workdir_derived_from_project_dir() { + let args = test_run_args(); + let config = Config::default(); + let result = build_with(&args, &config); + // project_dir is /tmp/project → workdir should be /project + assert_eq!(result.workdir, "/project"); + assert!(result.volumes.iter().any(|v| v.ends_with(":/project"))); + } + + #[test] + fn workdir_blocked_name_falls_back() { + assert_eq!( + resolve_workdir("/workspace", Path::new("/home/dev/bin")), + "/workspace" + ); + assert_eq!( + resolve_workdir("/workspace", Path::new("/home/dev/etc")), + "/workspace" + ); + assert_eq!( + resolve_workdir("/workspace", Path::new("/home/dev/tmp")), + "/workspace" + ); + assert_eq!( + resolve_workdir("/workspace", Path::new("/home/dev/cache")), + "/workspace" + ); + assert_eq!( + resolve_workdir("/workspace", Path::new("/home/dev/ssh-agent")), + "/workspace" + ); + } + + #[test] + fn workdir_custom_config_preserved() { + assert_eq!( + resolve_workdir("/code", Path::new("/home/dev/my-project")), + "/code" + ); + } + + #[test] + fn workdir_root_falls_back() { + assert_eq!(resolve_workdir("/workspace", Path::new("/")), "/workspace"); + } + + #[test] + fn workdir_normal_project_names() { + assert_eq!( + resolve_workdir("/workspace", Path::new("/home/dev/my-app")), + "/my-app" + ); + assert_eq!( + resolve_workdir("/workspace", Path::new("/Users/dean/Sandbox/minotaur")), + "/minotaur" + ); + } }