Skip to main content

truth_mirror/
surface.rs

1//! Per-agent reinjection surface installation.
2//!
3//! `install-hooks --claude|--codex|--pi` installs `truth-mirror reinject --agent <agent>`
4//! into each selected agent's repo-local configuration surface. Installs are
5//! non-clobbering (existing config is merged, not overwritten), idempotent, and
6//! reversible: uninstall removes only truth-mirror's own entries.
7
8use std::{
9    fs,
10    path::{Path, PathBuf},
11};
12
13use anyhow::{Context, Result};
14use serde_json::{Map, Value, json};
15
16use crate::cli::Agent;
17
18/// Agents whose reinjection surface is a JSON hook file this module manages.
19///
20/// Pi is intentionally excluded: verified against Pi 0.80.3 source, Pi has no
21/// `hooks.json` surface. Its project reinjection is a Pi extension package listed
22/// in `<repo>/.pi/settings.json` `packages[]` (tracked separately); it is handled
23/// outside this module.
24pub const FILE_SURFACE_AGENTS: [Agent; 2] = [Agent::Claude, Agent::Codex];
25
26pub fn agent_slug(agent: Agent) -> &'static str {
27    match agent {
28        Agent::Claude => "claude",
29        Agent::Codex => "codex",
30        Agent::Pi => "pi",
31    }
32}
33
34/// Repo-relative path of a file-surface agent's reinjection hook file.
35/// Only meaningful for [`FILE_SURFACE_AGENTS`].
36pub fn surface_relative_path(agent: Agent) -> &'static str {
37    match agent {
38        Agent::Claude => ".claude/settings.json",
39        Agent::Codex => ".codex/hooks.json",
40        // Pi has no hook file; its real project config is .pi/settings.json packages[].
41        Agent::Pi => ".pi/settings.json",
42    }
43}
44
45/// The exact command truth-mirror installs into each surface.
46pub fn reinject_command(agent: Agent) -> String {
47    format!("truth-mirror reinject --agent {}", agent_slug(agent))
48}
49
50/// Repo-relative path of the project-local Pi extension file.
51pub const PI_EXTENSION_RELATIVE: &str = ".pi/extensions/truth-mirror.js";
52
53/// Absolute path of the project-local Pi extension file.
54///
55/// Pi auto-loads every `*.js`/`*.ts` file in `<cwd>/.pi/extensions/` (verified
56/// against Pi 0.80.3 `core/extensions/loader.js:466-490,511-512`), subject to the
57/// one-time project-folder trust prompt (`core/project-trust.js`).
58pub fn pi_extension_path(repo_root: &Path) -> PathBuf {
59    repo_root.join(PI_EXTENSION_RELATIVE)
60}
61
62/// The project-local Pi extension. Default-exports a factory `(pi) => {}` that
63/// registers a `context` handler (fires before every LLM call) and appends the
64/// output of `truth-mirror reinject --agent pi` as a user message, with a dedup
65/// guard so findings inject once per change, not once per tool round-trip.
66pub const PI_EXTENSION_SOURCE: &str = r#"// truth-mirror Pi reinjection extension.
67// Auto-generated by `truth-mirror install-hooks --pi`. Pi auto-loads this file
68// from <repo>/.pi/extensions/ once the project folder is trusted.
69import { execFile } from "node:child_process";
70import { promisify } from "node:util";
71
72const run = promisify(execFile);
73
74export default function truthMirror(pi) {
75  let lastInjected = "";
76  pi.on("context", async (event) => {
77    let text = "";
78    try {
79      const { stdout } = await run("truth-mirror", ["reinject", "--agent", "pi"], {
80        cwd: process.cwd(),
81      });
82      text = (stdout || "").trim();
83    } catch {
84      return; // truth-mirror missing or errored: stay silent.
85    }
86    // `context` fires before every LLM call; dedup so findings inject once per change.
87    if (!text || text === lastInjected) return;
88    lastInjected = text;
89    return {
90      messages: [
91        ...event.messages,
92        { role: "user", content: [{ type: "text", text }] },
93      ],
94    };
95  });
96}
97"#;
98
99/// Write the project-local Pi reinjection extension into `<repo>/.pi/extensions/`.
100pub fn install_pi_extension(repo_root: &Path) -> Result<()> {
101    let path = pi_extension_path(repo_root);
102    if let Some(parent) = path.parent() {
103        fs::create_dir_all(parent)
104            .with_context(|| format!("creating pi extensions dir {}", parent.display()))?;
105    }
106    fs::write(&path, PI_EXTENSION_SOURCE)
107        .with_context(|| format!("writing pi extension {}", path.display()))?;
108    Ok(())
109}
110
111/// Remove the project-local Pi reinjection extension.
112pub fn uninstall_pi_extension(repo_root: &Path) -> Result<()> {
113    let path = pi_extension_path(repo_root);
114    if path.is_file() {
115        fs::remove_file(&path)
116            .with_context(|| format!("removing pi extension {}", path.display()))?;
117    }
118    Ok(())
119}
120
121/// The enforcement subcommand marker. Matched only when the command's program
122/// token is also `truth-mirror` (see `is_own_enforcement_command`), so preserved
123/// `--config`/`--state-dir` variants match but foreign hooks never do.
124pub const ENFORCE_COMMAND: &str = "gate --pre-tool-use";
125
126fn enforce_command(global_args: &str) -> String {
127    format!("truth-mirror {global_args}{ENFORCE_COMMAND}")
128}
129
130/// Install a `PreToolUse` enforcement hook into a nested (Claude/Codex) surface.
131/// The hook exits non-zero to block a mutating tool while the ledger has
132/// unresolved rejections beyond the configured threshold. `global_args` preserves
133/// the install-time `--config`/`--state-dir` so the hook uses the same config.
134pub fn install_enforcement(repo_root: &Path, agent: Agent, global_args: &str) -> Result<()> {
135    debug_assert!(is_nested(agent), "enforcement hook is nested-surface only");
136    let command = enforce_command(global_args);
137    let path = repo_root.join(surface_relative_path(agent));
138    let mut root = read_object(&path)?;
139    // Remove any prior truth-mirror enforcement entry first so a reinstall UPDATES
140    // the preserved flags (foreign hooks are left untouched).
141    remove_own_enforcement(&mut root, "PreToolUse");
142    let entries = event_array_mut(&mut root, "PreToolUse");
143    entries.push(json!({ "hooks": [ { "type": "command", "command": command } ] }));
144    write_object(&path, &root)
145}
146
147/// Remove the `PreToolUse` enforcement hook from a nested surface.
148pub fn uninstall_enforcement(repo_root: &Path, agent: Agent) -> Result<()> {
149    let path = repo_root.join(surface_relative_path(agent));
150    if !path.exists() {
151        return Ok(());
152    }
153    let mut root = read_object(&path)?;
154    remove_own_enforcement(&mut root, "PreToolUse");
155    if root.is_empty() {
156        fs::remove_file(&path)
157            .with_context(|| format!("removing empty surface {}", path.display()))?;
158    } else {
159        write_object(&path, &root)?;
160    }
161    Ok(())
162}
163
164/// Whether an entry is truth-mirror's OWN enforcement command — the program token
165/// must be `truth-mirror`, so a foreign hook like `external-auditor gate
166/// --pre-tool-use` is never matched, removed, or clobbered.
167fn is_own_enforcement_command(entry: &Value) -> bool {
168    entry
169        .get("command")
170        .and_then(Value::as_str)
171        .is_some_and(|value| {
172            value.split_whitespace().next() == Some("truth-mirror")
173                && value.contains(ENFORCE_COMMAND)
174        })
175}
176
177/// Mutable handle to `hooks.<event>` array, creating nested containers as needed.
178fn event_array_mut<'a>(root: &'a mut Map<String, Value>, event: &str) -> &'a mut Vec<Value> {
179    let hooks = root
180        .entry("hooks")
181        .or_insert_with(|| Value::Object(Map::new()));
182    if !hooks.is_object() {
183        *hooks = Value::Object(Map::new());
184    }
185    let hooks = hooks.as_object_mut().expect("hooks is object");
186    let entries = hooks
187        .entry(event.to_owned())
188        .or_insert_with(|| Value::Array(Vec::new()));
189    if !entries.is_array() {
190        *entries = Value::Array(Vec::new());
191    }
192    entries.as_array_mut().expect("event is array")
193}
194
195fn remove_own_enforcement(root: &mut Map<String, Value>, event: &str) {
196    let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
197        return;
198    };
199    if let Some(groups) = hooks.get_mut(event).and_then(Value::as_array_mut) {
200        for group in groups.iter_mut() {
201            if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
202                // Only truth-mirror's own enforcement command is removed; foreign
203                // hooks (even ones mentioning the subcommand) are left intact.
204                inner.retain(|entry| !is_own_enforcement_command(entry));
205            }
206        }
207        groups.retain(|group| {
208            group
209                .get("hooks")
210                .and_then(Value::as_array)
211                .is_none_or(|inner| !inner.is_empty())
212        });
213        if groups.is_empty() {
214            hooks.remove(event);
215        }
216    }
217    if hooks.is_empty() {
218        root.remove("hooks");
219    }
220}
221
222/// Both Claude Code (`.claude/settings.json`) and Codex (`.codex/hooks.json`)
223/// use the same nested shape: `hooks.UserPromptSubmit[].hooks[] = {type, command}`.
224/// Verified against Codex 0.142.4 source (`config/src/hook_config.rs`).
225fn is_nested(agent: Agent) -> bool {
226    matches!(agent, Agent::Claude | Agent::Codex)
227}
228
229#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct SurfacePlan {
231    pub agent: Agent,
232    pub path: PathBuf,
233}
234
235impl SurfacePlan {
236    pub fn for_agent(repo_root: &Path, agent: Agent) -> Self {
237        Self {
238            agent,
239            path: repo_root.join(surface_relative_path(agent)),
240        }
241    }
242
243    pub fn install(&self) -> Result<()> {
244        let mut root = read_object(&self.path)?;
245        install_command(self.agent, &mut root, &reinject_command(self.agent));
246        write_object(&self.path, &root)
247    }
248
249    pub fn uninstall(&self) -> Result<()> {
250        if !self.path.exists() {
251            return Ok(());
252        }
253        let mut root = read_object(&self.path)?;
254        remove_command(self.agent, &mut root, &reinject_command(self.agent));
255        if root.is_empty() {
256            fs::remove_file(&self.path)
257                .with_context(|| format!("removing empty surface {}", self.path.display()))?;
258        } else {
259            write_object(&self.path, &root)?;
260        }
261        Ok(())
262    }
263
264    pub fn contains_reinject(&self) -> Result<bool> {
265        if !self.path.exists() {
266            return Ok(false);
267        }
268        let root = read_object(&self.path)?;
269        Ok(surface_contains(
270            self.agent,
271            &root,
272            &reinject_command(self.agent),
273        ))
274    }
275}
276
277fn read_object(path: &Path) -> Result<Map<String, Value>> {
278    match fs::read_to_string(path) {
279        Ok(contents) if contents.trim().is_empty() => Ok(Map::new()),
280        Ok(contents) => {
281            let value: Value = serde_json::from_str(&contents)
282                .with_context(|| format!("parsing existing surface {}", path.display()))?;
283            match value {
284                Value::Object(map) => Ok(map),
285                _ => anyhow::bail!(
286                    "surface {} is not a JSON object; refusing to clobber",
287                    path.display()
288                ),
289            }
290        }
291        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
292        Err(error) => Err(error).with_context(|| format!("reading surface {}", path.display()))?,
293    }
294}
295
296fn write_object(path: &Path, root: &Map<String, Value>) -> Result<()> {
297    if let Some(parent) = path.parent() {
298        fs::create_dir_all(parent)
299            .with_context(|| format!("creating surface dir {}", parent.display()))?;
300    }
301    let mut serialized = serde_json::to_string_pretty(&Value::Object(root.clone()))?;
302    serialized.push('\n');
303    fs::write(path, serialized).with_context(|| format!("writing surface {}", path.display()))?;
304    Ok(())
305}
306
307fn install_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
308    let entries = user_prompt_submit_mut(agent, root);
309    if array_contains_command(agent, entries, command) {
310        return;
311    }
312    entries.push(surface_entry(agent, command));
313}
314
315fn remove_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
316    if is_nested(agent) {
317        let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
318            return;
319        };
320        if let Some(groups) = hooks
321            .get_mut("UserPromptSubmit")
322            .and_then(Value::as_array_mut)
323        {
324            for group in groups.iter_mut() {
325                if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
326                    inner.retain(|entry| !entry_matches_command(entry, command));
327                }
328            }
329            groups.retain(|group| {
330                group
331                    .get("hooks")
332                    .and_then(Value::as_array)
333                    .is_none_or(|inner| !inner.is_empty())
334            });
335            if groups.is_empty() {
336                hooks.remove("UserPromptSubmit");
337            }
338        }
339        if hooks.is_empty() {
340            root.remove("hooks");
341        }
342    } else if let Some(entries) = root
343        .get_mut("UserPromptSubmit")
344        .and_then(Value::as_array_mut)
345    {
346        entries.retain(|entry| !entry_matches_command(entry, command));
347        if entries.is_empty() {
348            root.remove("UserPromptSubmit");
349        }
350    }
351}
352
353/// Return a mutable handle to the array we append entries to, creating the
354/// nested containers if they do not exist.
355fn user_prompt_submit_mut(agent: Agent, root: &mut Map<String, Value>) -> &mut Vec<Value> {
356    if is_nested(agent) {
357        let hooks = root
358            .entry("hooks")
359            .or_insert_with(|| Value::Object(Map::new()));
360        if !hooks.is_object() {
361            *hooks = Value::Object(Map::new());
362        }
363        let hooks = hooks.as_object_mut().expect("hooks is object");
364        let entries = hooks
365            .entry("UserPromptSubmit")
366            .or_insert_with(|| Value::Array(Vec::new()));
367        if !entries.is_array() {
368            *entries = Value::Array(Vec::new());
369        }
370        entries.as_array_mut().expect("UserPromptSubmit is array")
371    } else {
372        let entries = root
373            .entry("UserPromptSubmit")
374            .or_insert_with(|| Value::Array(Vec::new()));
375        if !entries.is_array() {
376            *entries = Value::Array(Vec::new());
377        }
378        entries.as_array_mut().expect("UserPromptSubmit is array")
379    }
380}
381
382fn surface_entry(agent: Agent, command: &str) -> Value {
383    if is_nested(agent) {
384        json!({ "hooks": [ { "type": "command", "command": command } ] })
385    } else {
386        json!({ "command": command })
387    }
388}
389
390fn array_contains_command(agent: Agent, entries: &[Value], command: &str) -> bool {
391    if is_nested(agent) {
392        entries.iter().any(|group| {
393            group
394                .get("hooks")
395                .and_then(Value::as_array)
396                .is_some_and(|inner| inner.iter().any(|e| entry_matches_command(e, command)))
397        })
398    } else {
399        entries.iter().any(|e| entry_matches_command(e, command))
400    }
401}
402
403fn entry_matches_command(entry: &Value, command: &str) -> bool {
404    entry
405        .get("command")
406        .and_then(Value::as_str)
407        .is_some_and(|value| value == command)
408}
409
410/// Whether the surface JSON already carries the reinject command for an agent.
411pub fn surface_contains(agent: Agent, root: &Map<String, Value>, command: &str) -> bool {
412    if is_nested(agent) {
413        root.get("hooks")
414            .and_then(Value::as_object)
415            .and_then(|hooks| hooks.get("UserPromptSubmit"))
416            .and_then(Value::as_array)
417            .is_some_and(|entries| array_contains_command(agent, entries, command))
418    } else {
419        root.get("UserPromptSubmit")
420            .and_then(Value::as_array)
421            .is_some_and(|entries| array_contains_command(agent, entries, command))
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::{
428        Agent, SurfacePlan, install_command, reinject_command, remove_command, surface_contains,
429    };
430    use proptest::prelude::*;
431    use serde_json::{Map, Value, json};
432
433    fn install_into(agent: Agent, mut root: Map<String, Value>) -> Map<String, Value> {
434        install_command(agent, &mut root, &reinject_command(agent));
435        root
436    }
437
438    #[test]
439    fn claude_surface_uses_nested_user_prompt_submit() {
440        let root = install_into(Agent::Claude, Map::new());
441        let value = Value::Object(root.clone());
442
443        let command = value
444            .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
445            .and_then(Value::as_str)
446            .unwrap();
447        assert_eq!(command, "truth-mirror reinject --agent claude");
448        assert!(surface_contains(
449            Agent::Claude,
450            &root,
451            &reinject_command(Agent::Claude)
452        ));
453    }
454
455    #[test]
456    fn codex_uses_nested_user_prompt_submit_like_claude() {
457        // Verified against Codex 0.142.4: hooks.json is nested, not flat.
458        let root = install_into(Agent::Codex, Map::new());
459        let value = Value::Object(root.clone());
460
461        let command = value
462            .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
463            .and_then(Value::as_str)
464            .unwrap();
465        assert_eq!(command, "truth-mirror reinject --agent codex");
466        assert!(surface_contains(
467            Agent::Codex,
468            &root,
469            &reinject_command(Agent::Codex)
470        ));
471    }
472
473    #[test]
474    fn install_is_idempotent() {
475        let mut root = install_into(Agent::Claude, Map::new());
476        install_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
477
478        let count = Value::Object(root)
479            .pointer("/hooks/UserPromptSubmit")
480            .and_then(Value::as_array)
481            .map(Vec::len)
482            .unwrap();
483        assert_eq!(count, 1);
484    }
485
486    #[test]
487    fn install_preserves_foreign_config() {
488        let existing: Map<String, Value> = json!({
489            "model": "sonnet",
490            "hooks": { "PreToolUse": [ { "matcher": "Bash" } ] }
491        })
492        .as_object()
493        .cloned()
494        .unwrap();
495
496        let root = install_into(Agent::Claude, existing);
497        let value = Value::Object(root);
498
499        assert_eq!(
500            value.pointer("/model").and_then(Value::as_str),
501            Some("sonnet")
502        );
503        assert!(value.pointer("/hooks/PreToolUse").is_some());
504        assert!(value.pointer("/hooks/UserPromptSubmit/0").is_some());
505    }
506
507    #[test]
508    fn uninstall_removes_only_truth_mirror_entries() {
509        let existing: Map<String, Value> = json!({
510            "model": "sonnet",
511            "hooks": {
512                "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "other-tool" } ] } ]
513            }
514        })
515        .as_object()
516        .cloned()
517        .unwrap();
518
519        let mut root = install_into(Agent::Claude, existing);
520        remove_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
521        let value = Value::Object(root);
522
523        assert_eq!(
524            value.pointer("/model").and_then(Value::as_str),
525            Some("sonnet")
526        );
527        let commands: Vec<&str> = value
528            .pointer("/hooks/UserPromptSubmit")
529            .and_then(Value::as_array)
530            .unwrap()
531            .iter()
532            .filter_map(|group| group.pointer("/hooks/0/command").and_then(Value::as_str))
533            .collect();
534        assert_eq!(commands, ["other-tool"]);
535    }
536
537    #[test]
538    fn enforcement_hook_installs_and_coexists_with_reinject() {
539        let temp = tempfile::tempdir().unwrap();
540        let plan = SurfacePlan::for_agent(temp.path(), Agent::Claude);
541        plan.install().unwrap(); // UserPromptSubmit reinject
542        super::install_enforcement(temp.path(), Agent::Claude, "").unwrap();
543
544        let content = std::fs::read_to_string(&plan.path).unwrap();
545        assert!(content.contains("UserPromptSubmit"));
546        assert!(content.contains("PreToolUse"));
547        assert!(content.contains("truth-mirror gate --pre-tool-use"));
548
549        // Removing enforcement leaves the reinject hook intact.
550        super::uninstall_enforcement(temp.path(), Agent::Claude).unwrap();
551        let after = std::fs::read_to_string(&plan.path).unwrap();
552        assert!(after.contains("UserPromptSubmit"));
553        assert!(!after.contains("PreToolUse"));
554    }
555
556    #[test]
557    fn reinstalling_enforcement_updates_preserved_flags() {
558        let temp = tempfile::tempdir().unwrap();
559        super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
560        // Reinstall with a preserved --config: must UPDATE, not leave a stale entry.
561        super::install_enforcement(temp.path(), Agent::Codex, "--config '/abs/x.toml' ").unwrap();
562
563        let content = std::fs::read_to_string(temp.path().join(".codex/hooks.json")).unwrap();
564        assert_eq!(content.matches("gate --pre-tool-use").count(), 1);
565        assert!(content.contains("--config '/abs/x.toml'"));
566    }
567
568    #[test]
569    fn enforcement_leaves_foreign_pretooluse_hooks_intact() {
570        let temp = tempfile::tempdir().unwrap();
571        let path = temp.path().join(".codex/hooks.json");
572        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
573        // A FOREIGN hook that merely mentions the subcommand must not be clobbered.
574        let foreign = "external-auditor gate --pre-tool-use --keep";
575        std::fs::write(
576            &path,
577            format!(
578                "{{\"hooks\":{{\"PreToolUse\":[{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{foreign}\"}}]}}]}}}}"
579            ),
580        )
581        .unwrap();
582
583        super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
584        let after_install = std::fs::read_to_string(&path).unwrap();
585        assert!(
586            after_install.contains(foreign),
587            "foreign hook survives install"
588        );
589        assert!(after_install.contains("truth-mirror gate --pre-tool-use"));
590
591        super::uninstall_enforcement(temp.path(), Agent::Codex).unwrap();
592        let after_uninstall = std::fs::read_to_string(&path).unwrap();
593        assert!(
594            after_uninstall.contains(foreign),
595            "foreign hook survives uninstall"
596        );
597        assert!(!after_uninstall.contains("truth-mirror gate --pre-tool-use"));
598    }
599
600    #[test]
601    fn enforcement_round_trips_for_codex() {
602        let temp = tempfile::tempdir().unwrap();
603        super::install_enforcement(temp.path(), Agent::Codex, "").unwrap();
604        assert!(
605            std::fs::read_to_string(temp.path().join(".codex/hooks.json"))
606                .unwrap()
607                .contains("truth-mirror gate --pre-tool-use")
608        );
609        super::uninstall_enforcement(temp.path(), Agent::Codex).unwrap();
610        assert!(!temp.path().join(".codex/hooks.json").exists());
611    }
612
613    #[test]
614    fn install_then_uninstall_on_disk_round_trips() {
615        let temp = tempfile::tempdir().unwrap();
616        for agent in super::FILE_SURFACE_AGENTS {
617            let plan = SurfacePlan::for_agent(temp.path(), agent);
618            plan.install().unwrap();
619            assert!(plan.contains_reinject().unwrap());
620            plan.uninstall().unwrap();
621            assert!(!plan.contains_reinject().unwrap());
622            assert!(!plan.path.exists());
623        }
624    }
625
626    proptest! {
627        #[test]
628        fn foreign_keys_survive_install_uninstall(
629            key in "[a-z]{1,8}",
630            val in "[a-z0-9]{1,8}",
631        ) {
632            prop_assume!(key != "hooks" && key != "UserPromptSubmit");
633            let existing: Map<String, Value> = json!({ key.clone(): val.clone() })
634                .as_object()
635                .cloned()
636                .unwrap();
637
638            let mut root = existing.clone();
639            install_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
640            prop_assert!(surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
641
642            remove_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
643            prop_assert!(!surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
644            prop_assert_eq!(root.get(&key).and_then(Value::as_str), Some(val.as_str()));
645        }
646    }
647}