🥇 export from upstream (ce9e2c1)

This commit is contained in:
2026-05-23 15:29:45 +02:00
parent ee5238cb0f
commit e5820ab3ff
15 changed files with 1172 additions and 81 deletions
+2
View File
@@ -18,3 +18,5 @@ dirs = "6"
sha2 = "0.10"
hex = "0.4"
tempfile = "3"
dialoguer = "0.11"
toml_edit = "0.22"
+52 -1
View File
@@ -54,6 +54,8 @@ struct RawConfig {
trusted_projects: Option<Vec<PathBuf>>,
#[serde(default)]
container_workspace: Option<String>,
#[serde(default)]
agent_args: Option<HashMap<String, Vec<String>>>,
/// Absorb everything else so we can warn about unknown fields.
#[serde(flatten)]
@@ -85,13 +87,15 @@ pub struct SandcageConfig {
pub trusted_projects: Option<Vec<PathBuf>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub container_workspace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_args: Option<HashMap<String, Vec<String>>>,
}
// ---------------------------------------------------------------------------
// Known keys — used to filter the flattened `extra` map
// ---------------------------------------------------------------------------
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace"];
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args"];
// ---------------------------------------------------------------------------
// Validation helpers
@@ -143,6 +147,7 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
dockerfiles: raw.dockerfiles,
trusted_projects: raw.trusted_projects,
container_workspace: raw.container_workspace,
agent_args: raw.agent_args,
}
}
@@ -519,4 +524,50 @@ EDITOR = "vim"
let cfg = load_from_str("").expect("parse empty");
assert!(cfg.container_workspace.is_none());
}
#[test]
fn agent_args_defaults_to_none() {
let cfg = load_from_str("").expect("parse empty");
assert!(cfg.agent_args.is_none());
}
#[test]
fn resolve_agent_args_project_overrides_global() {
let global_toml = r#"
[agent_args]
claude = ["--global-flag"]
"#;
let project_yaml = r#"
agent_args:
claude:
- "--project-flag"
"#;
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
write!(global_tmp, "{global_toml}").expect("write global tmpfile");
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{project_yaml}").expect("write project tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()))
.expect("resolve");
let agent_args = cfg.agent_args.expect("agent_args should be Some");
let claude_args = agent_args.get("claude").expect("claude args");
assert_eq!(claude_args, &vec!["--project-flag"], "project should override global");
}
#[test]
fn parse_agent_args() {
let yaml = r#"
agent_args:
claude:
- "--dangerously-skip-permissions"
codex:
- "--full-auto"
"#;
let cfg = load_from_str(yaml).expect("parse agent_args");
let agent_args = cfg.agent_args.expect("agent_args should be Some");
let claude_args = agent_args.get("claude").expect("claude args");
assert_eq!(claude_args, &vec!["--dangerously-skip-permissions"]);
let codex_args = agent_args.get("codex").expect("codex args");
assert_eq!(codex_args, &vec!["--full-auto"]);
}
}
+132 -25
View File
@@ -153,13 +153,6 @@ pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result<Ha
"SANDCAGE_HOME".into(),
sandcage_home.to_string_lossy().into_owned(),
);
env.insert(
"SANDCAGE_GLOBAL_JUSTFILE".into(),
sandcage_home
.join("Justfile")
.to_string_lossy()
.into_owned(),
);
env.insert("SANDCAGE_CONTAINER_DIR".into(), container_dir);
Ok(env)
@@ -231,6 +224,15 @@ pub fn build_run_args(
}
}
if let Some(ref mount_list) = config.mounts {
for mount in mount_list {
if mount.contains(':') {
args.push("-v".to_string());
args.push(mount.clone());
}
}
}
if shell_override {
args.push("--entrypoint".to_string());
args.push("/bin/zsh".to_string());
@@ -238,6 +240,16 @@ pub fn build_run_args(
args.push(service.to_string());
if !shell_override {
if let Some(ref all_agent_args) = config.agent_args
&& let Some(default_args) = all_agent_args.get(service)
{
for arg in default_args {
args.push(arg.clone());
}
}
}
for arg in extra_args {
args.push(arg.clone());
}
@@ -555,10 +567,6 @@ mod tests {
assert!(env.contains_key("SANDCAGE_GID"), "missing SANDCAGE_GID");
assert!(env.contains_key("SANDCAGE_WORKSPACE"), "missing SANDCAGE_WORKSPACE");
assert!(env.contains_key("SANDCAGE_HOME"), "missing SANDCAGE_HOME");
assert!(
env.contains_key("SANDCAGE_GLOBAL_JUSTFILE"),
"missing SANDCAGE_GLOBAL_JUSTFILE"
);
assert!(env.contains_key("SANDCAGE_CONTAINER_DIR"), "missing SANDCAGE_CONTAINER_DIR");
}
@@ -580,20 +588,6 @@ mod tests {
);
}
#[test]
fn build_compose_env_justfile_is_under_home() {
let workspace = PathBuf::from("/tmp");
let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env");
assert!(
env["SANDCAGE_GLOBAL_JUSTFILE"].starts_with(&env["SANDCAGE_HOME"]),
"SANDCAGE_GLOBAL_JUSTFILE should be under SANDCAGE_HOME"
);
assert!(
env["SANDCAGE_GLOBAL_JUSTFILE"].ends_with("Justfile"),
"SANDCAGE_GLOBAL_JUSTFILE should end with Justfile"
);
}
#[test]
fn uid_gid_are_numeric() {
let workspace = PathBuf::from("/tmp");
@@ -821,6 +815,20 @@ mod tests {
assert!(help_pos > service_pos);
}
#[test]
fn build_run_args_shell_override_skips_agent_args() {
let mut agent_args = HashMap::new();
agent_args.insert("claude".to_string(), vec!["--dangerously-skip-permissions".to_string()]);
let config = SandcageConfig {
agent_args: Some(agent_args),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, true, &[]);
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()),
"agent_args must not be passed in shell mode");
assert!(args.contains(&"--entrypoint".to_string()));
}
#[test]
fn build_compose_env_container_dir_auto_derived() {
let workspace = PathBuf::from("/home/user/projects/my-app");
@@ -854,6 +862,105 @@ mod tests {
);
}
#[test]
fn build_run_args_with_mounts() {
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");
assert!(args.contains(&"~/.ssh:/home/agent/.ssh:ro".to_string()));
assert!(args.contains(&"~/.gitconfig:/home/agent/.gitconfig:ro".to_string()));
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");
}
#[test]
fn build_run_args_skips_invalid_mounts() {
let config = SandcageConfig {
mounts: Some(vec![
"/valid:/mount:ro".to_string(),
"/no-colon-here".to_string(),
]),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
let v_count = args.iter().filter(|a| a.as_str() == "-v").count();
assert_eq!(v_count, 1, "only valid mounts should produce -v flags");
assert!(args.contains(&"/valid:/mount:ro".to_string()));
assert!(!args.contains(&"/no-colon-here".to_string()));
}
#[test]
fn build_run_args_mounts_with_env_and_extra_args() {
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 mount_pos = args.iter().position(|a| a == "~/.ssh:/home/agent/.ssh:ro").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");
}
#[test]
fn build_run_args_prepends_default_agent_args() {
let mut agent_args = HashMap::new();
agent_args.insert("claude".to_string(), vec!["--dangerously-skip-permissions".to_string()]);
let config = SandcageConfig {
agent_args: Some(agent_args),
..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 default_pos = args.iter().position(|a| a == "--dangerously-skip-permissions").unwrap();
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
assert!(default_pos > service_pos, "default args come after service name");
assert!(default_pos < resume_pos, "default args come before CLI args");
}
#[test]
fn build_run_args_ignores_other_service_defaults() {
let mut agent_args = HashMap::new();
agent_args.insert("codex".to_string(), vec!["--full-auto".to_string()]);
let config = SandcageConfig {
agent_args: Some(agent_args),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
assert!(!args.contains(&"--full-auto".to_string()), "codex args should not appear for claude");
}
#[test]
fn build_run_args_no_defaults_cli_args_still_work() {
let config = SandcageConfig::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 resume_pos = args.iter().position(|a| a == "--resume").unwrap();
assert!(resume_pos > service_pos);
}
#[test]
fn build_compose_env_container_dir_root_fallback() {
let workspace = PathBuf::from("/");
+40 -21
View File
@@ -34,16 +34,20 @@ pub fn preseed() -> Result<()> {
pub fn preseed_with_home(home: &Path) -> Result<()> {
let sandcage_home = home.join(".sandcage");
let agent_home = sandcage_home.join("home");
// 1. ~/.sandcage/.claude/
let claude_dir = sandcage_home.join(".claude");
// 1. ~/.sandcage/home/ — persistent agent home directory
create_dir_all(&agent_home)?;
// 2. ~/.sandcage/home/.claude/
let claude_dir = agent_home.join(".claude");
create_dir_all(&claude_dir)?;
// 2. ~/.sandcage/.codex/
let codex_dir = sandcage_home.join(".codex");
// 3. ~/.sandcage/home/.codex/
let codex_dir = agent_home.join(".codex");
create_dir_all(&codex_dir)?;
// 3. Seed Claude settings.json from the bundled template
// 4. Seed Claude settings.json from the bundled template
let settings_dest = claude_dir.join("settings.json");
if !settings_dest.exists() {
std::fs::write(&settings_dest, CLAUDE_SETTINGS_TEMPLATE)
@@ -51,15 +55,15 @@ pub fn preseed_with_home(home: &Path) -> Result<()> {
println!("sandcage: seeded {} from template", settings_dest.display());
}
// 4. Seed .claude.json so the bind mount targets a file, not a directory
let claude_json = sandcage_home.join(".claude.json");
// 5. Seed .claude.json so the bind mount targets a file, not a directory
let claude_json = agent_home.join(".claude.json");
if !claude_json.exists() {
std::fs::write(&claude_json, "{}\n")
.map_err(|e| InitError::WriteFileFailed(claude_json.clone(), e))?;
}
// 5. Create an empty Justfile if absent
let justfile = sandcage_home.join("Justfile");
// 6. Create an empty .justfile if absent
let justfile = agent_home.join(".justfile");
if !justfile.exists() {
std::fs::write(&justfile, "")
.map_err(|e| InitError::WriteFileFailed(justfile.clone(), e))?;
@@ -146,7 +150,12 @@ fn build_yaml(ecosystem: Ecosystem) -> String {
{toolchain_and_packages}\
\n\
# mounts:\n\
# - /path/on/host:/path/in/container\n\
# - ~/.ssh:/home/agent/.ssh:ro\n\
# - ~/.gitconfig:/home/agent/.gitconfig:ro\n\
\n\
# agent_args:\n\
# claude:\n\
# - \"--dangerously-skip-permissions\"\n\
\n\
# shell: zsh\n\
\n\
@@ -190,17 +199,27 @@ mod tests {
preseed_with_home(home)
}
#[test]
fn creates_home_dir() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
assert!(
home.path().join(".sandcage/home").is_dir(),
"home dir should exist"
);
}
#[test]
fn creates_claude_and_codex_dirs() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
assert!(
home.path().join(".sandcage/.claude").is_dir(),
home.path().join(".sandcage/home/.claude").is_dir(),
".claude dir should exist"
);
assert!(
home.path().join(".sandcage/.codex").is_dir(),
home.path().join(".sandcage/home/.codex").is_dir(),
".codex dir should exist"
);
}
@@ -210,7 +229,7 @@ mod tests {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
let settings = home.path().join(".sandcage/.claude/settings.json");
let settings = home.path().join(".sandcage/home/.claude/settings.json");
assert!(settings.exists(), "settings.json should be created");
let content = std::fs::read_to_string(&settings).expect("read settings");
@@ -225,7 +244,7 @@ mod tests {
fn does_not_overwrite_existing_claude_settings() {
let home = TempDir::new().expect("create tempdir");
// Create the directory and put custom content in settings.json first
let claude_dir = home.path().join(".sandcage/.claude");
let claude_dir = home.path().join(".sandcage/home/.claude");
std::fs::create_dir_all(&claude_dir).expect("mkdir");
let settings = claude_dir.join("settings.json");
std::fs::write(&settings, r#"{"existing": true}"#).expect("write");
@@ -244,7 +263,7 @@ mod tests {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
let justfile = home.path().join(".sandcage/Justfile");
let justfile = home.path().join(".sandcage/home/.justfile");
assert!(justfile.exists(), "Justfile should be created");
assert_eq!(
std::fs::read_to_string(&justfile).expect("read"),
@@ -257,9 +276,9 @@ mod tests {
fn does_not_overwrite_existing_justfile() {
let home = TempDir::new().expect("create tempdir");
// Seed a Justfile with content first
let sandcage_dir = home.path().join(".sandcage");
let sandcage_dir = home.path().join(".sandcage/home");
std::fs::create_dir_all(&sandcage_dir).expect("mkdir");
let justfile = sandcage_dir.join("Justfile");
let justfile = sandcage_dir.join(".justfile");
std::fs::write(&justfile, "default:\n\t@echo hello").expect("write");
run(home.path()).expect("preseed");
@@ -278,10 +297,10 @@ mod tests {
run(home.path()).expect("second preseed should also succeed");
// All artefacts should still exist after two runs
assert!(home.path().join(".sandcage/.claude").is_dir());
assert!(home.path().join(".sandcage/.codex").is_dir());
assert!(home.path().join(".sandcage/.claude/settings.json").exists());
assert!(home.path().join(".sandcage/Justfile").exists());
assert!(home.path().join(".sandcage/home/.claude").is_dir());
assert!(home.path().join(".sandcage/home/.codex").is_dir());
assert!(home.path().join(".sandcage/home/.claude/settings.json").exists());
assert!(home.path().join(".sandcage/home/.justfile").exists());
}
// -----------------------------------------------------------------------
+1
View File
@@ -1,4 +1,5 @@
pub mod config;
pub mod docker;
pub mod init;
pub mod setup;
pub mod workspace;
+29
View File
@@ -6,6 +6,7 @@ use thiserror::Error;
use sandcage::config;
use sandcage::docker;
use sandcage::init;
use sandcage::setup;
use sandcage::workspace;
/// Sandboxed containers for AI coding agents
@@ -60,6 +61,25 @@ enum Commands {
},
/// Initialize a .sandcage.yml for a project
Init,
/// Guided configuration helpers
Setup {
#[command(subcommand)]
action: SetupAction,
},
}
#[derive(Subcommand, Debug)]
enum SetupAction {
/// Configure SSH key access for containers
Ssh {
/// Write to global config instead of project config
#[arg(long)]
global: bool,
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
},
}
#[derive(Debug, Error, Diagnostic)]
@@ -79,6 +99,10 @@ enum AppError {
#[error(transparent)]
#[diagnostic(transparent)]
Docker(#[from] docker::DockerError),
#[error(transparent)]
#[diagnostic(transparent)]
Setup(#[from] setup::SetupError),
}
fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
@@ -119,6 +143,11 @@ fn main() -> miette::Result<()> {
let workspace = workspace::resolve_workspace(None)?;
init::scaffold_project(&workspace)?;
}
Commands::Setup { action } => match action {
SetupAction::Ssh { global, yes } => {
setup::run_ssh_setup(global, yes)?;
}
},
}
Ok(())
+540
View File
@@ -0,0 +1,540 @@
use std::path::{Path, PathBuf};
use miette::Diagnostic;
use thiserror::Error;
use crate::config::SandcageConfig;
#[derive(Debug, Error, Diagnostic)]
pub enum SetupError {
#[error("No SSH directory found at {0}")]
#[diagnostic(code(sandcage::setup::no_ssh_dir))]
NoSshDir(PathBuf),
#[error("Failed to read directory {0}: {1}")]
#[diagnostic(code(sandcage::setup::read_dir_failed))]
ReadDirFailed(PathBuf, #[source] std::io::Error),
#[error("No .sandcage.yml found. Run `sandcage init` first, or use `--global`.")]
#[diagnostic(code(sandcage::setup::no_project_config))]
NoProjectConfig,
#[error("Failed to read config file {0}: {1}")]
#[diagnostic(code(sandcage::setup::config_read_failed))]
ConfigReadFailed(PathBuf, #[source] std::io::Error),
#[error("Failed to parse config file {0}: {1}")]
#[diagnostic(code(sandcage::setup::config_parse_failed))]
ConfigParseFailed(PathBuf, String),
#[error("Failed to write config file {0}: {1}")]
#[diagnostic(code(sandcage::setup::config_write_failed))]
ConfigWriteFailed(PathBuf, #[source] std::io::Error),
#[error("Cannot determine home directory")]
#[diagnostic(code(sandcage::setup::no_home_dir))]
NoHomeDir,
}
pub type Result<T> = std::result::Result<T, SetupError>;
const SSH_MOUNT: &str = "~/.ssh:/home/agent/.ssh:ro";
const SSH_CONFIG_FILES: &[&str] = &["config", "known_hosts", "known_hosts.old", "authorized_keys"];
#[derive(Debug, Clone, PartialEq)]
pub struct SshScanResult {
pub keys: Vec<String>,
pub config_files: Vec<String>,
pub other_count: usize,
}
pub fn scan_ssh_dir(ssh_dir: &Path) -> Result<SshScanResult> {
if !ssh_dir.is_dir() {
return Err(SetupError::NoSshDir(ssh_dir.to_path_buf()));
}
let entries = std::fs::read_dir(ssh_dir)
.map_err(|e| SetupError::ReadDirFailed(ssh_dir.to_path_buf(), e))?;
let mut keys = Vec::new();
let mut config_files = Vec::new();
let mut other_count: usize = 0;
for entry in entries {
let entry = entry.map_err(|e| SetupError::ReadDirFailed(ssh_dir.to_path_buf(), e))?;
let file_name = entry.file_name().to_string_lossy().into_owned();
if file_name.ends_with(".pub") {
let base = file_name.strip_suffix(".pub").unwrap().to_string();
keys.push(base);
} else if SSH_CONFIG_FILES.contains(&file_name.as_str()) {
config_files.push(file_name);
} else {
other_count += 1;
}
}
keys.sort();
config_files.sort();
Ok(SshScanResult {
keys,
config_files,
other_count,
})
}
pub fn check_existing_mount(config: &SandcageConfig, mount: &str) -> bool {
config
.mounts
.as_ref()
.is_some_and(|m| m.iter().any(|entry| entry == mount))
}
pub fn add_mount_to_yaml(path: &Path, mount: &str) -> Result<()> {
let content = std::fs::read_to_string(path)
.map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?;
let mut config: SandcageConfig = if content.trim().is_empty() {
SandcageConfig::default()
} else {
serde_yaml::from_str(&content)
.map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?
};
let mounts = config.mounts.get_or_insert_with(Vec::new);
mounts.push(mount.to_string());
let yaml = serde_yaml::to_string(&config)
.map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?;
let output = rehydrate_yaml_comments(&content, &yaml);
std::fs::write(path, output)
.map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?;
Ok(())
}
fn rehydrate_yaml_comments(original: &str, generated: &str) -> String {
use std::collections::HashMap;
let mut key_comments: Vec<(Option<String>, Vec<String>)> = Vec::new();
let mut pending: Vec<String> = Vec::new();
for line in original.lines() {
let trimmed = line.trim();
let is_blank = trimmed.is_empty();
let is_top_comment =
trimmed.starts_with('#') && !line.starts_with(' ') && !line.starts_with('\t');
let is_top_level_key = !is_blank
&& !is_top_comment
&& !line.starts_with(' ')
&& !line.starts_with('\t')
&& !line.starts_with('-')
&& line.contains(':');
if is_top_level_key {
let key = line.split(':').next().unwrap().trim().to_string();
key_comments.push((Some(key), std::mem::take(&mut pending)));
} else if is_blank || is_top_comment {
pending.push(line.to_string());
}
}
if !pending.is_empty() {
key_comments.push((None, pending));
}
let comment_map: HashMap<&str, &[String]> = key_comments
.iter()
.filter_map(|(k, v)| k.as_deref().map(|k| (k, v.as_slice())))
.collect();
let preamble = key_comments
.first()
.and_then(|(k, v)| if k.is_some() { Some(v.as_slice()) } else { None });
let trailer = key_comments
.last()
.and_then(|(k, v)| if k.is_none() { Some(v.as_slice()) } else { None });
let mut result = String::new();
let mut first_key = true;
for line in generated.lines() {
let trimmed = line.trim();
let is_top_level_key = !trimmed.is_empty()
&& !line.starts_with(' ')
&& !line.starts_with('\t')
&& !line.starts_with('-')
&& line.contains(':');
if is_top_level_key {
let key = line.split(':').next().unwrap().trim();
if first_key {
if let Some(pre) = preamble {
for c in pre {
result.push_str(c);
result.push('\n');
}
}
first_key = false;
} else if let Some(comments) = comment_map.get(key) {
for c in *comments {
result.push_str(c);
result.push('\n');
}
}
}
result.push_str(line);
result.push('\n');
}
if let Some(trail) = trailer {
for c in trail {
result.push_str(c);
result.push('\n');
}
}
result
}
pub fn add_mount_to_toml(path: &Path, mount: &str) -> Result<()> {
let content = if path.exists() {
std::fs::read_to_string(path)
.map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?
} else {
String::new()
};
let mut doc: toml_edit::DocumentMut = content
.parse()
.map_err(|e: toml_edit::TomlError| {
SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string())
})?;
let mounts = doc.entry("mounts").or_insert_with(|| {
toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new()))
});
if let Some(arr) = mounts.as_array_mut() {
arr.push(mount);
}
std::fs::write(path, doc.to_string())
.map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?;
Ok(())
}
fn display_scan_result(result: &SshScanResult) {
if !result.keys.is_empty() {
eprintln!(" Keys: {}", result.keys.join(", "));
}
if !result.config_files.is_empty() {
eprintln!(" Config: {}", result.config_files.join(", "));
}
if result.other_count > 0 {
eprintln!(
" Other: {} other file{}",
result.other_count,
if result.other_count == 1 { "" } else { "s" }
);
}
if result.keys.is_empty() && result.config_files.is_empty() && result.other_count == 0 {
eprintln!(" (empty)");
}
eprintln!();
eprintln!("This will mount ~/.ssh into containers as read-only.");
}
pub fn run_ssh_setup(global: bool, yes: bool) -> Result<()> {
let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?;
let ssh_dir = home.join(".ssh");
let result = scan_ssh_dir(&ssh_dir)?;
eprintln!("sandcage: found SSH directory at ~/.ssh");
eprintln!();
display_scan_result(&result);
let (config_path, config_label) = if global {
(
home.join(".sandcage").join("config.toml"),
"~/.sandcage/config.toml".to_string(),
)
} else {
let cwd =
std::env::current_dir().map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
let project_config = cwd.join(".sandcage.yml");
if !project_config.exists() {
return Err(SetupError::NoProjectConfig);
}
(project_config, ".sandcage.yml".to_string())
};
// Check if already configured
if config_path.exists() {
let existing = if global {
let content = std::fs::read_to_string(&config_path)
.map_err(|e| SetupError::ConfigReadFailed(config_path.clone(), e))?;
let doc: toml_edit::DocumentMut = content
.parse::<toml_edit::DocumentMut>()
.map_err(|e: toml_edit::TomlError| {
SetupError::ConfigParseFailed(config_path.clone(), e.to_string())
})?;
doc.get("mounts")
.and_then(|m: &toml_edit::Item| m.as_array())
.is_some_and(|arr: &toml_edit::Array| {
arr.iter()
.any(|v: &toml_edit::Value| v.as_str() == Some(SSH_MOUNT))
})
} else {
let cfg = crate::config::load(&config_path)
.map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))?;
check_existing_mount(&cfg, SSH_MOUNT)
};
if existing {
eprintln!("sandcage: SSH mount already configured in {config_label}");
return Ok(());
}
}
// Confirm
if !yes {
let confirmed = dialoguer::Confirm::new()
.with_prompt(format!("Add SSH mount to {config_label}?"))
.default(false)
.interact()
.unwrap_or(false);
if !confirmed {
return Ok(());
}
}
// Write
if global {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| SetupError::ConfigWriteFailed(config_path.clone(), e))?;
}
add_mount_to_toml(&config_path, SSH_MOUNT)?;
} else {
add_mount_to_yaml(&config_path, SSH_MOUNT)?;
}
eprintln!("sandcage: added SSH mount to {config_label}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
fn touch(dir: &Path, name: &str) {
std::fs::write(dir.join(name), "").expect("touch file");
}
#[test]
fn scan_finds_keys_by_pub_extension() {
let dir = TempDir::new().unwrap();
touch(dir.path(), "id_ed25519");
touch(dir.path(), "id_ed25519.pub");
touch(dir.path(), "work_github");
touch(dir.path(), "work_github.pub");
let result = scan_ssh_dir(dir.path()).unwrap();
assert_eq!(result.keys, vec!["id_ed25519", "work_github"]);
}
#[test]
fn scan_finds_config_files() {
let dir = TempDir::new().unwrap();
touch(dir.path(), "config");
touch(dir.path(), "known_hosts");
touch(dir.path(), "authorized_keys");
let result = scan_ssh_dir(dir.path()).unwrap();
assert_eq!(
result.config_files,
vec!["authorized_keys", "config", "known_hosts"]
);
assert_eq!(result.keys, Vec::<String>::new());
}
#[test]
fn scan_counts_other_files() {
let dir = TempDir::new().unwrap();
touch(dir.path(), "id_rsa");
touch(dir.path(), "id_rsa.pub");
touch(dir.path(), "config");
touch(dir.path(), "environment");
touch(dir.path(), "random_file");
let result = scan_ssh_dir(dir.path()).unwrap();
assert_eq!(result.keys, vec!["id_rsa"]);
assert_eq!(result.config_files, vec!["config"]);
assert_eq!(result.other_count, 3);
}
#[test]
fn scan_empty_dir() {
let dir = TempDir::new().unwrap();
let result = scan_ssh_dir(dir.path()).unwrap();
assert_eq!(result.keys, Vec::<String>::new());
assert_eq!(result.config_files, Vec::<String>::new());
assert_eq!(result.other_count, 0);
}
#[test]
fn scan_nonexistent_dir_errors() {
let result = scan_ssh_dir(Path::new("/nonexistent/ssh/dir"));
assert!(matches!(result, Err(SetupError::NoSshDir(_))));
}
// -- check_existing_mount tests --
#[test]
fn check_existing_mount_found() {
let config = SandcageConfig {
mounts: Some(vec!["~/.ssh:/home/agent/.ssh:ro".to_string()]),
..Default::default()
};
assert!(check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn check_existing_mount_not_found() {
let config = SandcageConfig {
mounts: Some(vec!["/data:/mnt:ro".to_string()]),
..Default::default()
};
assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn check_existing_mount_no_mounts() {
let config = SandcageConfig::default();
assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro"));
}
// -- add_mount_to_yaml tests --
#[test]
fn add_mount_to_yaml_with_existing_mounts() {
let yaml = "env:\n FOO: bar\nmounts:\n - /data:/mnt\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("/data:/mnt"));
assert!(content.contains("FOO"));
}
#[test]
fn add_mount_to_yaml_no_existing_mounts() {
let yaml = "env:\n FOO: bar\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("FOO"));
}
#[test]
fn add_mount_to_yaml_empty_file() {
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn add_mount_to_yaml_preserves_comments() {
let yaml = "# Environment\nenv:\n FOO: bar\n\n# Mount points\nmounts:\n- /data:/mnt\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("# Environment"), "preamble comment preserved");
assert!(content.contains("# Mount points"), "inline comment preserved");
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("/data:/mnt"));
}
#[test]
fn add_mount_to_yaml_preserves_trailing_comments() {
let yaml = "mounts:\n- /data:/mnt\n\n# end of file\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("# end of file"), "trailing comment preserved");
}
#[test]
fn rehydrate_preserves_blank_lines_between_keys() {
let original = "env:\n A: 1\n\nmounts:\n- /data:/mnt\n";
let generated = "env:\n A: '1'\nmounts:\n- /data:/mnt\n- /new:/new\n";
let result = rehydrate_yaml_comments(original, generated);
assert!(result.contains("\n\nmounts:"), "blank line before mounts preserved");
}
// -- add_mount_to_toml tests --
#[test]
fn add_mount_to_toml_new_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn add_mount_to_toml_existing_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "shell = \"zsh\"\n").unwrap();
add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("zsh"));
}
#[test]
fn add_mount_to_toml_existing_mounts() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "mounts = [\"/data:/mnt\"]\n").unwrap();
add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("/data:/mnt"));
}
}