use std::path::{Path, PathBuf};
use std::process::Command;
pub fn name_ok(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 40
&& name != "."
&& name != ".."
&& !name.starts_with('.')
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
pub fn agents_dir() -> PathBuf {
crate::identity::home().join("agents")
}
fn home_for(name: &str) -> PathBuf {
agents_dir().join(name)
}
fn run_in(home: &Path, args: &[&str]) -> Result<String, String> {
let exe = std::env::current_exe().map_err(|e| format!("cannot find this binary: {e}"))?;
let out = Command::new(exe)
.args(args)
.env("GAGE_HOME", home)
.env_remove("COUNTERSIGN_HOME")
.env_remove("CTX_HOME")
.output()
.map_err(|e| format!("could not run gage {}: {e}", args.join(" ")))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
let err = err.trim();
return Err(if err.is_empty() {
format!("gage {} failed", args.join(" "))
} else {
err.to_string()
});
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
fn whoami_in(home: &Path) -> Result<(String, String), String> {
let out = run_in(home, &["whoami"])?;
let line = out.lines().next().unwrap_or("");
match line.split_once('\t') {
Some((h, k)) if !k.trim().is_empty() => Ok((h.trim().to_string(), k.trim().to_string())),
_ => Err("could not read the new identity's key".into()),
}
}
pub struct Agent {
pub name: String,
pub handle: String,
pub key: String,
pub home: PathBuf,
}
impl Agent {
pub fn fingerprint(&self) -> String {
let (sign, boxpub) = crate::identity::split_key(&self.key);
crate::words::fingerprint_of(&sign, boxpub.as_deref())
}
}
pub fn list() -> Vec<Agent> {
let dir = agents_dir();
let Ok(rd) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut names: Vec<String> = rd
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| name_ok(n))
.collect();
names.sort();
names
.into_iter()
.filter_map(|name| {
let home = home_for(&name);
let (handle, key) = whoami_in(&home).ok()?;
Some(Agent {
name,
handle,
key,
home,
})
})
.collect()
}
pub fn create(name: &str) -> Result<Agent, String> {
if !name_ok(name) {
return Err("A name is 1 to 40 characters: letters, digits, - and _.".into());
}
let home = home_for(name);
if home.exists() {
return Err(format!("An agent called {name} already exists."));
}
let me = crate::identity::Identity::load()
.map_err(|_| "This machine has no identity yet.".to_string())?;
std::fs::create_dir_all(&home)
.map_err(|e| format!("could not make {}: {e}", home.display()))?;
let built = (|| -> Result<Agent, String> {
run_in(&home, &["init", name])?;
let (handle, key) = whoami_in(&home)?;
let my_key = me.full_key();
let my_home = crate::identity::home();
let my_home = my_home.to_string_lossy().to_string();
let home_str = home.to_string_lossy().to_string();
run_in(
&crate::identity::home(),
&["add", name, &key, "--same-machine"],
)?;
run_in(&home, &["add", &me.handle, &my_key, "--same-machine"])?;
run_in(&crate::identity::home(), &["route", name, &home_str])?;
run_in(&home, &["route", &me.handle, &my_home])?;
Ok(Agent {
name: name.to_string(),
handle,
key,
home: home.clone(),
})
})();
if built.is_err() {
let _ = std::fs::remove_dir_all(&home);
}
built
}
#[derive(Debug)]
pub struct Removed {
pub name: String,
pub key: Option<String>,
pub route_removed: bool,
pub home: PathBuf,
}
pub fn remove(name: &str) -> Result<Removed, String> {
if !name_ok(name) {
return Err("A name is 1 to 40 characters: letters, digits, - and _.".into());
}
let home = home_for(name);
if !home.is_dir() {
return Err(format!("No agent called {name} on this machine."));
}
let key_from_home = whoami_in(&home).ok().map(|(_, k)| k);
let mut c = crate::trust::Contacts::load();
let sign = key_from_home
.as_deref()
.map(|k| crate::identity::split_key(k).0)
.or_else(|| c.key_for_name(name));
if let Some(sign) = &sign {
let now = crate::bundle::now_rfc3339();
if c.retire(sign, &now, 0, false, None) {
c.save()
.map_err(|e| format!("could not save contacts: {e}"))?;
let seq = crate::state::LocalState::observe_and_cut(crate::routes::scan_inbox_knowing)
.map_err(|e| format!("could not take the retirement cut: {e}"))?;
if let Some(r) = c.retired.get_mut(sign) {
r.retired_seq = seq;
}
c.save()
.map_err(|e| format!("could not save contacts: {e}"))?;
}
}
let mut r = crate::routes::Routes::load();
let route_removed = r.local.remove(name).is_some();
if route_removed {
r.save()
.map_err(|e| format!("could not save routes: {e}"))?;
}
std::fs::remove_dir_all(&home)
.map_err(|e| format!("could not remove {}: {e}", home.display()))?;
Ok(Removed {
name: name.to_string(),
key: sign,
route_removed,
home,
})
}
pub fn configs(agent: &Agent) -> serde_json::Value {
let exe = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "gage".into());
let home = agent.home.to_string_lossy().to_string();
let json_block = format!(
"{{\n \"mcpServers\": {{\n \"gage\": {{\n \"command\": {exe:?},\n \
\"args\": [\"mcp\"],\n \"env\": {{ \"GAGE_HOME\": {home:?} }}\n }}\n }}\n}}"
);
serde_json::json!({
"claude_code": format!(
"GAGE_HOME={} claude mcp add gage -- {} mcp",
shell_quote(&home), shell_quote(&exe)),
"claude_desktop": json_block,
"codex": json_block,
"generic": format!("{} mcp # with GAGE_HOME={}", exe, home),
})
}
fn shell_quote(s: &str) -> String {
if s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./:".contains(c))
{
s.to_string()
} else {
format!("'{}'", s.replace('\'', r"'\''"))
}
}
pub fn grant_line(agent: &Agent) -> String {
format!("allow {} answer auto-accept max-exchanges 200", agent.name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_name_that_could_leave_the_agents_directory_is_refused() {
for bad in [
"..",
".",
"../escape",
"a/b",
"a\\b",
"/abs",
"",
".hidden",
"has space",
"semi;colon",
"nul\0byte",
"new\nline",
"quote'name",
"dollar$name",
"tilde~name",
] {
assert!(!name_ok(bad), "accepted a dangerous agent name: {bad:?}");
}
let long = "a".repeat(41);
assert!(!name_ok(&long), "accepted a 41-character name");
}
#[test]
fn ordinary_agent_names_are_accepted() {
for good in [
"codex",
"grok",
"claude-code",
"bot_2",
"a",
&"a".repeat(40),
] {
assert!(name_ok(good), "refused an ordinary agent name: {good:?}");
}
}
#[test]
fn remove_refuses_a_dangerous_name_before_touching_anything() {
for bad in [
"..",
".",
"../escape",
"a/b",
"/abs",
"",
".hidden",
"nul\0byte",
] {
let e = remove(bad).unwrap_err();
assert!(
e.contains("1 to 40 characters"),
"remove({bad:?}) refused for the wrong reason: {e}"
);
}
}
#[test]
fn a_path_with_a_space_is_quoted_for_the_shell() {
assert_eq!(shell_quote("/home/o/.ctx"), "/home/o/.ctx");
assert_eq!(
shell_quote("/home/o/My Home/.ctx"),
"'/home/o/My Home/.ctx'"
);
assert_eq!(shell_quote("it's"), r"'it'\''s'");
}
}