pub mod daytona;
pub mod docker;
pub mod local;
pub mod modal;
pub mod singularity;
#[cfg(unix)]
pub mod ssh;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use async_trait::async_trait;
use regex::Regex;
use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken;
use lingshu_types::ToolError;
#[derive(Debug, Clone)]
pub struct ExecOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
impl ExecOutput {
pub fn format(&self, max_stdout: usize, max_stderr: usize) -> String {
let stdout = truncate_output(&self.stdout, max_stdout);
let stderr = truncate_output(&self.stderr, max_stderr);
let mut out = String::new();
if !stdout.is_empty() {
out.push_str(&stdout);
}
if !stderr.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str("[stderr]\n");
out.push_str(&stderr);
}
if self.exit_code != 0 {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("[exit code: {}]", self.exit_code));
}
if out.is_empty() {
out = format!("[command completed with exit code: {}]", self.exit_code);
}
out
}
}
fn truncate_output(s: &str, max_len: usize) -> String {
if max_len == 0 || s.len() <= max_len {
return s.to_string();
}
let head_len = max_len * 2 / 5;
let tail_len = max_len - head_len;
let head_end = (0..=head_len)
.rev()
.find(|&i| s.is_char_boundary(i))
.unwrap_or(0);
let tail_start_raw = s.len().saturating_sub(tail_len);
let tail_start = (tail_start_raw..=s.len())
.find(|&i| s.is_char_boundary(i))
.unwrap_or(s.len());
if tail_start <= head_end {
return format!(
"{}\n\n... [{} bytes omitted] ...",
&s[..head_end],
s.len() - head_end,
);
}
let omitted = tail_start - head_end;
format!(
"{}\n\n... [{} bytes omitted] ...\n\n{}",
&s[..head_end],
omitted,
&s[tail_start..],
)
}
struct RedactPatterns {
prefix: Regex,
env_assign: Regex,
auth_header: Regex,
db_connstr: Regex,
privkey: Regex,
}
fn redact_patterns() -> &'static RedactPatterns {
static P: std::sync::OnceLock<RedactPatterns> = std::sync::OnceLock::new();
P.get_or_init(|| RedactPatterns {
prefix: Regex::new(
r"(sk-[A-Za-z0-9_-]{10,}|ghp_[A-Za-z0-9]{10,}|github_pat_[A-Za-z0-9_]{10,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[A-Za-z0-9_-]{30,}|pplx-[A-Za-z0-9]{10,}|fal_[A-Za-z0-9_-]{10,}|fc-[A-Za-z0-9]{10,}|AKIA[A-Z0-9]{16}|sk_live_[A-Za-z0-9]{10,}|sk_test_[A-Za-z0-9]{10,}|hf_[A-Za-z0-9]{10,}|r8_[A-Za-z0-9]{10,})"
).expect("prefix regex"),
env_assign: Regex::new(
r#"(?i)([A-Z_]*(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)[A-Z_]*)\s*=\s*(\S{4,})"#
).expect("env_assign regex"),
auth_header: Regex::new(
r"(?i)(Authorization:\s*(?:Bearer|Token)\s+)(\S+)"
).expect("auth_header regex"),
db_connstr: Regex::new(
r"(?i)((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:]+:)([^@\s]+)(@)"
).expect("db_connstr regex"),
privkey: Regex::new(
r"-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----"
).expect("privkey regex"),
})
}
fn mask_token(token: &str) -> String {
let len = token.chars().count();
if len < 18 {
"[REDACTED]".to_string()
} else {
let head: String = token.chars().take(6).collect();
let tail: String = token
.chars()
.rev()
.take(4)
.collect::<String>()
.chars()
.rev()
.collect();
format!("{head}...[REDACTED]...{tail}")
}
}
pub(crate) fn redact_output(s: &str) -> String {
let p = redact_patterns();
let s = p
.prefix
.replace_all(s, |caps: ®ex::Captures| mask_token(&caps[1]))
.into_owned();
let s = p
.env_assign
.replace_all(&s, |caps: ®ex::Captures| -> String {
format!("{}=[REDACTED]", &caps[1])
})
.into_owned();
let s = p
.auth_header
.replace_all(&s, |caps: ®ex::Captures| -> String {
format!("{}[REDACTED]", &caps[1])
})
.into_owned();
let s = p
.db_connstr
.replace_all(&s, |caps: ®ex::Captures| -> String {
format!("{}[REDACTED]{}", &caps[1], &caps[3])
})
.into_owned();
p.privkey
.replace_all(&s, "[PRIVATE KEY REDACTED]")
.into_owned()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum BackendKind {
#[default]
Local,
Docker,
Ssh,
Modal,
Daytona,
Singularity,
}
impl BackendKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Local => "local",
Self::Docker => "docker",
Self::Ssh => "ssh",
Self::Modal => "modal",
Self::Daytona => "daytona",
Self::Singularity => "singularity",
}
}
}
impl std::fmt::Display for BackendKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for BackendKind {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.to_lowercase().as_str() {
"docker" => Self::Docker,
"ssh" => Self::Ssh,
"modal" => Self::Modal,
"daytona" => Self::Daytona,
"singularity" | "apptainer" => Self::Singularity,
_ => Self::Local,
})
}
}
pub use crate::tool_progress_tail::{ExecuteOptions, OutputProgressFn};
#[async_trait]
pub trait ExecutionBackend: Send + Sync {
async fn execute(
&self,
command: &str,
cwd: &str,
timeout: Duration,
cancel: CancellationToken,
options: ExecuteOptions,
) -> Result<ExecOutput, ToolError>;
async fn execute_oneshot(
&self,
command: &str,
cwd: &str,
timeout: Duration,
cancel: CancellationToken,
) -> Result<ExecOutput, ToolError> {
self.execute(command, cwd, timeout, cancel, ExecuteOptions::default())
.await
}
async fn cleanup(&self) -> Result<(), ToolError>;
fn kind(&self) -> BackendKind;
fn supports_remote_execute_code(&self) -> bool {
false
}
async fn is_healthy(&self) -> bool {
true
}
}
pub fn finalize_execute_progress(options: &ExecuteOptions, output: &ExecOutput) {
crate::tool_progress_tail::emit_batch_output_progress(options, &output.stdout, &output.stderr);
}
pub fn start_execute_progress(options: &ExecuteOptions, backend: &str, command: &str) {
crate::tool_progress_tail::emit_execution_start(options, backend, command);
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DockerWorkspaceMount {
pub host_path: String,
pub container_path: String,
pub read_only: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DockerBackendConfig {
pub image: String,
pub workspace_mount: Option<DockerWorkspaceMount>,
pub pids_limit: Option<i64>,
pub memory_bytes: Option<i64>,
pub nano_cpus: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SshBackendConfig {
pub host: String,
pub user: Option<String>,
pub port: Option<u16>,
pub key_path: Option<PathBuf>,
pub strict_host_checking: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ModalTransportMode {
#[default]
Auto,
Direct,
Managed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ModalBackendConfig {
pub mode: ModalTransportMode,
pub image: String,
pub token_id: String,
pub token_secret: String,
pub managed_gateway_url: Option<String>,
pub managed_user_token: Option<String>,
pub cpu: u32,
pub memory_mb: u32,
pub disk_mb: u32,
pub persistent_filesystem: bool,
}
impl Default for ModalBackendConfig {
fn default() -> Self {
Self {
mode: ModalTransportMode::Auto,
image: "python:3.11-slim".into(),
token_id: String::new(),
token_secret: String::new(),
managed_gateway_url: None,
managed_user_token: None,
cpu: 1,
memory_mb: 5120,
disk_mb: 0,
persistent_filesystem: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DaytonaBackendConfig {
pub image: String,
pub cpu: u32,
pub memory_mb: u32,
pub disk_mb: u32,
pub persistent_filesystem: bool,
}
impl Default for DaytonaBackendConfig {
fn default() -> Self {
Self {
image: "nikolaik/python-nodejs:python3.11-nodejs20".into(),
cpu: 1,
memory_mb: 5120,
disk_mb: 10240,
persistent_filesystem: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SingularityBackendConfig {
pub image: String,
pub cpu: f32,
pub memory_mb: u32,
pub disk_mb: u32,
pub persistent_filesystem: bool,
}
impl Default for SingularityBackendConfig {
fn default() -> Self {
Self {
image: "docker://nikolaik/python-nodejs:python3.11-nodejs20".into(),
cpu: 1.0,
memory_mb: 5120,
disk_mb: 10240,
persistent_filesystem: true,
}
}
}
#[derive(Debug, Clone)]
pub struct BackendConfig {
pub kind: BackendKind,
pub task_id: String,
pub docker: DockerBackendConfig,
pub ssh: SshBackendConfig,
pub modal: ModalBackendConfig,
pub daytona: DaytonaBackendConfig,
pub singularity: SingularityBackendConfig,
}
impl Default for BackendConfig {
fn default() -> Self {
Self {
kind: BackendKind::Local,
task_id: String::new(),
docker: DockerBackendConfig::default(),
ssh: SshBackendConfig::default(),
modal: ModalBackendConfig::default(),
daytona: DaytonaBackendConfig::default(),
singularity: SingularityBackendConfig::default(),
}
}
}
pub(crate) fn sandbox_root_dir() -> PathBuf {
std::env::var("TERMINAL_SANDBOX_DIR")
.map(PathBuf::from)
.or_else(|_| {
std::env::var("EDGECRAB_HOME")
.map(PathBuf::from)
.map(|home| home.join("sandboxes"))
})
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".lingshu")
.join("sandboxes")
})
}
pub(crate) fn sanitize_resource_name(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
out.push(ch.to_ascii_lowercase());
} else {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
if trimmed.is_empty() {
"lingshu".into()
} else {
trimmed.to_string()
}
}
pub(crate) fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
pub(crate) fn ensure_dir(path: &Path, label: &str) -> Result<(), ToolError> {
std::fs::create_dir_all(path).map_err(|e| ToolError::ExecutionFailed {
tool: "terminal".into(),
message: format!("Failed to create {label} at {}: {e}", path.display()),
})
}
pub fn transform_sudo(command: &str) -> (String, Option<String>) {
if !command.split_whitespace().any(|w| w == "sudo") {
return (command.to_string(), None);
}
let password = std::env::var("SUDO_PASSWORD").unwrap_or_default();
if password.is_empty() {
return (command.to_string(), None);
}
let transformed = replace_sudo_word(command);
(transformed, Some(format!("{password}\n")))
}
fn replace_sudo_word(input: &str) -> String {
let needle = "sudo";
let replacement = "sudo -S -p ''";
let mut result = String::with_capacity(input.len() + 32);
let bytes = input.as_bytes();
let n = bytes.len();
let mut i = 0;
while i < n {
if i + needle.len() <= n && &bytes[i..i + needle.len()] == needle.as_bytes() {
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
let after_pos = i + needle.len();
let after_ok = after_pos >= n
|| (!bytes[after_pos].is_ascii_alphanumeric() && bytes[after_pos] != b'_');
if before_ok && after_ok {
result.push_str(replacement);
i += needle.len();
continue;
}
}
result.push(bytes[i] as char);
i += 1;
}
result
}
pub async fn build_backend(config: BackendConfig) -> Result<Box<dyn ExecutionBackend>, ToolError> {
match config.kind {
BackendKind::Local => Ok(Box::new(local::LocalBackend::new(config.task_id))),
BackendKind::Docker => Ok(Box::new(docker::DockerBackend::new(
config.task_id,
config.docker,
))),
#[cfg(unix)]
BackendKind::Ssh => Ok(Box::new(ssh::SshBackend::new(config.task_id, config.ssh))),
#[cfg(not(unix))]
BackendKind::Ssh => Err(ToolError::ExecutionFailed {
tool: "terminal".into(),
message: "SSH backend requires a Unix-like OS (not supported on Windows)".into(),
}),
BackendKind::Modal => Ok(Box::new(modal::ModalBackend::new(
config.task_id,
config.modal,
))),
BackendKind::Daytona => Ok(Box::new(daytona::DaytonaBackend::new(
config.task_id,
config.daytona,
))),
BackendKind::Singularity => Ok(Box::new(singularity::SingularityBackend::new(
config.task_id,
config.singularity,
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_kind_roundtrip() {
for (s, expected) in [
("local", BackendKind::Local),
("docker", BackendKind::Docker),
("ssh", BackendKind::Ssh),
("modal", BackendKind::Modal),
("daytona", BackendKind::Daytona),
("singularity", BackendKind::Singularity),
("apptainer", BackendKind::Singularity),
("unknown", BackendKind::Local),
] {
assert_eq!(
s.parse::<BackendKind>().expect("infallible"),
expected,
"failed for {s}"
);
}
}
#[test]
fn exec_output_format_stdout_only() {
let o = ExecOutput {
stdout: "hello\n".into(),
stderr: String::new(),
exit_code: 0,
};
let f = o.format(usize::MAX, usize::MAX);
assert_eq!(f, "hello\n");
}
#[test]
fn exec_output_format_with_stderr_and_exit() {
let o = ExecOutput {
stdout: "out\n".into(),
stderr: "err\n".into(),
exit_code: 1,
};
let f = o.format(usize::MAX, usize::MAX);
assert!(f.contains("out\n"));
assert!(f.contains("[stderr]\nerr\n"));
assert!(f.contains("[exit code: 1]"));
}
#[test]
fn exec_output_truncation() {
let long = "a".repeat(200);
let o = ExecOutput {
stdout: long.clone(),
stderr: String::new(),
exit_code: 0,
};
let f = o.format(100, usize::MAX);
assert!(f.contains("omitted"), "expected omit marker; got: {f}");
assert!(
f.len() < long.len(),
"truncated output must be shorter than original"
);
let before_marker = f.split("omitted").next().unwrap_or("");
let after_marker = f.split("omitted").nth(1).unwrap_or("");
assert!(!before_marker.is_empty(), "head fragment must be non-empty");
assert!(
!after_marker.trim_start_matches(['.', ' ']).is_empty(),
"tail fragment must be non-empty"
);
}
#[test]
fn exec_output_empty_becomes_status() {
let o = ExecOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
};
let f = o.format(usize::MAX, usize::MAX);
assert!(f.contains("exit code: 0"), "got: {f}");
}
#[test]
fn truncate_output_exact_boundary() {
let s: String = "héllo".into();
let result = truncate_output(&s, 4);
assert!(result.starts_with("h"));
}
#[test]
fn backend_config_default_is_local() {
let cfg = BackendConfig::default();
assert_eq!(cfg.kind, BackendKind::Local);
}
#[test]
fn sanitize_resource_name_normalizes_symbols() {
assert_eq!(sanitize_resource_name("Task ID:/A B"), "task-id--a-b");
}
#[test]
fn shell_quote_escapes_single_quotes() {
assert_eq!(shell_quote("it's ok"), "'it'\\''s ok'");
}
#[tokio::test]
async fn build_backend_local() {
let cfg = BackendConfig {
kind: BackendKind::Local,
task_id: "t1".into(),
..Default::default()
};
let b = build_backend(cfg).await.expect("build");
assert_eq!(b.kind(), BackendKind::Local);
}
static SUDO_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn transform_sudo_no_sudo_word_passthrough() {
let _g = SUDO_ENV_LOCK.lock().unwrap();
let (cmd, pfx) = transform_sudo("ls -la /tmp");
assert_eq!(cmd, "ls -la /tmp");
assert!(pfx.is_none());
}
#[test]
fn transform_sudo_no_password_passthrough() {
let _g = SUDO_ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("SUDO_PASSWORD") };
let (cmd, pfx) = transform_sudo("sudo apt-get update");
assert_eq!(cmd, "sudo apt-get update");
assert!(pfx.is_none());
}
#[test]
fn transform_sudo_with_password() {
let _g = SUDO_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("SUDO_PASSWORD", "s3cr3t") };
let (cmd, pfx) = transform_sudo("sudo apt-get update");
assert!(cmd.contains("sudo -S -p ''"), "got: {cmd}");
assert!(
!cmd.contains("sudo apt-get"),
"original sudo must be replaced"
);
assert_eq!(pfx.as_deref(), Some("s3cr3t\n"));
unsafe { std::env::remove_var("SUDO_PASSWORD") };
}
#[test]
fn transform_sudo_does_not_rewrite_visudo() {
let _g = SUDO_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("SUDO_PASSWORD", "pass") };
let (cmd, _) = transform_sudo("visudo -c");
assert_eq!(cmd, "visudo -c", "visudo must not be rewritten");
unsafe { std::env::remove_var("SUDO_PASSWORD") };
}
#[test]
fn transform_sudo_multiple_sudo_tokens() {
let _g = SUDO_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("SUDO_PASSWORD", "p") };
let (cmd, pfx) = transform_sudo("sudo echo hi && sudo whoami");
assert!(cmd.matches("sudo -S -p ''").count() == 2, "got: {cmd}");
assert!(pfx.is_some());
unsafe { std::env::remove_var("SUDO_PASSWORD") };
}
#[test]
fn replace_sudo_word_word_boundaries() {
let out = replace_sudo_word("sudo foo sudoers pseudo");
assert!(out.starts_with("sudo -S -p ''"), "got: {out}");
assert!(out.contains("sudoers"), "sudoers must be preserved");
assert!(out.contains("pseudo"), "pseudo must be preserved");
}
#[test]
fn redact_output_passthrough_clean_text() {
let text = "hello world, nothing secret here";
assert_eq!(redact_output(text), text);
}
#[test]
fn redact_output_openai_key() {
let text = "key=sk-abcdefghij1234567890";
let out = redact_output(text);
assert!(
!out.contains("abcdefghij1234567890"),
"key must be masked; got: {out}"
);
assert!(
out.contains("REDACTED"),
"must contain REDACTED marker; got: {out}"
);
}
#[test]
fn redact_output_github_pat() {
let text = "export token=ghp_ABCDEFGHIJ1234567890";
let out = redact_output(text);
assert!(
!out.contains("ABCDEFGHIJ1234567890"),
"PAT must be masked; got: {out}"
);
}
#[test]
fn redact_output_env_assignment() {
let text = "MY_API_KEY=supersecretvalue123";
let out = redact_output(text);
assert!(
out.contains("MY_API_KEY="),
"key name must be preserved; got: {out}"
);
assert!(
!out.contains("supersecretvalue123"),
"value must be masked; got: {out}"
);
}
#[test]
fn redact_output_auth_header() {
let text = "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig";
let out = redact_output(text);
assert!(
out.contains("Authorization:"),
"header name preserved; got: {out}"
);
assert!(
!out.contains("eyJhbGciOiJSUzI1NiJ9"),
"bearer token masked; got: {out}"
);
assert!(
out.contains("[REDACTED]"),
"must contain REDACTED; got: {out}"
);
}
#[test]
fn redact_output_db_connstr() {
let text = "postgres://admin:topsecret@db.example.com:5432/mydb";
let out = redact_output(text);
assert!(
!out.contains("topsecret"),
"password must be masked; got: {out}"
);
assert!(out.contains("postgres://"), "scheme preserved; got: {out}");
assert!(out.contains("@"), "@ delimiter preserved; got: {out}");
}
#[test]
fn redact_output_private_key_block() {
let text = "key data:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\nend";
let out = redact_output(text);
assert!(
!out.contains("MIIEowIBAAKCAQEA"),
"key material must be masked; got: {out}"
);
assert!(
out.contains("[PRIVATE KEY REDACTED]"),
"must have redact label; got: {out}"
);
}
#[test]
fn redact_output_short_values_not_redacted() {
let text = "DEBUG=yes\nFLAG=1";
let out = redact_output(text);
assert_eq!(out, text, "non-secret env vars must pass through unchanged");
}
#[test]
fn redact_output_long_key_preserves_head_tail() {
let text = "sk-abcdefghijklmnopqrstuvwxyz0123456789";
let out = redact_output(text);
assert!(
out.contains("sk-abc"),
"first 6 chars preserved; got: {out}"
);
assert!(out.contains("..."), "ellipsis present; got: {out}");
}
}