#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpMount {
PerCall,
Persistent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Activation {
Enabled,
Disabled {
reason: &'static str,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliBackend {
pub key: &'static str,
pub binary: &'static str,
pub headless_invocation: &'static [&'static str],
pub stream_flags: &'static [&'static str],
pub tool_disable_flags: &'static [&'static str],
pub mcp_mount: McpMount,
pub home_env_var: &'static str,
pub activation: Activation,
pub capability_notes: &'static str,
}
pub const CLAUDE: CliBackend = CliBackend {
key: "claude",
binary: "claude",
headless_invocation: &["-p"],
stream_flags: &["--output-format", "stream-json"],
tool_disable_flags: &["--tools", "", "--strict-mcp-config"],
mcp_mount: McpMount::PerCall,
home_env_var: "CLAUDE_CONFIG_DIR",
activation: Activation::Enabled,
capability_notes: "--tools \"\" disables built-ins but NOT the user's own MCP \
servers; --strict-mcp-config is what empties the tool list",
};
pub const CODEX: CliBackend = CliBackend {
key: "codex",
binary: "codex",
headless_invocation: &["exec"],
stream_flags: &["--json"],
tool_disable_flags: &[],
mcp_mount: McpMount::Persistent,
home_env_var: "CODEX_HOME",
activation: Activation::Disabled {
reason: "codex's built-in shell cannot be disabled and the spawn path does not yet apply MUR's sandbox to it",
},
capability_notes: "prompt arrives on stdin; --json emits completed items \
with no incremental deltas; MCP mounts persistently, so \
a per-turn config must be written into the private home",
};
pub const AGY: CliBackend = CliBackend {
key: "agy",
binary: "agy",
headless_invocation: &["-p"],
stream_flags: &["--output-format", "stream-json"],
tool_disable_flags: &[],
mcp_mount: McpMount::Persistent,
home_env_var: "HOME",
activation: Activation::Disabled {
reason: "agy's 57 built-in tools cannot be disabled and the spawn path does not yet apply MUR's sandbox to it",
},
capability_notes: "HOME is the only lever and it moves everything, not just \
config; `-p` swallows a following flag as its prompt, so \
use `-p=<prompt>`; MCP mounts persistently at \
$HOME/.gemini/config/mcp_config.json",
};
pub const REGISTRY: &[CliBackend] = &[CLAUDE, CODEX, AGY];
pub fn backend(key: &str) -> Option<&'static CliBackend> {
REGISTRY.iter().find(|b| b.key == key)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendAvailability {
pub backend: &'static CliBackend,
pub path: std::path::PathBuf,
pub usable: bool,
}
pub fn available<F>(resolve: F) -> Vec<BackendAvailability>
where
F: Fn(&str) -> Option<std::path::PathBuf>,
{
REGISTRY
.iter()
.filter_map(|b| {
resolve(b.binary).map(|path| BackendAvailability {
backend: b,
path,
usable: matches!(b.activation, Activation::Enabled),
})
})
.collect()
}
pub fn home_dir(mur_home: &std::path::Path, key: &str) -> std::path::PathBuf {
mur_home.join("cli-homes").join(key)
}
pub fn ensure_home(
mur_home: &std::path::Path,
b: &CliBackend,
) -> std::io::Result<(&'static str, std::path::PathBuf)> {
let dir = home_dir(mur_home, b.key);
std::fs::create_dir_all(&dir)?;
Ok((b.home_env_var, dir))
}
pub const ISOLATION_FLAGS: &[&str] = &["--tools", "", "--strict-mcp-config"];
pub fn mcp_config_json(
shim_bin: &str,
socket: &std::path::Path,
task_id: &str,
) -> serde_json::Value {
serde_json::json!({
"mcpServers": {
"mur": {
"command": shim_bin,
"args": [
"mcp-shim",
"--socket", socket.to_string_lossy(),
"--task-id", task_id,
],
}
}
})
}
pub const PROVIDER_PREFIX: &str = "cli:";
pub fn from_provider(provider: &str) -> Option<&'static CliBackend> {
let key = provider.strip_prefix(PROVIDER_PREFIX)?;
backend(key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_row_matches_the_measured_capabilities() {
assert_eq!(CLAUDE.binary, "claude");
assert_eq!(CLAUDE.headless_invocation, &["-p"]);
assert_eq!(CLAUDE.stream_flags, &["--output-format", "stream-json"]);
assert_eq!(
CLAUDE.tool_disable_flags,
&["--tools", "", "--strict-mcp-config"]
);
assert_eq!(CLAUDE.mcp_mount, McpMount::PerCall);
assert_eq!(CLAUDE.home_env_var, "CLAUDE_CONFIG_DIR");
}
#[test]
fn claude_is_enabled_once_the_spawn_path_exists() {
assert!(matches!(CLAUDE.activation, Activation::Enabled));
}
#[test]
fn the_tool_disable_carries_both_halves() {
assert!(CLAUDE.tool_disable_flags.contains(&"--tools"));
assert!(CLAUDE.tool_disable_flags.contains(&"--strict-mcp-config"));
assert!(
!CLAUDE.tool_disable_flags.contains(&"--disallowedTools"),
"--disallowedTools is a named deny list, not a disable"
);
}
#[test]
fn every_row_is_fully_specified() {
for b in REGISTRY {
assert!(!b.key.is_empty(), "{}: empty key", b.key);
assert!(!b.binary.is_empty(), "{}: empty binary", b.key);
assert!(
!b.headless_invocation.is_empty(),
"{}: no headless flags",
b.key
);
assert!(!b.stream_flags.is_empty(), "{}: no stream flags", b.key);
assert!(!b.home_env_var.is_empty(), "{}: no home env var", b.key);
}
}
#[test]
fn unprobed_backends_are_absent_rather_than_guessed() {
assert!(backend("agy").is_some(), "agy's record is complete");
assert!(backend("codex").is_some(), "codex's record is complete");
assert!(backend("nope").is_none());
}
#[test]
fn lookup_finds_claude_and_rejects_unknown_keys() {
assert_eq!(backend("claude"), Some(&CLAUDE));
assert!(backend("nope").is_none());
}
use std::path::PathBuf;
fn found(_: &str) -> Option<PathBuf> {
Some(PathBuf::from("/opt/homebrew/bin/claude"))
}
fn missing(_: &str) -> Option<PathBuf> {
None
}
#[test]
fn an_absent_binary_produces_no_entry() {
assert!(available(missing).is_empty());
}
#[test]
fn a_present_binary_is_listed_with_the_path_that_was_resolved() {
let got = available(found);
assert_eq!(got.len(), REGISTRY.len());
for a in &got {
assert_eq!(a.path, PathBuf::from("/opt/homebrew/bin/claude"));
}
assert!(got.iter().any(|a| a.backend.key == "claude"));
}
#[test]
fn a_disabled_backend_would_be_listed_and_not_usable() {
let disabled = CliBackend {
activation: Activation::Disabled {
reason: "for the test",
},
..CLAUDE
};
let entry = BackendAvailability {
backend: &CLAUDE,
path: PathBuf::from("/opt/homebrew/bin/claude"),
usable: matches!(disabled.activation, Activation::Enabled),
};
assert!(!entry.usable, "a disabled backend must never be usable");
}
#[test]
fn usable_tracks_activation_and_nothing_else() {
for a in available(found) {
assert_eq!(
a.usable,
matches!(a.backend.activation, Activation::Enabled)
);
}
}
#[test]
fn home_dir_is_namespaced_under_cli_homes() {
let got = home_dir(std::path::Path::new("/tmp/murhome"), "claude");
assert_eq!(got, PathBuf::from("/tmp/murhome/cli-homes/claude"));
}
#[test]
fn ensure_home_creates_the_dir_and_returns_the_env_var() {
let tmp = std::env::temp_dir().join(format!("mur-cli-home-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let (var, dir) = ensure_home(&tmp, &CLAUDE).expect("create");
assert_eq!(var, "CLAUDE_CONFIG_DIR");
assert_eq!(dir, tmp.join("cli-homes").join("claude"));
assert!(dir.is_dir());
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn ensure_home_is_idempotent() {
let tmp = std::env::temp_dir().join(format!("mur-cli-home-idem-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
ensure_home(&tmp, &CLAUDE).expect("first");
let marker = home_dir(&tmp, "claude").join("settings.json");
std::fs::write(&marker, b"{}").expect("write marker");
ensure_home(&tmp, &CLAUDE).expect("second");
assert_eq!(std::fs::read(&marker).expect("read marker"), b"{}");
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn ensure_home_never_touches_the_users_own_cli_config() {
let base = std::env::temp_dir().join(format!("mur-cli-iso-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let user_cfg = base.join("user-claude");
std::fs::create_dir_all(&user_cfg).expect("user cfg");
let cred = user_cfg.join(".credentials.json");
std::fs::write(&cred, b"user-token").expect("seed");
ensure_home(&base.join("murhome"), &CLAUDE).expect("create");
assert_eq!(std::fs::read(&cred).expect("still there"), b"user-token");
assert!(
!base
.join("murhome")
.join("cli-homes")
.join("claude")
.join(".credentials.json")
.exists()
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn the_isolation_flags_stay_together() {
assert_eq!(ISOLATION_FLAGS, &["--tools", "", "--strict-mcp-config"]);
}
#[test]
fn the_mcp_config_names_the_shim_the_socket_and_the_task() {
let v = mcp_config_json(
"/usr/local/bin/mur_agent_x",
std::path::Path::new("/tmp/x/agent.sock"),
"t-9",
);
let s = &v["mcpServers"]["mur"];
assert_eq!(s["command"], "/usr/local/bin/mur_agent_x");
let args: Vec<String> = s["args"]
.as_array()
.expect("args")
.iter()
.map(|a| a.as_str().unwrap_or_default().to_string())
.collect();
assert_eq!(args[0], "mcp-shim");
assert!(args.contains(&"/tmp/x/agent.sock".to_string()));
assert!(args.contains(&"t-9".to_string()));
}
#[test]
fn the_config_declares_exactly_one_server() {
let v = mcp_config_json("bin", std::path::Path::new("/s"), "t");
assert_eq!(v["mcpServers"].as_object().expect("obj").len(), 1);
}
#[test]
fn a_prefixed_provider_names_the_backend() {
assert_eq!(from_provider("cli:claude").map(|b| b.key), Some("claude"));
}
#[test]
fn the_gateway_providers_are_not_the_cli_track() {
assert!(from_provider("claude").is_none());
assert!(from_provider("codex").is_none());
assert!(from_provider("openai").is_none());
}
#[test]
fn an_unknown_backend_is_none_even_when_prefixed() {
assert_eq!(from_provider("cli:agy").map(|b| b.key), Some("agy"));
assert!(from_provider("cli:nope").is_none());
}
#[test]
fn a_disabled_backend_still_resolves() {
let disabled = CliBackend {
activation: Activation::Disabled {
reason: "for the test",
},
..CLAUDE
};
assert!(matches!(disabled.activation, Activation::Disabled { .. }));
assert!(from_provider("cli:claude").is_some());
}
#[test]
fn codex_is_listed_and_never_usable() {
let c = backend("codex").expect("codex is registered");
match c.activation {
Activation::Disabled { reason } => {
assert!(reason.contains("sandbox"), "{reason}");
assert!(reason.contains("does not yet apply"), "{reason}");
assert!(reason.contains("shell"), "{reason}");
}
Activation::Enabled => panic!("codex must not be enabled without a verified sandbox"),
}
assert!(
available(found)
.iter()
.any(|a| a.backend.key == "codex" && !a.usable)
);
}
#[test]
fn cli_codex_resolves_so_the_refusal_can_name_itself() {
assert_eq!(from_provider("cli:codex").map(|b| b.key), Some("codex"));
}
#[test]
fn agy_is_listed_and_never_usable() {
let a = backend("agy").expect("agy is registered");
match a.activation {
Activation::Disabled { reason } => {
assert!(reason.contains("built-in tools"), "{reason}");
assert!(reason.contains("does not yet apply"), "{reason}");
}
Activation::Enabled => panic!("agy must not be enabled: 57 built-ins, none disablable"),
}
assert!(
available(found)
.iter()
.any(|a| a.backend.key == "agy" && !a.usable)
);
}
#[test]
fn home_is_a_real_lever_for_agy_not_a_placeholder() {
assert_eq!(AGY.home_env_var, "HOME");
let (var, dir) = ensure_home(std::path::Path::new("/tmp/mur-agy-x"), &AGY).expect("home");
assert_eq!(var, "HOME");
assert!(dir.ends_with("cli-homes/agy"));
std::fs::remove_dir_all("/tmp/mur-agy-x").ok();
}
}