use crate::canary;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Agent {
#[default]
Claude,
Copilot,
}
impl FromStr for Agent {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"claude" => Ok(Agent::Claude),
"copilot" => Ok(Agent::Copilot),
other => anyhow::bail!("unknown agent '{other}' — expected 'claude' or 'copilot'"),
}
}
}
pub fn resolve_image(language_image: &str, agent: Agent) -> String {
if agent == Agent::Claude {
return language_image.to_string();
}
let (repo, tag) = match language_image.split_once(':') {
Some((r, t)) => (r, t),
None => (language_image, "latest"),
};
let Some(name) = repo.strip_prefix("moor/") else {
return language_image.to_string();
};
if name == "copilot" || name.starts_with("copilot-") {
return language_image.to_string();
}
if name == "base" {
format!("moor/copilot:{tag}")
} else {
format!("moor/copilot-{name}:{tag}")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub name: String,
pub image: String,
#[serde(default)]
pub agent: Agent,
#[serde(default)]
pub github_repo: Option<String>,
#[serde(default)]
pub egress: Egress,
#[serde(default = "Resources::default")]
pub resources: Resources,
#[serde(default)]
pub secrets: Vec<String>,
#[serde(default = "canary::generate_token")]
pub canary_token: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Egress {
#[serde(default)]
pub allow: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resources {
pub cpu: String,
pub mem: String,
pub pids: u32,
}
impl Default for Resources {
fn default() -> Self {
Resources {
cpu: "2.0".to_string(),
mem: "4g".to_string(),
pids: 512,
}
}
}
impl Manifest {
pub fn new(name: &str, image: &str) -> Self {
Manifest {
name: name.to_string(),
image: image.to_string(),
agent: Agent::default(),
github_repo: None,
egress: Egress::default(),
resources: Resources::default(),
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(),
],
canary_token: canary::generate_token(),
}
}
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading manifest {}", path.display()))?;
serde_yaml::from_str(&text).with_context(|| format!("parsing manifest {}", path.display()))
}
pub fn save(&self, path: &Path) -> Result<()> {
let text = serde_yaml::to_string(self)?;
std::fs::write(path, text)
.with_context(|| format!("writing manifest {}", path.display()))?;
Ok(())
}
pub fn sandbox_container(&self) -> String {
format!("{}-sandbox", self.name)
}
pub fn egress_container(&self) -> String {
format!("{}-egress", self.name)
}
}
pub fn validate_name(name: &str) -> Result<()> {
let ok = !name.is_empty()
&& name.len() <= 63
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& name.chars().next().is_some_and(|c| c.is_ascii_alphabetic());
if !ok {
anyhow::bail!(
"invalid project name '{name}': use lowercase letters, digits, and dashes, starting with a letter"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_valid_names() {
for name in ["a", "sample-app", "my-project-2", "x1"] {
assert!(validate_name(name).is_ok(), "expected '{name}' to be valid");
}
}
#[test]
fn agent_defaults_to_claude() {
assert_eq!(Agent::default(), Agent::Claude);
let m = Manifest::new("sample", "moor/base:latest");
assert_eq!(m.agent, Agent::Claude);
let no_agent_yaml = "name: x\nimage: moor/base:latest\n";
let loaded: Manifest = serde_yaml::from_str(no_agent_yaml).unwrap();
assert_eq!(loaded.agent, Agent::Claude);
}
#[test]
fn agent_flag_sets_manifest_agent() {
let mut m = Manifest::new("sample", "moor/python:latest");
m.agent = "copilot".parse().unwrap();
assert_eq!(m.agent, Agent::Copilot);
let yaml = serde_yaml::to_string(&m).unwrap();
assert!(yaml.contains("agent: copilot"), "{yaml}");
let back: Manifest = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(back.agent, Agent::Copilot);
}
#[test]
fn unknown_agent_is_rejected() {
assert!("claude".parse::<Agent>().is_ok());
assert!("copilot".parse::<Agent>().is_ok());
assert!("Copilot".parse::<Agent>().is_ok()); for bad in ["gpt", "", "claude-code", "none"] {
assert!(bad.parse::<Agent>().is_err(), "expected '{bad}' rejected");
}
}
#[test]
fn copilot_composes_with_language() {
assert_eq!(
resolve_image("moor/python:latest", Agent::Copilot),
"moor/copilot-python:latest"
);
assert_eq!(
resolve_image("moor/node:latest", Agent::Copilot),
"moor/copilot-node:latest"
);
assert_eq!(
resolve_image("moor/rust:latest", Agent::Copilot),
"moor/copilot-rust:latest"
);
assert_eq!(
resolve_image("moor/base:latest", Agent::Copilot),
"moor/copilot:latest"
);
assert_eq!(
resolve_image("moor/copilot-python:latest", Agent::Copilot),
"moor/copilot-python:latest"
);
assert_eq!(
resolve_image("ubuntu:22.04", Agent::Copilot),
"ubuntu:22.04"
);
}
#[test]
fn claude_leaves_image_unchanged() {
for img in [
"moor/base:latest",
"moor/python:latest",
"moor/node:latest",
"moor/rust:latest",
] {
assert_eq!(resolve_image(img, Agent::Claude), img);
}
}
#[test]
fn rejects_empty() {
assert!(validate_name("").is_err());
}
#[test]
fn rejects_leading_digit_or_dash() {
assert!(validate_name("1app").is_err());
assert!(validate_name("-app").is_err());
}
#[test]
fn rejects_uppercase_and_symbols() {
assert!(validate_name("MyApp").is_err());
assert!(validate_name("my_app").is_err());
assert!(validate_name("my app").is_err());
assert!(validate_name("my/app").is_err());
}
#[test]
fn rejects_dots() {
assert!(validate_name("..").is_err());
assert!(validate_name("a.b").is_err());
}
#[test]
fn rejects_over_63_chars() {
let long = "a".repeat(64);
assert!(validate_name(&long).is_err());
let ok_len = "a".repeat(63);
assert!(validate_name(&ok_len).is_ok());
}
#[test]
fn default_manifest_has_sane_resource_limits() {
let m = Manifest::new("sample", "moor/base:latest");
assert_eq!(m.resources.pids, 512);
assert!(m.secrets.contains(&"ANTHROPIC_API_KEY".to_string()));
assert!(m.secrets.contains(&"CLAUDE_CODE_OAUTH_TOKEN".to_string()));
assert!(m.secrets.contains(&"COPILOT_GITHUB_TOKEN".to_string()));
assert!(m.secrets.contains(&"GH_TOKEN".to_string()));
assert!(m.secrets.contains(&"GITHUB_TOKEN".to_string()));
assert!(m.egress.allow.is_empty());
}
#[test]
fn manifest_round_trips_through_yaml() {
let m = Manifest::new("sample", "moor/node:latest");
let text = serde_yaml::to_string(&m).unwrap();
let back: Manifest = serde_yaml::from_str(&text).unwrap();
assert_eq!(back.name, m.name);
assert_eq!(back.image, m.image);
assert_eq!(back.resources.pids, m.resources.pids);
}
}