🥇 export from upstream (ce9e2c1)
This commit is contained in:
@@ -18,3 +18,5 @@ dirs = "6"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
tempfile = "3"
|
||||
dialoguer = "0.11"
|
||||
toml_edit = "0.22"
|
||||
|
||||
@@ -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
@@ -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
@@ -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,4 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod docker;
|
||||
pub mod init;
|
||||
pub mod setup;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user