use std::path::Path;
use tokio::process::Command;
pub(super) fn home(path: &Path) -> anyhow::Result<()> {
use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _};
anyhow::ensure!(
!path.as_os_str().is_empty(),
"harness_home_required: harness.home is required"
);
anyhow::ensure!(
path.is_absolute(),
"harness_home_must_be_absolute: harness.home must be absolute"
);
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(i32::try_from(
(rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::DIRECTORY
| rustix::fs::OFlags::NONBLOCK)
.bits(),
)?)
.open(path)
.map_err(|error| {
anyhow::anyhow!(
"harness_home_unreadable: harness.home cannot be opened: {}",
error.kind()
)
})?;
let metadata = file.metadata().map_err(|error| {
anyhow::anyhow!(
"harness_home_unreadable: harness.home metadata failed: {}",
error.kind()
)
})?;
anyhow::ensure!(
metadata.is_dir()
&& metadata.uid() == rustix::process::geteuid().as_raw()
&& metadata.permissions().mode().trailing_zeros() >= 6,
"harness_home_not_private: harness.home must be a directory owned by this caller with no group or other access"
);
Ok(())
}
const INHERITED: &[&str] = &[
"PATH",
"HOME",
"TMPDIR",
"SHELL",
"USER",
"LOGNAME",
"LANG",
"LC_ALL",
"TERM",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_CACHE_HOME",
"XDG_STATE_HOME",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
"TOKENIZERS_PARALLELISM",
];
const MODEL_ACCOUNT: &[&str] = &[
"ANTHROPIC_AUTH_TOKEN",
"CODEX_API_KEY",
"LYS_ACCOUNT_VARIABLE",
"ANTHROPIC_BASE_URL",
"OPENAI_BASE_URL",
];
const DECLARED_PROVIDER: &[&str] = &[
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
];
pub(super) fn check_provider(names: &[String]) -> anyhow::Result<()> {
for name in DECLARED_PROVIDER {
anyhow::ensure!(
std::env::var_os(name).is_none_or(|value| value.is_empty())
|| names.iter().any(|declared| declared == name),
"provider_environment_undeclared: {name}"
);
}
Ok(())
}
pub(super) fn validate(names: &[String]) -> anyhow::Result<()> {
for name in names {
let upper = name.to_ascii_uppercase();
anyhow::ensure!(
!name.is_empty()
&& name.len() <= 128
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'),
"environment_name_invalid"
);
anyhow::ensure!(
DECLARED_PROVIDER.contains(&name.as_str())
|| (!["KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL"]
.iter()
.any(|part| upper.contains(part))
&& !["_AUTH", "_PASS", "_PAT", "DATABASE_URL"]
.iter()
.any(|part| upper.ends_with(part))),
"credential_environment_refused: {name}"
);
}
Ok(())
}
pub(super) fn apply(command: &mut Command, declared: &[String]) -> anyhow::Result<()> {
validate(declared)?;
check_provider(declared)?;
command.env_clear();
for name in INHERITED.iter().chain(MODEL_ACCOUNT) {
if let Some(value) = std::env::var_os(name) {
command.env(name, value);
}
}
for name in declared {
let value = std::env::var_os(name)
.ok_or_else(|| anyhow::anyhow!("environment_declared_but_absent: {name}"))?;
command.env(name, value);
}
command.env_remove("LYS_AGENT_PASS");
Ok(())
}
pub(super) fn harness_home(
command: &mut Command,
harness: &super::config::Harness,
) -> anyhow::Result<()> {
home(&harness.home)?;
command
.env_remove("CODEX_HOME")
.env_remove("CLAUDE_CONFIG_DIR");
match harness.id.as_str() {
"codex" => {
command.env("CODEX_HOME", &harness.home);
}
"claude-code" => {
command.env("CLAUDE_CONFIG_DIR", &harness.home);
}
_ => {}
}
Ok(())
}