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 const BASE_IMAGE_TAG: &str = "scsh-base:latest";
pub const BASE_IMAGE_TARGET: &str = "scsh-base";
pub fn base_image_fingerprint(dockerfile: &str, uid: u32, gid: u32, tz: &str) -> String {
image_build_fingerprint(dockerfile, BASE_IMAGE_TARGET, uid, gid, tz)
}
pub fn image_tag(harness: Harness) -> String {
match harness {
Harness::Opencode => "scsh-opencode:latest".to_string(),
Harness::Claude => "scsh-claude:latest".to_string(),
Harness::Codex => "scsh-codex:latest".to_string(),
Harness::Grok => "scsh-grok:latest".to_string(),
Harness::Cursor => "scsh-cursor:latest".to_string(),
}
}
pub fn image_target(harness: Harness) -> &'static str {
match harness {
Harness::Opencode => "scsh-opencode",
Harness::Claude => "scsh-claude",
Harness::Codex => "scsh-codex",
Harness::Grok => "scsh-grok",
Harness::Cursor => "scsh-cursor",
}
}
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 CODEX_FORWARD_REL: &str = "tmp/.codex";
pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
pub fn codex_home_on_host() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("CODEX_HOME").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir));
}
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".codex"))
}
pub fn codex_auth_file_on_host() -> Option<PathBuf> {
codex_home_on_host().map(|d| d.join("auth.json")).filter(|p| p.is_file())
}
pub fn codex_container_auth_ready() -> bool {
codex_auth_file_on_host().is_some() || std::env::var(OPENAI_API_KEY_ENV).map(|v| !v.is_empty()).unwrap_or(false)
}
pub const GROK_FORWARD_REL: &str = "tmp/.grok";
pub const XAI_API_KEY_ENV: &str = "XAI_API_KEY";
pub fn grok_home_on_host() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("GROK_HOME").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir));
}
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".grok"))
}
pub fn grok_auth_file_on_host() -> Option<PathBuf> {
grok_home_on_host().map(|d| d.join("auth.json")).filter(|p| p.is_file())
}
pub fn grok_container_auth_ready() -> bool {
grok_auth_file_on_host().is_some() || std::env::var(XAI_API_KEY_ENV).map(|v| !v.is_empty()).unwrap_or(false)
}
pub const CURSOR_FORWARD_REL: &str = "tmp/.cursor";
pub const CURSOR_AUTH_FORWARD_REL: &str = "tmp/.config/cursor";
pub const CURSOR_API_KEY_ENV: &str = "CURSOR_API_KEY";
pub const CURSOR_CONFIG_DIR_ENV: &str = "CURSOR_CONFIG_DIR";
pub const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME";
pub fn cursor_home_on_host() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("CURSOR_HOME").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir));
}
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cursor"))
}
pub fn cursor_auth_file_on_host() -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
for path in [home.join(".config/cursor/auth.json"), home.join(".cursor/auth.json")] {
if path.is_file() {
return Some(path);
}
}
None
}
pub fn cursor_api_key() -> Option<String> {
std::env::var(CURSOR_API_KEY_ENV).ok().filter(|s| !s.is_empty())
}
#[cfg(target_os = "macos")]
fn keychain_secret(service: &str) -> Option<String> {
let out =
std::process::Command::new("security").args(["find-generic-password", "-s", service, "-w"]).output().ok()?;
if !out.status.success() {
return None;
}
let token = String::from_utf8(out.stdout).ok()?.trim().to_string();
(!token.is_empty()).then_some(token)
}
#[cfg(not(target_os = "macos"))]
fn keychain_secret(_service: &str) -> Option<String> {
None
}
pub fn cursor_keychain_access_token() -> Option<String> {
keychain_secret("cursor-access-token")
}
pub fn cursor_keychain_refresh_token() -> Option<String> {
keychain_secret("cursor-refresh-token")
}
pub fn claude_keychain_credentials_json() -> Option<String> {
keychain_secret("Claude Code-credentials").filter(|s| s.contains("claudeAiOauth"))
}
pub fn cursor_container_auth_ready() -> bool {
cursor_api_key().is_some() || cursor_auth_file_on_host().is_some() || cursor_keychain_access_token().is_some()
}
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_CAST_REL: &str = "tmp/scsh-run.log.cast";
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_verbose_enabled() -> bool {
!matches!(std::env::var("SCSH_QUIET").ok().as_deref(), Some("1") | Some("true"))
}
pub fn harness_container_env(harness: Harness) -> Vec<(String, String)> {
harness_container_env_verbose(harness, harness_verbose_enabled())
}
fn harness_container_env_verbose(harness: Harness, verbose: bool) -> Vec<(String, String)> {
match harness {
Harness::Opencode => vec![("OPENCODE_CLIENT".to_string(), "scsh".to_string())],
Harness::Claude if verbose => {
vec![("DEBUG".to_string(), "1".to_string()), ("CLAUDE_CODE_DEBUG_LOG_LEVEL".to_string(), "verbose".to_string())]
}
Harness::Claude => Vec::new(),
Harness::Codex if verbose => vec![("RUST_LOG".to_string(), "codex_core=info,codex_exec=info".to_string())],
Harness::Codex => Vec::new(),
Harness::Grok => Vec::new(),
Harness::Cursor => vec![
(CURSOR_CONFIG_DIR_ENV.to_string(), format!("{AGENT_REPO}/{CURSOR_FORWARD_REL}")),
(XDG_CONFIG_HOME_ENV.to_string(), format!("{AGENT_REPO}/tmp/.config")),
],
}
}
pub fn harness_command(
harness: Harness, model: Option<&str>, effort: Option<&str>, skill_source: &str, result: &str,
term: crate::config::Terminal,
) -> String {
harness_command_verbose(harness, model, effort, skill_source, result, harness_verbose_enabled(), term)
}
fn harness_command_verbose(
harness: Harness, model: Option<&str>, effort: Option<&str>, skill_source: &str, result: &str, verbose: bool,
term: crate::config::Terminal,
) -> 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 the path in 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 verbose {
cmd.push_str(" --print-logs --log-level DEBUG");
}
if let Some(m) = model {
cmd.push_str(" -m ");
cmd.push_str(&shell_quote(m));
}
cmd.push_str(" run ");
cmd.push_str(&shell_quote(&instruction));
wrap_harness_shell(harness, skill_source, model, &cmd, verbose, term)
}
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 the path in 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 tui = String::from("claude --permission-mode bypassPermissions");
if let Some(m) = model {
tui.push_str(" --model ");
tui.push_str(&shell_quote(m));
}
tui.push(' ');
tui.push_str(&shell_quote(&prompt));
wrap_tui_shell(harness, skill_source, model, &tui, TuiQuit::SlashExit, result, term)
}
Harness::Codex => {
let prompt = format!(
"Run the skill defined in .skills/{skill_source}/SKILL.md. Follow its instructions exactly. \
Write the required result file to the path in 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 tui = String::from("codex --dangerously-bypass-approvals-and-sandbox");
if let Some(m) = model {
tui.push_str(" -m ");
tui.push_str(&shell_quote(m));
}
if let Some(e) = effort {
tui.push_str(" -c ");
tui.push_str(&shell_quote(&format!("model_reasoning_effort={e}")));
}
tui.push(' ');
tui.push_str(&shell_quote(&prompt));
wrap_tui_shell(harness, skill_source, model, &tui, TuiQuit::DoubleCtrlC, result, term)
}
Harness::Grok => {
let prompt = format!(
"Run the skill defined in .skills/{skill_source}/SKILL.md. Follow its instructions exactly. \
Write the required result file to the path in 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("grok -p ");
cmd.push_str(&shell_quote(&prompt));
cmd.push_str(" --permission-mode bypassPermissions --always-approve");
if let Some(m) = model {
cmd.push_str(" -m ");
cmd.push_str(&shell_quote(m));
}
if let Some(e) = effort {
cmd.push_str(" --effort ");
cmd.push_str(&shell_quote(e));
}
if verbose {
cmd.push_str(" --debug --debug-file \"${");
cmd.push_str(RUN_LOG_VAR);
cmd.push_str("}.debug\"");
}
wrap_harness_shell(harness, skill_source, model, &cmd, verbose, term)
}
Harness::Cursor => {
let prompt = format!(
"Run the skill defined in .skills/{skill_source}/SKILL.md. Follow its instructions exactly. \
Write the required result file to the path in 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 trust_dir = format!("$HOME/.cursor/projects/{}", AGENT_REPO.trim_start_matches('/').replace('/', "-"));
let mut tui = format!(
"mkdir -p {trust_dir} && : > {trust_dir}/.workspace-trusted && cursor-agent --force --sandbox disabled"
);
if let Some(m) = model {
tui.push_str(" --model ");
tui.push_str(&shell_quote(&cursor_model_with_effort(m, effort)));
}
tui.push(' ');
tui.push_str(&shell_quote(&prompt));
wrap_tui_shell(harness, skill_source, model, &tui, TuiQuit::DoubleCtrlC, result, term)
}
}
}
#[derive(Debug, Clone, Copy)]
enum TuiQuit {
SlashExit,
DoubleCtrlC,
}
impl TuiQuit {
fn as_arg(self) -> &'static str {
match self {
TuiQuit::SlashExit => "slash-exit",
TuiQuit::DoubleCtrlC => "double-ctrl-c",
}
}
}
fn wrap_tui_shell(
harness: Harness, skill_source: &str, model: Option<&str>, tui_cmd: &str, quit: TuiQuit, result: &str,
term: crate::config::Terminal,
) -> String {
let model_label = model.unwrap_or("(harness default)");
format!(
"{{ echo \"scsh: harness={} skill={skill_source} model={model_label} tui=tmux \
log=${{{log_var}}} cast=${{{log_var}}}.cast\" >&2; \
scsh-tui-record {cols} {rows} {quit} {result_q} {tui_q}; }} 2>&1 | tee \"${{{log_var}}}\"",
harness.as_str(),
log_var = RUN_LOG_VAR,
cols = term.cols,
rows = term.rows,
quit = quit.as_arg(),
result_q = shell_quote(result),
tui_q = shell_quote(tui_cmd),
)
}
fn cursor_model_with_effort(model: &str, effort: Option<&str>) -> String {
if model.contains('[') {
return model.to_string();
}
let Some(effort) = effort else {
return model.to_string();
};
if model == "composer-2.5" || model.starts_with("composer-2.5-") {
return match effort {
"high" => "composer-2.5-fast".to_string(),
_ => "composer-2.5".to_string(),
};
}
let suffix = match effort {
"xhigh" if model.starts_with("gpt-5.5") => "extra-high",
other => other,
};
if model.ends_with(&format!("-{suffix}")) {
return model.to_string();
}
format!("{model}-{suffix}")
}
fn wrap_harness_shell(
harness: Harness, skill_source: &str, model: Option<&str>, inner: &str, verbose: bool, term: crate::config::Terminal,
) -> String {
let model_label = model.unwrap_or("(harness default)");
let post = match harness {
Harness::Claude if verbose => format!(
"if [ -f \"${{{log_var}}}.debug\" ]; then echo \"scsh: --- claude debug log ---\" >&2; cat \"${{{log_var}}}.debug\" >&2; fi",
log_var = RUN_LOG_VAR,
),
Harness::Codex if verbose => format!(
"if [ -f \"${{{log_var}}}.last\" ]; then echo \"scsh: --- codex final message ---\" >&2; cat \"${{{log_var}}}.last\" >&2; fi",
log_var = RUN_LOG_VAR,
),
Harness::Grok if verbose => format!(
"if [ -f \"${{{log_var}}}.debug\" ]; then echo \"scsh: --- grok debug log ---\" >&2; cat \"${{{log_var}}}.debug\" >&2; fi",
log_var = RUN_LOG_VAR,
),
_ => String::new(),
};
let recorded = format!(
"asciinema rec -q --cols {cols} --rows {rows} -c {inner_q} \"${{{log_var}}}.cast\"",
cols = term.cols,
rows = term.rows,
inner_q = shell_quote(inner),
log_var = RUN_LOG_VAR,
);
format!(
"{{ echo \"scsh: harness={} skill={skill_source} model={model_label} log=${{{log_var}}} cast=${{{log_var}}}.cast\" >&2; {recorded}; {post} }} 2>&1 | tee \"${{{log_var}}}\"",
harness.as_str(),
log_var = 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 host_ids() -> (u32, u32) {
(id_value("-u").unwrap_or(1000), id_value("-g").unwrap_or(1000))
}
fn id_value(flag: &str) -> Option<u32> {
let out = std::process::Command::new("id").arg(flag).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().parse().ok()).flatten()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageStatus {
pub name: String,
pub tag: String,
pub exists: bool,
pub up_to_date: bool,
pub created: Option<String>,
pub size: Option<String>,
}
pub fn image_statuses(runtime: &str) -> Vec<ImageStatus> {
let df = dockerfile();
let (uid, gid) = host_ids();
let tz = host_timezone();
let base_fp = base_image_fingerprint(&df, uid, gid, &tz);
let mut out = vec![image_status_of(runtime, "base", BASE_IMAGE_TAG, &base_fp)];
for h in crate::config::Harness::ALL {
let spec = image_build_spec(h, &df, uid, gid, &tz);
out.push(image_status_of(runtime, h.as_str(), &spec.tag, &spec.fingerprint));
}
out
}
fn image_status_of(runtime: &str, name: &str, tag: &str, expected_fp: &str) -> ImageStatus {
let actual = image_inspect_fingerprint(runtime, tag);
let exists = actual.is_some() || image_exists(runtime, tag);
let up_to_date = actual.as_deref() == Some(expected_fp);
let (created, size) = if exists { image_created_size(runtime, tag) } else { (None, None) };
ImageStatus { name: name.into(), tag: tag.into(), exists, up_to_date, created, size }
}
fn image_exists(runtime: &str, tag: &str) -> bool {
std::process::Command::new(runtime)
.args(["image", "inspect", tag])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn image_created_size(runtime: &str, tag: &str) -> (Option<String>, Option<String>) {
if runtime == "container" {
return (None, None);
}
let out =
std::process::Command::new(runtime).args(["image", "inspect", tag, "--format", "{{.Created}}\t{{.Size}}"]).output();
let Ok(out) = out else { return (None, None) };
if !out.status.success() {
return (None, None);
}
parse_created_size(&String::from_utf8_lossy(&out.stdout))
}
fn parse_created_size(line: &str) -> (Option<String>, Option<String>) {
let mut parts = line.trim().splitn(2, '\t');
let created = parts.next().filter(|s| !s.is_empty()).map(short_created);
let size = parts.next().and_then(|s| s.trim().parse::<u64>().ok()).map(format_image_size);
(created, size)
}
fn short_created(ts: &str) -> String {
if ts.len() >= 19 && ts.as_bytes()[10] == b'T' {
format!("{} {} UTC", &ts[..10], &ts[11..19])
} else {
ts.to_string()
}
}
fn format_image_size(bytes: u64) -> String {
const UNITS: [&str; 4] = ["B", "kB", "MB", "GB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1000.0 && unit + 1 < UNITS.len() {
value /= 1000.0;
unit += 1;
}
if unit == 0 {
format!("{bytes}B")
} else {
format!("{value:.1}{}", UNITS[unit])
}
}
pub fn build_command_stdin(
runtime: &str, tag: &str, target: &str, uid: u32, gid: u32, tz: &str, fingerprint: &str, no_cache: bool,
) -> Vec<String> {
let mut v = vec![runtime.into(), "build".into(), "-t".into(), tag.into(), "--target".into(), target.into()];
if no_cache {
v.push("--no-cache".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,
no_cache: bool,
) -> Vec<String> {
let mut v = vec![runtime.into(), "build".into(), "-t".into(), tag.into(), "--target".into(), target.into()];
if no_cache {
v.push("--no-cache".into());
}
v.extend(build_args(uid, gid, tz));
v.extend(build_labels(fingerprint));
v.push(context_dir.into());
v
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageBuildSpec {
pub harness: Harness,
pub tag: String,
pub target: String,
pub fingerprint: String,
}
pub fn image_build_spec(harness: Harness, dockerfile: &str, uid: u32, gid: u32, tz: &str) -> ImageBuildSpec {
let target = image_target(harness);
ImageBuildSpec {
harness,
tag: image_tag(harness),
target: target.to_string(),
fingerprint: image_build_fingerprint(dockerfile, target, uid, gid, tz),
}
}
#[allow(dead_code)] pub fn runtime_supports_bake(runtime: &str) -> bool {
std::process::Command::new(runtime)
.args(["buildx", "version"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[allow(dead_code)] pub fn build_command_bake(runtime: &str, bake_targets: &[String]) -> Vec<String> {
let mut v = vec![runtime.into(), "buildx".into(), "bake".into(), "--load".into(), "-f".into(), "-".into()];
v.extend(bake_targets.iter().cloned());
v
}
#[allow(dead_code)] pub fn bake_definition_json(context_dir: &str, specs: &[ImageBuildSpec], uid: u32, gid: u32, tz: &str) -> String {
use crate::json::quote;
let mut entries = Vec::with_capacity(specs.len());
for spec in specs {
entries.push(format!(
r#" {}: {{
"context": {},
"dockerfile": "Dockerfile",
"target": {},
"tags": [{}],
"args": {{
"AGENT_UID": "{uid}",
"AGENT_GID": "{gid}",
"TZ": {}
}},
"labels": {{
{}: {}
}}
}}"#,
quote(&spec.target),
quote(context_dir),
quote(&spec.target),
quote(&spec.tag),
quote(tz),
quote(BUILD_FINGERPRINT_LABEL),
quote(&spec.fingerprint),
));
}
format!("{{\n \"target\": {{\n{}\n }}\n}}", entries.join(",\n"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoMountMode {
Full,
TmpOnly,
}
pub fn run_command(
runtime: &str, tag: &str, run_dir: &str, name: &str, env: &[(String, String)], volumes: &[(&str, &str)],
command: &str, repo_mount: RepoMountMode,
) -> 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}"));
}
match repo_mount {
RepoMountMode::Full => {
v.push("-v".into());
v.push(format!("{run_dir}:{AGENT_REPO}"));
}
RepoMountMode::TmpOnly => {
v.push("-v".into());
v.push(format!("{run_dir}/tmp:{AGENT_REPO}/tmp"));
}
}
v.push(tag.into());
v.push("/bin/sh".into());
v.push("-c".into());
v.push(command.into());
v
}
pub fn container_named_exists(runtime: &str, name: &str) -> bool {
use std::process::{Command, Stdio};
if name.is_empty() {
return false;
}
let Ok(out) = Command::new(runtime).args(["inspect", name]).stderr(Stdio::null()).output() else {
return false;
};
if !out.status.success() {
return false;
}
let body = String::from_utf8_lossy(&out.stdout);
let body = body.trim();
!(body.is_empty() || body == "[]" || body == "null")
}
pub fn container_named_exists_any(name: &str) -> bool {
for rt in runtime_candidates(cfg!(target_os = "macos")) {
if which(rt).is_some() && container_named_exists(rt, name) {
return true;
}
}
false
}
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()
|| claude_keychain_credentials_json().is_some()
}
pub fn check_harness_host(harness: Harness) -> Result<(), String> {
match harness {
Harness::Opencode => {
if opencode_auth_ready() {
Ok(())
} else {
Err("opencode harness unavailable (auth not found at ~/.local/share/opencode/auth.json — run `opencode auth login`)".into())
}
}
Harness::Claude => {
if claude_container_auth_ready() {
Ok(())
} else {
Err(
"claude harness unavailable (no CLAUDE_CODE_OAUTH_TOKEN, no ~/.claude/.credentials.json, and no macOS \
keychain credentials — log in with `claude`, or run `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN)"
.into(),
)
}
}
Harness::Codex => {
if codex_container_auth_ready() {
Ok(())
} else {
Err(
"codex harness unavailable (no ~/.codex/auth.json and OPENAI_API_KEY is not set \
— run `codex login`, or export OPENAI_API_KEY in your shell)"
.into(),
)
}
}
Harness::Grok => {
if grok_container_auth_ready() {
Ok(())
} else {
Err(
"grok harness unavailable (no ~/.grok/auth.json and XAI_API_KEY is not set \
— run `grok login` (or `grok login --device-auth`), or export XAI_API_KEY in your shell)"
.into(),
)
}
}
Harness::Cursor => {
if cursor_container_auth_ready() {
Ok(())
} else {
Err(
"cursor harness unavailable (no cursor auth on host — run `cursor agent login`, \
or export CURSOR_API_KEY in your shell)"
.into(),
)
}
}
}
}
pub struct OpencodeModelProbe {
available: Option<std::collections::HashSet<String>>,
}
impl OpencodeModelProbe {
pub fn for_selected(skills: &[crate::config::ResolvedInvocation]) -> Self {
let requested = requested_opencode_models(skills);
if requested.is_empty() {
return Self { available: None };
}
if which("opencode").is_none() || !opencode_auth_ready() {
return Self { available: None };
}
Self { available: Some(load_opencode_models_for(&requested).unwrap_or_default()) }
}
pub fn check_model(&self, model: &str) -> Result<(), String> {
match &self.available {
Some(set) if set.contains(model) => Ok(()),
Some(_) => Err(format!("opencode model '{model}' not listed by `opencode models` on this host")),
None => Ok(()),
}
}
}
fn requested_opencode_models(skills: &[crate::config::ResolvedInvocation]) -> std::collections::HashSet<String> {
skills
.iter()
.filter(|s| s.harness == Harness::Opencode)
.filter_map(|s| s.model.as_deref())
.map(str::to_string)
.collect()
}
fn opencode_model_provider(model: &str) -> &str {
model.split('/').next().unwrap_or(model)
}
fn opencode_providers_for_models(models: &std::collections::HashSet<String>) -> Vec<String> {
let mut providers: Vec<String> = models.iter().map(|m| opencode_model_provider(m).to_string()).collect();
providers.sort_unstable();
providers.dedup();
providers
}
pub fn check_skill_host(harness: Harness, model: Option<&str>, probe: &OpencodeModelProbe) -> Result<(), String> {
check_harness_host(harness)?;
if harness == Harness::Opencode {
if let Some(m) = model {
probe.check_model(m)?;
}
}
Ok(())
}
fn load_opencode_models_for(
requested: &std::collections::HashSet<String>,
) -> Result<std::collections::HashSet<String>, String> {
let mut all = std::collections::HashSet::new();
for provider in opencode_providers_for_models(requested) {
let output = std::process::Command::new("opencode")
.args(["models", &provider])
.output()
.map_err(|e| format!("could not run `opencode models {provider}`: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.trim();
let msg =
if detail.is_empty() { format!("opencode models {provider} exited with an error") } else { detail.to_string() };
return Err(msg);
}
all.extend(parse_opencode_models(&String::from_utf8_lossy(&output.stdout)));
}
Ok(all)
}
fn parse_opencode_models(stdout: &str) -> std::collections::HashSet<String> {
stdout.lines().map(|line| line.trim()).filter(|line| !line.is_empty()).map(|line| line.to_string()).collect()
}
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 harness_volumes(harness: Harness) -> Vec<(String, String)> {
match harness {
Harness::Opencode => opencode_host_mounts(),
Harness::Claude | Harness::Codex | Harness::Grok | Harness::Cursor => Vec::new(),
}
}
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 const TRANSPORT_BARE: &str = "transport.git";
pub const PULL_BARE: &str = "pull.git";
pub const GIT_TRANSPORT_HOST_ENV: &str = "SCSH_GIT_HOST";
pub const GIT_TRANSPORT_PORT_ENV: &str = "SCSH_GIT_PORT";
pub const GIT_TRANSPORT_HOST_SHELL: &str =
"host=${SCSH_GIT_HOST:-$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')}";
pub const GIT_TRANSPORT_HOST_GUARD: &str =
"[ -n \"$host\" ] || { echo \"scsh: could not determine host gateway for git transport (set SCSH_GIT_HOST)\" >&2; exit 1; }";
pub fn uses_git_transport(runtime: &str) -> bool {
if cfg!(target_os = "macos") && runtime == "container" {
return true;
}
match std::env::var("SCSH_GIT_TRANSPORT").ok().as_deref() {
Some("0") | Some("false") => false,
Some("1") | Some("true") => true,
_ => false,
}
}
pub fn pick_ephemeral_port() -> Result<u16, String> {
use std::net::TcpListener;
let listener = TcpListener::bind("0.0.0.0:0").map_err(|e| format!("could not bind an ephemeral port: {e}"))?;
listener.local_addr().map(|a| a.port()).map_err(|e| format!("could not read ephemeral port: {e}"))
}
pub fn clone_command(src: &str, dst: &str) -> Vec<String> {
vec!["git".into(), "clone".into(), src.into(), dst.into()]
}
pub fn fsck_command(repo: &str) -> Vec<String> {
vec!["git".into(), "-C".into(), repo.into(), "fsck".into(), "--no-progress".into()]
}
pub fn init_bare_repo(path: &Path) -> Result<(), String> {
if path.is_dir() {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?;
}
use std::process::Command;
Command::new("git")
.args(["init", "--bare"])
.arg(path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
.then_some(())
.ok_or_else(|| format!("git init --bare failed for {}", path.display()))
}
pub fn push_transport_refs(root: &Path, bare: &Path) -> Result<(), String> {
init_bare_repo(bare)?;
let bare_s = bare.to_string_lossy();
let Some(heads) = git_stdout(root, &["for-each-ref", "--format=%(refname)", "refs/heads"]) else {
return Err(format!("could not read local branches in {}", root.display()));
};
let mut pushed = false;
for line in heads.lines() {
let refname = line.trim();
if refname.is_empty() {
continue;
}
let spec = format!("{refname}:{refname}");
if !git_ok(root, &["push", "--quiet", &bare_s, &spec]) {
return Err(format!("git push {refname} to {} failed", bare.display()));
}
pushed = true;
}
if !pushed {
return Err(format!("no local branches to push from {}", root.display()));
}
if let Some(branch) = git_stdout(root, &["rev-parse", "--abbrev-ref", "HEAD"]) {
let branch = branch.trim();
if !branch.is_empty() && branch != "HEAD" {
let head_ref = format!("refs/heads/{branch}");
if !git_bare_ok(bare, &["symbolic-ref", "HEAD", &head_ref]) {
return Err(format!("could not set HEAD on {}", bare.display()));
}
}
}
Ok(())
}
pub fn commits_fetch_path(run_dir: &Path) -> PathBuf {
let pull = run_dir.join(PULL_BARE);
if pull.is_dir() {
pull
} else {
run_dir.to_path_buf()
}
}
pub fn git_transport_entry(harness: &str, push_commits: bool, commit_name: &str, commit_email: &str) -> String {
let mut script = format!(
"set -e\n\
{host_shell}\n\
{host_guard}\n\
git clone \"git://${{host}}:${{{port}}}/transport.git\" /home/agent/.scsh-clone\n\
(cd /home/agent/.scsh-clone && tar -cf - .) | (cd {repo} && tar -xf -)\n\
rm -rf /home/agent/.scsh-clone\n\
cd {repo}\n\
git rev-parse --verify origin/main >/dev/null 2>&1 || {{ echo \"scsh: origin/main missing after git transport clone (point local main at the review base)\" >&2; exit 1; }}\n\
cur=$(git rev-parse --abbrev-ref HEAD)\n\
for ref in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin); do\n\
branch=${{ref#origin/}}\n\
[ \"$branch\" = HEAD ] && continue\n\
[ \"$branch\" = \"$cur\" ] && continue\n\
git branch --force \"$branch\" \"origin/$branch\" >/dev/null 2>&1 || true\n\
done\n",
host_shell = GIT_TRANSPORT_HOST_SHELL,
host_guard = GIT_TRANSPORT_HOST_GUARD,
port = GIT_TRANSPORT_PORT_ENV,
repo = AGENT_REPO,
);
if push_commits {
script.push_str(&format!(
"git config user.email {}\ngit config user.name {}\n",
shell_quote(commit_email),
shell_quote(commit_name),
));
}
script.push_str(harness);
if push_commits {
script.push_str("\ngit push \"git://${host}:${SCSH_GIT_PORT}/pull.git\" HEAD");
}
script
}
fn git_ok(dir: &Path, args: &[&str]) -> bool {
use std::process::Command;
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn git_bare_ok(bare: &Path, args: &[&str]) -> bool {
use std::process::Command;
Command::new("git")
.arg("--git-dir")
.arg(bare)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn git_stdout(dir: &Path, args: &[&str]) -> Option<String> {
use std::process::Command;
let out = Command::new("git").arg("-C").arg(dir).args(args).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
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");
assert_eq!(image_tag(Harness::Codex), "scsh-codex:latest");
assert_eq!(image_tag(Harness::Grok), "scsh-grok:latest");
assert_eq!(image_tag(Harness::Cursor), "scsh-cursor:latest");
}
#[test]
fn dockerfile_has_shared_base_and_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("FROM scsh-base AS scsh-codex"));
assert!(df.contains("npm install -g opencode-ai"));
assert!(df.contains("npm install -g @anthropic-ai/claude-code"));
assert!(df.contains("npm install -g @openai/codex"));
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_codex_stage_points_codex_home_into_repo_tmp() {
let df = dockerfile();
assert!(df.contains("ENV CODEX_HOME=/home/agent/repo/tmp/.codex"));
assert!(df.contains("codex --version"));
}
#[test]
fn dockerfile_grok_stage_points_grok_home_into_repo_tmp() {
let df = dockerfile();
assert!(df.contains("FROM scsh-base AS scsh-grok"));
assert!(df.contains("npm install -g @xai-official/grok"));
assert!(df.contains("ENV GROK_HOME=/home/agent/repo/tmp/.grok"));
assert!(df.contains("grok --version"));
}
#[test]
fn dockerfile_cursor_stage_points_cursor_home_into_repo_tmp() {
let df = dockerfile();
assert!(df.contains("FROM scsh-base AS scsh-cursor"));
assert!(df.contains("downloads.cursor.com/lab/"));
assert!(df.contains("ENV CURSOR_AGENT_HOME=/usr/local/share/cursor-agent"));
assert!(df.contains("mv \"$tmp/dist-package\" \"$CURSOR_AGENT_HOME\""));
assert!(df.contains("ENV CURSOR_CONFIG_DIR=/home/agent/repo/tmp/.cursor"));
assert!(df.contains("ENV XDG_CONFIG_HOME=/home/agent/repo/tmp/.config"));
assert!(df.contains("cursor-agent --version"));
}
#[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_tui_recorder_relaunches_and_quits_gracefully() {
let df = dockerfile();
assert!(df.contains("pane_relaunch="), "relaunch loop missing from scsh-tui-record");
assert!(df.contains(r#"[ -f \"$result\" ] || [ \$n -ge 2 ]"#), "relaunch stop conditions missing");
assert!(df.contains("re-send $quit"), "graceful quit re-send missing");
assert!(df.contains("killed session (harness ignored quit)"), "force-kill fallback missing");
}
#[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_verbose(
Harness::Opencode,
Some("openai/gpt-5.5"),
None,
"add",
"tmp/add.json",
true,
crate::config::Terminal::default(),
);
assert!(cmd.contains("scsh: harness=opencode"));
assert!(cmd.contains("opencode --print-logs --log-level DEBUG"));
assert!(cmd.contains("-m openai/gpt-5.5"));
assert!(cmd.contains(" run "));
assert!(cmd.contains("run skill add"));
assert!(cmd.contains("SCSH_RESULT"));
assert!(cmd.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
let cmd = harness_command_verbose(
Harness::Opencode,
None,
None,
"multiply",
"tmp/mul.json",
true,
crate::config::Terminal::default(),
);
assert!(cmd.contains("opencode --print-logs --log-level DEBUG run "));
let quiet = harness_command_verbose(
Harness::Opencode,
None,
None,
"multiply",
"tmp/mul.json",
false,
crate::config::Terminal::default(),
);
assert!(!quiet.contains("--print-logs"));
assert!(quiet.contains("scsh: harness=opencode"));
assert!(quiet.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
}
#[test]
fn harness_command_builds_claude_invocation() {
let cmd = harness_command_verbose(
Harness::Claude,
Some("sonnet"),
None,
"add",
"tmp/add_claude_sonnet_4_6_result.json",
true,
crate::config::Terminal::default(),
);
assert!(cmd.contains(".skills/add/SKILL.md"));
assert!(cmd.contains("scsh-tui-record 200 50 slash-exit tmp/add_claude_sonnet_4_6_result.json "), "got: {cmd}");
assert!(cmd.contains("claude --permission-mode bypassPermissions --model sonnet"), "got: {cmd}");
assert!(!cmd.contains("--settings"), "got: {cmd}");
assert!(!cmd.contains("claude -p"), "got: {cmd}");
assert!(!cmd.contains("capture-pane"), "got: {cmd}");
assert!(!cmd.contains("send-keys"), "got: {cmd}");
assert!(cmd.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""), "got: {cmd}");
}
#[test]
fn harness_command_builds_codex_invocation() {
let cmd = harness_command_verbose(
Harness::Codex,
Some("gpt-5.5"),
None,
"add",
"tmp/add_codex_result.json",
true,
crate::config::Terminal::default(),
);
assert!(cmd.contains("scsh: harness=codex"));
assert!(cmd.contains("scsh-tui-record 200 50 double-ctrl-c tmp/add_codex_result.json "), "got: {cmd}");
assert!(cmd.contains("codex --dangerously-bypass-approvals-and-sandbox"), "got: {cmd}");
assert!(!cmd.contains("codex exec"), "got: {cmd}");
assert!(cmd.contains(" -m gpt-5.5"));
assert!(!cmd.contains("config.toml"), "got: {cmd}");
assert!(!cmd.contains("capture-pane"), "got: {cmd}");
assert!(cmd.contains(".skills/add/SKILL.md"));
assert!(cmd.contains("SCSH_RESULT"));
assert!(cmd.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
let quiet = harness_command_verbose(
Harness::Codex,
None,
None,
"multiply",
"tmp/mul.json",
false,
crate::config::Terminal::default(),
);
assert!(quiet.contains("codex --dangerously-bypass-approvals-and-sandbox"));
assert!(!quiet.contains(" -m "));
assert!(quiet.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
}
#[test]
fn harness_command_builds_grok_invocation() {
let cmd = harness_command_verbose(
Harness::Grok,
Some("grok-build"),
Some("high"),
"add",
"tmp/add_grok.json",
true,
crate::config::Terminal::default(),
);
assert!(cmd.contains("scsh: harness=grok"));
assert!(cmd.contains("grok -p "));
assert!(cmd.contains(" --permission-mode bypassPermissions --always-approve"));
assert!(cmd.contains(" -m grok-build"));
assert!(cmd.contains(" --effort high"));
assert!(cmd.contains(" --debug --debug-file \"${SCSH_RUN_LOG}.debug\""));
assert!(cmd.contains("scsh: --- grok debug log ---"));
assert!(cmd.contains(".skills/add/SKILL.md"));
assert!(cmd.contains("SCSH_RESULT"));
assert!(cmd.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
let quiet = harness_command_verbose(
Harness::Grok,
None,
None,
"multiply",
"tmp/mul.json",
false,
crate::config::Terminal::default(),
);
assert!(quiet.contains("grok -p "));
assert!(!quiet.contains("--debug"));
assert!(!quiet.contains(" --effort "));
assert!(!quiet.contains(" -m "));
}
#[test]
fn harness_command_builds_cursor_invocation() {
let cmd = harness_command_verbose(
Harness::Cursor,
Some("composer-2.5"),
Some("high"),
"add",
"tmp/add_cursor.json",
true,
crate::config::Terminal::default(),
);
assert!(cmd.contains("scsh: harness=cursor"));
assert!(cmd.contains("scsh-tui-record 200 50 double-ctrl-c tmp/add_cursor.json "), "got: {cmd}");
assert!(cmd.contains("cursor-agent --force --sandbox disabled"), "got: {cmd}");
assert!(!cmd.contains("cursor-agent -p"), "got: {cmd}");
assert!(!cmd.contains("--trust"), "got: {cmd}");
assert!(cmd.contains(" --model composer-2.5-fast"));
assert!(cmd.contains(".cursor/projects/home-agent-repo/.workspace-trusted"), "got: {cmd}");
assert!(!cmd.contains("capture-pane"), "got: {cmd}");
assert!(!cmd.contains("send-keys"), "got: {cmd}");
assert!(cmd.contains(".skills/add/SKILL.md"));
assert!(cmd.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
let quiet = harness_command_verbose(
Harness::Cursor,
None,
None,
"multiply",
"tmp/mul.json",
false,
crate::config::Terminal::default(),
);
assert!(quiet.contains("cursor-agent --force --sandbox disabled"));
assert!(!quiet.contains(" --model "));
}
#[test]
fn cursor_model_with_effort_maps_to_cursor_agent_slugs() {
assert_eq!(cursor_model_with_effort("claude-opus-4-8[effort=low]", Some("high")), "claude-opus-4-8[effort=low]");
assert_eq!(cursor_model_with_effort("composer-2.5", Some("high")), "composer-2.5-fast");
assert_eq!(cursor_model_with_effort("composer-2.5", Some("low")), "composer-2.5");
assert_eq!(cursor_model_with_effort("claude-opus-4-8", Some("low")), "claude-opus-4-8-low");
assert_eq!(cursor_model_with_effort("gpt-5.5", Some("xhigh")), "gpt-5.5-extra-high");
}
#[test]
fn harness_recorded_at_configured_pty_size() {
let term = crate::config::Terminal { cols: 120, rows: 30 };
for h in [Harness::Claude, Harness::Codex, Harness::Cursor] {
let cmd = harness_command_verbose(h, Some("m"), None, "add", "tmp/add.json", false, term);
assert!(cmd.contains("scsh-tui-record 120 30 "), "harness {h:?} got: {cmd}");
assert!(cmd.contains("cast=${SCSH_RUN_LOG}.cast"), "harness {h:?} got: {cmd}");
}
for h in [Harness::Opencode, Harness::Grok] {
let cmd = harness_command_verbose(h, None, None, "add", "tmp/add.json", false, term);
assert!(cmd.contains("asciinema rec -q --cols 120 --rows 30 -c "), "harness {h:?} got: {cmd}");
}
}
#[test]
fn harness_command_codex_passes_reasoning_effort() {
let cmd = harness_command_verbose(
Harness::Codex,
Some("gpt-5.5"),
Some("xhigh"),
"add",
"tmp/add.json",
true,
crate::config::Terminal::default(),
);
assert!(cmd.contains(" -c model_reasoning_effort=xhigh"));
let without = harness_command_verbose(
Harness::Codex,
Some("gpt-5.5"),
None,
"add",
"tmp/add.json",
true,
crate::config::Terminal::default(),
);
assert!(!without.contains("model_reasoning_effort"));
}
#[test]
fn harness_container_env_depends_on_verbosity() {
assert_eq!(harness_container_env_verbose(Harness::Opencode, true).len(), 1);
assert_eq!(harness_container_env_verbose(Harness::Opencode, false).len(), 1);
assert_eq!(harness_container_env_verbose(Harness::Claude, true).len(), 2);
assert!(harness_container_env_verbose(Harness::Claude, false).is_empty());
let codex = harness_container_env_verbose(Harness::Codex, true);
assert_eq!(codex.len(), 1);
assert_eq!(codex[0].0, "RUST_LOG");
assert!(harness_container_env_verbose(Harness::Codex, false).is_empty());
let cursor = harness_container_env_verbose(Harness::Cursor, false);
assert_eq!(cursor.len(), 2);
assert_eq!(cursor[0].0, "CURSOR_CONFIG_DIR");
assert_eq!(cursor[1].0, "XDG_CONFIG_HOME");
}
#[test]
fn harness_verbose_disabled_when_scsh_quiet() {
let key = "SCSH_QUIET";
let prev = std::env::var_os(key);
std::env::set_var(key, "1");
assert!(!harness_verbose_enabled());
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[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 bake_definition_json_lists_every_target() {
let df = dockerfile();
let specs = vec![
image_build_spec(Harness::Opencode, &df, 501, 20, "UTC"),
image_build_spec(Harness::Claude, &df, 501, 20, "UTC"),
];
let json = bake_definition_json("/tmp/ctx", &specs, 501, 20, "UTC");
assert!(json.contains("\"scsh-opencode\""));
assert!(json.contains("\"scsh-claude\""));
assert!(json.contains("\"scsh-opencode:latest\""));
assert!(json.contains("\"scsh-claude:latest\""));
assert!(json.contains("\"/tmp/ctx\""));
}
#[test]
fn build_command_bake_names_each_target() {
let cmd = build_command_bake("docker", &["scsh-opencode".into(), "scsh-claude".into()]);
let want: Vec<String> = vec![
"docker".into(),
"buildx".into(),
"bake".into(),
"--load".into(),
"-f".into(),
"-".into(),
"scsh-opencode".into(),
"scsh-claude".into(),
];
assert_eq!(cmd, want);
}
#[test]
fn base_image_fingerprint_matches_scsh_base_target() {
let df = dockerfile();
assert_eq!(
base_image_fingerprint(&df, 501, 20, "UTC"),
image_build_fingerprint(&df, BASE_IMAGE_TARGET, 501, 20, "UTC")
);
assert_ne!(base_image_fingerprint(&df, 501, 20, "UTC"), image_build_fingerprint(&df, "scsh-codex", 501, 20, "UTC"));
}
#[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 image_created_size_parses_inspect_output() {
let (created, size) = parse_created_size("2026-07-05T01:02:03.123456789Z\t3222111000\n");
assert_eq!(created.as_deref(), Some("2026-07-05 01:02:03 UTC"));
assert_eq!(size.as_deref(), Some("3.2GB"));
let (created, size) = parse_created_size("2026-07-05T01:02:03Z");
assert_eq!(created.as_deref(), Some("2026-07-05 01:02:03 UTC"));
assert_eq!(size, None);
assert_eq!(parse_created_size(""), (None, None));
assert_eq!(short_created("yesterday"), "yesterday");
assert_eq!(format_image_size(999), "999B");
assert_eq!(format_image_size(482_000), "482.0kB");
assert_eq!(format_image_size(1_500_000), "1.5MB");
}
#[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, false),
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(),
]
);
let forced = build_command_stdin("docker", "t:l", "t", 1, 1, "UTC", "fp", true);
assert_eq!(forced[6], "--no-cache");
let forced_ctx = build_command_context("docker", "t:l", "t", "/ctx", 1, 1, "UTC", "fp", true);
assert!(forced_ctx.contains(&"--no-cache".to_string()));
assert!(!build_command_context("docker", "t:l", "t", "/ctx", 1, 1, "UTC", "fp", false)
.contains(&"--no-cache".to_string()));
assert_eq!(
run_command(
"docker",
"scsh-opencode:latest",
"/tmp/run",
"run-s",
&[],
&[],
"opencode run 'run skill s'",
RepoMountMode::Full,
),
vec![
"docker",
"run",
"--rm",
"--name",
"run-s",
"-v",
"/tmp/run:/home/agent/repo",
"scsh-opencode:latest",
"/bin/sh",
"-c",
"opencode run 'run skill s'"
]
);
assert_eq!(
run_command(
"container",
"scsh-opencode:latest",
"/tmp/run",
"run-s",
&[],
&[],
"git clone",
RepoMountMode::TmpOnly,
),
vec![
"container",
"run",
"--rm",
"--name",
"run-s",
"-v",
"/tmp/run/tmp:/home/agent/repo/tmp",
"scsh-opencode:latest",
"/bin/sh",
"-c",
"git clone"
]
);
assert_eq!(
run_command(
"podman",
"scsh-claude:latest",
"/tmp/run",
"run-s",
&[],
&[("/home/u/.claude", "/home/agent/.claude:ro")],
"claude -p hi",
RepoMountMode::Full,
),
vec![
"podman",
"run",
"--rm",
"--name",
"run-s",
"--userns=keep-id",
"-v",
"/home/u/.claude:/home/agent/.claude:ro",
"-v",
"/tmp/run:/home/agent/repo",
"scsh-claude:latest",
"/bin/sh",
"-c",
"claude -p hi"
]
);
assert_eq!(
run_command(
"docker",
"scsh-opencode:latest",
"/tmp/run",
"run-s",
&[],
&[("/home/u/.local/share/opencode/auth.json", OPENCODE_AUTH_MOUNT)],
"opencode run 'run skill s'",
RepoMountMode::Full,
),
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/run:/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/run",
"run-s",
&env,
&[],
"opencode run 'run skill s'",
RepoMountMode::Full,
),
vec![
"docker",
"run",
"--rm",
"--name",
"run-s",
"-e",
"A=20",
"-e",
"B=22",
"-v",
"/tmp/run:/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 fsck_command_checks_clone_integrity() {
assert_eq!(fsck_command("/tmp/dst"), vec!["git", "-C", "/tmp/dst", "fsck", "--no-progress"]);
}
#[test]
fn uses_git_transport_on_macos_apple_container_only() {
let prev = std::env::var("SCSH_GIT_TRANSPORT").ok();
std::env::remove_var("SCSH_GIT_TRANSPORT");
if cfg!(target_os = "macos") {
assert!(uses_git_transport("container"));
} else {
assert!(!uses_git_transport("container"));
}
assert!(!uses_git_transport("docker"));
std::env::set_var("SCSH_GIT_TRANSPORT", "0");
if cfg!(target_os = "macos") {
assert!(uses_git_transport("container"), "Apple Container always uses git transport");
} else {
assert!(!uses_git_transport("container"));
}
std::env::set_var("SCSH_GIT_TRANSPORT", "1");
assert!(uses_git_transport("docker"));
match prev {
Some(v) => std::env::set_var("SCSH_GIT_TRANSPORT", v),
None => std::env::remove_var("SCSH_GIT_TRANSPORT"),
}
}
#[test]
fn push_transport_refs_maps_origin_branches_to_heads() {
use std::process::Command;
let tmp = std::env::temp_dir().join(format!("scsh-push-transport-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let root = tmp.join("root");
let bare = tmp.join("bare.git");
Command::new("git").args(["init", "-q"]).arg(&root).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["config", "user.email", "t@example.com"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["config", "user.name", "t"]).status().unwrap();
std::fs::write(root.join("f"), "x").unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["add", "f"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["commit", "-qm", "init"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["branch", "-M", "main"]).status().unwrap();
Command::new("git")
.args(["-C"])
.arg(&root)
.args(["remote", "add", "origin", "https://example.invalid/scsh.git"])
.status()
.unwrap();
Command::new("git")
.args(["-C"])
.arg(&root)
.args(["update-ref", "refs/remotes/origin/main", "HEAD"])
.status()
.unwrap();
std::fs::write(root.join("f"), "y").unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["add", "f"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["commit", "-qm", "feature"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["checkout", "-q", "-b", "feature"]).status().unwrap();
push_transport_refs(&root, &bare).unwrap();
let show = Command::new("git").args(["-C"]).arg(&bare).args(["show-ref"]).output().unwrap();
let refs = String::from_utf8_lossy(&show.stdout);
assert!(refs.contains("refs/heads/main"), "expected refs/heads/main in bare, got:\n{refs}");
assert!(refs.contains("refs/heads/feature"), "expected feature branch in bare, got:\n{refs}");
assert!(!refs.contains("refs/remotes/origin/main"), "bare should not store remote-tracking refs:\n{refs}");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn push_transport_refs_uses_local_main_not_stale_origin() {
use std::process::Command;
let tmp = std::env::temp_dir().join(format!("scsh-push-main-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let root = tmp.join("root");
let bare = tmp.join("bare.git");
let work = tmp.join("work");
Command::new("git").args(["init", "-q"]).arg(&root).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["config", "user.email", "t@example.com"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["config", "user.name", "t"]).status().unwrap();
std::fs::write(root.join("f"), "stale").unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["add", "f"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["commit", "-qm", "stale"]).status().unwrap();
let stale = Command::new("git").args(["-C"]).arg(&root).args(["rev-parse", "HEAD"]).output().unwrap();
let stale = String::from_utf8_lossy(&stale.stdout).trim().to_string();
std::fs::write(root.join("f"), "base").unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["add", "f"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["commit", "-qm", "base"]).status().unwrap();
let base_sha = Command::new("git").args(["-C"]).arg(&root).args(["rev-parse", "HEAD"]).output().unwrap();
let base_sha = String::from_utf8_lossy(&base_sha.stdout).trim().to_string();
Command::new("git").args(["-C"]).arg(&root).args(["branch", "-M", "main"]).status().unwrap();
Command::new("git")
.args(["-C"])
.arg(&root)
.args(["remote", "add", "origin", "https://example.invalid/scsh.git"])
.status()
.unwrap();
Command::new("git")
.args(["-C"])
.arg(&root)
.args(["update-ref", "refs/remotes/origin/main", &stale])
.status()
.unwrap();
std::fs::write(root.join("f"), "feature").unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["add", "f"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["commit", "-qm", "feature"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["checkout", "-q", "-b", "feature"]).status().unwrap();
Command::new("git").args(["-C"]).arg(&root).args(["branch", "-f", "main", &base_sha]).status().unwrap();
push_transport_refs(&root, &bare).unwrap();
Command::new("git").args(["clone", "-q"]).arg(&bare).arg(&work).status().unwrap();
let origin_main = Command::new("git").args(["-C"]).arg(&work).args(["rev-parse", "origin/main"]).output().unwrap();
let origin_main = String::from_utf8_lossy(&origin_main.stdout).trim().to_string();
assert_eq!(origin_main, base_sha, "origin/main must match force-updated local main");
assert_ne!(origin_main, stale, "must not use stale refs/remotes/origin/main");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn git_transport_entry_clones_before_harness() {
let entry = git_transport_entry("echo hi", false, "bot", "bot@example.com");
assert!(entry.contains("ip -4 route show default"));
assert!(entry.contains("git clone"));
assert!(entry.contains("transport.git"));
assert!(entry.contains("origin/main missing after git transport clone"));
assert!(entry.contains("echo hi"));
assert!(!entry.contains("pull.git"));
let entry = git_transport_entry("echo hi", true, "bot", "bot@example.com");
assert!(entry.contains("pull.git"));
assert!(entry.contains("user.email"));
}
#[test]
fn opencode_model_provider_is_first_path_segment() {
assert_eq!(opencode_model_provider("openai/gpt-5.5"), "openai");
assert_eq!(opencode_model_provider("nebius-glm/zai-org/GLM-5.2"), "nebius-glm");
assert_eq!(opencode_model_provider("standalone"), "standalone");
}
#[test]
fn opencode_providers_for_models_dedupes_and_sorts() {
let models = std::collections::HashSet::from([
"openai/gpt-5.5".into(),
"openai/gpt-5.4-mini-fast".into(),
"nebius-glm/zai-org/GLM-5.2".into(),
]);
assert_eq!(opencode_providers_for_models(&models), vec!["nebius-glm".to_string(), "openai".to_string()]);
}
#[test]
fn requested_opencode_models_collects_explicit_models_from_selection() {
let skills = vec![
crate::config::ResolvedInvocation {
name: "a".into(),
skill_source: "add".into(),
harness: Harness::Opencode,
model: Some("openai/gpt-5.5".into()),
effort: None,
profile: None,
commits: false,
timeout: None,
env: vec![],
result: "tmp/a.json".into(),
terminal: crate::config::Terminal::default(),
},
crate::config::ResolvedInvocation {
name: "b".into(),
skill_source: "add".into(),
harness: Harness::Claude,
model: Some("sonnet".into()),
effort: None,
profile: None,
commits: false,
timeout: None,
env: vec![],
result: "tmp/b.json".into(),
terminal: crate::config::Terminal::default(),
},
crate::config::ResolvedInvocation {
name: "c".into(),
skill_source: "add".into(),
harness: Harness::Opencode,
model: None,
effort: None,
profile: None,
commits: false,
timeout: None,
env: vec![],
result: "tmp/c.json".into(),
terminal: crate::config::Terminal::default(),
},
];
let set = requested_opencode_models(&skills);
assert_eq!(set.len(), 1);
assert!(set.contains("openai/gpt-5.5"));
}
#[test]
fn parse_opencode_models_collects_trimmed_lines() {
let set = parse_opencode_models("openai/gpt-5.5\n\nnebius-glm/zai-org/GLM-5.2\n");
assert_eq!(set.len(), 2);
assert!(set.contains("openai/gpt-5.5"));
assert!(set.contains("nebius-glm/zai-org/GLM-5.2"));
}
#[test]
fn opencode_model_probe_checks_listed_models() {
let probe = OpencodeModelProbe { available: Some(std::collections::HashSet::from(["openai/gpt-5.5".into()])) };
assert!(probe.check_model("openai/gpt-5.5").is_ok());
let err = probe.check_model("openai/other").unwrap_err();
assert!(err.contains("openai/other"));
assert!(err.contains("opencode models"));
}
#[test]
fn opencode_model_probe_skips_when_not_loaded() {
let probe = OpencodeModelProbe { available: None };
assert!(probe.check_model("any/model").is_ok());
}
#[test]
fn opencode_model_probe_rejects_when_model_list_empty() {
let probe = OpencodeModelProbe { available: Some(std::collections::HashSet::new()) };
assert!(probe.check_model("openai/anything").is_err());
}
#[test]
fn opencode_model_probe_for_selected_skips_without_explicit_models() {
let skills = vec![crate::config::ResolvedInvocation {
name: "add".into(),
skill_source: "add".into(),
harness: Harness::Opencode,
model: None,
effort: None,
profile: None,
commits: false,
timeout: None,
env: vec![],
result: "tmp/add.json".into(),
terminal: crate::config::Terminal::default(),
}];
let probe = OpencodeModelProbe::for_selected(&skills);
assert!(probe.check_model("openai/anything").is_ok());
}
#[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);
let prev_home = std::env::var_os("HOME");
let empty_home = std::env::temp_dir().join(format!("scsh-empty-home-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&empty_home);
std::fs::create_dir_all(&empty_home).unwrap();
std::env::remove_var(key);
std::env::set_var("HOME", &empty_home);
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),
}
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
let _ = std::fs::remove_dir_all(&empty_home);
}
#[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'");
}
}