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(harness: Harness) -> String {
match harness {
Harness::Opencode => "scsh-opencode:latest".to_string(),
Harness::Claude => "scsh-claude:latest".to_string(),
}
}
pub fn image_target(harness: Harness) -> &'static str {
match harness {
Harness::Opencode => "scsh-opencode",
Harness::Claude => "scsh-claude",
}
}
pub const CLAUDE_CONFIG_MOUNT: &str = "/home/agent/.claude";
pub const CLAUDE_JSON_MOUNT: &str = "/home/agent/.claude.json";
pub const CLAUDE_AUTH_REL: &str = "tmp/.claude-auth";
pub const OPENCODE_AUTH_MOUNT: &str = "/home/agent/repo/tmp/.xdg-data/opencode/auth.json";
pub const OPENCODE_FORWARD_REL: &str = "tmp/.opencode-forward";
pub const OPENCODE_CONFIG_JSON_MOUNT: &str = "/home/agent/.config/opencode/opencode.json";
pub const OPENCODE_CONFIG_JSONC_MOUNT: &str = "/home/agent/.config/opencode/opencode.jsonc";
pub const CLAUDE_OAUTH_TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN";
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_source: &str, result: &str) -> String {
match harness {
Harness::Opencode => {
let instruction = format!(
"run skill {skill_source}. Follow .skills/{skill_source}/SKILL.md exactly. \
Write the required result file to {result} (also available as the SCSH_RESULT environment variable). \
Do not git fetch, pull, push, or clone — scsh preloaded a full local clone; use only refs already present."
);
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(&instruction));
format!("{cmd} 2>&1 | tee \"${RUN_LOG_VAR}\"")
}
Harness::Claude => {
let prompt = format!(
"Run the skill defined in .skills/{skill_source}/SKILL.md. Follow its instructions exactly. \
Write the required result file to {result} (also available as the SCSH_RESULT environment variable). \
Do not git fetch, pull, push, or clone — scsh preloaded a full local clone; use only refs already present."
);
let mut cmd = String::from("claude -p ");
cmd.push_str(&shell_quote(&prompt));
cmd.push_str(" --permission-mode bypassPermissions --no-session-persistence");
if let Some(m) = model {
cmd.push_str(" --model ");
cmd.push_str(&shell_quote(m));
}
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
}
}
pub const BUILD_FINGERPRINT_LABEL: &str = "scsh.build.fingerprint";
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}"),
]
}
fn build_labels(fingerprint: &str) -> Vec<String> {
vec!["--label".into(), format!("{BUILD_FINGERPRINT_LABEL}={fingerprint}")]
}
pub fn image_build_fingerprint(dockerfile: &str, target: &str, uid: u32, gid: u32, tz: &str) -> String {
let blob = format!("target={target}\nuid={uid}\ngid={gid}\ntz={tz}\n---\n{dockerfile}");
crate::sha256::sha256_hex(blob.as_bytes())
}
pub fn image_inspect_fingerprint(runtime: &str, tag: &str) -> Option<String> {
use std::process::Command;
let out = if runtime == "container" {
Command::new("container").args(["image", "inspect", tag]).output().ok()?
} else {
let format = format!(r#"{{{{index .Config.Labels "{BUILD_FINGERPRINT_LABEL}"}}}}"#);
Command::new(runtime).args(["image", "inspect", tag, "--format", &format]).output().ok()?
};
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if runtime == "container" {
parse_label_from_container_inspect(&s, BUILD_FINGERPRINT_LABEL)
} else if s.is_empty() {
None
} else {
Some(s)
}
}
pub fn image_is_up_to_date(runtime: &str, tag: &str, fingerprint: &str) -> bool {
image_inspect_fingerprint(runtime, tag).as_deref() == Some(fingerprint)
}
fn parse_label_from_container_inspect(json: &str, key: &str) -> Option<String> {
let needle = format!(r#""{key}":""#);
let start = json.find(&needle)? + needle.len();
let rest = &json[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
pub fn build_command_stdin(
runtime: &str, tag: &str, target: &str, uid: u32, gid: u32, tz: &str, fingerprint: &str,
) -> Vec<String> {
let mut v = vec![runtime.into(), "build".into(), "-t".into(), tag.into(), "--target".into(), target.into()];
v.extend(build_args(uid, gid, tz));
v.extend(build_labels(fingerprint));
v.push("-".into());
v
}
pub fn build_command_context(
runtime: &str, tag: &str, target: &str, context_dir: &str, uid: u32, gid: u32, tz: &str, fingerprint: &str,
) -> Vec<String> {
let mut v = vec![runtime.into(), "build".into(), "-t".into(), tag.into(), "--target".into(), target.into()];
v.extend(build_args(uid, gid, tz));
v.extend(build_labels(fingerprint));
v.push(context_dir.into());
v
}
pub fn run_command(
runtime: &str,
tag: &str,
clone_dir: &str,
name: &str,
env: &[(String, String)],
volumes: &[(&str, &str)],
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}"));
}
for (host, mount) in volumes {
v.push("-v".into());
v.push(format!("{host}:{mount}"));
}
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 opencode_auth_in(xdg_data_home: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
let base = match xdg_data_home {
Some(x) if !x.is_empty() => PathBuf::from(x),
_ => PathBuf::from(home?).join(".local").join("share"),
};
Some(base.join("opencode").join("auth.json"))
}
pub fn opencode_config_dir(xdg_config_home: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
let base = match xdg_config_home {
Some(x) if !x.is_empty() => PathBuf::from(x),
_ => PathBuf::from(home?).join(".config"),
};
Some(base.join("opencode"))
}
pub fn opencode_config_json_in(xdg_config_home: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
let path = opencode_config_dir(xdg_config_home, home)?.join("opencode.json");
path.is_file().then_some(path)
}
pub fn opencode_config_jsonc_in(xdg_config_home: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
let path = opencode_config_dir(xdg_config_home, home)?.join("opencode.jsonc");
path.is_file().then_some(path)
}
pub fn opencode_auth_ready() -> bool {
opencode_auth_in(std::env::var_os("XDG_DATA_HOME").as_deref(), std::env::var_os("HOME").as_deref())
.is_some_and(|p| p.is_file())
}
pub fn claude_oauth_token() -> Option<String> {
std::env::var(CLAUDE_OAUTH_TOKEN_ENV).ok().filter(|s| !s.is_empty())
}
fn claude_credentials_file_on_host() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
let path = PathBuf::from(home).join(".claude").join(".credentials.json");
path.is_file().then_some(path)
}
pub fn claude_container_auth_ready() -> bool {
claude_oauth_token().is_some() || claude_credentials_file_on_host().is_some()
}
pub fn check_harness_host(harness: Harness) -> Result<(), String> {
match harness {
Harness::Opencode => {
if opencode_auth_ready() {
Ok(())
} else {
Err("opencode auth not found (~/.local/share/opencode/auth.json) — run `opencode auth login`".into())
}
}
Harness::Claude => {
if claude_container_auth_ready() {
Ok(())
} else {
Err(
"CLAUDE_CODE_OAUTH_TOKEN is not set and ~/.claude/.credentials.json was not found \
— run `claude setup-token`, then export CLAUDE_CODE_OAUTH_TOKEN in your shell"
.into(),
)
}
}
}
}
pub fn opencode_host_mounts() -> Vec<(String, String)> {
opencode_host_mounts_from(
std::env::var_os("XDG_DATA_HOME").as_deref(),
std::env::var_os("XDG_CONFIG_HOME").as_deref(),
std::env::var_os("HOME").as_deref(),
)
}
pub fn opencode_host_mounts_from(
xdg_data_home: Option<&OsStr>,
xdg_config_home: Option<&OsStr>,
home: Option<&OsStr>,
) -> Vec<(String, String)> {
let mut out = Vec::new();
if let Some(auth) = opencode_auth_in(xdg_data_home, home).filter(|p| p.is_file()) {
out.push((auth.to_string_lossy().into_owned(), OPENCODE_AUTH_MOUNT.to_string()));
}
if let Some(cfg) = opencode_config_json_in(xdg_config_home, home) {
out.push((cfg.to_string_lossy().into_owned(), OPENCODE_CONFIG_JSON_MOUNT.to_string()));
}
if let Some(cfg) = opencode_config_jsonc_in(xdg_config_home, home) {
out.push((cfg.to_string_lossy().into_owned(), OPENCODE_CONFIG_JSONC_MOUNT.to_string()));
}
out
}
pub fn opencode_forward_mounts(forward_root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
let auth = forward_root.join("xdg/opencode/auth.json");
if auth.is_file() {
out.push((auth.to_string_lossy().into_owned(), OPENCODE_AUTH_MOUNT.to_string()));
}
let json = forward_root.join("config/opencode/opencode.json");
if json.is_file() {
out.push((json.to_string_lossy().into_owned(), OPENCODE_CONFIG_JSON_MOUNT.to_string()));
}
let jsonc = forward_root.join("config/opencode/opencode.jsonc");
if jsonc.is_file() {
out.push((jsonc.to_string_lossy().into_owned(), OPENCODE_CONFIG_JSONC_MOUNT.to_string()));
}
out
}
pub fn claude_auth_mounts(auth_root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
let claude_dir = auth_root.join(".claude");
if claude_dir.is_dir() {
out.push((claude_dir.to_string_lossy().into_owned(), CLAUDE_CONFIG_MOUNT.to_string()));
}
let claude_json = auth_root.join(".claude.json");
if claude_json.is_file() {
out.push((claude_json.to_string_lossy().into_owned(), CLAUDE_JSON_MOUNT.to_string()));
}
out
}
pub fn harness_volumes(harness: Harness) -> Vec<(String, String)> {
match harness {
Harness::Opencode => opencode_host_mounts(),
Harness::Claude => {
let Some(home) = std::env::var_os("HOME") else {
return Vec::new();
};
let home = PathBuf::from(home);
let mut out = Vec::new();
let claude_dir = home.join(".claude");
if claude_dir.is_dir() {
out.push((claude_dir.to_string_lossy().into_owned(), CLAUDE_CONFIG_MOUNT.to_string()));
}
let claude_json = home.join(".claude.json");
if claude_json.is_file() {
out.push((claude_json.to_string_lossy().into_owned(), CLAUDE_JSON_MOUNT.to_string()));
}
out
}
}
}
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 const CONTAINER_ID_MAX_LEN: usize = 64;
pub fn random_nonce_6() -> String {
let mut buf = [0u8; 6];
let filled = std::fs::File::open("/dev/urandom")
.and_then(|mut f| std::io::Read::read_exact(&mut f, &mut buf))
.is_ok();
if !filled {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0) as u64;
let seed = nanos ^ ((std::process::id() as u64) << 32);
for (i, b) in buf.iter_mut().enumerate() {
*b = ((seed.wrapping_mul(1_103_515_245).wrapping_add(i as u64)) % 26) as u8;
}
}
buf.iter().map(|b| (b'a' + (b % 26)) as char).collect()
}
pub fn truncate_middle(s: &str, max_len: usize) -> String {
if max_len == 0 {
return String::new();
}
if s.len() <= max_len {
return s.to_string();
}
if max_len <= 2 {
return s.chars().take(max_len).collect();
}
let keep = max_len - 2;
let first_k = keep.div_ceil(2);
let last_n = keep / 2;
let bytes = s.as_bytes();
let first = &s[..first_k];
let last = std::str::from_utf8(&bytes[bytes.len() - last_n..]).unwrap_or("");
format!("{first}..{last}")
}
fn apple_container_run_dir_name_with_nonce(skill: &str, nonce: &str) -> String {
let prefix = format!("scsh-{nonce}-run-");
let budget = CONTAINER_ID_MAX_LEN.saturating_sub(prefix.len());
let skill_part = truncate_middle(skill, budget);
format!("{prefix}{skill_part}")
}
pub fn is_scsh_run_dir_name(name: &str) -> bool {
if !name.starts_with("scsh-") {
return false;
}
if name.contains("-utc-run-") {
return true;
}
let rest = match name.strip_prefix("scsh-") {
Some(r) => r,
None => return false,
};
let (nonce, _) = match rest.split_once("-run-") {
Some(pair) => pair,
None => return false,
};
nonce.len() == 6 && nonce.chars().all(|c| c.is_ascii_lowercase())
}
pub fn run_dir_name(epoch_secs: u64, skill: &str, runtime: &str) -> String {
let skill = sanitize_component(skill);
if runtime == "container" {
apple_container_run_dir_name_with_nonce(&skill, &random_nonce_6())
} else {
format!("scsh-{}-utc-run-{}", format_utc_timestamp(epoch_secs), 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_tags_are_per_harness() {
assert_eq!(image_tag(Harness::Opencode), "scsh-opencode:latest");
assert_eq!(image_tag(Harness::Claude), "scsh-claude:latest");
}
#[test]
fn dockerfile_has_shared_base_and_two_harness_targets() {
let df = dockerfile();
assert!(df.contains("FROM debian:bookworm-slim AS scsh-base"));
assert!(df.contains("FROM scsh-base AS scsh-opencode"));
assert!(df.contains("FROM scsh-base AS scsh-claude"));
assert!(df.contains("npm install -g opencode-ai"));
assert!(df.contains("npm install -g @anthropic-ai/claude-code"));
assert!(!df.contains("CMD ["));
assert!(df.contains("ENV SCSH_RUN_LOG=/home/agent/repo/tmp/scsh-run.log"));
assert!(df.contains("ENV SCSH=1"));
}
#[test]
fn dockerfile_opencode_stage_has_unattended_env() {
let df = dockerfile();
assert!(df.contains("ENV OPENCODE_YOLO=true"));
assert!(df.contains("opencode --version"));
}
#[test]
fn dockerfile_claude_stage_verifies_cli() {
assert!(dockerfile().contains("claude --version"));
}
#[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() {
let cmd = harness_command(Harness::Opencode, Some("openai/gpt-5.5"), "add", "tmp/add.json");
assert!(cmd.starts_with("opencode -m openai/gpt-5.5 run "));
assert!(cmd.contains("run skill add"));
assert!(cmd.contains("tmp/add.json"));
assert!(cmd.contains("SCSH_RESULT"));
assert!(cmd.contains("preloaded"));
assert!(cmd.ends_with("2>&1 | tee \"$SCSH_RUN_LOG\""));
let cmd = harness_command(Harness::Opencode, None, "multiply", "tmp/mul.json");
assert!(cmd.starts_with("opencode run "));
assert!(cmd.contains("tmp/mul.json"));
}
#[test]
fn harness_command_builds_claude_invocation() {
let cmd = harness_command(Harness::Claude, Some("sonnet"), "add", "tmp/add_claude_sonnet_4_6_result.json");
assert!(cmd.contains(".skills/add/SKILL.md"));
assert!(cmd.contains("--model sonnet"));
assert!(cmd.contains("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 image_build_fingerprint_is_stable_and_target_specific() {
let df = dockerfile();
let a = image_build_fingerprint(&df, "scsh-opencode", 501, 20, "UTC");
let b = image_build_fingerprint(&df, "scsh-opencode", 501, 20, "UTC");
let c = image_build_fingerprint(&df, "scsh-claude", 501, 20, "UTC");
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(a.len(), 64);
}
#[test]
fn parse_label_from_container_inspect_json() {
let json = r#"{"variants":[{"config":{"config":{"Labels":{"scsh.generated":"true","scsh.build.fingerprint":"abc123"}}}}}]"#;
assert_eq!(parse_label_from_container_inspect(json, BUILD_FINGERPRINT_LABEL).as_deref(), Some("abc123"));
assert!(parse_label_from_container_inspect(json, "missing").is_none());
}
#[test]
fn commands_have_expected_shape() {
let fp = image_build_fingerprint("FROM scratch", "scsh-opencode", 1006, 1007, "Europe/Berlin");
let label = format!("{BUILD_FINGERPRINT_LABEL}={fp}");
assert_eq!(
build_command_stdin("docker", "scsh-opencode:latest", "scsh-opencode", 1006, 1007, "Europe/Berlin", &fp),
vec![
"docker".into(),
"build".into(),
"-t".into(),
"scsh-opencode:latest".into(),
"--target".into(),
"scsh-opencode".into(),
"--build-arg".into(),
"AGENT_UID=1006".into(),
"--build-arg".into(),
"AGENT_GID=1007".into(),
"--build-arg".into(),
"TZ=Europe/Berlin".into(),
"--label".into(),
label,
"-".into(),
]
);
assert_eq!(
run_command("docker", "scsh-opencode:latest", "/tmp/clone", "run-s", &[], &[], "opencode run 'run skill s'"),
vec![
"docker",
"run",
"--rm",
"--name",
"run-s",
"-v",
"/tmp/clone:/home/agent/repo",
"scsh-opencode:latest",
"/bin/sh",
"-c",
"opencode run 'run skill s'"
]
);
assert_eq!(
run_command(
"podman",
"scsh-claude:latest",
"/tmp/clone",
"run-s",
&[],
&[("/home/u/.claude", "/home/agent/.claude:ro")],
"claude -p hi"
),
vec![
"podman",
"run",
"--rm",
"--name",
"run-s",
"--userns=keep-id",
"-v",
"/home/u/.claude:/home/agent/.claude:ro",
"-v",
"/tmp/clone:/home/agent/repo",
"scsh-claude:latest",
"/bin/sh",
"-c",
"claude -p hi"
]
);
assert_eq!(
run_command(
"docker",
"scsh-opencode:latest",
"/tmp/clone",
"run-s",
&[],
&[(
"/home/u/.local/share/opencode/auth.json",
OPENCODE_AUTH_MOUNT
)],
"opencode run 'run skill s'"
),
vec![
"docker",
"run",
"--rm",
"--name",
"run-s",
"-v",
"/home/u/.local/share/opencode/auth.json:/home/agent/repo/tmp/.xdg-data/opencode/auth.json",
"-v",
"/tmp/clone:/home/agent/repo",
"scsh-opencode:latest",
"/bin/sh",
"-c",
"opencode run 'run skill s'"
]
);
}
#[test]
fn opencode_forward_mounts_maps_copied_tree() {
let base = std::env::temp_dir().join(format!("scsh-opencode-forward-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("xdg/opencode")).unwrap();
std::fs::create_dir_all(base.join("config/opencode")).unwrap();
std::fs::write(base.join("xdg/opencode/auth.json"), "{}").unwrap();
std::fs::write(base.join("config/opencode/opencode.json"), "{}").unwrap();
let mounts = opencode_forward_mounts(&base);
assert_eq!(mounts.len(), 2);
assert_eq!(mounts[0].1, OPENCODE_AUTH_MOUNT);
assert_eq!(mounts[1].1, OPENCODE_CONFIG_JSON_MOUNT);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn opencode_host_mounts_empty_when_nothing_on_host() {
assert!(opencode_host_mounts_from(None, None, None).is_empty());
}
#[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-opencode: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-opencode: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 claude_container_auth_accepts_oauth_token_env() {
let key = CLAUDE_OAUTH_TOKEN_ENV;
let prev = std::env::var_os(key);
std::env::set_var(key, "test-token");
assert!(claude_container_auth_ready());
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn check_claude_harness_errors_without_token_or_credentials_file() {
let key = CLAUDE_OAUTH_TOKEN_ENV;
let prev = std::env::var_os(key);
std::env::remove_var(key);
let err = check_harness_host(Harness::Claude).unwrap_err();
assert!(err.contains("CLAUDE_CODE_OAUTH_TOKEN"));
assert!(err.contains("setup-token"));
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[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", "docker"), "scsh-20231114-221320-utc-run-add");
assert_eq!(run_dir_name(0, "My Skill!", "docker"), "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 truncate_middle_keeps_ends() {
assert_eq!(truncate_middle("abcdef", 6), "abcdef");
assert_eq!(truncate_middle("abcdefgh", 6), "ab..gh");
assert_eq!(truncate_middle("abcdefgh", 5), "ab..h");
}
#[test]
fn apple_container_run_dir_fits_long_reviewer_names() {
let skill = "reviewability-reviewer-opencode-glm-5.2";
let name = apple_container_run_dir_name_with_nonce(skill, "abcdef");
assert_eq!(name, "scsh-abcdef-run-reviewability-reviewer-opencode-glm-5.2");
assert!(name.len() <= CONTAINER_ID_MAX_LEN);
assert!(is_scsh_run_dir_name(&name));
}
#[test]
fn apple_container_run_dir_middle_truncates_when_needed() {
let skill = "a".repeat(80);
let name = apple_container_run_dir_name_with_nonce(&skill, "abcdef");
assert!(name.len() <= CONTAINER_ID_MAX_LEN);
assert!(name.contains(".."));
assert!(name.starts_with("scsh-abcdef-run-"));
assert!(is_scsh_run_dir_name(&name));
}
#[test]
fn is_scsh_run_dir_name_recognizes_both_formats() {
assert!(is_scsh_run_dir_name("scsh-20231114-221320-utc-run-add"));
assert!(is_scsh_run_dir_name("scsh-abcdef-run-add"));
assert!(!is_scsh_run_dir_name("scsh-installskills-1-2"));
assert!(!is_scsh_run_dir_name("scsh-abcdefg-run-add"));
}
#[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'");
}
}