🥇 export from upstream (0a94b11)

This commit is contained in:
2026-05-23 18:18:07 +02:00
parent b3e25a283c
commit 0089c340f7
7 changed files with 1399 additions and 29 deletions
-17
View File
@@ -10,23 +10,6 @@
---
> [!WARNING]
> **⚠️ Unreleased Software ⚠️**
>
> 🚧 This project is under active development and **not yet released**. APIs, configuration formats, and behavior may change without notice. Please **do not use without contacting the author** about the current state of the project. 🚧
> [!CAUTION]
> **⚠️ Development Tool Only ⚠️**
>
> 🚧 Sandcage is designed for **local development use**. Do **not** use it in CI pipelines or production environments — container isolation is not yet hardened for those contexts. 🚧
### Planned Features
- **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials
- **ACP integration** via [`dirigate`](https://github.com/dirigence/dirigate) — Agent Communication Protocol support for structured agent orchestration
---
## Why Sandcage?
AI coding agents need broad access to do their work: shell, filesystem, network. Letting them run directly on your machine means they share your credentials, your session history, and your entire environment.
+65 -1
View File
@@ -29,6 +29,16 @@ pub enum ConfigError {
pub type Result<T> = std::result::Result<T, ConfigError>;
// ---------------------------------------------------------------------------
// Sub-structs
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SshKeyEntry {
pub host: String,
pub identity_file: String,
}
// ---------------------------------------------------------------------------
// Raw deserialization type — captures unknown keys
// ---------------------------------------------------------------------------
@@ -56,6 +66,10 @@ struct RawConfig {
container_workspace: Option<String>,
#[serde(default)]
agent_args: Option<HashMap<String, Vec<String>>>,
#[serde(default)]
ssh_mode: Option<String>,
#[serde(default)]
ssh_keys: Option<Vec<SshKeyEntry>>,
/// Absorb everything else so we can warn about unknown fields.
#[serde(flatten)]
@@ -89,13 +103,17 @@ pub struct SandcageConfig {
pub container_workspace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_args: Option<HashMap<String, Vec<String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_keys: Option<Vec<SshKeyEntry>>,
}
// ---------------------------------------------------------------------------
// Known keys — used to filter the flattened `extra` map
// ---------------------------------------------------------------------------
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args"];
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys"];
// ---------------------------------------------------------------------------
// Validation helpers
@@ -148,6 +166,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
trusted_projects: raw.trusted_projects,
container_workspace: raw.container_workspace,
agent_args: raw.agent_args,
ssh_mode: raw.ssh_mode,
ssh_keys: raw.ssh_keys,
}
}
@@ -570,4 +590,48 @@ agent_args:
let codex_args = agent_args.get("codex").expect("codex args");
assert_eq!(codex_args, &vec!["--full-auto"]);
}
#[test]
fn parse_ssh_mode() {
let yaml = "ssh_mode: volume\n";
let cfg = load_from_str(yaml).expect("parse ssh_mode");
assert_eq!(cfg.ssh_mode.as_deref(), Some("volume"));
}
#[test]
fn parse_ssh_keys() {
let yaml = r#"
ssh_mode: volume
ssh_keys:
- host: github.com
identity_file: ~/.ssh/id_ed25519
- host: gitea
identity_file: ~/.ssh/work_gitea
"#;
let cfg = load_from_str(yaml).expect("parse ssh_keys");
let keys = cfg.ssh_keys.expect("ssh_keys should be Some");
assert_eq!(keys.len(), 2);
assert_eq!(keys[0].host, "github.com");
assert_eq!(keys[0].identity_file, "~/.ssh/id_ed25519");
assert_eq!(keys[1].host, "gitea");
}
#[test]
fn ssh_mode_defaults_to_none() {
let cfg = load_from_str("").expect("parse empty");
assert!(cfg.ssh_mode.is_none());
assert!(cfg.ssh_keys.is_none());
}
#[test]
fn resolve_ssh_mode_project_overrides_global() {
let global_toml = r#"ssh_mode = "bind""#;
let project_yaml = "ssh_mode: volume\n";
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");
assert_eq!(cfg.ssh_mode.as_deref(), Some("volume"));
}
}
+88 -7
View File
@@ -253,6 +253,20 @@ pub fn build_run_args(
}
}
// SSH mode-aware mount
match config.ssh_mode.as_deref() {
Some("volume") => {
args.push("--mount".to_string());
args.push("type=volume,source=sandcage-ssh,target=/home/agent/.ssh,readonly".to_string());
}
Some("bind") => {
let mount = expand_mount_path("~/.ssh:/home/agent/.ssh:ro");
args.push("-v".to_string());
args.push(mount);
}
_ => {} // "none" or absent: no SSH mount
}
if shell_override {
args.push("--entrypoint".to_string());
args.push("/bin/zsh".to_string());
@@ -260,13 +274,12 @@ 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());
}
if !shell_override
&& 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());
}
}
@@ -290,6 +303,17 @@ pub fn run_service(
let image = image_for_service(service);
require_image(&docker, image)?;
if config.ssh_mode.as_deref() == Some("volume") {
let vol_check = Command::new(&docker)
.args(["volume", "inspect", "sandcage-ssh"])
.output();
if !vol_check.map(|o| o.status.success()).unwrap_or(false) {
eprintln!(
"sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it"
);
}
}
let compose_file = write_compose_tempfile()?;
let compose_path = compose_file.path().to_string_lossy().into_owned();
@@ -984,6 +1008,63 @@ mod tests {
assert!(resume_pos > service_pos);
}
#[test]
fn build_run_args_ssh_mode_volume() {
let config = SandcageConfig {
ssh_mode: Some("volume".to_string()),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
assert!(args.contains(&"--mount".to_string()));
let mount_idx = args.iter().position(|a| a == "--mount").unwrap();
assert_eq!(
args[mount_idx + 1],
"type=volume,source=sandcage-ssh,target=/home/agent/.ssh,readonly"
);
}
#[test]
fn build_run_args_ssh_mode_bind() {
let config = SandcageConfig {
ssh_mode: Some("bind".to_string()),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
assert!(args.contains(&"-v".to_string()));
// Find the -v flag and check the mount string contains .ssh
let v_positions: Vec<_> = args
.iter()
.enumerate()
.filter(|(_, a)| *a == "-v")
.map(|(i, _)| i)
.collect();
let has_ssh_mount = v_positions.iter().any(|&i| {
args.get(i + 1)
.map(|a| a.contains(".ssh") && a.ends_with(":ro"))
.unwrap_or(false)
});
assert!(has_ssh_mount, "should have .ssh bind mount, args: {:?}", args);
}
#[test]
fn build_run_args_ssh_mode_none() {
let config = SandcageConfig {
ssh_mode: Some("none".to_string()),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
assert!(!args.contains(&"--mount".to_string()));
assert!(!args.iter().any(|a| a.contains(".ssh")));
}
#[test]
fn build_run_args_ssh_mode_absent() {
let config = SandcageConfig::default();
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
assert!(!args.contains(&"--mount".to_string()));
// May have .ssh in other mounts, but no SSH-specific mount
}
#[test]
fn build_compose_env_container_dir_root_fallback() {
let workspace = PathBuf::from("/");
+1
View File
@@ -2,4 +2,5 @@ pub mod config;
pub mod docker;
pub mod init;
pub mod setup;
pub mod ssh_config;
pub mod workspace;
+10 -2
View File
@@ -79,6 +79,14 @@ enum SetupAction {
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
/// Re-populate the SSH volume using saved selection
#[arg(long)]
refresh: bool,
/// Use legacy full bind mount instead of volume copy
#[arg(long)]
bind: bool,
},
}
@@ -144,8 +152,8 @@ fn main() -> miette::Result<()> {
init::scaffold_project(&workspace)?;
}
Commands::Setup { action } => match action {
SetupAction::Ssh { global, yes } => {
setup::run_ssh_setup(global, yes)?;
SetupAction::Ssh { global, yes, refresh, bind } => {
setup::run_ssh_setup(global, yes, refresh, bind)?;
}
},
}
+786 -2
View File
@@ -1,9 +1,13 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use miette::Diagnostic;
use thiserror::Error;
use crate::config::SandcageConfig;
use figment::providers::Format as _;
use crate::config::{SandcageConfig, SshKeyEntry};
use crate::ssh_config::{self, SshHostBlock};
#[derive(Debug, Error, Diagnostic)]
pub enum SetupError {
@@ -34,6 +38,10 @@ pub enum SetupError {
#[error("Cannot determine home directory")]
#[diagnostic(code(sandcage::setup::no_home_dir))]
NoHomeDir,
#[error("Docker command failed: {0}")]
#[diagnostic(code(sandcage::setup::docker_failed))]
DockerFailed(String),
}
pub type Result<T> = std::result::Result<T, SetupError>;
@@ -229,6 +237,77 @@ pub fn add_mount_to_toml(path: &Path, mount: &str) -> Result<()> {
Ok(())
}
pub fn extract_git_remote_hosts(git_remote_output: &str) -> Vec<String> {
use std::collections::BTreeSet;
let mut hosts: BTreeSet<String> = BTreeSet::new();
for line in git_remote_output.lines() {
// Each line: "<name>\t<url> (fetch)" or "<name>\t<url> (push)"
let url = match line.split('\t').nth(1) {
Some(u) => u,
None => continue,
};
// Strip trailing " (fetch)" / " (push)"
let url = url
.trim_end_matches(" (push)")
.trim_end_matches(" (fetch)")
.trim();
if url.starts_with("https://") || url.starts_with("http://") {
continue;
}
if let Some(after_scheme) = url.strip_prefix("ssh://") {
// ssh://[user@]host[:port]/path
// Drop optional user@
let host_part = match after_scheme.find('@') {
Some(at) => &after_scheme[at + 1..],
None => after_scheme,
};
// host is everything before the first '/' or ':'
let host = host_part
.split(&['/', ':'][..])
.next()
.unwrap_or("")
.trim();
if !host.is_empty() {
hosts.insert(host.to_string());
}
} else if url.contains('@') && url.contains(':') {
// git@host:path format
let after_at = match url.find('@') {
Some(at) => &url[at + 1..],
None => continue,
};
let host = match after_at.find(':') {
Some(colon) => &after_at[..colon],
None => continue,
};
let host = host.trim();
if !host.is_empty() {
hosts.insert(host.to_string());
}
}
}
hosts.into_iter().collect()
}
pub fn discover_git_remote_hosts() -> Vec<String> {
let output = std::process::Command::new("git")
.args(["remote", "-v"])
.output();
match output {
Ok(out) if out.status.success() => {
let text = String::from_utf8_lossy(&out.stdout);
extract_git_remote_hosts(&text)
}
_ => Vec::new(),
}
}
fn display_scan_result(result: &SshScanResult) {
if !result.keys.is_empty() {
eprintln!(" Keys: {}", result.keys.join(", "));
@@ -250,7 +329,380 @@ fn display_scan_result(result: &SshScanResult) {
eprintln!("This will mount ~/.ssh into containers as read-only.");
}
pub fn run_ssh_setup(global: bool, yes: bool) -> Result<()> {
pub fn has_legacy_ssh_mount(config: &SandcageConfig) -> bool {
config.ssh_mode.is_none() && check_existing_mount(config, SSH_MOUNT)
}
pub fn write_ssh_config_to_yaml(path: &Path, entries: &[SshDiscoveryEntry]) -> 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()))?
};
config.ssh_mode = Some("volume".to_string());
config.ssh_keys = Some(
entries
.iter()
.map(|e| SshKeyEntry {
host: e
.ssh_host_alias
.clone()
.unwrap_or_else(|| e.display_host.clone()),
identity_file: e.identity_file.clone().unwrap_or_default(),
})
.collect(),
);
// Remove old bind mount if present
if let Some(ref mut mounts) = config.mounts {
mounts.retain(|m| m != SSH_MOUNT);
if mounts.is_empty() {
config.mounts = None;
}
}
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(())
}
pub fn write_ssh_config_to_toml(path: &Path, entries: &[SshDiscoveryEntry]) -> 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())
})?;
doc["ssh_mode"] = toml_edit::value("volume");
// Remove old bind mount if present
if let Some(mounts) = doc.get_mut("mounts").and_then(|m| m.as_array_mut()) {
mounts.retain(|v| v.as_str() != Some(SSH_MOUNT));
if mounts.is_empty() {
doc.remove("mounts");
}
}
// Build ssh_keys array of tables
let mut arr = toml_edit::ArrayOfTables::new();
for entry in entries {
let mut table = toml_edit::Table::new();
table["host"] = toml_edit::value(
entry
.ssh_host_alias
.clone()
.unwrap_or_else(|| entry.display_host.clone()),
);
table["identity_file"] =
toml_edit::value(entry.identity_file.clone().unwrap_or_default());
arr.push(table);
}
doc.insert("ssh_keys", toml_edit::Item::ArrayOfTables(arr));
std::fs::write(path, doc.to_string())
.map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?;
Ok(())
}
pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Result<()> {
if bind {
return run_ssh_setup_bind(global, yes);
}
let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?;
let ssh_dir = home.join(".ssh");
if refresh {
return run_ssh_refresh(global, &ssh_dir);
}
// Check for existing bind mount (migration path)
let config_path_for_check = if global {
home.join(".sandcage").join("config.toml")
} else {
let cwd = std::env::current_dir()
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
cwd.join(".sandcage.yml")
};
if config_path_for_check.exists() {
let existing_cfg = if global {
let figment = figment::Figment::from(
figment::providers::Serialized::defaults(SandcageConfig::default()),
)
.merge(figment::providers::Toml::file(&config_path_for_check));
figment.extract::<SandcageConfig>().unwrap_or_default()
} else {
crate::config::load(&config_path_for_check).unwrap_or_default()
};
if has_legacy_ssh_mount(&existing_cfg) {
eprintln!("sandcage: existing SSH bind mount detected in config");
if !yes {
let migrate = dialoguer::Confirm::new()
.with_prompt("Migrate to volume-based SSH? (bind mount will be removed)")
.default(true)
.interact()
.unwrap_or(false);
if !migrate {
// Set explicit bind mode so we don't ask again
if global {
let mut doc: toml_edit::DocumentMut =
std::fs::read_to_string(&config_path_for_check)
.unwrap_or_default()
.parse()
.unwrap_or_default();
doc["ssh_mode"] = toml_edit::value("bind");
std::fs::write(&config_path_for_check, doc.to_string()).map_err(
|e| SetupError::ConfigWriteFailed(config_path_for_check, e),
)?;
} else {
let content = std::fs::read_to_string(&config_path_for_check)
.map_err(|e| {
SetupError::ConfigReadFailed(config_path_for_check.clone(), e)
})?;
let mut cfg: SandcageConfig =
serde_yaml::from_str(&content).unwrap_or_default();
cfg.ssh_mode = Some("bind".to_string());
let yaml = serde_yaml::to_string(&cfg).unwrap_or_default();
let output = rehydrate_yaml_comments(&content, &yaml);
std::fs::write(&config_path_for_check, output).map_err(|e| {
SetupError::ConfigWriteFailed(config_path_for_check, e)
})?;
}
eprintln!("sandcage: keeping bind mount, set ssh_mode: bind");
return Ok(());
}
}
// Continue with volume mode setup — the write_ssh_config_to_* functions
// will remove the old mount entry
}
}
// Discover candidates
let ssh_config_path = ssh_dir.join("config");
let ssh_blocks = if ssh_config_path.exists() {
let content = std::fs::read_to_string(&ssh_config_path)
.map_err(|e| SetupError::ConfigReadFailed(ssh_config_path.clone(), e))?;
let ssh_dir_clone = ssh_dir.clone();
let read_include = move |path: &str| {
let include_path = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
ssh_dir_clone.join(path)
};
std::fs::read_to_string(include_path).ok()
};
ssh_config::parse_ssh_config_with_includes(&content, &read_include)
} else {
Vec::new()
};
let git_remote_hosts = discover_git_remote_hosts();
let entries = build_ssh_discovery(&ssh_blocks, &git_remote_hosts, &ssh_dir);
if entries.is_empty() {
eprintln!("sandcage: no git SSH hosts found in remotes or ~/.ssh/config");
return Ok(());
}
// Display discoveries
eprintln!("sandcage: scanning git remotes and SSH config...\n");
for entry in &entries {
let source_label = match entry.source {
SshDiscoverySource::Both => "git remote + ssh config",
SshDiscoverySource::GitRemote => "git remote",
SshDiscoverySource::SshConfig => "ssh config (User git)",
};
let key_label = entry.identity_file.as_deref().unwrap_or("(default key)");
let alias_label = entry
.ssh_host_alias
.as_deref()
.filter(|a| *a != entry.display_host)
.map(|a| format!(" (alias: {a})"))
.unwrap_or_default();
eprintln!(
" {}{alias_label}{key_label} [{source_label}]",
entry.display_host
);
}
eprintln!();
// Interactive selection
let (selected_entries, include_known_hosts) = if yes {
(entries.clone(), true)
} else {
let items: Vec<String> = entries
.iter()
.map(|e| {
let key = e.identity_file.as_deref().unwrap_or("(default)");
format!("{}{key}", e.display_host)
})
.collect();
let defaults: Vec<bool> = vec![true; items.len()];
let selected_indices = dialoguer::MultiSelect::new()
.with_prompt("Select SSH keys to copy")
.items(&items)
.defaults(&defaults)
.interact()
.unwrap_or_else(|_| (0..items.len()).collect());
let selected: Vec<SshDiscoveryEntry> = selected_indices
.iter()
.map(|&i| entries[i].clone())
.collect();
let include_kh = dialoguer::Confirm::new()
.with_prompt("Include known_hosts? (recommended)")
.default(true)
.interact()
.unwrap_or(true);
(selected, include_kh)
};
if selected_entries.is_empty() {
eprintln!("sandcage: no keys selected, nothing to do");
return Ok(());
}
// Determine config target
let (config_path, is_global) = if global {
(home.join(".sandcage").join("config.toml"), true)
} 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, false)
};
// Write config
if is_global {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| SetupError::ConfigWriteFailed(config_path.clone(), e))?;
}
write_ssh_config_to_toml(&config_path, &selected_entries)?;
} else {
write_ssh_config_to_yaml(&config_path, &selected_entries)?;
}
// Populate volume
eprintln!("sandcage: populating SSH volume...");
populate_ssh_volume(&selected_entries, &ssh_blocks, include_known_hosts)?;
let config_label = if is_global {
"~/.sandcage/config.toml"
} else {
".sandcage.yml"
};
eprintln!("sandcage: SSH keys copied to volume 'sandcage-ssh'");
eprintln!("sandcage: configuration saved to {config_label}");
Ok(())
}
fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> {
let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?;
let config_path = if global {
home.join(".sandcage").join("config.toml")
} else {
let cwd = std::env::current_dir()
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
cwd.join(".sandcage.yml")
};
if !config_path.exists() {
return Err(SetupError::ConfigReadFailed(
config_path,
std::io::Error::new(std::io::ErrorKind::NotFound, "config file not found"),
));
}
let cfg = if global {
let figment = figment::Figment::from(
figment::providers::Serialized::defaults(SandcageConfig::default()),
)
.merge(figment::providers::Toml::file(&config_path));
figment
.extract::<SandcageConfig>()
.map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))?
} else {
crate::config::load(&config_path)
.map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))?
};
let ssh_keys = cfg.ssh_keys.unwrap_or_default();
if ssh_keys.is_empty() {
eprintln!("sandcage: no ssh_keys in config — run `sandcage setup ssh` first");
return Ok(());
}
// Rebuild entries from saved config
let entries: Vec<SshDiscoveryEntry> = ssh_keys
.iter()
.map(|k| SshDiscoveryEntry {
display_host: k.host.clone(),
ssh_host_alias: Some(k.host.clone()),
identity_file: Some(k.identity_file.clone()),
host_block: None,
source: SshDiscoverySource::SshConfig,
})
.collect();
// Re-read SSH config to get full Host blocks for config synthesis
let ssh_config_path = ssh_dir.join("config");
let ssh_blocks = if ssh_config_path.exists() {
let content = std::fs::read_to_string(&ssh_config_path)
.map_err(|e| SetupError::ConfigReadFailed(ssh_config_path.clone(), e))?;
ssh_config::parse_ssh_config(&content)
} else {
Vec::new()
};
// Rebuild entries with host blocks from current SSH config
let entries_with_blocks: Vec<SshDiscoveryEntry> = entries
.into_iter()
.map(|mut e| {
e.host_block = ssh_blocks
.iter()
.find(|b| {
b.aliases
.first()
.map(|a| a.as_str())
== Some(&e.display_host)
})
.cloned();
e
})
.collect();
eprintln!("sandcage: refreshing SSH volume...");
populate_ssh_volume(&entries_with_blocks, &ssh_blocks, true)?;
eprintln!("sandcage: SSH volume refreshed");
Ok(())
}
fn run_ssh_setup_bind(global: bool, yes: bool) -> Result<()> {
let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?;
let ssh_dir = home.join(".ssh");
@@ -331,6 +783,242 @@ pub fn run_ssh_setup(global: bool, yes: bool) -> Result<()> {
Ok(())
}
// ── Task 7: SSH host discovery ──────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
pub enum SshDiscoverySource {
GitRemote,
SshConfig,
Both,
}
#[derive(Debug, Clone)]
pub struct SshDiscoveryEntry {
pub display_host: String,
pub ssh_host_alias: Option<String>,
pub identity_file: Option<String>,
pub host_block: Option<SshHostBlock>,
pub source: SshDiscoverySource,
}
/// Find a default SSH private key in `ssh_dir` by checking common names.
fn find_default_key(ssh_dir: &Path) -> Option<String> {
let candidates = ["id_ed25519", "id_ecdsa", "id_rsa"];
for name in candidates {
if ssh_dir.join(name).exists() {
return Some(format!("~/.ssh/{name}"));
}
}
None
}
/// Merge SSH host candidates from SSH config blocks and git remote hosts.
///
/// Returns a deduplicated, sorted list of [`SshDiscoveryEntry`] values.
pub fn build_ssh_discovery(
ssh_blocks: &[SshHostBlock],
git_remote_hosts: &[String],
ssh_dir: &Path,
) -> Vec<SshDiscoveryEntry> {
// Keyed by display_host for deduplication and sorted output.
let mut map: BTreeMap<String, SshDiscoveryEntry> = BTreeMap::new();
// 1. Add SSH config blocks where user is git.
for block in ssh_blocks {
if !block.is_git_user() {
continue;
}
// display_host = HostName directive, or first alias as fallback.
let display_host = block
.hostname()
.unwrap_or_else(|| block.aliases.first().map(|s| s.as_str()).unwrap_or(""))
.to_string();
if display_host.is_empty() {
continue;
}
let ssh_host_alias = block.aliases.first().cloned();
let identity_file = block.identity_file().map(|s| s.to_string());
map.entry(display_host.clone()).or_insert_with(|| SshDiscoveryEntry {
display_host,
ssh_host_alias,
identity_file,
host_block: Some(block.clone()),
source: SshDiscoverySource::SshConfig,
});
}
// 2. Process git remote hosts.
for git_host in git_remote_hosts {
if let Some(entry) = map.get_mut(git_host.as_str()) {
// Hostname already present via SSH config — upgrade source.
entry.source = SshDiscoverySource::Both;
} else {
// Check if any SSH block has this git_host as an alias.
let alias_match = ssh_blocks.iter().find(|b| {
b.is_git_user() && b.aliases.iter().any(|a| a == git_host)
});
if let Some(block) = alias_match {
// The display_host should already be in the map via step 1, but just in case
// the block wasn't added (e.g., HostName was set and we keyed on HostName),
// upgrade source on whichever entry owns this block's alias.
let display = block
.hostname()
.unwrap_or_else(|| block.aliases.first().map(|s| s.as_str()).unwrap_or(""))
.to_string();
if let Some(existing) = map.get_mut(&display) {
existing.source = SshDiscoverySource::Both;
} else {
// Shouldn't normally happen, but handle gracefully.
map.insert(display.clone(), SshDiscoveryEntry {
display_host: display,
ssh_host_alias: block.aliases.first().cloned(),
identity_file: block.identity_file().map(|s| s.to_string()),
host_block: Some(block.clone()),
source: SshDiscoverySource::Both,
});
}
} else {
// No SSH config match — pure git remote entry.
let identity_file = find_default_key(ssh_dir);
map.insert(git_host.clone(), SshDiscoveryEntry {
display_host: git_host.clone(),
ssh_host_alias: None,
identity_file,
host_block: None,
source: SshDiscoverySource::GitRemote,
});
}
}
}
map.into_values().collect()
}
// ── Task 8: Docker volume population ───────────────────────────────────────
/// Copy selected SSH identity files and (optionally) `known_hosts` into the
/// `sandcage-ssh` Docker volume via a temporary container.
pub fn populate_ssh_volume(
entries: &[SshDiscoveryEntry],
_ssh_blocks: &[SshHostBlock],
include_known_hosts: bool,
) -> Result<()> {
let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?;
let ssh_dir = home.join(".ssh");
// Build synthesized SSH config from selected entries' host blocks.
let selected_blocks: Vec<SshHostBlock> = entries
.iter()
.filter_map(|e| e.host_block.clone())
.collect();
let synthesized_config = ssh_config::synthesize_ssh_config(&selected_blocks);
// Collect key file names to copy.
let mut key_files: Vec<String> = Vec::new();
for entry in entries {
if let Some(ref id_file) = entry.identity_file {
let resolved = ssh_config::resolve_identity_file(id_file, &ssh_dir);
if let Some(file_name) = resolved.file_name() {
let name = file_name.to_string_lossy().into_owned();
if !key_files.contains(&name) {
key_files.push(name.clone());
}
// Include matching .pub key if it exists.
let pub_name = format!("{name}.pub");
let pub_path = ssh_dir.join(&pub_name);
if pub_path.exists() && !key_files.contains(&pub_name) {
key_files.push(pub_name);
}
}
}
}
// Optionally include known_hosts.
if include_known_hosts && ssh_dir.join("known_hosts").exists() {
let kh = "known_hosts".to_string();
if !key_files.contains(&kh) {
key_files.push(kh);
}
}
// Normalize path for Docker (Windows: backslash → forward slash).
let ssh_dir_str = ssh_dir.to_string_lossy().replace('\\', "/");
// Remove old volume.
let rm_status = std::process::Command::new("docker")
.args(["volume", "rm", "-f", "sandcage-ssh"])
.status()
.map_err(|e| SetupError::DockerFailed(format!("docker volume rm: {e}")))?;
if !rm_status.success() {
return Err(SetupError::DockerFailed(
format!("docker volume rm exited with {rm_status}"),
));
}
// Create volume.
let create_status = std::process::Command::new("docker")
.args(["volume", "create", "sandcage-ssh"])
.status()
.map_err(|e| SetupError::DockerFailed(format!("docker volume create: {e}")))?;
if !create_status.success() {
return Err(SetupError::DockerFailed(
format!("docker volume create exited with {create_status}"),
));
}
// Build in-container shell script.
let mut script = String::new();
// Ensure target directory exists.
script.push_str("mkdir -p /home/agent/.ssh && ");
// Write synthesized config.
// Escape single quotes in the config for sh -c '...' embedding.
let escaped_config = synthesized_config.replace('\'', "'\\''");
script.push_str(&format!(
"printf '%s' '{escaped_config}' > /home/agent/.ssh/config && chmod 644 /home/agent/.ssh/config"
));
// Copy key files.
for name in &key_files {
let is_pub_or_kh = name.ends_with(".pub") || name == "known_hosts";
let mode = if is_pub_or_kh { "644" } else { "600" };
script.push_str(&format!(
" && cp /host-ssh/{name} /home/agent/.ssh/{name} && chmod {mode} /home/agent/.ssh/{name}"
));
}
// Fix ownership.
script.push_str(" && chown -R agent:agent /home/agent/.ssh");
// Run the container.
let run_status = std::process::Command::new("docker")
.args([
"run",
"--rm",
&format!("-v{ssh_dir_str}:/host-ssh:ro"),
"--mount",
"type=volume,source=sandcage-ssh,target=/home/agent/.ssh",
"--user",
"root",
"sandcage:latest",
"sh",
"-c",
&script,
])
.status()
.map_err(|e| SetupError::DockerFailed(format!("docker run: {e}")))?;
if !run_status.success() {
return Err(SetupError::DockerFailed(
format!("docker run exited with {run_status}"),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -537,4 +1225,100 @@ mod tests {
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("/data:/mnt"));
}
// -- extract_git_remote_hosts tests --
#[test]
fn extract_hostnames_from_git_remote_output() {
let output = "\
origin\tgit@github.com:user/repo.git (fetch)
origin\tgit@github.com:user/repo.git (push)
upstream\tgit@gitea.example.com:org/repo.git (fetch)
upstream\tgit@gitea.example.com:org/repo.git (push)
https\thttps://gitlab.com/user/repo.git (fetch)
https\thttps://gitlab.com/user/repo.git (push)
";
let hosts = extract_git_remote_hosts(output);
assert_eq!(hosts, vec!["gitea.example.com", "github.com"]);
}
#[test]
fn extract_hostnames_ssh_url_with_port() {
let output = "origin\tssh://git@git.corp.com:2222/repo.git (fetch)\n";
let hosts = extract_git_remote_hosts(output);
assert_eq!(hosts, vec!["git.corp.com"]);
}
#[test]
fn extract_hostnames_empty_output() {
let hosts = extract_git_remote_hosts("");
assert!(hosts.is_empty());
}
// -- Task 7: build_ssh_discovery tests --
#[test]
fn discover_ssh_entries_merges_sources() {
use crate::ssh_config::parse_ssh_config;
let ssh_config_text = "\
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519
Host gitea
User git
HostName gitea.example.com
IdentityFile ~/.ssh/work_gitea
Host myserver
User admin
HostName server.example.com
";
let blocks = parse_ssh_config(ssh_config_text);
let git_remote_hosts = vec!["github.com".to_string(), "gitlab.com".to_string()];
let ssh_dir = Path::new("/nonexistent/.ssh"); // default key won't be found
let entries = build_ssh_discovery(&blocks, &git_remote_hosts, ssh_dir);
// github.com: from both (git remote + SSH config User git)
// gitea.example.com: from SSH config only (User git, HostName resolves)
// gitlab.com: from git remote only, no SSH config match
// myserver: NOT included (User admin, not in git remotes)
assert_eq!(entries.len(), 3);
let github = entries.iter().find(|e| e.display_host == "github.com").unwrap();
assert_eq!(github.source, SshDiscoverySource::Both);
assert_eq!(github.ssh_host_alias.as_deref(), Some("github.com"));
let gitea = entries.iter().find(|e| e.display_host == "gitea.example.com").unwrap();
assert_eq!(gitea.source, SshDiscoverySource::SshConfig);
assert_eq!(gitea.ssh_host_alias.as_deref(), Some("gitea"));
let gitlab = entries.iter().find(|e| e.display_host == "gitlab.com").unwrap();
assert_eq!(gitlab.source, SshDiscoverySource::GitRemote);
assert!(gitlab.ssh_host_alias.is_none());
}
// -- has_legacy_ssh_mount tests --
#[test]
fn detect_existing_bind_mount_in_config() {
let config = SandcageConfig {
mounts: Some(vec![SSH_MOUNT.to_string()]),
ssh_mode: None,
..Default::default()
};
assert!(has_legacy_ssh_mount(&config));
}
#[test]
fn no_legacy_mount_when_ssh_mode_set() {
let config = SandcageConfig {
mounts: Some(vec![SSH_MOUNT.to_string()]),
ssh_mode: Some("bind".to_string()),
..Default::default()
};
assert!(!has_legacy_ssh_mount(&config));
}
}
+449
View File
@@ -0,0 +1,449 @@
use std::path::{Path, PathBuf};
/// A parsed SSH config `Host` block.
#[derive(Debug, Clone, PartialEq)]
pub struct SshHostBlock {
/// The patterns listed on the `Host` line (e.g. `["github.com", "gh"]`).
pub aliases: Vec<String>,
/// Key-value directives in the order they appear in the config.
pub directives: Vec<(String, String)>,
}
impl SshHostBlock {
/// Return the value of the first directive matching `key` (case-insensitive).
pub fn get(&self, key: &str) -> Option<&str> {
let key_lower = key.to_lowercase();
self.directives
.iter()
.find(|(k, _)| k.to_lowercase() == key_lower)
.map(|(_, v)| v.as_str())
}
/// Return the `User` directive value, if present.
pub fn user(&self) -> Option<&str> {
self.get("User")
}
/// Return the `IdentityFile` directive value, if present.
pub fn identity_file(&self) -> Option<&str> {
self.get("IdentityFile")
}
/// Return the `HostName` directive value, if present.
pub fn hostname(&self) -> Option<&str> {
self.get("HostName")
}
/// Returns `true` if this block's `User` is `git` (common for GitHub/GitLab host entries).
pub fn is_git_user(&self) -> bool {
self.user().map(|u| u == "git").unwrap_or(false)
}
}
/// Resolve an SSH `IdentityFile` path to an absolute [`PathBuf`].
///
/// - `~/...` paths expand `~` via [`dirs::home_dir`], falling back to `ssh_dir.parent()`.
/// - Relative paths are joined onto `ssh_dir`.
/// - Absolute paths are returned as-is.
pub fn resolve_identity_file(identity_file: &str, ssh_dir: &Path) -> PathBuf {
if let Some(suffix) = identity_file.strip_prefix("~/") {
let home = dirs::home_dir()
.unwrap_or_else(|| ssh_dir.parent().unwrap_or(ssh_dir).to_path_buf());
home.join(suffix)
} else if Path::new(identity_file).is_absolute() {
PathBuf::from(identity_file)
} else {
ssh_dir.join(identity_file)
}
}
/// Produce a valid SSH config file string from a slice of [`SshHostBlock`]s.
///
/// Blocks are separated by blank lines; directives are indented with 4 spaces.
pub fn synthesize_ssh_config(blocks: &[SshHostBlock]) -> String {
let mut out = String::new();
for (i, block) in blocks.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str("Host ");
out.push_str(&block.aliases.join(" "));
out.push('\n');
for (key, value) in &block.directives {
out.push_str(" ");
out.push_str(key);
out.push(' ');
out.push_str(value);
out.push('\n');
}
}
out
}
/// Parse SSH config text into a list of [`SshHostBlock`]s, supporting `Include` directives.
///
/// When an `Include <path>` line is encountered:
/// - The current block is flushed.
/// - `read_include(path)` is called; if it returns `Some(content)`, that content is parsed
/// recursively and its blocks are appended.
/// - Parsing of the main content then continues.
///
/// Pass `&|_| None` to disable Include handling (identical to [`parse_ssh_config`]).
pub fn parse_ssh_config_with_includes(
content: &str,
read_include: &dyn Fn(&str) -> Option<String>,
) -> Vec<SshHostBlock> {
let mut blocks: Vec<SshHostBlock> = Vec::new();
// None → no active block yet; Some(true) → Host block; Some(false) → Match block (skip)
let mut current_aliases: Option<Vec<String>> = None;
let mut current_directives: Vec<(String, String)> = Vec::new();
let mut in_host_block = false;
let flush = |blocks: &mut Vec<SshHostBlock>,
aliases: &mut Option<Vec<String>>,
directives: &mut Vec<(String, String)>,
in_host: &mut bool| {
if *in_host {
if let Some(a) = aliases.take() {
blocks.push(SshHostBlock {
aliases: a,
directives: std::mem::take(directives),
});
}
} else {
aliases.take();
directives.clear();
}
*in_host = false;
};
for line in content.lines() {
let trimmed = line.trim();
// Skip blank lines and comments
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Split keyword from the rest on first whitespace or '='
let (keyword, rest) = split_keyword(trimmed);
match keyword.to_lowercase().as_str() {
"host" => {
// Flush previous block
flush(
&mut blocks,
&mut current_aliases,
&mut current_directives,
&mut in_host_block,
);
let aliases: Vec<String> = rest
.split_whitespace()
.map(|s| s.to_string())
.collect();
current_aliases = Some(aliases);
in_host_block = true;
}
"match" => {
// Flush previous block, then enter skip mode
flush(
&mut blocks,
&mut current_aliases,
&mut current_directives,
&mut in_host_block,
);
current_aliases = Some(Vec::new()); // placeholder so flush knows there's a block
in_host_block = false;
}
"include" => {
// Flush the current block before processing the include
flush(
&mut blocks,
&mut current_aliases,
&mut current_directives,
&mut in_host_block,
);
if let Some(included_content) = read_include(rest.trim()) {
let included_blocks =
parse_ssh_config_with_includes(&included_content, read_include);
blocks.extend(included_blocks);
}
}
_ => {
if in_host_block && current_aliases.is_some() {
current_directives.push((keyword.to_string(), rest.to_string()));
}
// In Match block or before any block → ignore
}
}
}
// Flush last block
flush(
&mut blocks,
&mut current_aliases,
&mut current_directives,
&mut in_host_block,
);
blocks
}
/// Parse SSH config text into a list of [`SshHostBlock`]s.
///
/// - Only `Host` blocks are returned; `Match` blocks are skipped entirely.
/// - Comments (`#`) and blank lines are ignored.
/// - Directive lines are split on the first run of whitespace or `=`.
/// - Multiple aliases on a single `Host` line are all captured.
/// - `Include` directives are ignored (use [`parse_ssh_config_with_includes`] to handle them).
pub fn parse_ssh_config(content: &str) -> Vec<SshHostBlock> {
parse_ssh_config_with_includes(content, &|_| None)
}
/// Split a trimmed SSH config line into (keyword, value).
///
/// The separator is the first `=` (with optional surrounding whitespace) or the first
/// run of whitespace — whichever comes first.
fn split_keyword(line: &str) -> (String, String) {
// Find position of first '=' or first whitespace character
let eq_pos = line.find('=');
let ws_pos = line.find(|c: char| c.is_ascii_whitespace());
let sep_pos = match (eq_pos, ws_pos) {
(Some(e), Some(w)) => Some(e.min(w)),
(Some(e), None) => Some(e),
(None, Some(w)) => Some(w),
(None, None) => None,
};
match sep_pos {
None => (line.to_string(), String::new()),
Some(pos) => {
let keyword = line[..pos].to_string();
// Skip separator characters (whitespace and '=')
let rest = line[pos..].trim_start_matches(|c: char| c == '=' || c.is_ascii_whitespace());
(keyword, rest.to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_single_host_block() {
let config = "\
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519
HostName github.com
";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 1);
let b = &blocks[0];
assert_eq!(b.aliases, vec!["github.com"]);
assert_eq!(b.user(), Some("git"));
assert_eq!(b.identity_file(), Some("~/.ssh/id_ed25519"));
assert_eq!(b.hostname(), Some("github.com"));
}
#[test]
fn parse_multiple_blocks_filter_git_user() {
let config = "\
Host work-gitlab
HostName gitlab.mycompany.com
User git
IdentityFile ~/.ssh/id_work
Host personal
HostName github.com
User git
IdentityFile ~/.ssh/id_personal
Host bastion
HostName bastion.mycompany.com
User admin
IdentityFile ~/.ssh/id_bastion
";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 3);
let git_blocks: Vec<_> = blocks.iter().filter(|b| b.is_git_user()).collect();
assert_eq!(git_blocks.len(), 2);
let git_aliases: Vec<&str> = git_blocks.iter().map(|b| b.aliases[0].as_str()).collect();
assert!(git_aliases.contains(&"work-gitlab"));
assert!(git_aliases.contains(&"personal"));
let non_git: Vec<_> = blocks.iter().filter(|b| !b.is_git_user()).collect();
assert_eq!(non_git.len(), 1);
assert_eq!(non_git[0].aliases[0], "bastion");
}
#[test]
fn parse_host_with_multiple_aliases() {
let config = "\
Host github.com gh
User git
IdentityFile ~/.ssh/id_github
";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].aliases, vec!["github.com", "gh"]);
}
#[test]
fn skip_match_blocks() {
let config = "\
Host first
User alice
Match host internal.example.com
ProxyJump bastion
Host second
User bob
";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].aliases, vec!["first"]);
assert_eq!(blocks[0].user(), Some("alice"));
assert_eq!(blocks[1].aliases, vec!["second"]);
assert_eq!(blocks[1].user(), Some("bob"));
}
#[test]
fn handle_tabs_and_varied_indentation() {
let config = "Host tabbed\n\tUser git\n\tIdentityFile\t~/.ssh/id_tab\n";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].user(), Some("git"));
assert_eq!(blocks[0].identity_file(), Some("~/.ssh/id_tab"));
}
#[test]
fn ignore_comments_and_blank_lines() {
let config = "\
# Global settings comment
# Host section
Host example.com
# inline comment
User git
IdentityFile ~/.ssh/id_example
";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].aliases, vec!["example.com"]);
assert_eq!(blocks[0].user(), Some("git"));
assert_eq!(blocks[0].identity_file(), Some("~/.ssh/id_example"));
// The inline comment line should not appear as a directive
assert!(!blocks[0].directives.iter().any(|(k, _)| k.starts_with('#')));
}
#[test]
fn empty_config() {
let blocks = parse_ssh_config("");
assert!(blocks.is_empty());
}
#[test]
fn comments_only() {
let config = "\
# This is a comment
# Another comment
# Yet another
";
let blocks = parse_ssh_config(config);
assert!(blocks.is_empty());
}
// --- Task 2: identity file resolution ---
#[test]
fn resolve_identity_file_tilde() {
let ssh_dir = Path::new("/home/user/.ssh");
let path = resolve_identity_file("~/.ssh/id_ed25519", ssh_dir);
let home = dirs::home_dir().unwrap();
assert_eq!(path, home.join(".ssh/id_ed25519"));
}
#[test]
fn resolve_identity_file_relative() {
let path = resolve_identity_file("id_ed25519", Path::new("/home/user/.ssh"));
assert_eq!(path, PathBuf::from("/home/user/.ssh/id_ed25519"));
}
#[test]
fn resolve_identity_file_absolute() {
let path = resolve_identity_file("/etc/ssh/key", Path::new("/home/user/.ssh"));
assert_eq!(path, PathBuf::from("/etc/ssh/key"));
}
#[test]
fn synthesize_config_from_blocks() {
let blocks = vec![
SshHostBlock {
aliases: vec!["github.com".into()],
directives: vec![
("User".into(), "git".into()),
("IdentityFile".into(), "~/.ssh/id_ed25519".into()),
("HostName".into(), "github.com".into()),
],
},
SshHostBlock {
aliases: vec!["gitea".into()],
directives: vec![
("User".into(), "git".into()),
("HostName".into(), "gitea.example.com".into()),
("IdentityFile".into(), "~/.ssh/work_gitea".into()),
("Port".into(), "2222".into()),
],
},
];
let config = synthesize_ssh_config(&blocks);
let expected = "\
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519
HostName github.com
Host gitea
User git
HostName gitea.example.com
IdentityFile ~/.ssh/work_gitea
Port 2222
";
assert_eq!(config, expected);
}
// --- Task 3: Include directive support ---
#[test]
fn parse_with_include_callback() {
let main_config = "\
Include extra_config
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519
";
let extra_config = "\
Host gitea.local
User git
IdentityFile ~/.ssh/gitea_key
";
let blocks = parse_ssh_config_with_includes(main_config, &|path: &str| {
if path == "extra_config" {
Some(extra_config.to_string())
} else {
None
}
});
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].aliases, vec!["gitea.local"]);
assert_eq!(blocks[1].aliases, vec!["github.com"]);
}
}