Files
sandcage/docs/superpowers/plans/2026-05-23-mount-tilde-expansion.md
T
2026-05-23 17:11:31 +02:00

7.2 KiB

Mount Tilde Expansion Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Expand ~ in mount paths to the user's home directory at Docker invocation time, so mounts work on all platforms without relying on shell expansion.

Architecture: A single pure function expand_mount_path in docker.rs replaces ~ with dirs::home_dir() and normalizes Windows separators. Called from build_run_args where mounts become -v flags. Config files keep the portable ~ form.

Tech Stack: Rust, dirs crate (already a dependency)


Task 1: Add expand_mount_path function with TDD

Files:

  • Modify: crates/sandcage/src/docker.rs:227-233 (call site)

  • Modify: crates/sandcage/src/docker.rs (add function + tests at end of file)

  • Step 1: Write the failing tests for expand_mount_path

Add these tests inside the existing #[cfg(test)] mod tests block in docker.rs:

#[test]
fn expand_mount_path_expands_tilde_slash() {
    let home = dirs::home_dir().unwrap();
    let home_str = home.to_string_lossy().replace('\\', "/");
    let result = expand_mount_path("~/.ssh:/home/agent/.ssh:ro");
    assert_eq!(result, format!("{home_str}/.ssh:/home/agent/.ssh:ro"));
}

#[test]
fn expand_mount_path_expands_bare_tilde() {
    let home = dirs::home_dir().unwrap();
    let home_str = home.to_string_lossy().replace('\\', "/");
    let result = expand_mount_path("~:/container");
    assert_eq!(result, format!("{home_str}:/container"));
}

#[test]
fn expand_mount_path_ignores_absolute() {
    let result = expand_mount_path("/absolute:/container:ro");
    assert_eq!(result, "/absolute:/container:ro");
}

#[test]
fn expand_mount_path_ignores_relative() {
    let result = expand_mount_path("./relative:/container");
    assert_eq!(result, "./relative:/container");
}

#[test]
fn expand_mount_path_ignores_no_colon() {
    let result = expand_mount_path("no-colon");
    assert_eq!(result, "no-colon");
}
  • Step 2: Run tests to verify they fail

Run: cargo test -p sandcage expand_mount_path Expected: compilation error — expand_mount_path is not defined.

  • Step 3: Implement expand_mount_path

Add this function in docker.rs, just above build_run_args:

fn expand_mount_path(mount: &str) -> String {
    let Some(colon_pos) = mount.find(':') else {
        return mount.to_string();
    };

    let host_path = &mount[..colon_pos];
    let rest = &mount[colon_pos..];

    let expanded = if host_path == "~" {
        match dirs::home_dir() {
            Some(home) => home.to_string_lossy().into_owned(),
            None => return mount.to_string(),
        }
    } else if let Some(suffix) = host_path.strip_prefix("~/") {
        match dirs::home_dir() {
            Some(home) => format!("{}/{suffix}", home.to_string_lossy()),
            None => return mount.to_string(),
        }
    } else {
        return mount.to_string();
    };

    let normalized = expanded.replace('\\', "/");
    format!("{normalized}{rest}")
}
  • Step 4: Run tests to verify they pass

Run: cargo test -p sandcage expand_mount_path Expected: all 5 tests PASS.

  • Step 5: Commit
git add crates/sandcage/src/docker.rs
git commit -m "✨ Add expand_mount_path for cross-platform tilde expansion"

Task 2: Wire expand_mount_path into build_run_args and update existing tests

Files:

  • Modify: crates/sandcage/src/docker.rs:229-231 (call site)

  • Modify: crates/sandcage/src/docker.rs (existing tests at lines 866-921)

  • Step 1: Replace mount.clone() with expand_mount_path(mount) in build_run_args

Change lines 229-231 from:

            if mount.contains(':') {
                args.push("-v".to_string());
                args.push(mount.clone());
            }

To:

            if mount.contains(':') {
                args.push("-v".to_string());
                args.push(expand_mount_path(mount));
            }
  • Step 2: Update build_run_args_with_mounts test

The test at line 866 currently asserts literal ~/.ssh strings. Replace the assertions:

#[test]
fn build_run_args_with_mounts() {
    let home = dirs::home_dir().unwrap();
    let home_str = home.to_string_lossy().replace('\\', "/");
    let config = SandcageConfig {
        mounts: Some(vec![
            "~/.ssh:/home/agent/.ssh:ro".to_string(),
            "~/.gitconfig:/home/agent/.gitconfig:ro".to_string(),
        ]),
        ..Default::default()
    };
    let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);

    assert!(args.contains(&"-v".to_string()), "should contain -v flag");
    let expected_ssh = format!("{home_str}/.ssh:/home/agent/.ssh:ro");
    let expected_git = format!("{home_str}/.gitconfig:/home/agent/.gitconfig:ro");
    assert!(args.contains(&expected_ssh), "SSH mount should be expanded");
    assert!(args.contains(&expected_git), "gitconfig mount should be expanded");

    let service_pos = args.iter().position(|a| a == "claude").unwrap();
    let first_v = args.iter().position(|a| a == "-v").unwrap();
    assert!(first_v < service_pos, "-v flags must come before the service name");
}
  • Step 3: Update build_run_args_mounts_with_env_and_extra_args test

The test at line 902 asserts literal ~/.ssh. Replace the mount assertion:

#[test]
fn build_run_args_mounts_with_env_and_extra_args() {
    let home = dirs::home_dir().unwrap();
    let home_str = home.to_string_lossy().replace('\\', "/");
    let mut env = HashMap::new();
    env.insert("FOO".to_string(), "bar".to_string());
    let config = SandcageConfig {
        env: Some(env),
        mounts: Some(vec!["~/.ssh:/home/agent/.ssh:ro".to_string()]),
        ..Default::default()
    };
    let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &["--resume".to_string()]);

    let service_pos = args.iter().position(|a| a == "claude").unwrap();
    let env_pos = args.iter().position(|a| a == "FOO=bar").unwrap();
    let expected_mount = format!("{home_str}/.ssh:/home/agent/.ssh:ro");
    let mount_pos = args.iter().position(|a| *a == expected_mount).unwrap();
    let resume_pos = args.iter().position(|a| a == "--resume").unwrap();

    assert!(env_pos < service_pos, "env before service");
    assert!(mount_pos < service_pos, "mounts before service");
    assert!(resume_pos > service_pos, "extra args after service");
}
  • Step 4: Run the full test suite

Run: cargo test -p sandcage Expected: all tests PASS.

  • Step 5: Commit
git add crates/sandcage/src/docker.rs
git commit -m "🐛 Expand tilde in mount paths before passing to Docker"

Task 3: Clean up the literal ~ directory artifact

  • Step 1: Remove the literal ~ directory from the project root
rm -rf "~"

Verify it's gone:

ls -la "~" 2>&1

Expected: "No such file or directory"

  • Step 2: Verify .gitignore doesn't need updating

Check if the ~ directory was tracked by git:

git status

If it shows as untracked (it should, since git wouldn't track a ~ directory), no .gitignore change is needed.