use crate::manifest::Manifest;
const TEMPLATE: &str = include_str!("../templates/project.compose.yml.tmpl");
pub fn render(m: &Manifest) -> String {
render_with(m, &host_git_identity())
}
fn host_git_identity() -> Vec<(&'static str, String)> {
["user.name", "user.email"]
.into_iter()
.filter_map(|key| {
let (status, out) = crate::proc::run_capture("git", &["config", "--get", key]).ok()?;
let value = out.trim().to_string();
(status.success() && !value.is_empty()).then_some((key, value))
})
.collect()
}
fn compose_quoted(v: &str) -> String {
format!(
"\"{}\"",
v.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "$$")
)
}
fn git_config_env_lines(identity: &[(&str, String)]) -> String {
if identity.is_empty() {
return String::new();
}
let mut lines = format!(" GIT_CONFIG_COUNT: \"{}\"\n", identity.len());
for (i, (key, value)) in identity.iter().enumerate() {
lines.push_str(&format!(" GIT_CONFIG_KEY_{i}: \"{key}\"\n"));
lines.push_str(&format!(
" GIT_CONFIG_VALUE_{i}: {}\n",
compose_quoted(value)
));
}
lines.trim_end_matches('\n').to_string()
}
pub fn render_with(m: &Manifest, identity: &[(&str, String)]) -> String {
let secret_env_lines = if m.secrets.is_empty() {
String::new()
} else {
let mut lines = String::new();
for s in &m.secrets {
lines.push_str(&format!(" {s}: \"${{{s}:-}}\"\n"));
}
lines.trim_end_matches('\n').to_string()
};
let extra_allow = m.egress.allow.join(",");
TEMPLATE
.replace("{{PROJECT_NAME}}", &m.name)
.replace("{{IMAGE}}", &m.image)
.replace("{{CPU_LIMIT}}", &m.resources.cpu)
.replace("{{MEM_LIMIT}}", &m.resources.mem)
.replace("{{PIDS_LIMIT}}", &m.resources.pids.to_string())
.replace("{{SECRET_ENV_LINES}}", &secret_env_lines)
.replace("{{GIT_CONFIG_ENV_LINES}}", &git_config_env_lines(identity))
.replace("{{EXTRA_ALLOW_DOMAINS}}", &extra_allow)
.replace("{{CANARY_TOKEN}}", &m.canary_token)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
#[test]
fn no_known_placeholder_survives_rendering() {
let m = Manifest::new("sample-app", "moor/node:latest");
let rendered = render(&m);
for token in [
"{{PROJECT_NAME}}",
"{{IMAGE}}",
"{{CPU_LIMIT}}",
"{{MEM_LIMIT}}",
"{{PIDS_LIMIT}}",
"{{SECRET_ENV_LINES}}",
"{{GIT_CONFIG_ENV_LINES}}",
"{{EXTRA_ALLOW_DOMAINS}}",
"{{CANARY_TOKEN}}",
] {
assert!(
!rendered.contains(token),
"unreplaced placeholder {token} left in rendered compose:\n{rendered}"
);
}
}
#[test]
fn project_name_is_substituted_everywhere_it_appears() {
let m = Manifest::new("sample-app", "moor/node:latest");
let rendered = render(&m);
assert!(rendered.contains("sample-app-sandbox"));
assert!(rendered.contains("sample-app-egress"));
assert!(rendered.contains("sample-app-internal"));
assert!(rendered.contains("sample-app-workspace"));
}
#[test]
fn secrets_become_compose_default_empty_env_lines() {
let mut m = Manifest::new("sample-app", "moor/node:latest");
m.secrets = vec![
"ANTHROPIC_API_KEY".to_string(),
"CLAUDE_CODE_OAUTH_TOKEN".to_string(),
"COPILOT_GITHUB_TOKEN".to_string(),
"GH_TOKEN".to_string(),
"GITHUB_TOKEN".to_string(),
];
let rendered = render(&m);
assert!(rendered.contains("ANTHROPIC_API_KEY: \"${ANTHROPIC_API_KEY:-}\""));
assert!(rendered.contains("CLAUDE_CODE_OAUTH_TOKEN: \"${CLAUDE_CODE_OAUTH_TOKEN:-}\""));
assert!(rendered.contains("COPILOT_GITHUB_TOKEN: \"${COPILOT_GITHUB_TOKEN:-}\""));
assert!(rendered.contains("GH_TOKEN: \"${GH_TOKEN:-}\""));
assert!(rendered.contains("GITHUB_TOKEN: \"${GITHUB_TOKEN:-}\""));
}
#[test]
fn no_secrets_still_renders_valid_yaml_shape() {
let mut m = Manifest::new("sample-app", "moor/node:latest");
m.secrets = vec![];
let rendered = render(&m);
assert!(!rendered.contains("{{SECRET_ENV_LINES}}"));
}
#[test]
fn extra_allow_domains_joined_with_commas() {
let mut m = Manifest::new("sample-app", "moor/node:latest");
m.egress.allow = vec![
"registry.npmjs.org".to_string(),
"example-registry.dev".to_string(),
];
let rendered = render(&m);
assert!(
rendered.contains("EXTRA_ALLOW_DOMAINS: \"registry.npmjs.org,example-registry.dev\"")
);
}
#[test]
fn sandbox_has_no_bind_mount_volume_entries() {
let m = Manifest::new("sample-app", "moor/base:latest");
let rendered = render(&m);
assert!(rendered.contains("workspace:/workspace"));
assert!(rendered.contains("cache:/home/agent/.cache"));
assert!(rendered.contains("claude-state:/home/agent/.claude"));
assert!(rendered.contains("copilot-state:/home/agent/.copilot"));
assert!(!rendered.contains("/Users/"));
assert!(!rendered.contains("${HOME}"));
}
#[test]
fn sandbox_gets_attestation_and_sink_without_bind_mounts() {
let m = Manifest::new("sample-app", "moor/base:latest");
let rendered = render(&m);
let sandbox = rendered.split("\n egress:").next().unwrap();
assert!(sandbox.contains("KEEL_RUNTIME_ATTESTATION: \"/run/moor/posture.json\""));
assert!(sandbox.contains("KEEL_CHAIN_SINK: \"/run/moor-sink/keel.jsonl\""));
assert!(sandbox.contains("- /run/moor:mode=0755"));
assert!(sandbox.contains("- /run/moor-sink:mode=1777"));
for line in sandbox
.lines()
.map(str::trim)
.filter(|l| l.starts_with("- ") && l.contains(':'))
{
let source = line.trim_start_matches("- ").split(':').next().unwrap();
assert!(
!source.starts_with('/') || line.contains("mode="),
"host path mounted: {line}"
);
assert!(
!source.starts_with('.') && !source.starts_with('~') && !source.contains('$'),
"host path mounted: {line}"
);
}
}
#[test]
fn host_git_identity_is_rendered_as_git_config_env() {
let m = Manifest::new("sample-app", "moor/base:latest");
let identity = [
("user.name", r#"Dane "D" Balia $HOME\x"#.to_string()),
("user.email", "dane@example.com".to_string()),
];
let rendered = render_with(&m, &identity);
let sandbox = rendered.split("\n egress:").next().unwrap();
assert!(
sandbox.contains(" GIT_CONFIG_COUNT: \"2\""),
"{sandbox}"
);
assert!(sandbox.contains(" GIT_CONFIG_KEY_0: \"user.name\""));
assert!(
sandbox.contains(r#" GIT_CONFIG_VALUE_0: "Dane \"D\" Balia $$HOME\\x""#),
"quote, $ and backslash not escaped:\n{sandbox}"
);
assert!(sandbox.contains(" GIT_CONFIG_KEY_1: \"user.email\""));
assert!(sandbox.contains(" GIT_CONFIG_VALUE_1: \"dane@example.com\""));
let doc: serde_yaml::Value = serde_yaml::from_str(&rendered).unwrap();
let env = &doc["services"]["sandbox"]["environment"];
assert_eq!(env["GIT_CONFIG_VALUE_0"], r#"Dane "D" Balia $$HOME\x"#);
}
#[test]
fn no_host_identity_renders_no_git_config_env() {
let m = Manifest::new("sample-app", "moor/base:latest");
let rendered = render_with(&m, &[]);
assert!(!rendered.contains("GIT_CONFIG_"), "{rendered}");
serde_yaml::from_str::<serde_yaml::Value>(&rendered).unwrap();
}
#[test]
fn sandbox_network_is_internal_only() {
let m = Manifest::new("sample-app", "moor/base:latest");
let rendered = render(&m);
assert!(rendered.contains("internal: true"));
}
}