use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use crate::config::Harness;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Runtime {
pub name: String,
pub path: PathBuf,
}
pub fn runtime_candidates(is_macos: bool) -> &'static [&'static str] {
if is_macos {
&["container", "docker", "podman"]
} else {
&["docker", "podman"]
}
}
pub fn detect_runtime() -> Option<Runtime> {
if let Some(name) = std::env::var_os("SCSH_RUNTIME") {
let name = name.to_string_lossy().into_owned();
if !name.is_empty() {
return which(&name).map(|path| Runtime { name, path });
}
}
let path = std::env::var_os("PATH").unwrap_or_default();
detect_runtime_in(cfg!(target_os = "macos"), &path)
}
pub fn detect_runtime_in(is_macos: bool, path: &OsStr) -> Option<Runtime> {
let found: Vec<Runtime> = runtime_candidates(is_macos)
.iter()
.filter_map(|&name| which_in(name, path).map(|p| Runtime { name: name.to_string(), path: p }))
.collect();
let snap_docker_first = matches!(found.first(), Some(r) if r.name == "docker" && is_snap_confined(&r.path));
if snap_docker_first {
if let Some(other) = found.iter().find(|r| r.name != "docker") {
return Some(other.clone());
}
}
found.into_iter().next()
}
pub fn is_snap_confined(path: &Path) -> bool {
path.to_string_lossy().contains("/snap/")
}
pub fn which(cmd: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
which_in(cmd, &path)
}
pub fn which_in(cmd: &str, path: &OsStr) -> Option<PathBuf> {
if cmd.contains('/') {
let p = PathBuf::from(cmd);
return is_executable(&p).then_some(p);
}
for dir in std::env::split_paths(path) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join(cmd);
if is_executable(&candidate) {
return Some(candidate);
}
}
None
}
#[cfg(unix)]
fn is_executable(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(p) {
Ok(m) => m.is_file() && (m.permissions().mode() & 0o111 != 0),
Err(_) => false,
}
}
#[cfg(not(unix))]
fn is_executable(p: &Path) -> bool {
std::fs::metadata(p).map(|m| m.is_file()).unwrap_or(false)
}
pub fn image_tag() -> String {
"scsh:latest".to_string()
}
pub const AGENT_REPO: &str = "/home/agent/repo";
pub const AGENT_XDG_DATA_REL: &str = "tmp/.xdg-data";
pub const RUN_LOG_REL: &str = "tmp/scsh-run.log";
pub const RUN_LOG_VAR: &str = "SCSH_RUN_LOG";
pub fn dockerfile() -> String {
include_str!("Dockerfile").to_string()
}
pub fn host_timezone() -> String {
if let Ok(tz) = std::env::var("TZ") {
let tz = tz.trim();
if !tz.is_empty() {
return tz.to_string();
}
}
if let Ok(target) = std::fs::read_link("/etc/localtime") {
let s = target.to_string_lossy();
if let Some(idx) = s.find("zoneinfo/") {
let tz = s[idx + "zoneinfo/".len()..].trim_matches('/');
if !tz.is_empty() {
return tz.to_string();
}
}
}
if let Ok(contents) = std::fs::read_to_string("/etc/timezone") {
let tz = contents.trim();
if !tz.is_empty() {
return tz.to_string();
}
}
"UTC".to_string()
}
pub fn harness_command(harness: Harness, model: Option<&str>, skill: &str) -> String {
match harness {
Harness::Opencode => {
let mut cmd = String::from("opencode");
if let Some(m) = model {
cmd.push(' ');
cmd.push_str("-m ");
cmd.push_str(&shell_quote(m));
}
cmd.push_str(" run ");
cmd.push_str(&shell_quote(&format!("run skill {skill}")));
format!("{cmd} 2>&1 | tee \"${RUN_LOG_VAR}\"")
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildMethod {
Stdin,
ContextDir,
}
pub const CONTEXT_DOCKERFILE_NAME: &str = "Dockerfile";
pub fn build_method(runtime: &str) -> BuildMethod {
if runtime == "container" {
BuildMethod::ContextDir
} else {
BuildMethod::Stdin
}
}
fn build_args(uid: u32, gid: u32, tz: &str) -> Vec<String> {
vec![
"--build-arg".into(),
format!("AGENT_UID={uid}"),
"--build-arg".into(),
format!("AGENT_GID={gid}"),
"--build-arg".into(),
format!("TZ={tz}"),
]
}
pub fn build_command_stdin(runtime: &str, tag: &str, uid: u32, gid: u32, tz: &str) -> Vec<String> {
let mut v = vec![runtime.into(), "build".into(), "-t".into(), tag.into()];
v.extend(build_args(uid, gid, tz));
v.push("-".into());
v
}
pub fn build_command_context(runtime: &str, tag: &str, context_dir: &str, uid: u32, gid: u32, tz: &str) -> Vec<String> {
let mut v = vec![runtime.into(), "build".into(), "-t".into(), tag.into()];
v.extend(build_args(uid, gid, tz));
v.push(context_dir.into());
v
}
pub fn run_command(
runtime: &str, tag: &str, clone_dir: &str, name: &str, env: &[(String, String)], command: &str,
) -> Vec<String> {
let mut v = vec![runtime.into(), "run".into(), "--rm".into(), "--name".into(), name.into()];
if runtime == "podman" {
v.push("--userns=keep-id".into());
}
for (key, value) in env {
v.push("-e".into());
v.push(format!("{key}={value}"));
}
v.push("-v".into());
v.push(format!("{clone_dir}:{AGENT_REPO}"));
v.push(tag.into());
v.push("/bin/sh".into());
v.push("-c".into());
v.push(command.into());
v
}
pub fn shell_join(args: &[String]) -> String {
args.iter().map(|a| shell_quote(a)).collect::<Vec<_>>().join(" ")
}
fn shell_quote(s: &str) -> String {
let safe = !s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | ':' | '=' | '+'));
if safe {
s.to_string()
} else {
format!("'{}'", s.replace('\'', r"'\''"))
}
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
pub fn format_utc_timestamp(epoch_secs: u64) -> String {
let days = (epoch_secs / 86_400) as i64;
let tod = epoch_secs % 86_400;
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
let (y, m, d) = civil_from_days(days);
format!("{y:04}{m:02}{d:02}-{h:02}{mi:02}{s:02}")
}
pub fn run_dir_name(epoch_secs: u64, skill: &str) -> String {
format!("scsh-{}-utc-run-{}", format_utc_timestamp(epoch_secs), sanitize_component(skill))
}
pub fn backup_name(file_name: &str, epoch_secs: u64) -> String {
format!("{file_name}.bak.{}-utc", format_utc_timestamp(epoch_secs))
}
pub fn sanitize_component(s: &str) -> String {
let mapped: String = s
.chars()
.map(|c| {
let c = c.to_ascii_lowercase();
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'-'
}
})
.collect();
let trimmed = mapped.trim_matches(|c| matches!(c, '.' | '_' | '-'));
if trimmed.is_empty() {
"skill".to_string()
} else {
trimmed.to_string()
}
}
pub fn clone_command(src: &str, dst: &str) -> Vec<String> {
vec!["git".into(), "clone".into(), src.into(), dst.into()]
}
pub fn local_branches_to_create(for_each_ref: &str, current_branch: &str) -> Vec<String> {
let mut out = Vec::new();
for line in for_each_ref.lines() {
let line = line.trim();
let branch = match line.strip_prefix("origin/") {
Some(b) => b,
None => continue,
};
if branch == "HEAD" || branch == current_branch || branch.is_empty() {
continue;
}
if !out.iter().any(|b: &String| b == branch) {
out.push(branch.to_string());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn tmp(tag: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let mut p = std::env::temp_dir();
p.push(format!("scsh-ut-{tag}-{}-{nanos}-{n}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
#[cfg(unix)]
fn make_exec(p: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(p, "#!/bin/sh\n").unwrap();
let mut perms = std::fs::metadata(p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(p, perms).unwrap();
}
#[test]
fn candidates_depend_on_os() {
assert_eq!(runtime_candidates(true), &["container", "docker", "podman"]);
assert_eq!(runtime_candidates(false), &["docker", "podman"]);
}
#[cfg(unix)]
#[test]
fn which_in_finds_executable_only() {
let d = tmp("which");
let exe = d.join("mytool");
make_exec(&exe);
let plain = d.join("notexe");
std::fs::write(&plain, "data").unwrap();
let path = OsString::from(d.to_str().unwrap());
assert_eq!(which_in("mytool", &path), Some(exe));
assert_eq!(which_in("notexe", &path), None);
assert_eq!(which_in("missing", &path), None);
}
#[cfg(unix)]
#[test]
fn detect_prefers_docker_on_linux() {
let d = tmp("detect-linux");
make_exec(&d.join("docker"));
make_exec(&d.join("podman"));
let path = OsString::from(d.to_str().unwrap());
assert_eq!(detect_runtime_in(false, &path).unwrap().name, "docker");
}
#[cfg(unix)]
#[test]
fn detect_falls_back_to_podman() {
let d = tmp("detect-podman");
make_exec(&d.join("podman"));
let path = OsString::from(d.to_str().unwrap());
assert_eq!(detect_runtime_in(false, &path).unwrap().name, "podman");
}
#[cfg(unix)]
#[test]
fn detect_prefers_apple_container_on_macos() {
let d = tmp("detect-macos");
make_exec(&d.join("container"));
make_exec(&d.join("docker"));
let path = OsString::from(d.to_str().unwrap());
assert_eq!(detect_runtime_in(true, &path).unwrap().name, "container");
}
#[cfg(unix)]
#[test]
fn detect_none_when_empty() {
let d = tmp("detect-empty");
let path = OsString::from(d.to_str().unwrap());
assert!(detect_runtime_in(false, &path).is_none());
}
#[test]
fn snap_confined_paths_are_detected() {
assert!(is_snap_confined(Path::new("/snap/bin/docker")));
assert!(!is_snap_confined(Path::new("/usr/bin/docker")));
assert!(!is_snap_confined(Path::new("/usr/local/bin/podman")));
}
#[cfg(unix)]
#[test]
fn detect_skips_snap_docker_for_another_runtime() {
let d = tmp("detect-confined");
std::fs::create_dir_all(d.join("snap/bin")).unwrap();
std::fs::create_dir_all(d.join("bin")).unwrap();
make_exec(&d.join("snap/bin/docker")); make_exec(&d.join("bin/podman"));
let path = OsString::from(format!("{}:{}", d.join("snap/bin").display(), d.join("bin").display()));
assert_eq!(detect_runtime_in(false, &path).unwrap().name, "podman");
let only = OsString::from(d.join("snap/bin").to_str().unwrap());
assert_eq!(detect_runtime_in(false, &only).unwrap().name, "docker");
}
#[test]
fn image_tag_is_the_generic_tag() {
assert_eq!(image_tag(), "scsh:latest");
}
#[test]
fn dockerfile_is_generic_with_no_baked_command() {
let df = dockerfile();
assert!(df.contains("FROM debian:bookworm-slim")); assert!(!df.contains("scsh.skill"));
assert!(!df.contains("CMD ["));
assert!(df.contains("ENV SCSH_RUN_LOG=/home/agent/repo/tmp/scsh-run.log"));
assert!(df.contains("ENV SCSH=1"), "skills should see SCSH=1 so they know they run under scsh");
}
#[test]
fn dockerfile_installs_and_verifies_opencode() {
let df = dockerfile();
assert!(df.contains("RUN set -eux;"));
assert!(df.contains("npm install -g opencode-ai"), "opencode should install via npm");
assert!(df.contains("nodejs") && df.contains("npm"), "the image must install node + npm");
assert!(df.contains("ripgrep"), "the image must install ripgrep");
assert!(df.contains(" git "), "the image must install git");
assert!(df.contains("ENV OPENCODE_YOLO=true"));
assert!(df.contains("ENV OPENCODE_DANGEROUSLY_SKIP_PERMISSIONS=true"));
assert!(df.contains("npm root -g"));
assert!(df.contains("opencode --version"));
let run_at = df.find("RUN ").expect("a RUN layer");
let user_at = df.find("\nUSER agent\n").expect("a USER layer");
assert!(run_at < user_at, "opencode must be installed before USER agent");
}
#[test]
fn dockerfile_bakes_the_toolchain_and_excludes_java() {
let df = dockerfile();
for tool in [
"python3",
"python3-venv",
"perl",
"gawk",
"build-essential",
"pkg-config",
"cmake",
"jq",
"sqlite3",
"postgresql-client",
"protobuf-compiler",
"shellcheck",
"git-lfs",
"openssh-client",
"iputils-ping",
"traceroute",
"netcat-openbsd",
"astral-sh/uv",
"mikefarah/yq",
"dl.k8s.io",
"cli.github.com",
"go.dev/dl",
"sh.rustup.rs",
"awscli-exe-linux",
"google-cloud-cli",
] {
assert!(df.contains(tool), "image should install {tool}");
}
let lower = df.to_lowercase();
assert!(!lower.contains("openjdk") && !lower.contains("-jdk") && !lower.contains("-jre"), "no Java by design");
assert!(df.contains("ENV LANG=C.UTF-8"));
assert!(df.contains("ARG TZ=UTC") && df.contains("ENV TZ=${TZ}"));
assert!(df.contains("/usr/local/go/bin") && df.contains("/usr/local/cargo/bin"));
}
#[test]
fn dockerfile_is_platform_agnostic() {
let df = dockerfile();
assert!(
df.matches("dpkg --print-architecture").count() >= 4,
"each arch-specific layer must resolve arch at build time"
);
for token in ["amd64", "arm64", "x86_64", "aarch64"] {
assert!(df.contains(token), "arch mapping must cover {token}");
}
assert!(df.contains("google-cloud-cli-linux-${gclarch}"), "gcloud download must be arch-parameterized");
for bad in [
"uv-x86_64-unknown-linux-gnu",
"yq_linux_amd64",
"linux/amd64/kubectl",
"linux-amd64.tar.gz",
"awscli-exe-linux-x86_64.zip",
"google-cloud-cli-linux-x86_64.tar.gz",
] {
assert!(!df.contains(bad), "download URL must not hardcode an architecture: {bad}");
}
}
#[test]
fn dockerfile_matches_the_path_constants() {
let df = dockerfile();
assert!(df.contains(&format!("WORKDIR {AGENT_REPO}")), "WORKDIR must match AGENT_REPO");
assert!(
df.contains(&format!("ENV XDG_DATA_HOME={AGENT_REPO}/{AGENT_XDG_DATA_REL}")),
"XDG_DATA_HOME must match AGENT_REPO/AGENT_XDG_DATA_REL"
);
assert!(
df.contains(&format!("ENV {RUN_LOG_VAR}={AGENT_REPO}/{RUN_LOG_REL}")),
"Dockerfile run-log ENV must match RUN_LOG_VAR and RUN_LOG_REL"
);
}
#[test]
fn dockerfile_keeps_home_separate_from_the_repo_mount() {
let df = dockerfile();
assert!(df.contains("ENV HOME=/home/agent"), "HOME must be the agent's home");
assert!(df.contains(&format!("WORKDIR {AGENT_REPO}")));
assert_ne!("/home/agent", AGENT_REPO, "the mount must not be the home dir");
assert!(AGENT_REPO.starts_with("/home/agent/"), "the repo mount lives under the home dir");
assert!(AGENT_XDG_DATA_REL.starts_with("tmp/") && RUN_LOG_REL.starts_with("tmp/"));
}
#[test]
fn dockerfile_creates_agent_user_and_runs_as_it() {
let df = dockerfile();
assert!(df.contains("ARG AGENT_UID=1000"));
assert!(df.contains("ARG AGENT_GID=1000"));
assert!(df.contains("WORKDIR /home/agent/repo"));
assert!(df.contains("\nUSER agent\n"));
let agent_at = df.find("-d /home/agent").expect("agent-user layer");
let user_at = df.find("\nUSER agent\n").expect("a USER layer");
assert!(agent_at < user_at, "the agent user must exist before USER agent");
}
#[test]
fn harness_command_builds_opencode_invocation() {
assert_eq!(
harness_command(Harness::Opencode, Some("openai/gpt-5.5"), "add"),
"opencode -m openai/gpt-5.5 run 'run skill add' 2>&1 | tee \"$SCSH_RUN_LOG\""
);
assert_eq!(
harness_command(Harness::Opencode, None, "multiply"),
"opencode run 'run skill multiply' 2>&1 | tee \"$SCSH_RUN_LOG\""
);
}
#[test]
fn build_method_depends_on_runtime() {
assert_eq!(build_method("container"), BuildMethod::ContextDir);
assert_eq!(build_method("docker"), BuildMethod::Stdin);
assert_eq!(build_method("podman"), BuildMethod::Stdin);
}
#[test]
fn commands_have_expected_shape() {
assert_eq!(
build_command_stdin("docker", "scsh-demo:latest", 1006, 1007, "Europe/Berlin"),
vec![
"docker",
"build",
"-t",
"scsh-demo:latest",
"--build-arg",
"AGENT_UID=1006",
"--build-arg",
"AGENT_GID=1007",
"--build-arg",
"TZ=Europe/Berlin",
"-"
]
);
assert_eq!(
build_command_context("container", "scsh-demo:latest", "/tmp/ctx", 1000, 1000, "UTC"),
vec![
"container",
"build",
"-t",
"scsh-demo:latest",
"--build-arg",
"AGENT_UID=1000",
"--build-arg",
"AGENT_GID=1000",
"--build-arg",
"TZ=UTC",
"/tmp/ctx"
]
);
assert_eq!(
run_command("docker", "scsh-demo:latest", "/tmp/clone", "run-s", &[], "opencode run 'run skill s'"),
vec![
"docker",
"run",
"--rm",
"--name",
"run-s",
"-v",
"/tmp/clone:/home/agent/repo",
"scsh-demo:latest",
"/bin/sh",
"-c",
"opencode run 'run skill s'"
]
);
assert_eq!(
run_command("podman", "scsh-demo:latest", "/tmp/clone", "run-s", &[], "opencode run 'run skill s'"),
vec![
"podman",
"run",
"--rm",
"--name",
"run-s",
"--userns=keep-id",
"-v",
"/tmp/clone:/home/agent/repo",
"scsh-demo:latest",
"/bin/sh",
"-c",
"opencode run 'run skill s'"
]
);
}
#[test]
fn run_command_forwards_env_as_e_flags() {
let env = vec![("A".to_string(), "20".to_string()), ("B".to_string(), "22".to_string())];
assert_eq!(
run_command("docker", "scsh-demo:latest", "/tmp/clone", "run-s", &env, "opencode run 'run skill s'"),
vec![
"docker",
"run",
"--rm",
"--name",
"run-s",
"-e",
"A=20",
"-e",
"B=22",
"-v",
"/tmp/clone:/home/agent/repo",
"scsh-demo:latest",
"/bin/sh",
"-c",
"opencode run 'run skill s'"
]
);
}
#[test]
fn clone_command_is_a_full_local_clone() {
assert_eq!(clone_command("/repo", "/tmp/dst"), vec!["git", "clone", "/repo", "/tmp/dst"]);
}
#[test]
fn utc_timestamp_formats_known_epochs() {
assert_eq!(format_utc_timestamp(0), "19700101-000000");
assert_eq!(format_utc_timestamp(1_700_000_000), "20231114-221320");
}
#[test]
fn run_dir_and_backup_names() {
assert_eq!(run_dir_name(1_700_000_000, "add"), "scsh-20231114-221320-utc-run-add");
assert_eq!(run_dir_name(0, "My Skill!"), "scsh-19700101-000000-utc-run-my-skill");
assert_eq!(backup_name("add_result.json", 1_700_000_000), "add_result.json.bak.20231114-221320-utc");
}
#[test]
fn branch_materialization_skips_head_and_current() {
let refs = "origin/HEAD\norigin/main\norigin/feature-x\norigin/release\n";
assert_eq!(local_branches_to_create(refs, "main"), vec!["feature-x", "release"]);
assert!(local_branches_to_create("origin/HEAD\norigin/main\n", "main").is_empty());
}
#[test]
fn shell_join_quotes_when_needed() {
assert_eq!(shell_join(&["docker".into(), "build".into()]), "docker build");
assert_eq!(shell_join(&["a b".into()]), "'a b'");
}
}