Deduplicate SSH types and utilities within fabro-sandbox

Extract shared SSH types (SshOutput, SshRunner, GitCloneParams) and
utility functions (wrap_bash_command, resolve_clone_url, clone_repo)
into a new ssh_common module, eliminating ~270 lines of duplication
between the exe and ssh sandbox implementations.

Also extract a shared resolve_path helper used by four sandbox
implementations, and fix an O(n log n) metadata syscall issue in
LocalSandbox::glob by switching to sort_by_cached_key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-19 20:24:06 -04:00
parent 9b1cc75b20
commit 1fc3495ba9
No known key found for this signature in database
8 changed files with 228 additions and 326 deletions

View file

@ -140,13 +140,8 @@ impl DaytonaSandbox {
}
}
/// Resolve a path: relative paths are prepended with the working directory.
fn resolve_path(&self, path: &str) -> String {
if Path::new(path).is_absolute() {
path.to_string()
} else {
format!("{WORKING_DIRECTORY}/{path}")
}
crate::sandbox::resolve_path(path, WORKING_DIRECTORY)
}
/// Get the sandbox, returning an error if not yet initialized.

View file

@ -5,16 +5,19 @@ use std::path::Path;
use std::time::Instant;
use crate::shell_quote;
use crate::ssh_common;
use crate::{
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
SandboxEventCallback,
};
use async_trait::async_trait;
use base64::Engine;
use tokio_util::sync::CancellationToken;
pub use crate::ssh_common::{GitCloneParams, SshOutput, SshRunner};
pub use openssh_runner::OpensshRunner;
pub use fabro_config::sandbox::ExeConfig;
const WORKING_DIRECTORY: &str = "/home/exedev";
const PROVIDER: &str = "exe";
@ -52,40 +55,6 @@ type DataSshFactory = Box<
+ Sync,
>;
/// Output from an SSH command execution.
pub struct SshOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub exit_code: i32,
}
/// Trait abstracting SSH operations for testability.
#[async_trait]
pub trait SshRunner: Send + Sync {
async fn run_command(&self, command: &str) -> Result<SshOutput, String>;
async fn run_command_with_timeout(
&self,
command: &str,
timeout: std::time::Duration,
) -> Result<SshOutput, String>;
async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>;
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String>;
}
pub use fabro_config::sandbox::ExeConfig;
/// Parameters for cloning a git repo into the sandbox during initialization.
#[derive(Clone, Debug)]
pub struct GitCloneParams {
/// Clean HTTPS URL (no embedded credentials).
pub url: String,
/// Branch to clone. If None, uses the remote's default.
pub branch: Option<String>,
}
/// Sandbox that runs all operations inside an exe.dev VM via SSH.
///
/// Uses two SSH connections:
@ -206,128 +175,22 @@ impl ExeSandbox {
Ok(format!("ssh {host}"))
}
/// Wrap a shell command in base64 encoding to avoid escaping issues.
fn wrap_bash_command(command: &str) -> String {
let encoded = base64::engine::general_purpose::STANDARD.encode(command);
format!("echo '{encoded}' | base64 -d | sh")
}
/// Resolve an authenticated clone URL from the clean URL and github_app credentials.
async fn resolve_clone_url(&self, url: &str) -> Result<String, String> {
match &self.github_app {
Some(creds) => fabro_github::resolve_authenticated_url(creds, url)
.await
.or_else(|_| Ok(url.to_string())),
None => Ok(url.to_string()),
}
}
/// Clone a git repo into the sandbox working directory.
async fn clone_repo(&self, params: &GitCloneParams) -> Result<(), String> {
let ssh = self.data_ssh()?;
self.emit(SandboxEvent::GitCloneStarted {
url: params.url.clone(),
branch: params.branch.clone(),
});
let clone_start = Instant::now();
let clone_url = self.resolve_clone_url(&params.url).await?;
let branch_flag = params
.branch
.as_deref()
.map(|b| format!(" --branch {}", shell_quote(b)))
.unwrap_or_default();
let clone_script = format!(
"git clone{branch_flag} {} {WORKING_DIRECTORY}",
shell_quote(&clone_url)
);
let clone_cmd = Self::wrap_bash_command(&clone_script);
let clone_timeout = std::time::Duration::from_secs(300);
let clone_output = ssh
.run_command_with_timeout(&clone_cmd, clone_timeout)
.await
.map_err(|e| {
let err = format!("git clone failed: {e}");
self.emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
err
})?;
if clone_output.exit_code != 0 {
let stderr = String::from_utf8_lossy(&clone_output.stderr);
// Fall back to init + fetch + checkout if directory is not empty
if stderr.contains("not an empty directory")
|| stderr.contains("already exists and is not an empty")
{
let branch = params.branch.as_deref().unwrap_or("main");
let fallback_script = format!(
"cd {WORKING_DIRECTORY} && git init && git remote add origin {} && git fetch origin && git checkout {}",
shell_quote(&clone_url),
shell_quote(branch),
);
let fallback_cmd = Self::wrap_bash_command(&fallback_script);
let fallback_output = ssh
.run_command_with_timeout(&fallback_cmd, clone_timeout)
.await
.map_err(|e| {
let err = format!("git fallback clone failed: {e}");
self.emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
err
})?;
if fallback_output.exit_code != 0 {
let fallback_stderr = String::from_utf8_lossy(&fallback_output.stderr);
let err = format!(
"git fallback clone failed (exit {}): {fallback_stderr}",
fallback_output.exit_code,
);
self.emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
return Err(err);
}
} else {
let err = format!(
"git clone failed (exit {}): {stderr}",
clone_output.exit_code,
);
self.emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
return Err(err);
}
}
// Store the clean URL as origin_url for credential refresh
let _ = self.origin_url.set(params.url.clone());
let duration_ms = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::GitCloneCompleted {
url: params.url.clone(),
duration_ms,
});
Ok(())
ssh_common::clone_repo(
ssh,
WORKING_DIRECTORY,
params,
self.github_app.as_ref(),
&self.origin_url,
&|event| self.emit(event),
)
.await
}
/// Resolve a path: relative paths are prepended with the working directory.
fn resolve_path(&self, path: &str) -> String {
if Path::new(path).is_absolute() {
path.to_string()
} else {
format!("{WORKING_DIRECTORY}/{path}")
}
crate::sandbox::resolve_path(path, WORKING_DIRECTORY)
}
}
@ -488,7 +351,7 @@ impl Sandbox for ExeSandbox {
};
script.push_str(&format!("cd {} && {command}", shell_quote(&dir)));
let full_cmd = Self::wrap_bash_command(&script);
let full_cmd = ssh_common::wrap_bash_command(&script);
let timeout = std::time::Duration::from_millis(timeout_ms);
let token = cancel_token.unwrap_or_default();
@ -841,6 +704,7 @@ impl Sandbox for ExeSandbox {
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use std::sync::{Arc, Mutex};
/// A recorded command sent to the mock SSH runner.

View file

@ -2,6 +2,9 @@ pub mod sandbox;
pub mod read_guard;
#[cfg(feature = "ssh")]
pub(crate) mod ssh_common;
#[cfg(feature = "local")]
pub mod local;

View file

@ -347,15 +347,13 @@ impl Sandbox for LocalSandbox {
.map(|p| p.to_string_lossy().into_owned())
.collect();
// Sort by mtime (newest first)
results.sort_by(|a, b| {
let mtime_a = std::fs::metadata(a)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
let mtime_b = std::fs::metadata(b)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
mtime_b.cmp(&mtime_a)
// Sort by mtime (newest first), caching metadata to avoid O(n log n) syscalls
results.sort_by_cached_key(|path| {
std::cmp::Reverse(
std::fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH),
)
});
Ok(results)

View file

@ -431,6 +431,22 @@ pub trait Sandbox: Send + Sync {
fn mark_agent_read(&self, _path: &str) {}
}
/// Resolve a path: relative paths are prepended with the working directory.
/// Used by feature-gated sandbox implementations (exe, ssh, sprites, daytona).
#[cfg(any(
feature = "exe",
feature = "ssh",
feature = "sprites",
feature = "daytona"
))]
pub(crate) fn resolve_path(path: &str, working_dir: &str) -> String {
if std::path::Path::new(path).is_absolute() {
path.to_string()
} else {
format!("{working_dir}/{path}")
}
}
/// Shell-quote a string using `shlex::try_quote`, with a fallback for edge cases.
pub fn shell_quote(s: &str) -> String {
shlex::try_quote(s).map_or_else(

View file

@ -80,11 +80,7 @@ impl SpritesSandbox {
}
fn resolve_path(&self, path: &str) -> String {
if Path::new(path).is_absolute() {
path.to_string()
} else {
format!("{WORKING_DIRECTORY}/{path}")
}
crate::sandbox::resolve_path(path, WORKING_DIRECTORY)
}
fn sprite_name(&self) -> Result<&str, String> {

View file

@ -5,51 +5,20 @@ use std::path::Path;
use std::time::Instant;
use crate::shell_quote;
use crate::ssh_common;
use crate::{
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
SandboxEventCallback,
};
use async_trait::async_trait;
use base64::Engine;
use tokio_util::sync::CancellationToken;
pub use crate::ssh_common::{GitCloneParams, SshOutput, SshRunner};
pub use openssh_runner::OpensshRunner;
const PROVIDER: &str = "ssh";
/// Output from an SSH command execution.
pub struct SshOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub exit_code: i32,
}
/// Trait abstracting SSH operations for testability.
#[async_trait]
pub trait SshRunner: Send + Sync {
async fn run_command(&self, command: &str) -> Result<SshOutput, String>;
async fn run_command_with_timeout(
&self,
command: &str,
timeout: std::time::Duration,
) -> Result<SshOutput, String>;
async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>;
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String>;
}
pub use fabro_config::sandbox::SshConfig;
/// Parameters for cloning a git repo into the sandbox during initialization.
#[derive(Clone, Debug)]
pub struct GitCloneParams {
/// Clean HTTPS URL (no embedded credentials).
pub url: String,
/// Branch to clone. If None, uses the remote's default.
pub branch: Option<String>,
}
const PROVIDER: &str = "ssh";
/// Sandbox that runs all operations on a user-provided SSH host.
///
@ -127,131 +96,22 @@ impl SshSandbox {
format!("ssh {}", self.config.destination)
}
/// Wrap a shell command in base64 encoding to avoid escaping issues.
fn wrap_bash_command(command: &str) -> String {
let encoded = base64::engine::general_purpose::STANDARD.encode(command);
format!("echo '{encoded}' | base64 -d | sh")
}
/// Resolve an authenticated clone URL from the clean URL and github_app credentials.
async fn resolve_clone_url(&self, url: &str) -> Result<String, String> {
match &self.github_app {
Some(creds) => fabro_github::resolve_authenticated_url(creds, url)
.await
.or_else(|_| Ok(url.to_string())),
None => Ok(url.to_string()),
}
}
/// Clone a git repo into the sandbox working directory.
async fn clone_repo(&self, params: &GitCloneParams) -> Result<(), String> {
let ssh = self.ssh()?;
let working_dir = &self.config.working_directory;
self.emit(SandboxEvent::GitCloneStarted {
url: params.url.clone(),
branch: params.branch.clone(),
});
let clone_start = Instant::now();
let clone_url = self.resolve_clone_url(&params.url).await?;
let branch_flag = params
.branch
.as_deref()
.map(|b| format!(" --branch {}", shell_quote(b)))
.unwrap_or_default();
let clone_script = format!(
"git clone{branch_flag} {} {}",
shell_quote(&clone_url),
shell_quote(working_dir),
);
let clone_cmd = Self::wrap_bash_command(&clone_script);
let clone_timeout = std::time::Duration::from_secs(300);
let clone_output = ssh
.run_command_with_timeout(&clone_cmd, clone_timeout)
.await
.map_err(|e| {
let err = format!("git clone failed: {e}");
self.emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
err
})?;
if clone_output.exit_code != 0 {
let stderr = String::from_utf8_lossy(&clone_output.stderr);
// Fall back to init + fetch + checkout if directory is not empty
if stderr.contains("not an empty directory")
|| stderr.contains("already exists and is not an empty")
{
let branch = params.branch.as_deref().unwrap_or("main");
let fallback_script = format!(
"cd {} && git init && git remote add origin {} && git fetch origin && git checkout {}",
shell_quote(working_dir),
shell_quote(&clone_url),
shell_quote(branch),
);
let fallback_cmd = Self::wrap_bash_command(&fallback_script);
let fallback_output = ssh
.run_command_with_timeout(&fallback_cmd, clone_timeout)
.await
.map_err(|e| {
let err = format!("git fallback clone failed: {e}");
self.emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
err
})?;
if fallback_output.exit_code != 0 {
let fallback_stderr = String::from_utf8_lossy(&fallback_output.stderr);
let err = format!(
"git fallback clone failed (exit {}): {fallback_stderr}",
fallback_output.exit_code,
);
self.emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
return Err(err);
}
} else {
let err = format!(
"git clone failed (exit {}): {stderr}",
clone_output.exit_code,
);
self.emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
return Err(err);
}
}
// Store the clean URL as origin_url for credential refresh
let _ = self.origin_url.set(params.url.clone());
let duration_ms = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::GitCloneCompleted {
url: params.url.clone(),
duration_ms,
});
Ok(())
ssh_common::clone_repo(
ssh,
&self.config.working_directory,
params,
self.github_app.as_ref(),
&self.origin_url,
&|event| self.emit(event),
)
.await
}
/// Resolve a path: relative paths are prepended with the working directory.
fn resolve_path(&self, path: &str) -> String {
if Path::new(path).is_absolute() {
path.to_string()
} else {
format!("{}/{path}", self.config.working_directory)
}
crate::sandbox::resolve_path(path, &self.config.working_directory)
}
}
@ -362,7 +222,7 @@ impl Sandbox for SshSandbox {
};
script.push_str(&format!("cd {} && {command}", shell_quote(&dir)));
let full_cmd = Self::wrap_bash_command(&script);
let full_cmd = ssh_common::wrap_bash_command(&script);
let timeout = std::time::Duration::from_millis(timeout_ms);
let token = cancel_token.unwrap_or_default();
@ -725,6 +585,7 @@ impl Sandbox for SshSandbox {
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use std::sync::{Arc, Mutex};
/// A recorded command sent to the mock SSH runner.

View file

@ -0,0 +1,169 @@
//! Shared types and utilities for SSH-based sandbox implementations (exe, ssh).
use std::time::Instant;
use async_trait::async_trait;
use base64::Engine;
use crate::{shell_quote, SandboxEvent};
/// Output from an SSH command execution.
pub struct SshOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub exit_code: i32,
}
/// Trait abstracting SSH operations for testability.
#[async_trait]
pub trait SshRunner: Send + Sync {
async fn run_command(&self, command: &str) -> Result<SshOutput, String>;
async fn run_command_with_timeout(
&self,
command: &str,
timeout: std::time::Duration,
) -> Result<SshOutput, String>;
async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>;
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String>;
}
/// Parameters for cloning a git repo into the sandbox during initialization.
#[derive(Clone, Debug)]
pub struct GitCloneParams {
/// Clean HTTPS URL (no embedded credentials).
pub url: String,
/// Branch to clone. If None, uses the remote's default.
pub branch: Option<String>,
}
/// Wrap a shell command in base64 encoding to avoid escaping issues.
pub(crate) fn wrap_bash_command(command: &str) -> String {
let encoded = base64::engine::general_purpose::STANDARD.encode(command);
format!("echo '{encoded}' | base64 -d | sh")
}
/// Resolve an authenticated clone URL using GitHub App credentials, falling back to the
/// original URL if authentication fails or no credentials are provided.
pub(crate) async fn resolve_clone_url(
url: &str,
github_app: Option<&fabro_github::GitHubAppCredentials>,
) -> Result<String, String> {
match github_app {
Some(creds) => fabro_github::resolve_authenticated_url(creds, url)
.await
.or_else(|_| Ok(url.to_string())),
None => Ok(url.to_string()),
}
}
/// Clone a git repo into a sandbox working directory over SSH.
///
/// Handles the common clone logic including fallback to init+fetch when the
/// directory is not empty, event emission, and origin URL tracking.
pub(crate) async fn clone_repo(
ssh: &dyn SshRunner,
working_dir: &str,
params: &GitCloneParams,
github_app: Option<&fabro_github::GitHubAppCredentials>,
origin_url: &tokio::sync::OnceCell<String>,
emit: &(dyn Fn(SandboxEvent) + Send + Sync),
) -> Result<(), String> {
emit(SandboxEvent::GitCloneStarted {
url: params.url.clone(),
branch: params.branch.clone(),
});
let clone_start = Instant::now();
let clone_url = resolve_clone_url(&params.url, github_app).await?;
let branch_flag = params
.branch
.as_deref()
.map(|b| format!(" --branch {}", shell_quote(b)))
.unwrap_or_default();
let clone_script = format!(
"git clone{branch_flag} {} {}",
shell_quote(&clone_url),
shell_quote(working_dir),
);
let clone_cmd = wrap_bash_command(&clone_script);
let clone_timeout = std::time::Duration::from_secs(300);
let clone_output = ssh
.run_command_with_timeout(&clone_cmd, clone_timeout)
.await
.map_err(|e| {
let err = format!("git clone failed: {e}");
emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
err
})?;
if clone_output.exit_code != 0 {
let stderr = String::from_utf8_lossy(&clone_output.stderr);
// Fall back to init + fetch + checkout if directory is not empty
if stderr.contains("not an empty directory")
|| stderr.contains("already exists and is not an empty")
{
let branch = params.branch.as_deref().unwrap_or("main");
let fallback_script = format!(
"cd {} && git init && git remote add origin {} && git fetch origin && git checkout {}",
shell_quote(working_dir),
shell_quote(&clone_url),
shell_quote(branch),
);
let fallback_cmd = wrap_bash_command(&fallback_script);
let fallback_output = ssh
.run_command_with_timeout(&fallback_cmd, clone_timeout)
.await
.map_err(|e| {
let err = format!("git fallback clone failed: {e}");
emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
err
})?;
if fallback_output.exit_code != 0 {
let fallback_stderr = String::from_utf8_lossy(&fallback_output.stderr);
let err = format!(
"git fallback clone failed (exit {}): {fallback_stderr}",
fallback_output.exit_code,
);
emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
return Err(err);
}
} else {
let err = format!(
"git clone failed (exit {}): {stderr}",
clone_output.exit_code,
);
emit(SandboxEvent::GitCloneFailed {
url: params.url.clone(),
error: err.clone(),
});
return Err(err);
}
}
// Store the clean URL as origin_url for credential refresh
let _ = origin_url.set(params.url.clone());
let duration_ms = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX);
emit(SandboxEvent::GitCloneCompleted {
url: params.url.clone(),
duration_ms,
});
Ok(())
}