Skip to main content

mur_common/
cli_backend.rs

1//! CLI-spawn backend registry.
2//!
3//! One row per coding CLI MUR can drive. The registry is data, not code
4//! paths: Gemini CLI was replaced by Antigravity inside a year, and the
5//! design records that hardcoding per-CLI flags guarantees rewriting this on
6//! the next replacement.
7//!
8//! A row exists only when every field is known. `agy`'s home env var and the
9//! `codex` / `agy` streaming envelopes are still open questions in
10//! `docs/superpowers/specs/2026-09-16-cli-spawn-backends-design.md`, so those
11//! rows are absent rather than half-filled — an unknown field here would be
12//! read as fact by every consumer.
13//!
14//! Nothing in this module spawns a process or reads the user's CLI config.
15
16/// How a backend's MCP configuration reaches the CLI.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum McpMount {
19    /// Flags on every invocation; nothing is written to disk.
20    PerCall,
21    /// Written once into the backend's private home.
22    Persistent,
23}
24
25/// Whether MUR may actually drive this backend.
26///
27/// Separate from binary presence on purpose. The spec's activation gate reads:
28/// "Failure or unknown results keep the backend disabled; binary presence is
29/// insufficient." A disabled backend is still listed and still rendered — it
30/// is the panel that carries the explanation and the controls, so hiding it
31/// would strand the user exactly as hiding a subscription provider did.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Activation {
34    Enabled,
35    /// `reason` is shown to the user. It names the unmet requirement.
36    Disabled {
37        reason: &'static str,
38    },
39}
40
41/// One CLI-spawn backend, as data.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CliBackend {
44    /// Stable identifier used in paths and UI keys.
45    pub key: &'static str,
46    /// Executable name, resolved against the user's shell PATH by the caller.
47    pub binary: &'static str,
48    /// Flags that put the CLI in headless mode.
49    pub headless_invocation: &'static [&'static str],
50    /// Flags that select a machine-readable streaming envelope.
51    pub stream_flags: &'static [&'static str],
52    /// Flags that disable the CLI's own built-in tools.
53    pub tool_disable_flags: &'static [&'static str],
54    pub mcp_mount: McpMount,
55    /// Environment variable that relocates this CLI's home.
56    pub home_env_var: &'static str,
57    pub activation: Activation,
58    /// Free text for the panel: what is known, and what is not.
59    pub capability_notes: &'static str,
60}
61
62/// `claude`, measured at 2.1.273 by running it, not only by reading `--help`.
63///
64/// `stream_flags` carries `--verbose`: since at least 2.1.281, `-p` with
65/// `--output-format stream-json` and no `--verbose` exits 1 before any event
66/// ("--output-format=stream-json requires --verbose"). Re-measured at 2.1.283;
67/// the flag adds no events the stream parser does not already skip.
68///
69/// `tool_disable_flags` is `--tools ""`, not `--disallowedTools`: the latter
70/// is a named deny list, and denying `Bash` merely sent the model to `Glob`.
71///
72/// Both halves of the disable are required. `--tools ""` alone left 44 tools
73/// mounted — every MCP server in the user's own config — so the flags below
74/// are the pair, and `--strict-mcp-config` is load-bearing. Verified from the
75/// `system init` event's `tools` array, which reported `[]`.
76///
77/// Enabled as of the CLI-spawn backend. The reason moved three times before
78/// it could be: the tool probe, then serving tools over MCP, then the spawn. The
79/// probe is answered; MUR does now serve its tools over MCP (`tools/list`,
80/// `tools/call`, and the shim that forwards to them). What is missing is the
81/// other half: nothing writes the per-turn `--mcp-config` and nothing runs
82/// the CLI, so this row describes a backend that could work rather than one
83/// that does.
84///
85/// Keep this sentence true. A row whose stated reason outlives the thing it
86/// described is worse than a bare `false` — it explains itself confidently
87/// and wrongly, and a user reading the panel has no way to tell.
88pub const CLAUDE: CliBackend = CliBackend {
89    key: "claude",
90    binary: "claude",
91    headless_invocation: &["-p"],
92    stream_flags: &["--output-format", "stream-json", "--verbose"],
93    tool_disable_flags: &["--tools", "", "--strict-mcp-config"],
94    mcp_mount: McpMount::PerCall,
95    home_env_var: "CLAUDE_CONFIG_DIR",
96    activation: Activation::Enabled,
97    capability_notes: "--tools \"\" disables built-ins but NOT the user's own MCP \
98                       servers; --strict-mcp-config is what empties the tool list",
99};
100
101/// `codex`, measured at 0.154.0 by running it (#1339).
102///
103/// Disabled, and for a reason no probe can close. Its shell is core — there
104/// is no flag that removes it, and `-s read-only` restricts the filesystem
105/// rather than establishing action safety — so a spawned `codex` can execute
106/// commands that never pass MUR's handler, entitlements or HITL gate. The
107/// design admits it only behind a *verified* process sandbox inherited by
108/// child processes, and no such sandbox exists yet.
109///
110/// It has a row rather than being absent because absence says nothing. A
111/// user who has `codex` installed should see it listed with this reason, not
112/// silently missing — the same lesson as the subscription rail in #1334.
113///
114/// `stream_flags` is `--json`, not `--output-format stream-json`: measured,
115/// and it carries completed items only, so a codex-backed turn cannot stream
116/// partial output even once the sandbox exists.
117pub const CODEX: CliBackend = CliBackend {
118    key: "codex",
119    binary: "codex",
120    headless_invocation: &["exec"],
121    stream_flags: &["--json"],
122    tool_disable_flags: &[],
123    mcp_mount: McpMount::Persistent,
124    home_env_var: "CODEX_HOME",
125    activation: Activation::Disabled {
126        reason: "codex's built-in shell cannot be disabled and the spawn path does not yet apply MUR's sandbox to it",
127    },
128    capability_notes: "prompt arrives on stdin; --json emits completed items \
129                       with no incremental deltas; MCP mounts persistently, so \
130                       a per-turn config must be written into the private home",
131};
132
133/// `agy`, measured at 1.2.3 by running it (#1339, #1340).
134///
135/// Disabled for the same reason as `codex` and not a different one: it has
136/// execution it cannot be asked to give up — 57 built-in tools, enumerated by
137/// its own `init` event, including `run_command`, `write_to_file` and a full
138/// browser-control set — and the spawn path does not yet apply MUR's sandbox.
139/// Containment does not care whether a tool is "disabled"; it cares what the
140/// process can do. So this is the same blocker, not a worse one.
141///
142/// Its own `--sandbox` is not a mitigation. Probed with the approval layer
143/// removed, it still read outside the workspace, read `$HOME`, wrote outside
144/// the workspace and reached the public internet.
145///
146/// `home_env_var` is `HOME`, and that is an honest value rather than a
147/// missing one — an earlier note here called it "no honest value", conflating
148/// "no dedicated variable" with "no usable value". Setting `HOME` does
149/// relocate agy's config, measured. What it also does is relocate everything
150/// else that process resolves under `HOME`, which is a blunter instrument
151/// than `CODEX_HOME` and is the one way this row is genuinely worse than
152/// `codex`'s.
153pub const AGY: CliBackend = CliBackend {
154    key: "agy",
155    binary: "agy",
156    headless_invocation: &["-p"],
157    stream_flags: &["--output-format", "stream-json"],
158    tool_disable_flags: &[],
159    mcp_mount: McpMount::Persistent,
160    home_env_var: "HOME",
161    activation: Activation::Disabled {
162        reason: "agy's 57 built-in tools cannot be disabled and the spawn path does not yet apply MUR's sandbox to it",
163    },
164    capability_notes: "HOME is the only lever and it moves everything, not just \
165                       config; `-p` swallows a following flag as its prompt, so \
166                       use `-p=<prompt>`; MCP mounts persistently at \
167                       $HOME/.gemini/config/mcp_config.json",
168};
169
170/// Every backend whose record is complete. Absence is a statement: a CLI
171/// missing here has an unanswered probe, not a missing implementation.
172pub const REGISTRY: &[CliBackend] = &[CLAUDE, CODEX, AGY];
173
174/// Look up a backend by key.
175pub fn backend(key: &str) -> Option<&'static CliBackend> {
176    REGISTRY.iter().find(|b| b.key == key)
177}
178
179/// A backend whose binary was found, plus whether MUR may drive it.
180///
181/// `usable == false` is a backend that is present and listed but must not be
182/// spawned; the caller renders it with `Activation::Disabled`'s reason. It is
183/// deliberately not filtered out — the disabled entry is what carries the
184/// explanation.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct BackendAvailability {
187    pub backend: &'static CliBackend,
188    pub path: std::path::PathBuf,
189    pub usable: bool,
190}
191
192/// Which backends the user actually has, given a binary resolver.
193///
194/// `resolve` is injected rather than calling a `which` helper directly: this
195/// crate is consumed by the Hub, the runtime and the CLI, each of which
196/// resolves binaries differently (the Hub must ask an interactive login shell,
197/// because a Finder-launched app inherits a bare PATH). Injection also makes
198/// every case below testable without touching the real PATH.
199pub fn available<F>(resolve: F) -> Vec<BackendAvailability>
200where
201    F: Fn(&str) -> Option<std::path::PathBuf>,
202{
203    REGISTRY
204        .iter()
205        .filter_map(|b| {
206            resolve(b.binary).map(|path| BackendAvailability {
207                backend: b,
208                path,
209                usable: matches!(b.activation, Activation::Enabled),
210            })
211        })
212        .collect()
213}
214
215/// `<mur_home>/cli-homes/<key>/` — this backend's private CLI home.
216///
217/// `mur_home` is a parameter rather than a call to `trust::mur_home()` so the
218/// path is a pure function of its inputs and every test runs against a temp
219/// dir. Same shape as `local_llm::local_model_dir`.
220pub fn home_dir(mur_home: &std::path::Path, key: &str) -> std::path::PathBuf {
221    mur_home.join("cli-homes").join(key)
222}
223
224/// Create this backend's private home if absent and return the environment
225/// variable that points the CLI at it.
226///
227/// The home starts empty and stays MUR's: the user authenticates once inside
228/// it, and their own `~/.claude` / `~/.codex` is never read or written. We do
229/// not copy `auth.json` — two holders of one refresh-token lineage each
230/// rotating would log the user out of their own CLI, which is why the gateway
231/// is the sole token holder on the other track.
232pub fn ensure_home(
233    mur_home: &std::path::Path,
234    b: &CliBackend,
235) -> std::io::Result<(&'static str, std::path::PathBuf)> {
236    let dir = home_dir(mur_home, b.key);
237    std::fs::create_dir_all(&dir)?;
238    Ok((b.home_env_var, dir))
239}
240
241/// The flags that make a spawned CLI see MUR's tools and nothing else.
242///
243/// One constant, not three arguments assembled at the call site. Measured
244/// 2026-09-16: `--tools ""` alone still left 44 tools mounted — every MCP
245/// server in the user's own config — and none of those pass MUR's handler,
246/// entitlements or HITL gate. `--strict-mcp-config` is what empties the
247/// list, so the three travel together or the isolation is not there.
248pub const ISOLATION_FLAGS: &[&str] = &["--tools", "", "--strict-mcp-config"];
249
250/// The `--mcp-config` document for one turn.
251///
252/// Names the shim, the agent socket it dials back on, and the task it
253/// belongs to. `task_id` is what binds a spawned `bash` job to an owner and
254/// routes an approval prompt, so it is an argument rather than something the
255/// shim could infer.
256pub fn mcp_config_json(
257    shim_bin: &str,
258    socket: &std::path::Path,
259    task_id: &str,
260    shim_ticket: &str,
261) -> serde_json::Value {
262    serde_json::json!({
263        "mcpServers": {
264            "mur": {
265                "command": shim_bin,
266                "args": [
267                    "mcp-shim",
268                    "--socket", socket.to_string_lossy(),
269                    "--task-id", task_id,
270                ],
271                // Not an argv flag: argv is visible to every process in `ps`.
272                // The file itself is readable by the agent's tools too, which
273                // is why the runtime also checks the redeemer's lineage.
274                "env": { SHIM_TICKET_ENV: shim_ticket },
275            }
276        }
277    })
278}
279
280/// Env var carrying a CLI-spawn turn's one-time shim ticket from the MCP
281/// config to the shim. See `mur-agent-runtime/src/hitl/shim_ticket.rs`.
282pub const SHIM_TICKET_ENV: &str = "MUR_SHIM_TICKET";
283
284/// Marks a model registry `provider` as naming the CLI-spawn track.
285///
286/// The gateway track already selects on `provider` (`claude` and `codex`
287/// dispatch to loopback clients), so the CLI track uses the same field
288/// rather than inventing a second way to say which track an agent is on.
289/// The prefix keeps the two readable side by side — `claude` is the
290/// gateway, `cli:claude` is the spawn — and cannot be confused with a
291/// vendor slug, which a `-cli` suffix would have to be parsed off a name
292/// that may itself contain dashes.
293pub const PROVIDER_PREFIX: &str = "cli:";
294
295/// The backend a registry `provider` names, if it names one.
296///
297/// Returns the row whether or not it is enabled. Activation is the caller's
298/// gate to apply and to report: a disabled backend must produce a turn that
299/// explains itself, which it cannot do if this returns `None` and the
300/// provider merely looks unknown.
301pub fn from_provider(provider: &str) -> Option<&'static CliBackend> {
302    let key = provider.strip_prefix(PROVIDER_PREFIX)?;
303    backend(key)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn claude_row_matches_the_measured_capabilities() {
312        assert_eq!(CLAUDE.binary, "claude");
313        assert_eq!(CLAUDE.headless_invocation, &["-p"]);
314        assert_eq!(
315            CLAUDE.stream_flags,
316            &["--output-format", "stream-json", "--verbose"]
317        );
318        assert_eq!(
319            CLAUDE.tool_disable_flags,
320            &["--tools", "", "--strict-mcp-config"]
321        );
322        assert_eq!(CLAUDE.mcp_mount, McpMount::PerCall);
323        assert_eq!(CLAUDE.home_env_var, "CLAUDE_CONFIG_DIR");
324    }
325
326    #[test]
327    fn claude_is_enabled_once_the_spawn_path_exists() {
328        // The gate is not "a probe answered"; it is that every isolation
329        // requirement was demonstrated. The boxes are in the plan.
330        assert!(matches!(CLAUDE.activation, Activation::Enabled));
331    }
332
333    #[test]
334    fn the_tool_disable_carries_both_halves() {
335        // Regression guard for the measured hazard: `--tools ""` on its own
336        // left 44 of the user's own MCP tools mounted. Dropping
337        // --strict-mcp-config here would silently reopen that hole.
338        assert!(CLAUDE.tool_disable_flags.contains(&"--tools"));
339        assert!(CLAUDE.tool_disable_flags.contains(&"--strict-mcp-config"));
340        assert!(
341            !CLAUDE.tool_disable_flags.contains(&"--disallowedTools"),
342            "--disallowedTools is a named deny list, not a disable"
343        );
344    }
345
346    #[test]
347    fn every_row_is_fully_specified() {
348        // The rule the registry exists to enforce: no half-filled row. A
349        // backend with an unknown field belongs outside the registry, not
350        // inside it with a plausible-looking guess.
351        for b in REGISTRY {
352            assert!(!b.key.is_empty(), "{}: empty key", b.key);
353            assert!(!b.binary.is_empty(), "{}: empty binary", b.key);
354            assert!(
355                !b.headless_invocation.is_empty(),
356                "{}: no headless flags",
357                b.key
358            );
359            assert!(!b.stream_flags.is_empty(), "{}: no stream flags", b.key);
360            assert!(!b.home_env_var.is_empty(), "{}: no home env var", b.key);
361        }
362    }
363
364    #[test]
365    fn unprobed_backends_are_absent_rather_than_guessed() {
366        // Both probes are answered (#1339); what differs is what the answers
367        // did. `codex`'s made a row possible — it has all five required
368        // fields — so it is registered and disabled, not absent.
369        //
370        // `agy`'s answer is what keeps it out, and structurally: it has no
371        // home environment variable at all, only `HOME`, so there is no
372        // honest value for `home_env_var` and the completeness rule above
373        // would reject the row. Absence here is the measurement, not a gap.
374        // Every probed backend now has a row. Absence is reserved for a CLI
375        // nobody has measured — it is a statement about knowledge, not about
376        // safety, and both of these are disabled rather than missing.
377        assert!(backend("agy").is_some(), "agy's record is complete");
378        assert!(backend("codex").is_some(), "codex's record is complete");
379        assert!(backend("nope").is_none());
380    }
381
382    #[test]
383    fn lookup_finds_claude_and_rejects_unknown_keys() {
384        assert_eq!(backend("claude"), Some(&CLAUDE));
385        assert!(backend("nope").is_none());
386    }
387
388    use std::path::PathBuf;
389
390    fn found(_: &str) -> Option<PathBuf> {
391        Some(PathBuf::from("/opt/homebrew/bin/claude"))
392    }
393
394    fn missing(_: &str) -> Option<PathBuf> {
395        None
396    }
397
398    #[test]
399    fn an_absent_binary_produces_no_entry() {
400        // "Backends whose binary is absent do not appear in the UI at all."
401        assert!(available(missing).is_empty());
402    }
403
404    #[test]
405    fn a_present_binary_is_listed_with_the_path_that_was_resolved() {
406        // Every registered backend, each carrying the path the resolver gave
407        // for it. Asserted over the whole registry rather than over a count,
408        // so adding a row does not break a test about path plumbing.
409        let got = available(found);
410        assert_eq!(got.len(), REGISTRY.len());
411        for a in &got {
412            assert_eq!(a.path, PathBuf::from("/opt/homebrew/bin/claude"));
413        }
414        assert!(got.iter().any(|a| a.backend.key == "claude"));
415    }
416
417    #[test]
418    fn a_disabled_backend_would_be_listed_and_not_usable() {
419        // The distinction `available()` exists to hold: absent means gone,
420        // disabled means shown-and-not-usable. It used to be asserted through
421        // the `claude` row, which was disabled at the time; now that `claude`
422        // is enabled there is no disabled row to borrow, so the mapping is
423        // asserted directly rather than deleted along with its example.
424        let disabled = CliBackend {
425            activation: Activation::Disabled {
426                reason: "for the test",
427            },
428            ..CLAUDE
429        };
430        let entry = BackendAvailability {
431            backend: &CLAUDE,
432            path: PathBuf::from("/opt/homebrew/bin/claude"),
433            usable: matches!(disabled.activation, Activation::Enabled),
434        };
435        assert!(!entry.usable, "a disabled backend must never be usable");
436    }
437
438    #[test]
439    fn usable_tracks_activation_and_nothing_else() {
440        // Guards against a future row being enabled by the mere fact that its
441        // binary resolved.
442        for a in available(found) {
443            assert_eq!(
444                a.usable,
445                matches!(a.backend.activation, Activation::Enabled)
446            );
447        }
448    }
449
450    #[test]
451    fn home_dir_is_namespaced_under_cli_homes() {
452        let got = home_dir(std::path::Path::new("/tmp/murhome"), "claude");
453        assert_eq!(got, PathBuf::from("/tmp/murhome/cli-homes/claude"));
454    }
455
456    #[test]
457    fn ensure_home_creates_the_dir_and_returns_the_env_var() {
458        let tmp = std::env::temp_dir().join(format!("mur-cli-home-{}", std::process::id()));
459        let _ = std::fs::remove_dir_all(&tmp);
460        let (var, dir) = ensure_home(&tmp, &CLAUDE).expect("create");
461        assert_eq!(var, "CLAUDE_CONFIG_DIR");
462        assert_eq!(dir, tmp.join("cli-homes").join("claude"));
463        assert!(dir.is_dir());
464        std::fs::remove_dir_all(&tmp).ok();
465    }
466
467    #[test]
468    fn ensure_home_is_idempotent() {
469        let tmp = std::env::temp_dir().join(format!("mur-cli-home-idem-{}", std::process::id()));
470        let _ = std::fs::remove_dir_all(&tmp);
471        ensure_home(&tmp, &CLAUDE).expect("first");
472        let marker = home_dir(&tmp, "claude").join("settings.json");
473        std::fs::write(&marker, b"{}").expect("write marker");
474        ensure_home(&tmp, &CLAUDE).expect("second");
475        assert_eq!(std::fs::read(&marker).expect("read marker"), b"{}");
476        std::fs::remove_dir_all(&tmp).ok();
477    }
478
479    #[test]
480    fn ensure_home_never_touches_the_users_own_cli_config() {
481        // The Global Constraint, asserted rather than assumed. A stand-in for
482        // ~/.claude sits OUTSIDE the mur home; creating the private home must
483        // leave its bytes untouched.
484        let base = std::env::temp_dir().join(format!("mur-cli-iso-{}", std::process::id()));
485        let _ = std::fs::remove_dir_all(&base);
486        let user_cfg = base.join("user-claude");
487        std::fs::create_dir_all(&user_cfg).expect("user cfg");
488        let cred = user_cfg.join(".credentials.json");
489        std::fs::write(&cred, b"user-token").expect("seed");
490
491        ensure_home(&base.join("murhome"), &CLAUDE).expect("create");
492
493        assert_eq!(std::fs::read(&cred).expect("still there"), b"user-token");
494        assert!(
495            !base
496                .join("murhome")
497                .join("cli-homes")
498                .join("claude")
499                .join(".credentials.json")
500                .exists()
501        );
502        std::fs::remove_dir_all(&base).ok();
503    }
504
505    #[test]
506    fn the_isolation_flags_stay_together() {
507        // Each of the three is load-bearing and the middle row of the table
508        // in the plan is why: dropping --strict-mcp-config re-mounts the
509        // user's own MCP servers, and the spawn still looks correct.
510        assert_eq!(ISOLATION_FLAGS, &["--tools", "", "--strict-mcp-config"]);
511    }
512
513    #[test]
514    fn the_mcp_config_names_the_shim_the_socket_and_the_task() {
515        let v = mcp_config_json(
516            "/usr/local/bin/mur_agent_x",
517            std::path::Path::new("/tmp/x/agent.sock"),
518            "t-9",
519            "tk",
520        );
521        let s = &v["mcpServers"]["mur"];
522        assert_eq!(s["env"][SHIM_TICKET_ENV], "tk");
523        assert_eq!(s["command"], "/usr/local/bin/mur_agent_x");
524        let args: Vec<String> = s["args"]
525            .as_array()
526            .expect("args")
527            .iter()
528            .map(|a| a.as_str().unwrap_or_default().to_string())
529            .collect();
530        assert_eq!(args[0], "mcp-shim");
531        assert!(args.contains(&"/tmp/x/agent.sock".to_string()));
532        assert!(args.contains(&"t-9".to_string()));
533    }
534
535    #[test]
536    fn the_config_declares_exactly_one_server() {
537        // `--strict-mcp-config` means this document is the whole tool
538        // surface. A second entry here would be a second unaudited source.
539        let v = mcp_config_json("bin", std::path::Path::new("/s"), "t", "k");
540        assert_eq!(v["mcpServers"].as_object().expect("obj").len(), 1);
541    }
542
543    #[test]
544    fn a_prefixed_provider_names_the_backend() {
545        assert_eq!(from_provider("cli:claude").map(|b| b.key), Some("claude"));
546    }
547
548    #[test]
549    fn the_gateway_providers_are_not_the_cli_track() {
550        // `claude` and `codex` already mean the loopback gateway. If this
551        // ever matched them, putting an agent on the gateway would silently
552        // spawn a CLI instead.
553        assert!(from_provider("claude").is_none());
554        assert!(from_provider("codex").is_none());
555        assert!(from_provider("openai").is_none());
556    }
557
558    #[test]
559    fn an_unknown_backend_is_none_even_when_prefixed() {
560        // `cli:agy` resolves now that agy has a row — that is the point of
561        // giving it one. An agent pointed there gets "disabled, and here is
562        // why" instead of the unknown-provider path, which reads as a typo.
563        assert_eq!(from_provider("cli:agy").map(|b| b.key), Some("agy"));
564        assert!(from_provider("cli:nope").is_none());
565    }
566
567    #[test]
568    fn a_disabled_backend_still_resolves() {
569        // The caller needs the row to report *why* it is off. Returning
570        // `None` would make a disabled backend indistinguishable from a typo.
571        let disabled = CliBackend {
572            activation: Activation::Disabled {
573                reason: "for the test",
574            },
575            ..CLAUDE
576        };
577        assert!(matches!(disabled.activation, Activation::Disabled { .. }));
578        assert!(from_provider("cli:claude").is_some());
579    }
580
581    #[test]
582    fn codex_is_listed_and_never_usable() {
583        // The distinction the registry exists to express: present, so a user
584        // with `codex` installed sees it and its reason; not usable, because
585        // its shell cannot be disabled and no verified sandbox exists.
586        let c = backend("codex").expect("codex is registered");
587        match c.activation {
588            Activation::Disabled { reason } => {
589                assert!(reason.contains("sandbox"), "{reason}");
590                // Not "no sandbox exists" — one does, in
591                // `mur-agent-runtime/src/sandbox/`. What is missing is its
592                // application to the spawn path, and the reason must say
593                // which, or it sends the next reader off to build one.
594                assert!(reason.contains("does not yet apply"), "{reason}");
595                assert!(reason.contains("shell"), "{reason}");
596            }
597            Activation::Enabled => panic!("codex must not be enabled without a verified sandbox"),
598        }
599        assert!(
600            available(found)
601                .iter()
602                .any(|a| a.backend.key == "codex" && !a.usable)
603        );
604    }
605
606    #[test]
607    fn cli_codex_resolves_so_the_refusal_can_name_itself() {
608        // An agent pointed at `cli:codex` must get "disabled, and here is
609        // why" — not the unknown-provider path, which reads as a typo. This
610        // is the guard that was deferred while `from_provider` lived on an
611        // unmerged branch; it belongs beside the row it protects.
612        assert_eq!(from_provider("cli:codex").map(|b| b.key), Some("codex"));
613    }
614
615    #[test]
616    fn agy_is_listed_and_never_usable() {
617        // Same shape as codex's guard. The reason must name the blocker, not
618        // the absence of one, or the next reader is told to solve the wrong
619        // problem — which is what "no honest value for home_env_var" did.
620        let a = backend("agy").expect("agy is registered");
621        match a.activation {
622            Activation::Disabled { reason } => {
623                assert!(reason.contains("built-in tools"), "{reason}");
624                assert!(reason.contains("does not yet apply"), "{reason}");
625            }
626            Activation::Enabled => panic!("agy must not be enabled: 57 built-ins, none disablable"),
627        }
628        assert!(
629            available(found)
630                .iter()
631                .any(|a| a.backend.key == "agy" && !a.usable)
632        );
633    }
634
635    #[test]
636    fn home_is_a_real_lever_for_agy_not_a_placeholder() {
637        // Measured (#1339): setting HOME relocates agy's config. The row says
638        // `HOME` because that works, not because nothing else would fit.
639        assert_eq!(AGY.home_env_var, "HOME");
640        let (var, dir) = ensure_home(std::path::Path::new("/tmp/mur-agy-x"), &AGY).expect("home");
641        assert_eq!(var, "HOME");
642        assert!(dir.ends_with("cli-homes/agy"));
643        std::fs::remove_dir_all("/tmp/mur-agy-x").ok();
644    }
645}