use std::env;
use std::io::IsTerminal;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxProvider {
Codespaces,
Gitpod,
Devcontainer,
Replit,
E2b,
Modal,
Daytona,
ClaudeCode,
Cursor,
GenericContainer,
}
impl SandboxProvider {
pub fn name(&self) -> &'static str {
match self {
Self::Codespaces => "GitHub Codespaces",
Self::Gitpod => "Gitpod",
Self::Devcontainer => "Devcontainer",
Self::Replit => "Replit",
Self::E2b => "e2b",
Self::Modal => "Modal",
Self::Daytona => "Daytona",
Self::ClaudeCode => "Claude Code",
Self::Cursor => "Cursor",
Self::GenericContainer => "container",
}
}
}
impl std::fmt::Display for SandboxProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallCapability {
Full,
VerifyOnly(VerifyOnlyReason),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyOnlyReason {
NoJarvyHome,
ReadOnlyRoot,
NoInstallPath,
Forced,
}
impl std::fmt::Display for VerifyOnlyReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::NoJarvyHome => "no_jarvy_home",
Self::ReadOnlyRoot => "read_only_root",
Self::NoInstallPath => "no_install_path",
Self::Forced => "forced",
};
f.write_str(s)
}
}
#[derive(Debug, Clone)]
pub struct SandboxEnvironment {
pub provider: SandboxProvider,
pub forced: bool,
}
pub fn detect() -> Option<SandboxEnvironment> {
cached_detect()
}
#[cfg(not(test))]
fn cached_detect() -> Option<SandboxEnvironment> {
static CACHE: OnceLock<Option<SandboxEnvironment>> = OnceLock::new();
CACHE
.get_or_init(|| {
let result = detect_uncached();
if let Some(env_) = &result {
tracing::info!(
event = "sandbox.detected",
provider = %env_.provider,
forced = env_.forced,
"seamless mode active"
);
} else if env::var("JARVY_SANDBOX").as_deref() == Ok("0") {
tracing::info!(
event = "sandbox.detect.suppressed",
reason = "JARVY_SANDBOX=0",
"sandbox detection disabled by user override"
);
}
result
})
.clone()
}
#[cfg(test)]
fn cached_detect() -> Option<SandboxEnvironment> {
detect_uncached()
}
fn detect_uncached() -> Option<SandboxEnvironment> {
if env::var("JARVY_SANDBOX").as_deref() == Ok("0") {
return None;
}
let forced = env::var("JARVY_SANDBOX").as_deref() == Ok("1");
let provider = detect_provider();
match (forced, provider) {
(true, Some(p)) => Some(SandboxEnvironment {
provider: p,
forced: true,
}),
(true, None) => Some(SandboxEnvironment {
provider: SandboxProvider::GenericContainer,
forced: true,
}),
(false, Some(p)) => Some(SandboxEnvironment {
provider: p,
forced: false,
}),
(false, None) => None,
}
}
pub fn is_sandbox() -> bool {
detect().is_some()
}
pub fn is_seamless() -> bool {
is_sandbox() || crate::ci::is_ci()
}
pub fn is_seamless_auto() -> bool {
let sandbox_auto = detect().map(|e| !e.forced).unwrap_or(false);
sandbox_auto || crate::ci::is_ci()
}
pub fn install_capability() -> InstallCapability {
static CACHED: OnceLock<InstallCapability> = OnceLock::new();
*CACHED.get_or_init(probe_install_capability)
}
fn detect_provider() -> Option<SandboxProvider> {
if env::var("CODESPACES").as_deref() == Ok("true") || env::var("CODESPACE_NAME").is_ok() {
return Some(SandboxProvider::Codespaces);
}
if env::var("GITPOD_WORKSPACE_ID").is_ok() {
return Some(SandboxProvider::Gitpod);
}
if env::var("REMOTE_CONTAINERS").as_deref() == Ok("true")
|| env::var("DEVCONTAINER").as_deref() == Ok("true")
{
return Some(SandboxProvider::Devcontainer);
}
if env::var("REPL_ID").is_ok() {
return Some(SandboxProvider::Replit);
}
if env::var("E2B_SANDBOX_ID").is_ok() {
return Some(SandboxProvider::E2b);
}
if env::var("MODAL_TASK_ID").is_ok() {
return Some(SandboxProvider::Modal);
}
if env::var("DAYTONA_WS_ID").is_ok() {
return Some(SandboxProvider::Daytona);
}
if env::var("CLAUDECODE").as_deref() == Ok("1") || env::var("CLAUDE_CODE_ENTRYPOINT").is_ok() {
return Some(SandboxProvider::ClaudeCode);
}
if env::var("CURSOR_AGENT").as_deref() == Ok("1") {
return Some(SandboxProvider::Cursor);
}
if is_generic_container(
std::path::Path::new("/.dockerenv").exists(),
std::io::stdin().is_terminal(),
) {
return Some(SandboxProvider::GenericContainer);
}
None
}
fn is_generic_container(dockerenv_present: bool, stdin_is_tty: bool) -> bool {
dockerenv_present && !stdin_is_tty
}
fn probe_install_capability() -> InstallCapability {
if env::var("JARVY_FORCE_VERIFY_ONLY").as_deref() == Ok("1") {
return InstallCapability::VerifyOnly(VerifyOnlyReason::Forced);
}
let Ok(home) = crate::paths::jarvy_home() else {
tracing::warn!(
event = "sandbox.capability.probe_failed",
probe = "jarvy_home",
reason = "no_home_dir"
);
return InstallCapability::VerifyOnly(VerifyOnlyReason::NoJarvyHome);
};
if let Err(e) = std::fs::create_dir_all(&home) {
tracing::warn!(
event = "sandbox.capability.probe_failed",
probe = "create_jarvy_home",
path = %home.display(),
err = %e
);
return InstallCapability::VerifyOnly(VerifyOnlyReason::ReadOnlyRoot);
}
let probe = home.join(format!(".probe-{}", std::process::id()));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)
{
Ok(_) => {
let _ = std::fs::remove_file(&probe);
}
Err(e) => {
tracing::warn!(
event = "sandbox.capability.probe_failed",
probe = "rootfs_write",
path = %probe.display(),
err = %e
);
return InstallCapability::VerifyOnly(VerifyOnlyReason::ReadOnlyRoot);
}
}
if user_scope_pkg_manager_available() {
return InstallCapability::Full;
}
if passwordless_sudo_available() {
return InstallCapability::Full;
}
InstallCapability::VerifyOnly(VerifyOnlyReason::NoInstallPath)
}
fn user_scope_pkg_manager_available() -> bool {
["brew", "cargo", "winget", "scoop"]
.iter()
.any(|c| which::which(c).is_ok())
}
fn passwordless_sudo_available() -> bool {
std::process::Command::new("sudo")
.args(["-n", "true"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
static BANNER_EMITTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn print_banner_once(env_: &SandboxEnvironment) {
if BANNER_EMITTED.swap(true, std::sync::atomic::Ordering::Relaxed) {
return;
}
let suffix = if env_.forced { " (forced)" } else { "" };
eprintln!(
"[jarvy] detected {} — seamless mode{} (override: JARVY_SANDBOX=0)",
env_.provider, suffix
);
}
#[cfg(test)]
mod tests {
use super::*;
const SANDBOX_VARS: &[&str] = &[
"JARVY_SANDBOX",
"CODESPACES",
"CODESPACE_NAME",
"GITPOD_WORKSPACE_ID",
"REMOTE_CONTAINERS",
"DEVCONTAINER",
"REPL_ID",
"E2B_SANDBOX_ID",
"MODAL_TASK_ID",
"DAYTONA_WS_ID",
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CURSOR_AGENT",
"CI",
"GITHUB_ACTIONS",
"GITLAB_CI",
"JARVY_CI",
"JARVY_NO_CI",
];
#[allow(unsafe_code)]
fn with_env<F, R>(vars: &[(&str, &str)], f: F) -> R
where
F: FnOnce() -> R,
{
let cleared: Vec<(&str, Option<String>)> = SANDBOX_VARS
.iter()
.map(|k| {
let orig = env::var(k).ok();
unsafe { env::remove_var(k) };
(*k, orig)
})
.collect();
let originals: Vec<_> = vars
.iter()
.map(|(k, v)| {
let orig = env::var(k).ok();
unsafe { env::set_var(k, v) };
(*k, orig)
})
.collect();
let result = f();
for (k, orig) in originals {
match orig {
Some(v) => unsafe { env::set_var(k, v) },
None => unsafe { env::remove_var(k) },
}
}
for (k, orig) in cleared {
match orig {
Some(v) => unsafe { env::set_var(k, v) },
None => unsafe { env::remove_var(k) },
}
}
result
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_codespaces() {
with_env(&[("CODESPACES", "true")], || {
let env = detect().expect("codespaces should detect");
assert_eq!(env.provider, SandboxProvider::Codespaces);
assert!(!env.forced);
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_codespaces_via_name() {
with_env(&[("CODESPACE_NAME", "test-codespace")], || {
let env = detect().expect("codespaces should detect via name");
assert_eq!(env.provider, SandboxProvider::Codespaces);
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_gitpod() {
with_env(&[("GITPOD_WORKSPACE_ID", "ws-1234")], || {
assert_eq!(detect().map(|e| e.provider), Some(SandboxProvider::Gitpod));
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_devcontainer_via_remote_containers() {
with_env(&[("REMOTE_CONTAINERS", "true")], || {
assert_eq!(
detect().map(|e| e.provider),
Some(SandboxProvider::Devcontainer)
);
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_devcontainer_via_devcontainer_env() {
with_env(&[("DEVCONTAINER", "true")], || {
assert_eq!(
detect().map(|e| e.provider),
Some(SandboxProvider::Devcontainer)
);
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_replit() {
with_env(&[("REPL_ID", "abc")], || {
assert_eq!(detect().map(|e| e.provider), Some(SandboxProvider::Replit));
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_e2b() {
with_env(&[("E2B_SANDBOX_ID", "sb-123")], || {
assert_eq!(detect().map(|e| e.provider), Some(SandboxProvider::E2b));
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_modal() {
with_env(&[("MODAL_TASK_ID", "task-123")], || {
assert_eq!(detect().map(|e| e.provider), Some(SandboxProvider::Modal));
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_daytona() {
with_env(&[("DAYTONA_WS_ID", "ws-1")], || {
assert_eq!(detect().map(|e| e.provider), Some(SandboxProvider::Daytona));
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_claude_code_via_claudecode_var() {
with_env(&[("CLAUDECODE", "1")], || {
assert_eq!(
detect().map(|e| e.provider),
Some(SandboxProvider::ClaudeCode)
);
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_claude_code_via_entrypoint() {
with_env(&[("CLAUDE_CODE_ENTRYPOINT", "cli")], || {
assert_eq!(
detect().map(|e| e.provider),
Some(SandboxProvider::ClaudeCode)
);
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn detects_cursor_agent() {
with_env(&[("CURSOR_AGENT", "1")], || {
assert_eq!(detect().map(|e| e.provider), Some(SandboxProvider::Cursor));
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn no_detection_in_clean_env() {
with_env(&[], || {
match detect() {
None => {}
Some(env) => assert_eq!(env.provider, SandboxProvider::GenericContainer),
}
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn forced_via_jarvy_sandbox_1() {
with_env(&[("JARVY_SANDBOX", "1")], || {
let env = detect().expect("forced should detect");
assert!(env.forced);
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn disabled_via_jarvy_sandbox_0() {
with_env(&[("JARVY_SANDBOX", "0"), ("CODESPACES", "true")], || {
assert!(detect().is_none());
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn is_seamless_true_for_sandbox() {
with_env(&[("CODESPACES", "true")], || {
assert!(is_seamless());
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn is_seamless_true_for_ci_only() {
with_env(&[("CI", "true")], || {
assert!(is_seamless());
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn provider_names_are_stable() {
assert_eq!(SandboxProvider::Codespaces.name(), "GitHub Codespaces");
assert_eq!(SandboxProvider::ClaudeCode.name(), "Claude Code");
assert_eq!(SandboxProvider::GenericContainer.name(), "container");
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn forced_with_no_signals_yields_generic_container() {
with_env(&[("JARVY_SANDBOX", "1")], || {
let env = detect().expect("forced should detect");
assert_eq!(env.provider, SandboxProvider::GenericContainer);
assert!(env.forced);
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn forced_with_named_signal_keeps_named_provider() {
with_env(&[("JARVY_SANDBOX", "1"), ("CODESPACES", "true")], || {
let env = detect().expect("forced+named should detect");
assert_eq!(env.provider, SandboxProvider::Codespaces);
assert!(env.forced);
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn jarvy_sandbox_0_does_not_disable_seamless_when_ci_true() {
with_env(&[("JARVY_SANDBOX", "0"), ("CI", "true")], || {
assert!(is_seamless());
assert!(!is_sandbox());
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn is_seamless_auto_excludes_forced_sandbox() {
with_env(&[("JARVY_SANDBOX", "1")], || {
assert!(is_seamless()); assert!(!is_seamless_auto()); });
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn is_seamless_auto_true_for_auto_detected_sandbox() {
with_env(&[("CODESPACES", "true")], || {
assert!(is_seamless_auto());
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn is_seamless_auto_true_for_ci() {
with_env(&[("CI", "true")], || {
assert!(is_seamless_auto());
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn generic_container_predicate_requires_both_signals() {
assert!(!is_generic_container(false, false));
assert!(!is_generic_container(false, true));
assert!(!is_generic_container(true, true)); assert!(is_generic_container(true, false));
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn verify_only_reason_display_is_stable() {
assert_eq!(VerifyOnlyReason::NoJarvyHome.to_string(), "no_jarvy_home");
assert_eq!(VerifyOnlyReason::ReadOnlyRoot.to_string(), "read_only_root");
assert_eq!(
VerifyOnlyReason::NoInstallPath.to_string(),
"no_install_path"
);
assert_eq!(VerifyOnlyReason::Forced.to_string(), "forced");
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn force_verify_only_env_short_circuits_probe() {
with_env(&[("JARVY_FORCE_VERIFY_ONLY", "1")], || {
let cap = probe_install_capability();
assert_eq!(cap, InstallCapability::VerifyOnly(VerifyOnlyReason::Forced));
});
}
#[test]
#[serial_test::serial(ci_sandbox_env)]
fn banner_idempotent_per_process() {
BANNER_EMITTED.store(false, std::sync::atomic::Ordering::Relaxed);
let env = SandboxEnvironment {
provider: SandboxProvider::GenericContainer,
forced: false,
};
assert!(!BANNER_EMITTED.load(std::sync::atomic::Ordering::Relaxed));
print_banner_once(&env);
assert!(BANNER_EMITTED.load(std::sync::atomic::Ordering::Relaxed));
print_banner_once(&env);
assert!(BANNER_EMITTED.load(std::sync::atomic::Ordering::Relaxed));
}
}